NIO的三大核心元件詳解,充分說明為什麼NIO在網路IO中擁有高效能!

JavaBuild發表於2024-07-22

一、寫在開頭

我們在上一篇博文中提到了Java IO中常見得三大模型(BIO,NIO,AIO),其中NIO是我們在日常開發中使用比較多的一種IO模型,我們今天就一起來詳細的學習一下。

在傳統的IO中,多以這種同步阻塞的IO模型為主,程式發起IO請求後,處理執行緒處於阻塞狀態,直到請求的IO資料從核心空間複製到使用者空間。如下圖可以直觀的體現整個流程(圖源:沉默王二)。

image

如果發起IO的應用程式併發量不高的情況下,這種模型是沒問題的。但很明顯,當前的網際網路中,很多應用都有高併發IO請求的情況,這時就迫切的需要一款高效的IO模型啦。

NIO中的這個N既可以命名為NEW代表一種新型的IO模型,又可以理解為Non-Blocking,非阻塞之意。Java NIO 是 Java 1.4 版本引入的,基於通道(Channel)和緩衝區(Buffer)進行操作,採用非阻塞式 IO 操作,允許執行緒在等待 IO 時執行其他任務。常見的 NIO 類有 ByteBuffer、FileChannel、SocketChannel、ServerSocketChannel 等。(圖源:深入拆解Tomcat & Jetty)

image

雖然在應用發起IO請求時,之多多次發起,無須阻塞。但在核心將資料複製到使用者空間時,還是會阻塞的,為了保證資料的準確性和系統的安全穩定。

二、NIO的三大元件

在計算機與外部通訊過程中,並非所有場景下NIO的效能都會好,對於連線少,併發地的應用系統中傳統的BIO效能反而更好,因為在NIO中應用程式需要不斷進行 I/O 系統呼叫輪詢資料是否已經準備好的過程是十分消耗 CPU 資源的。

image

為了更好的熟悉和掌握NIO,我們這裡從NIO的三大元件入手,這也是很多大廠面試官在面試時會問到的點,雖然頻率不高,但一定得會!

三個核心元件:

  • Selector(選擇器): 一種基於事件驅動的I/O多路複用模型,允許一個執行緒處理多個Channel,多個Channel註冊到一個Selector上,然後由Selector進行輪詢監聽每一個Channel的變化。
  • Channel(通道): 是一個雙向的,可讀可寫的資料傳輸管道,透過它來實現資料的輸入與輸出工作,它只負責運輸資料,不負責處理資料,處理資料在Buffer中。一般將管道分為檔案通道套接字通道
  • Buffer(緩衝區): NIO中資料的操作都是在緩衝區中完成的。讀操作是將Channel中運輸過來的資料填充到Buffer中;寫操作是將Buffer中的資料寫入到Channel中。

為了更好的理解NIO基於三大核心元件的執行流程,畫了一個思維導圖,如下:

image

三、元件詳解

下面,我們針對上一章總結的三大元件,進行一個個的詳細介紹。

3.1 Buffer(緩衝區)

在傳統的BIO中,資料的讀寫操作是基於流的,寫入採用輸入位元組流或字元流,而寫出採用都的是輸出位元組流或者字元流,本質上都是基於位元組的資料操作。而NIO庫中,採用的是緩衝區,無論是寫入還是寫出資料,都不會進入到緩衝區裡,由緩衝區進行下一步的操作。

image

上圖是Buffer子類的繼承關係結構圖,我們可以看到,在Buffer中命名是基於基本資料型別的,而我們在日常使用中,ByteBuffer緩衝類最多,它是基於位元組儲存的,這一點和流一樣。

而進入到這些緩衝類的內部夠,我們可以發現,其實它們就相當於一個陣列容器。在Buffer的原始碼中,有這樣的幾個引數:

public abstract class Buffer {
    // Invariants: mark <= position <= limit <= capacity
    private int mark = -1;
    private int position = 0;
    private int limit;
    private int capacity;
}

