面試官:Java NIO 瞭解?

大家好,我是七哥,今天我們聊聊一個 Java 中的難點 NIO。

NIO(Non-blocking I/O,在 Java 領域,也稱爲 New I/O),是一種同步非阻塞的 I/O 模型,也是 I/O 多路複用的基礎,已經被越來越多地應用到大型應用服務器,成爲解決高併發與大量連接、I/O 處理問題的有效方式。

什麼是 NIO

之前我們也聊過了同步非阻塞 IO 的特點是,如果 TCP RecvBuffer 有數據,就把數據從網卡讀到內存,並且返回給用戶;反之則直接返回 0,永遠不會阻塞。

傳統的 BIO 裏面 socket.read(),如果 TCP RecvBuffer 裏沒有數據,線程會一直阻塞,直到用戶空間收到數據,才返回。

上圖紅色表示線程處於阻塞狀態,綠色表示線程處於非阻塞狀態。

最新的 AIO(Async I/O) 裏面會更進一步:不但等待就緒是非阻塞的,就連數據從網卡到內存的過程也是異步的。

換句話說,BIO 裏用戶最關心 “我要讀”,NIO 裏用戶最關心” 我可以讀了”,在 AIO 模型裏用戶更需要關注的是“讀完了”。

NIO 一個重要的特點是:socket 主要的讀、寫、註冊和接收函數,在等待就緒階段都是非阻塞的,真正的 I/O 操作是同步阻塞的(消耗 CPU 但性能非常高)。

使用非阻塞 IO

回憶 BIO 模型,之所以需要多線程,是因爲在進行 I/O 操作的時候,一是沒有辦法知道到底能不能寫、能不能讀,只能” 傻等”,即使通過各種估算,算出來操作系統沒有能力進行讀寫,線程也沒法在 socket.read() 和 socket.write() 函數中返回,這兩個函數無法進行有效的中斷。所以除了多開線程另起爐竈,沒有好的辦法利用 CPU。

下面我們先直接使用 java NIO 的非阻塞特性來實現一段代碼:

public class NioTest {

    public static void main(String[] args) throws IOException {
        //新接連池
        List<SocketChannel> socketChannelList = new ArrayList<>(8);
        //開啓服務端Socket
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        serverSocketChannel.bind(new InetSocketAddress(5555));
        //設置爲非阻塞
        serverSocketChannel.configureBlocking(false);
        while (true) {
            //探測新連接,由於設置了非阻塞,這裏即使沒有新連接也不會阻塞,而是直接返回null
            SocketChannel socketChannel = serverSocketChannel.accept();
            //當返回值不爲null的時候,證明存在新連接
            if (socketChannel != null) {
                System.out.println("新連接接入");
                //將客戶端設置爲非阻塞  這樣read/write不會阻塞
                socketChannel.configureBlocking(false);
                //將新連接加入到線程池
                socketChannelList.add(socketChannel);
            }
            //迭代器遍歷連接池
            Iterator<SocketChannel> iterator = socketChannelList.iterator();
            while (iterator.hasNext()) {
                ByteBuffer byteBuffer = ByteBuffer.allocate(128);
                SocketChannel channel = iterator.next();
                //讀取客戶端數據 當客戶端數據沒有寫入完成的時候也不會阻塞,長度爲0
                int read = channel.read(byteBuffer);

                if (read > 0) {
                    //當存在數據的時候打印數據
                    System.out.println(new String(byteBuffer.array()));
                } else if (read == -1) {
                    //客戶端退出的時候刪除該連接
                    iterator.remove();
                    System.out.println("斷開連接");
                }
            }
        }
    }
}

上述代碼我們可以看到一個關鍵的邏輯:

serverSocketChannel.configureBlocking(false);

這裏被設置爲非阻塞的時候無論是 accept 還是 read/write 都不會阻塞!

其實這裏我們只用了非阻塞的這一特性,並沒有使用 IO 多路複用中的選擇器,我們看一下這種實現邏輯有什麼問題!

表面上看,我們似乎的確使用了一條線程處理了所有的連接以及讀寫操作,但是假設我們有 10w 連接,活躍連接(經常 read/write)只有 1000,但是我們這個線程需要每次都輪詢 10w 條數據處理,極大的消耗了 CPU!是不是和 IO 多用複用中的 select/poll 實現相似呢,都是會做無用功。

我們期待什麼?期待的是,每次輪詢只輪詢有數據的 Channel, 沒有數據的就不管他(死循環 CPU 空跑也是浪費),比如剛剛的例子,只有 1000 個活躍連接,那麼每次就只輪詢這 1000 個,其他的有數據才輪詢,沒讀寫就不輪詢!同時這個死循環,

結合多路複用使用 NIO 同步非阻塞特性

多路複用模型是 JAVA NIO 推薦使用的經典模型,內部通過 Selector 進行事件選擇,Selector 事件選擇通過系統實現。

