IO和NIO的那些事

IO的由來

咱們一直說,學習IONIO,但爲何要學習這些呢?
咱們分兩塊來看一下:html

  • 本地

本地的IO很簡單,就是文件嘛,或者緩存,或者其餘的可保存數據的渠道。舉個很簡單的例子,若是咱們要把某些數據保存到文件裏面,好比咱們這篇文章,要保存到硬盤中,確定就須要寫入到硬盤中。這裏的寫入咱們就能夠稱爲涉及到IO的使用。java

  • 網絡

網絡的IO理解起來會稍微不大同樣,由於他不像本地那麼直觀。
咱們先放一下,來複習一下網絡傳輸實際是怎樣的。程序員

服務器 -> 路由器 -> tcp/http/其餘協議 -> 路由器 -> 本地機器。通常狀況下咱們的理解是這樣的。若是咱們不往細了看,總體的流程是這樣的。但實際上從 服務器->路由器或者 路由器->本地機器這個過程當中涉及到 內核用戶態的一系列的協調,它們的協調處理才把數據真正傳輸完成。
服務器->路由器:這種狀況下,數據會由應用程序,即 用戶線程,經 內核線程,再經由 網卡,最後把數據傳輸到遠程機器,這裏數據在各個流程中的流轉,咱們也都稱他們涉及到 IO,由於他們涉及到存儲。
路由器->本地機器:這種狀況下,數據會由 網卡,經 內核線程,再傳到 用戶線程,即給到咱們的應用程序進行處理,這裏的流程中的轉換,也是涉及到 IO

LinuxUnix的哲學中,他們把全部的設備都當成是一個文件來處理,每個文件均可讀可寫,每個設備也是可讀可寫,這樣的抽象真是完美無缺緩存

Java程序員之痛

曾已什麼時候,Java程序員只有java.io包中的那一系列相關的類,這些類,在咱們眼中稱爲BIO,全稱爲Blocking-IO,即阻塞性IO。什麼叫阻塞性IO呢?服務器

阻塞性IO要求應用程序在處理時,須要等待當前的IO徹底處理完成後才能夠繼續後面的操做,好比讀取文件,須要徹底讀取成功/或出現異常,才返回;寫入文件,則須要所有寫入成功後/或拋出異常才返回。阻塞,阻塞,就意味着你一旦開始作某件事情,就是必定要等到這件事作完才能夠。

這種狀況在正常狀況下是沒問題的,但試想一下,若是當前機器的IO負載比較高,你這裏再來一個寫入文件的操做,是否是要等到天荒地老;或者你來個讀文件,原本都卡得快動不了了,你還讀文件,估計是更慘了。網絡

口說無憑,咱們來看段代碼,看看咱們以前是怎麼來對待這些IO,而且被他們折磨的。框架

阻塞性Server

阻塞性Server有兩層概念:socket

  1. 咱們的Server會一直等待客戶端的鏈接,一直到它正常創建鏈接,咱們的Server都幹不了其餘事情。
  2. 鏈接創建後,Server還會一直等待客戶端的發送或者Server會主動發送消息給客戶端

咱們直接看一下代碼:tcp

public class ServerSocketTest {

    public static void main(String[] args) throws IOException {
        ServerSocket serverSocket = new ServerSocket(8080);
        //這裏會阻塞一直到有鏈接創建
        Socket socket = serverSocket.accept();
        //這裏讀取由客戶端發過來的內容
        System.out.println(new BufferedReader(new InputStreamReader(socket.getInputStream())).readLine());
        socket.close();
        serverSocket.close();
    }

}

能夠看到我這裏有兩行註釋,第一個是接收客戶端的鏈接,這裏是會阻塞,直到創建鏈接纔會正常返回。而第二個則會讀取客戶端發過來的內容,這裏會一直阻塞到客戶端調用write發送完成爲止。所以這裏對應了咱們上面說的兩層阻塞概念。ide

阻塞性Client