這四個成員變數的具體含義如下:

  1. 容量(capacity):Buffer可以儲存的最大資料量,Buffer建立時設定且不可改變;
  2. 界限(limit):Buffer 中可以讀/寫資料的邊界。寫模式下,limit 代表最多能寫入的資料,一般等於 capacity(可以透過limit(int newLimit)方法設定);讀模式下,limit 等於 Buffer 中實際寫入的資料大小;
  3. 位置(position):下一個可以被讀寫的資料的位置(索引)。從寫操作模式到讀操作模式切換的時候(flip),position 都會歸零,這樣就可以從頭開始讀寫了;
  4. 標記(mark):Buffer允許將位置直接定位到該標記處,這是一個可選屬性。

並且,上述變數滿足如下的關係:0 <= mark <= position <= limit <= capacity

這裡我們需要注意一點,Buffer擁有讀和寫兩種模式。Buffer被建立後,預設是寫模式 ,呼叫flip()可以切換到讀模式,再呼叫clear()或者compact()方法切換為寫模式。

image

1️⃣ Buffer的例項化

Buffer無法透過呼叫構造方法來建立物件,而是需要透過靜態方法進行例項化。我們以ByteBuffer為例:

// 分配堆記憶體,將緩衝區建立在JVM的記憶體中
public static ByteBuffer allocate(int capacity);
// 分配直接記憶體,將緩衝區建立在實體記憶體中,可以提交效率。但這裡的資料不會被垃圾回收,容易導致記憶體溢位。
public static ByteBuffer allocateDirect(int capacity);

2️⃣ Buffer的核心方法

Buffer中我們常用的方法有:

  1. get : 讀取緩衝區的資料;
  2. put :向緩衝區寫入資料;
  3. flip :將緩衝區從寫模式切換到讀模式,它會將 limit 的值設定為當前 position 的值,將 position 的值設定為 0;
  4. clear: 清空緩衝區,將緩衝區從讀模式切換到寫模式,並將 position 的值設定為 0,將 limit 的值設定為 capacity 的值。

image

3️⃣ Buffer的測試用例

基於以上的理論知識學習後,我們寫一個小的測試demo,來感受一下Buffer的使用。

【測試案例】

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

            // 分配一個容量為8的CharBuffer,預設為寫模式
            CharBuffer buffer = CharBuffer.allocate(8);
            System.out.println("起始狀態:");
            printState(buffer);

            // 向buffer寫入3個字元
            buffer.put('a').put('b').put('c');
            System.out.println("寫入3個字元後的狀態:");
            printState(buffer);

            // 呼叫flip()方法,切換為讀模式,
            // 準備讀取buffer中的資料,將 position 置 0,limit 置 3
            buffer.flip();
            System.out.println("呼叫flip()方法後的狀態:");
            printState(buffer);

            // 讀取字元
            //hasRemaining()方法用於判斷當前位置和限制之間是否有任何元素。
            //當且僅當此緩衝區中至少剩餘一個元素時,此方法才會返回true。
            while (buffer.hasRemaining()) {
                System.out.println("讀取字元:" + buffer.get());
            }
            // 呼叫clear()方法,清空緩衝區,將 position 的值置為 0,將 limit 的值置為 capacity 的值
            //呼叫clear()方法後,由讀模式切換為寫模式。
            buffer.clear();
            System.out.println("呼叫clear()方法後的狀態:");
            printState(buffer);

        }

        // 列印buffer的capacity、limit、position、mark的位置
        private static void printState(CharBuffer buffer) {
            //容量
            System.out.print("capacity: " + buffer.capacity());
            //界限
            System.out.print(", limit: " + buffer.limit());
            //下一個讀寫位置
            System.out.print(", position: " + buffer.position());
            //標記
            System.out.print(", mark 開始讀取的字元: " + buffer.mark());
            System.out.println("\n");
        }
}

【輸出:】

起始狀態:
capacity: 8, limit: 8, position: 0, mark 開始讀取的字元:         

寫入3個字元後的狀態:
capacity: 8, limit: 8, position: 3, mark 開始讀取的字元:      

呼叫flip()方法後的狀態:
capacity: 8, limit: 3, position: 0, mark 開始讀取的字元: abc