因爲 NIO 的讀寫函數可以立刻返回,這就給了我們不開線程利用 CPU 的最好機會:如果一個連接不能讀寫,即 socket.read() 返回 0 或者 socket.write() 返回 0,我們可以把這件事記下來,記錄的方式通常是在 Selector 上註冊標記位,然後切換到其它就緒的連接(channel)繼續進行讀寫。

下面具體看下如何利用事件模型單線程處理所有 I/O 請求:

NIO 的主要事件有幾個:讀就緒、寫就緒、有新連接到來

我們首先需要註冊當這幾個事件到來的時候所對應的處理器。然後在合適的時機告訴事件選擇器:我對這個事件感興趣。

對於寫操作,就是寫不出去的時候對寫就緒事件感興趣(可以寫了叫我);對於讀操作,就是完成連接和系統沒有辦法承載新讀入的數據時對讀就緒事件感興趣(可以讀了叫我);對於新連接到來,一般是服務器剛啓動的時候對這個事件感興趣(啓動後就等新連接到)。

其次,用一個死循環選擇就緒的事件,會執行系統調用(Linux 2.6 之前是 select、poll,2.6 之後是 epoll,Windows 是 IOCP),還會阻塞的等待新事件的到來。新事件到來的時候,會在selector上註冊標記位,標示可讀、可寫或者有連接到來。

注意,select 是阻塞的,無論是通過操作系統的通知(epoll)還是不停的輪詢 (select,poll),這個函數是阻塞的。所以你可以放心大膽地在一個 while(true) 裏面調用這個函數而不用擔心 CPU 空轉。

所以我們的程序大概的模樣是:

interface ChannelHandler{
    void channelReadable(Channel channel);
    void channelWritable(Channel channel);
}
class Channel{
 Socket socket;
    //讀,寫或者連接
    Event event;
}

//IO線程主循環:
class IoThread extends Thread{
   public void run(){
  Channel channel;
        //選擇就緒的事件和對應的連接
  while(channel=Selector.select()){
            //如果是新連接,則註冊一個新的讀寫處理器
   if(channel.event==accept){
          registerNewChannelHandler(channel);
        }
            //如果可以寫,則執行寫事件
         if(channel.event==write){
          getChannelHandler(channel).channelWritable(channel);
         }
            //如果可以讀,則執行讀事件
         if(channel.event==read){
          getChannelHandler(channel).channelReadable(channel);
         }
     }
    }
    //所有channel的對應事件處理器
   Map<Channel,ChannelHandler> handlerMap;
}

這個程序很簡短,也是最簡單的 Reactor 模式:註冊所有感興趣的事件處理器,單線程輪詢選擇就緒事件,執行事件處理器。

到這裏,我們就解決了上面的一個同步非阻塞 I/O 的痛點:CPU 總是在做很多無用的輪詢。在這個模型裏被解決了,這個模型從 selector 中獲取到的 Channel 全部是就緒的,也就是說他每次輪詢都不會做無用功!

Java NIO 都包含什麼

通過上面的學習,你應該已經知道了 Java NIO 有什麼用了,但是具體到寫代碼,我們還是要看下它都包含了哪些東西。

那麼 NIO 都提供了什麼呢?

  1. 基於緩衝區的雙向管道,Channel 和 Buffer;

  2. IO 多路複用器 Selector;

  3. 更爲易用的 API;

Buffer 的使用

在 NIO 中提供了各種不同的 Buffer,最常用的就是 ByteBuffer:

可以看到,Buffer 中有幾個比較重要的變量:

NIO 的 Buffer 有兩種模式,讀模式和寫模式。剛創建默認就是寫模式,使用 flip() 可以切換到讀模式。

關於這幾個位置的使用,可以參考下面的代碼,沒啥多說的,你跑一下就理解了!

public class ByteBufferTest {
    public static void main(String[] args) {

        ByteBuffer buffer = ByteBuffer.allocate(88);
        System.out.println(buffer);

        String value = "七哥測試NIO";
        buffer.put(value.getBytes());
        System.out.println(buffer);
        // 切換到讀模式
        buffer.flip();
        System.out.println(buffer);

        byte[] v = new byte[buffer.remaining()];
        buffer.get(v);

        System.out.println(buffer);
        System.out.println(new String(v));
    }
}

得到的輸出爲:

java.nio.HeapByteBuffer[pos=15 lim=88 cap=88]
java.nio.HeapByteBuffer[pos=lim=15 cap=88]
java.nio.HeapByteBuffer[pos=15 lim=15 cap=88]
七哥測試NIO

最後關於 ByteBuffer 在 Channel 中的使用,可以參考下面的代碼:

public class BufferTest {