阻塞性Client也有一樣的兩層概念:

  1. 當前Client會和服務端等待和客戶端的鏈接,正常創建鏈接後纔會返回
  2. 鏈接創建後,Client會發送消息給服務端,這裏會阻塞直到發送成功,而且等待服務端的返回,一樣也會阻塞直到返回。

咱們一樣看一下代碼:

public class ClientSocketTest {

    public static void main(String[] args) throws IOException {
        //創建和服務端的鏈接
        Socket socket = new Socket("localhost", 8080);
        //發送消息給服務端
        socket.getOutputStream().write("helloworld".getBytes());
        socket.close();
    }

}

這裏咱們演示發送消息給服務端。
單純說可能仍是比較難理解阻塞這個概念的,咱們能夠運行上面的示例。在Server中的讀取客戶端輸入行設置斷點,在Client中的發送消息設置斷點。按照如下的步驟進行調試

  1. 啓動Server
  2. 啓動Client
  3. 單步執行Server——這裏咱們能夠發現執行完後會卡住
  4. 單步執行Client——這裏咱們繼續執行到socket.close後,只有close後纔會真正把消息發送出去。
  5. 回到Server,咱們發現已經正常返回了。

從上面的現象,咱們能夠下結論,Server在讀取Client的發送數據時會阻塞,一直到收取消息完成,同理,Server在發送數據到Client時候也是同樣的,也是會阻塞直到發送完成。

痛定思痛

看完上面的阻塞性代碼,你有什麼想法呢?
想一想,假設若是咱們這樣寫代碼,有多個客戶端同時鏈接的時候,要怎麼搞呢?

第一個客戶端鏈接成功,發送完成消息,斷開
第二個客戶端鏈接
...

就這樣,活生生變成了順序化的程序了。

那咱們應該怎麼辦呢?總不能就這樣將就用吧,讓每一個用戶等其餘人用完,估計會被用戶錘出翔啊。
Image.png
這樣英年早逝還怎麼寫代碼呢?
聰明的程序員確定能想出辦法的。

阻塞的優化版

既然它阻塞住了,那我就把它放到另一個線程處理唄,怎麼搞都不關我事。
那麼又有了這樣一個優化版本
說是阻塞的優化版,固然仍是阻塞了,不要想着能玩出什麼花。

public class ServerSocketTest {

    public static void main(String[] args) throws IOException {
        ServerSocket serverSocket = new ServerSocket(8080);
        while(true) {
            new Thread(() -> {
                //這裏會阻塞一直到有鏈接創建
                Socket socket = null;
                try {
                    socket = serverSocket.accept();
                    //這裏讀取由客戶端發過來的內容
                    System.out.println(new BufferedReader(new InputStreamReader(socket.getInputStream())).readLine());
                    socket.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }).start();
        }
    }

}

這裏咱們看到咱們來了個while(true)這個很是嚇人的死循環,估計放其餘代碼裏面,頭都要被人打爆,但在這裏,是正常的,先不要激動。咱們這裏針對每個鏈接都起一個新的線程,這樣阻塞就不會影響到總體的運行了。
你們能夠再運行測試一下,看看是否是已經不會 阻塞 了。

  1. 運行Server
  2. 運行兩個Client,能夠設置斷點在大括號,模擬發送完消息暫停
  3. 查看Server的輸出

咱們能夠看到有兩個輸出:
Image2.png
這下牛叉了,不 阻塞 了。但真的OK嗎?

問題

咱們都知道,操做系統能夠啓用的線程數量是有限的,不能無限啓動,而且線程的上下文切換成本是很高的。若是不受限制地開線程,會致使系統CPU飆升,估計系統都會不可用。因此若是咱們用這種方式,假設有10個客戶端的時候,好像還沒啥事,但當去到100個,甚至500個的時候,估計系統都會開始運行緩慢了——咱們這種沒啥複雜業務的線程很快就結束了,對線程的佔用時間比較短,影響不算太大。但當業務複雜,每一個線程執行時間比較長的時候,就會出問題了。

阻塞的優化版2

從上面咱們瞭解到當線程數量一多的時候,就會致使系統出現各類各樣的問題。那應該怎麼辦呢?太多不行,那我限制一下總能夠了吧。我用線程池,限制能夠啓動的線程數量,這樣就不會由於線程數太多出問題了吧。

public class ServerSocketTest {