讀取字元:a
讀取字元:b
讀取字元:c

呼叫clear()方法後的狀態:
capacity: 8, limit: 8, position: 0, mark 開始讀取的字元: abc     

3.2 Channel(通道)

在上面的總結中,我們已經提過了,Channel作為一種雙向的資料通道,給外部屬於與程式之間搭建了一個傳輸的橋樑。讀操作的時候將 Channel 中的資料填充到 Buffer 中,而寫操作時將 Buffer 中的資料寫入到 Channel 中。甚至還可以同時讀寫!

image

Channel 的子類如下圖所示。
image

這裡雖然有很多通道類,但我們在日常生活中常用的,無非是 FileChannel:檔案訪問通道;SocketChannel、ServerSocketChannel:TCP 通訊通道;DatagramChannel:UDP 通訊通道;

FileChannel:用於檔案 I/O 的通道,支援檔案的讀、寫和追加操作。FileChannel 允許在檔案的任意位置進行資料傳輸,支援檔案鎖定以及記憶體對映檔案等高階功能。FileChannel 無法設定為非阻塞模式,因此它只適用於阻塞式檔案操作。

SocketChannel:用於 TCP 套接字 I/O 的通道。SocketChannel 支援非阻塞模式,可以與 Selector(下文會講)一起使用,實現高效的網路通訊。SocketChannel 允許連線到遠端主機,進行資料傳輸。

與之匹配的有ServerSocketChannel:用於監聽 TCP 套接字連線的通道。與 SocketChannel 類似,ServerSocketChannel 也支援非阻塞模式,並可以與 Selector 一起使用。ServerSocketChannel 負責監聽新的連線請求,接收到連線請求後,可以建立一個新的 SocketChannel 以處理資料傳輸。

DatagramChannel:用於 UDP 套接字 I/O 的通道。DatagramChannel 支援非阻塞模式,可以傳送和接收資料包包,適用於無連線的、不可靠的網路通訊。

1️⃣ Channel的核心方法

  1. read :讀取資料並寫入到 Buffer 中;
  2. write :將 Buffer 中的資料寫入到 Channel 中。

2️⃣ Channel的測試案例

RandomAccessFile reader = new RandomAccessFile("E:\\testChannel.txt", "r");
FileChannel channel = reader.getChannel();
ByteBuffer buffer = ByteBuffer.allocate(1024);
channel.read(buffer);
System.out.println("讀取字元:" + new String(buffer.array()));

image

3.3 Selector(選擇器)

選擇器的概念在上面已經介紹過了,我們現在主要介紹它的運作原理:
透過 Selector 註冊通道的事件,Selector 會不斷地輪詢註冊在其上的 Channel。當事件發生時,比如:某個 Channel 上面有新的 TCP 連線接入、讀和寫事件,這個 Channel 就處於就緒狀態,會被 Selector 輪詢出來。Selector 會將相關的 Channel 加入到就緒集合中。透過 SelectionKey 可以獲取就緒 Channel 的集合,然後對這些就緒的 Channel 進行相應的 I/O 操作。

主要監視事件型別:

  • SelectionKey.OP_ACCEPT:表示通道接受連線的事件,這通常用於 ServerSocketChannel;
  • SelectionKey.OP_CONNECT:表示通道完成連線的事件,這通常用於 SocketChannel;
  • SelectionKey.OP_READ:表示通道準備好進行讀取的事件,即有資料可讀;
  • SelectionKey.OP_WRITE:表示通道準備好進行寫入的事件,即可以寫入資料。

SelectionKey集合:

  • 所有的 SelectionKey 集合:代表了註冊在該 Selector 上的 Channel,這個集合可以透過 keys() 方法返回;
  • 被選擇的 SelectionKey 集合:代表了所有可透過 select() 方法獲取的、需要進行 IO 處理的 Channel,這個集合可以透過 selectedKeys() 返回;
  • 被取消的 SelectionKey 集合:代表了所有被取消註冊關係的 Channel,在下一次執行 select() 方法時,這些 Channel 對應的 SelectionKey 會被徹底刪除,程式通常無須直接訪問該集合,也沒有暴露訪問的方法。