    public static void main(String[] args) throws IOException {
        String file = "/Users/sevenluo/dev_tools/IdeaProjects/Java-Tutorial/netty-practice/src/main/resources/test.txt";
        RandomAccessFile accessFile = new RandomAccessFile(file,"rw");
        FileChannel fileChannel = accessFile.getChannel();
        // 20個字節
        ByteBuffer buffer = ByteBuffer.allocate(20);
        int bytesRead = fileChannel.read(buffer);
        // buffer.put()也能寫入buffer
        while(bytesRead!=-1){
            // 寫切換到讀
            buffer.flip();

            while(buffer.hasRemaining()){
                System.out.println((char)buffer.get());
            }
            // buffer.rewind() 重新讀
            // buffer.mark() 標記position
            // buffer.reset() 恢復
            // 清除緩衝區
            buffer.clear();
            // buffer.compact(); 清除讀過的數據
            System.out.println("=================");
            bytesRead = fileChannel.read(buffer);
        }
    }
}

這樣,就熟悉了 Channel 和 ByteBuffer 的使用。

Buffer 的選擇

通常情況下,操作系統的一次寫操作分爲兩步:1. 將數據從用戶空間拷貝到系統空間。2. 從系統空間往網卡寫。同理,讀操作也分爲兩步:1. 將數據從網卡拷貝到系統空間;2. 將數據從系統空間拷貝到用戶空間。

對於 NIO 來說,緩存的使用可以使用 DirectByteBuffer 和 HeapByteBuffer。如果使用了 DirectByteBuffer,一般來說可以減少一次系統空間到用戶空間的拷貝。但 Buffer 創建和銷燬的成本更高,更不宜維護,通常會用內存池來提高性能。

如果數據量比較小的中小應用情況下,可以考慮使用 HeapBuffer,反之可以用 DirectBuffer。

接下來,看看服務器中的具體應用吧。

NIO 在服務器端使用

之前介紹 BIO 的服務器,是來一個連接就創建一個新的線程響應。這裏基於 NIO 的多路複用,可以這樣寫:

public class NIOServerTest {

    public static void main(String[] args) throws IOException {
        //開啓服務端Socket
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        serverSocketChannel.bind(new InetSocketAddress(5555));
        //設置爲非阻塞
        serverSocketChannel.configureBlocking(false);
        //開啓一個選擇器
        Selector selector = Selector.open();
        serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
        while (true) {
            // 阻塞等待需要處理的事件發生
            selector.select();
            // 獲取selector中註冊的全部事件的 SelectionKey 實例
            Set<SelectionKey> selectionKeys = selector.selectedKeys();
            //獲取已經準備完成的key
            Iterator<SelectionKey> iterator = selectionKeys.iterator();
            while (iterator.hasNext()) {
                SelectionKey next = iterator.next();
                //當發現連接事件
                if (next.isAcceptable()) {
                    //獲取客戶端連接
                    SocketChannel socketChannel = serverSocketChannel.accept();
                    //設置非阻塞
                    socketChannel.configureBlocking(false);
                    //將該客戶端連接註冊進選擇器 並關注讀事件
                    socketChannel.register(selector, SelectionKey.OP_READ);
                    System.out.println("accepted connection from " + socketChannel);
                    //如果是讀事件
                } else if (next.isReadable()) {
                    ByteBuffer allocate = ByteBuffer.allocate(128);
                    //獲取與此key唯一綁定的channel
                    SocketChannel channel = (SocketChannel) next.channel();
                    //開始讀取數據
                    int read = channel.read(allocate);
                    if (read > 0) {
                        System.out.println(new String(allocate.array()));
                    } else if (read == -1) {
                        System.out.println("斷開連接");
                        channel.close();
                    }
                }
                //刪除這個事件
                iterator.remove();
            }
        }
    }
}

Java NIO 這個編程模型,抽象來看就是如下步驟:

  1. 創建 ServerSocketChannel 並綁定端口;

  2. 創建 Selector 多路複用器,並註冊 Channel;

  3. 循環監聽是否有感興趣的事件發生 selector.select();

  4. 獲得事件的句柄,並進行處理;

我們之前講 5 種網絡 IO 模型 時,具體聊過 IO 多路複用中的 epoll 模型,這裏 JavaNIO 對應 epoll 中的三個關鍵函數關係如下:

總結

使用 NIO != 高性能,當連接數 < 1000,併發程度不高或者局域網環境下 NIO 並沒有顯著的性能優勢。

NIO 並沒有完全屏蔽平臺差異,它仍然是基於各個操作系統的 I/O 系統實現的,差異仍然存在。使用 NIO 做網絡編程構建事件驅動模型並不容易,陷阱重重。

推薦大家使用成熟的 NIO 框架,如 Netty,MINA 等。解決了很多 NIO 的陷阱,並屏蔽了操作系統的差異,有較好的性能和編程模型。

最後總結一下到底 NIO 給我們帶來了些什麼:

作者介紹: 七哥,一個熱愛技術的程序員,寫文章也經常拍視頻,專注於 Java 技術乾貨分享,願望是陪家人平淡快樂的度過一生!

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/mZobf-U8OSYQfHfYBEB6KA