    private static final ExecutorService EXECUTOR_SERVICE = new ThreadPoolExecutor(5, 10, 3, TimeUnit.SECONDS,
            new ArrayBlockingQueue<>(10), r -> {
                Thread t = new Thread(r);
                t.setName("處理線程");
                return new Thread(r);
            });

    public static void main(String[] args) throws IOException {
        ServerSocket serverSocket = new ServerSocket(8080);
        while(true) {
            EXECUTOR_SERVICE.execute(() -> {
                //這裏會阻塞一直到有鏈接創建
                Socket socket = null;
                try {
                    socket = serverSocket.accept();
                    //這裏讀取由客戶端發過來的內容
                    System.out.println(new BufferedReader(new InputStreamReader(socket.getInputStream())).readLine());
                    socket.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            });
        }
    }

}

先來看一下,咱們的線程池定義,5,10,3,10,這幾個是什麼鬼東西,不知道的能夠看看ThreadPoolExecutorJavaDocDoug Lea大神寫得很是清楚了。我這裏大概描述下:

  1. 咱們定義了一個核心線程數爲5,最大線程數爲10,線程空閒時間爲3,線程隊列爲10的線程池
  2. 當咱們提交一個新的線程,正在運行的線程數未達到5時,則直接新建一個新的線程數
  3. 若是正在運行的線程數達到5了,看一下線程隊列有沒有滿,若是還未達到10,則放到線程隊列中
  4. 若是線程隊列也滿了,那咱們再看一下正在運行線程數有沒有達到最大的數量10了,若是還沒達到,則直接啓動一個新的
  5. 若是線程數已經達到了最大10了,則執行相應的rejectHandler,默認狀況下爲RejectedExecutionException,即當有新的任務提交時,直接拒絕執行。

注意,這裏的條件裏面的判斷條件都是運行的線程。不運行的是不算入數量裏面的。關於這個ThreadPoolExecutor也是塊硬骨頭,後面再詳細聊聊,咱們仍是回到正題的IO這裏。

這裏咱們用了一個線程池去執行咱們的socket鏈接後的處理邏輯——即咱們的阻塞讀取操做。那麼各個線程之間的阻塞就不會對其餘的線程形成影響。
但一樣的,有了線程池咱們就高枕無憂了嗎?
咱們看一下這裏咱們總的線程數是10(最大線程數量)+10(隊列數)=20,那假設20個線程都用完了,咱們的執行業務又須要去到幾秒鐘,那麼後面提交的就會被拒絕了。

有人說,那簡單,把線程數調大點,來個5000就行了。這。。。,估計沒仔細看前面的,回到前面看看,線程太大會致使切換損耗加大,對性能會有很大的影響。那不能調大線程數,那就加大隊列。呃,這也是能夠的,只是若是咱們的線程處理原本就慢,加大隊列只是徒增內存的壓力而已,並不會有任何用處。

那,咱們就沒辦法了嗎?乾瞪眼嗎?

程序員是不會認輸的。。。
Image3.png
因此纔有咱們這篇文章的NIO

NIO的橫空出世

NIO是啥東西來的?有些人叫New IO,都2020年了,這JDK1.5出的咱們還叫New IO,這想一想都感受怪怪的。實際上在當時剛出的時間來看,叫New IO是沒問題的,但慢慢隨着時間的推移,就不該該這樣的。而咱們看看New IO的引入主要解決了什麼問題——阻塞。因此,咱們把NIO稱爲Non-Blocking IO會更合適一點,即非阻塞IO

非阻塞就表明它不阻塞嗎?固然不是,NIO也是支持阻塞調用的,就跟回到解放前同樣,用着複雜NIOAPI幹着舊的java.io乾的事情。這好很差,相信你有本身的見解。

爲了區分前面的普通IO和咱們如今的NIO,咱們把以前的IO稱爲BIO,請你們注意。

NIO真的是非阻塞嗎?

前面咱們說了非阻塞不表明它就是原生非阻塞,你一樣能夠寫出阻塞的代碼。嗯,是的,咱們要回到解放前,來看看這種非通常的作法。

NIO版阻塞Server

阻塞版的NIO,服務端代碼咱們能夠看看。

public class BlockingMyServer {
    public static void main(String[] args) throws IOException {
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        serverSocketChannel.bind(new InetSocketAddress(8080));
        //這裏會阻塞一直到有鏈接創建
        SocketChannel socketChannel = null;
        try {
            socketChannel = serverSocketChannel.accept();
            //這裏讀取由客戶端發過來的內容
            ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
            int num = socketChannel.read(byteBuffer);
            System.out.println(new String(byteBuffer.array(), 0, num));
            socketChannel.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
        serverSocketChannel.close();
    }

}

從上面代碼咱們能夠看到,比正常的BIO代碼複雜了一些,主要是引入了一個新的ByteBuffer類。這個類是啥東西呢?後面咱們再看,咱們先來看看這段代碼跟以前的BIO的有什麼流程上的區別嗎?while(true)就不說了,只是寫法上的區別哈。咱們看到基本上大致流程一致:

  1. 綁定端口
  2. 讀取客戶端傳輸內容

NIO版阻塞Client

阻塞版的NIO客戶端代碼以下:

public class BlockingMyClient {

    public static void main(String[] args) throws IOException {
        //創建和服務端的鏈接
        SocketChannel socket = SocketChannel.open(new InetSocketAddress(8080));
        ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
        byteBuffer.put("helloworld".getBytes());
        byteBuffer.flip();
        //阻塞直到寫入成功
        socket.write(byteBuffer);
        socket.close();
    }

}

咱們能夠看到大致流程也跟BIO客戶端代碼相似。但一樣也有一個奇怪的ByteBuffer

NIO中的容器Buffer

咱們前面看到ServerClient都有一個ByteBuffer,這究竟是個啥玩意。
那接下來咱們一塊兒來看一下Buffer這個東西。
咱們首先能夠看到Buffer這個類的JavaDoc文檔的第一名話:

A container for data of a specific primitive type.
A buffer is a linear, finite sequence of elements of a specific primitive type.  Aside from its content, the essential properties of a buffer are its capacity, limit, and position

咱們能夠看到Buffer基礎類型容器,注意是基礎類型,而不是什麼自定義類型,而且它最重要的幾個屬性是capacitylimitposition,咱們來講一下這幾個概念:

  • capacity——容量
故名思義,容量是指當前這個 Buffer最大能容納的內容,好比 capacity是20,那麼最大就只能容納20個咱們 指定類型的數據。
  • limit——大小限制
limit可能理解起來會比較難,它表示的是可讀或可寫的限制位置。
  • position——可讀可寫的起始位置
每個操做都會有它的起始位置,如讀即讀的起始位置,寫即寫的起始位置。

咱們用一張圖來幫忙理解:
Image4.png
來源:http://tutorials.jenkov.com/j...

在上面的Write Mode中,只有在positionlimit中的空間是容許寫入,當大於limit,則會拋出BufferOverflowException
而對於Read Mode來講是相似的,只有在positionlimit中的空間是容許讀取的,當大於limit,則會拋bm BufferUnderflowException異常。
至於爲何這兩個異常不使用同一個,估計只有JSR的專家才能解釋了。

有了這部分知識的補充,咱們回到上面的場景,咱們爲何要調用flip呢,由於咱們put完數據 後,此時的position已是跟limit是在同一個位置了,若是咱們此時調用write,則會從當前的position繼續讀數據以經過socket傳輸,但這明顯是有問題的,後面並無任何數據 ,咱們須要把position置到從頭開始,而且其餘的limit也必須設置爲上次寫入的大小,由於須要調用flip
咱們直接看一下flip的代碼就能夠容易理解了:

public final Buffer flip() {
    limit = position;
    position = 0;
    mark = -1;
    return this;
}

把當前的位置,做爲可讀/可寫的限制,以後把位置置爲0,而把標記置爲未指定(-1)。挺好理解的。

多路複用(Multiplexing)

在真正開始非阻塞的探索前,咱們先來看看多路複用這個東西。
這概念相應你們都挺熟的,畢竟提到多路複用基本上就至關因而pollselectepoll
多路複用實際上有一個最大的好處:

系統負載小,由內核原生支持,不須要額外建立進程/線程。

說了這麼多,什麼叫多路複用呢?
多路複用的概念是這樣的:

有一個原生的進程能夠監視多個 描述符,一旦某個 描述符就緒,系統就能夠通知到應用程序,此時應用程序再根據相應的 描述符執行相應的 邏輯便可

那它又跟NIO有啥關係呢?
咱們前面說了這麼多,IO的阻塞的最主要的緣由就是不知道讀寫何時結束。若是系統告訴我,何時能夠讀寫,那麼我在那個合適的時候去作合適的事情,那不就很省事了。其餘時間該幹嗎幹嗎去。

NIO版非阻塞Server

前面咱們使用了NIO實現了阻塞版的Server,那感受真是酸爽,用一個原本不是這樣用的API,硬是這樣搞,太彆扭了。因此,下面咱們來實現一版正常的NIO非阻塞Server,這裏咱們要用到上面說的多路複用的知識。
多路複用的概念在NIO裏面的對應概念是Selector。咱們直接來看代碼:

public class MyServer {

    public static void main(String[] args) throws IOException {
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        serverSocketChannel.configureBlocking(false);
        serverSocketChannel.bind(new InetSocketAddress("localhost", 8001));

        Selector selector = Selector.open();
        serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);

        String str = "";
        while(!Thread.currentThread().isInterrupted()) {
            //這裏是一直阻塞,直到有描述符就緒
            selector.select();
            Set<SelectionKey> selectionKeys = selector.selectedKeys();
            Iterator<SelectionKey> keyIterator = selectionKeys.iterator();
            while(keyIterator.hasNext()) {
                SelectionKey key = keyIterator.next();
                keyIterator.remove();
                //鏈接創建
                if (key.isAcceptable()) {
                    try {
                        SocketChannel clientChannel = serverSocketChannel.accept();
                        clientChannel.configureBlocking(false);
                        clientChannel.register(selector, SelectionKey.OP_READ);
                    } catch (ClosedChannelException e) {
                        e.printStackTrace();
                    }
                }
                //鏈接可讀,這時能夠直接讀
                else if (key.isReadable()) {
                    ByteBuffer readBuffer = ByteBuffer.allocate(1024);

                    SocketChannel socketChannel = (SocketChannel) key.channel();
                    try {
                        int num = socketChannel.read(readBuffer);
                        str = new String(readBuffer.array(), 0, num);
                        System.out.println("received message:" + str);
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
            }
        }
    }

}

咱們能夠看到代碼比較複雜,先來理一下步驟:

  1. 打開ServerSocketChannel,監聽8001端口
  2. 使用configureBlocking(false) 設置channel爲非阻塞——關鍵
  3. 調用Selector.open打開Selector
  4. 註冊起始的描述符——通常狀況下確定是ACCEPT
  5. 調用select判斷是否有就緒的描述符,這裏阻塞的
  6. 使用selectKeys獲取就緒的描述符
  7. 遍歷selectKeys返回的描述符,進行相應的處理——這裏須要記得把處理完成的SelectionKey刪除掉,即remove
  8. 處理完成後須要從新註冊須要關注的描述符,即從新register對應的SelectionKey

咱們看到多路複用的實現代碼比較複雜,步驟也比原來的BIO的複製不少。但咱們須要看到這裏一個最大的進步就是由原來的等待處理變成了由系統來通知咱們去處理。而這裏的通知,咱們是經過selectKeys方法來實現的。

NIO非阻塞Client

咱們這裏的Client也是使用多路複用的方式來使用,咱們直接看一下代碼。

public class MyClient {

    public static void main(String[] args) throws IOException {
        SocketChannel socketChannel = SocketChannel.open();
        socketChannel.configureBlocking(false);
        socketChannel.connect(new InetSocketAddress("localhost", 8001));

        Selector selector = Selector.open();
        socketChannel.register(selector, SelectionKey.OP_CONNECT);
        while(!Thread.currentThread().isInterrupted()) {
            //阻塞直到有ready的SelectionKey返回
            selector.select();
            Set<SelectionKey> keys = selector.selectedKeys();
            Iterator<SelectionKey> keyIterator = keys.iterator();
            while(keyIterator.hasNext()) {
                SelectionKey key = keyIterator.next();
                keyIterator.remove();
                //鏈接已經創建了
                if (key.isConnectable()) {
                    try {
                        socketChannel.finishConnect();
                        //註冊寫描述符
                        socketChannel.register(selector, SelectionKey.OP_WRITE);
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
                //socket可寫,能夠發東西給服務端了
                else if (key.isWritable()) {
                    ByteBuffer writeBuffer = ByteBuffer.allocate(1024);
                    writeBuffer.put("hello world".getBytes());
                    try {
                        writeBuffer.flip();
                        socketChannel.write(writeBuffer);
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
            }
        }
    }
}

看完代碼,咱們梳理一下上面的步驟:

  1. 打開SocketChannel,鏈接8001端口
  2. 使用configureBlock(false)設置channel爲非阻塞——關鍵
  3. 調用Selector.open打開Selector
  4. 註冊起始的描述符——這裏客戶端須要註冊CONNECT
  5. 調用select判斷是否有就緒的描述符,這裏阻塞的
  6. 使用selectKeys獲取就緒的描述符
  7. 遍歷selectKeys返回的描述符,進行相應的處理——這裏須要記得把處理完成的SelectionKey刪除掉,即remove
  8. 處理完成後須要從新註冊須要關注的描述符,即從新register對應的SelectionKey

這裏咱們能夠看到步驟基本上跟服務端的步驟是一致的,只是初始的描述符不一致,serverACCEPT,而clientCONNECT

何謂非阻塞

咱們一直說非阻塞IO,那什麼算是非阻塞IO。而咱們前面的BIONIO最大區別也就是在對IO的處理上。

  • BIO

BIO使用的是直接調用讀/寫方法,一直到系統對其作出響應。

  • NIO

NIO使用的阻塞描述符(或者說信號),直到信號OK了——即咱們代碼裏面的select,直接返回,而後再進行處理,實際上在獲得描述符的時候仍是阻塞的,只是在真正執行讀/寫操做的時候,這個時候IO已是ready的狀態,這裏IO已經不是阻塞的狀態了。因此咱們這裏寫的非阻塞指的是IO,但描述符的獲取仍是阻塞的。

總結

說了這麼多,咱們對NIOBIO的一些介紹都已經基本上完了。如今基本上都比較少人直接使用NIOBIO進行編碼,都是經過netty或者其餘的一些高性能NIO框架來使用。——dubbo等在底層都使用了netty做爲網絡層框架。
後面咱們會找機會介紹一下nettyNIO的使用上給予咱們的一些便利,和它爲何更適合咱們使用。

參考文章

http://tutorials.jenkov.com/java-nio/buffers.html

相關文章
相關標籤/搜索