Selector中的select()方法:

  • int select():監控所有註冊的 Channel,當它們中間有需要處理的 IO 操作時,該方法返回,並將對應的 SelectionKey 加入被選擇的 SelectionKey 集合中,該方法返回這些 Channel 的數量;
  • int select(long timeout):可以設定超時時長的 select() 操作;
  • int selectNow():執行一個立即返回的 select() 操作,相對於無引數的 select() 方法而言,該方法不會阻塞執行緒;
  • Selector wakeup():使一個還未返回的 select() 方法立刻返回。

【測試案例】

public static void main(String[] args) {
     try {
         //1、透過open()方法構建一個服務套接字通道
         ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
         serverSocketChannel.configureBlocking(false);
         //封裝8080埠
         serverSocketChannel.socket().bind(new InetSocketAddress(8080));

         //2、透過open方法構建一個選擇器物件
         Selector selector = Selector.open();
         // 將 ServerSocketChannel 註冊到 Selector 並監聽 OP_ACCEPT 事件
         serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);

         while (true) {
             //監聽已註冊的通道中是否有連線事件,並將對應的 SelectionKey 加入被選擇的 SelectionKey 集合中
             int readyChannels = selector.select();
             if (readyChannels == 0) {
                 continue;
             }
             //透過selectedKeys返回所有需要進行 IO 處理的 Channel
             Set<SelectionKey> selectedKeys = selector.selectedKeys();
             Iterator<SelectionKey> keyIterator = selectedKeys.iterator();

             while (keyIterator.hasNext()) {
                 SelectionKey key = keyIterator.next();
                 // 處理連線事件
                 if (key.isAcceptable()) {
                     ServerSocketChannel server = (ServerSocketChannel) key.channel();
                     SocketChannel client = server.accept();
                     client.configureBlocking(false);
                     // 將客戶端通道註冊到 Selector 並監聽 OP_READ 事件
                     client.register(selector, SelectionKey.OP_READ);
                 } else if (key.isReadable()) {
                     // 處理讀事件
                     SocketChannel client = (SocketChannel) key.channel();
                     ByteBuffer buffer = ByteBuffer.allocate(1024);
                     int bytesRead = client.read(buffer);
                     if (bytesRead > 0) {
                         buffer.flip();
                         System.out.println("收到資料:" +new String(buffer.array(), 0, bytesRead));
                         // 將客戶端通道註冊到 Selector 並監聽 OP_WRITE 事件
                         client.register(selector, SelectionKey.OP_WRITE);
                     } else if (bytesRead < 0) {
                         // 客戶端斷開連線
                         client.close();
                     }
                 } else if (key.isWritable()) {
                     // 處理寫事件,立刻返回結果
                     SocketChannel client = (SocketChannel) key.channel();
                     ByteBuffer buffer = ByteBuffer.wrap("Hello, Client!".getBytes());
                     client.write(buffer);

                     // 將客戶端通道註冊到 Selector 並監聽 OP_READ 事件
                     client.register(selector, SelectionKey.OP_READ);
                 }

                 keyIterator.remove();
             }
         }
     } catch (IOException e) {
         e.printStackTrace();
     }
 }

上面的程式碼建立了一個基於 Java NIO 的簡單 TCP 伺服器。它使用 ServerSocketChannel 和 Selector 實現了非阻塞 I/O 和 I/O 多路複用。伺服器迴圈監聽事件,當有新的連線請求時,接受連線並將新的 SocketChannel 註冊到 Selector,關注 OP_READ 事件。當有資料可讀時,從 SocketChannel 中讀取資料並寫入 ByteBuffer,然後將資料從 ByteBuffer 寫回到 SocketChannel。

四、總結

到這裡基本上就把NIO的幾個重要的元件介紹完啦,肯定不能面面俱到,大家想更多瞭解的,還是要多翻看不同的書籍。同時,後面我們將基於這部分內容,寫一個小型的聊天室。

相關文章