Java NIO 是一種非阻塞的、面向塊而非字節的 IO 方式。雖然 Java 的傳統 IO 也進行了一些基于 NIO 的改造,NIO 仍然能夠帶來許多優勢。
面向流的 IO 方便我們一個字節一個字節地處理數據,有利于實現過濾等功能,更加優雅和簡單。相應地,其速度通常比較慢。
Java NIO 的模型由三部分組成。Channel 通道,類似于傳統 IO 中的流,用來實際傳輸數據。
Buffer 緩沖,我們用來讀取和發送數據的位置。
Selector 選擇器,可以在一個線程上綁定多個 Channel 和對應的 Buffer 。
Channel
Channel 和流非常相似。區別是,通道支持異步讀寫,支持雙向讀寫,而且基于緩沖區。相比之下,流通常是單向同步讀寫的。
常用的 Channel 主要包括:FileChannel 文件
DatagramChannel UDP數據報
SocketChannel TCP 套接字
ServerSocketChannel TCP 服務端套接字
Buffer
Java 中的各種基本類型都有其對應的 Buffer ,最常用的是 ByteBuffer 。可以通過 Channel 或者手動寫入數據。
然后,要從 buffer 中讀取數據,需要首先 flip() 它,變成讀取模式。
需要注意的是,很多 Channel ,如 FileChannel 和非阻塞模式下的 SocketChannel 的 write 方法并不能保證將 buffer 全部寫入文件。因此,要使用循環來處理:
下面是一個簡單的輸出文件內容的示例:
InputStream 和 OutputStream 類也有類似的 getChannel 方法。當然,這樣開啟的通道只能是單向的。下面是一個將輸入內容傳輸到輸出的可復用的代碼片段:
Scatter / Gather
Scatter 和 Gather 可以譯為分散和聚集,指的是向同一個通道寫入和讀出多個 Buffer 的過程。在處理復雜結構的數據,如 Header-Content 時,有利于代碼整潔。Scatter Read 指從一個 Channel 讀取到多個 Buffer ,Gather Write 指從多個 Buffer 寫入到一個 Channel 。關于網絡的內容還會在后面進一步解釋。
網絡和異步 IO
異步 IO 的模式實際上來自于操作系統,如 Linux 的 IO 復用和 Windows 的 IOCP 。因此,類似的編程模式很可能也適用于其他語言。
TCP 異步 IO 的例子
異步 IO 不會阻塞,這也使得它可以處理許多的 IO 連接。在傳統 IO 下,這通常需要通過輪詢或多線程來實現。
首先回顧一下普通的 IO 編程中處理 TCP 連接的方法:ServerSocket 類監聽端口,客戶端的 Socket 類構造時發出連接請求。這時,ServerSocket.accept() 從阻塞中脫離,返回服務端的 Socket 對象。
然后,我們來看異步處理的方法:
這里的 SelectionKey.OP_ACCEPT 是適用于 ServerSocketChannel 的唯一事件,即 TCP 連接建立的事件。
select() 方法會阻塞直到有任何一個連接建立。selectedKeys 返回一個 Set 對象。在異步 IO 的類似處理過程中,由于我們已經通過 select 得到了這個連接信息,就不必再擔心 accept 會阻塞:
可以看到,我們將 accept 得到的結果重新放回了 selector 的監聽列表,并且將監聽事件修改為了 SelectionKey.OP_READ ,即有數據到達的事件。這個過程和傳統 IO 中從 ServerSocket.accept() 獲得 Socket 的過程類似。
然后,在 if 語句的另一個分支,我們來處理接收數據的過程。使用 channel() 方法得到雙向讀寫的通道對象。隨后,我們就可以使用之前熟悉的 buffer 來處理這個連接了。
最后,我們需要把處理結束的連接從 keys 中刪除,以免重復處理。在實際的應用場景中,我們還需要把關閉的連接從 Selector 中去除,并且很有可能使用多線程。
SelectionKey
上面我們見到了 OP_READ 和 OP_ACCEPT 。除此之外,NIO 還有 OP_WRITE 和 OP_CONNECT 兩種事件。可以認為每個事件代表“就緒”:例如當連接另一方傳來數據時,連接處于“讀就緒”狀態,對應事件 OP_READ 。因此,寫就緒和連接就緒這兩種事件并不常用。
四種事件的值分別為 1、2、8、16,因此可以使用位操作來處理這些事件。例如:
相應地,SelecttionKey 也提供了一些處理這些信息的方法。
還可以為每個 SectionKey 附加一個對象,以方便識別類似的對象。
Selector
除了 select() 方法外,Selector 類同樣提供了帶有超時的阻塞方法和非阻塞,允許返回 0 的方法。
如果在阻塞期間調用 Selector 的 wakeUp() 方法(當然,是在另一個線程里),線程會立刻放棄阻塞。在操作結束之后,需要使用 Selector.close() 方法。這將會使所有的 SelectionKey 都無效,但并不會關閉 Channel 。
異步 IO 設計
概述
阻塞 IO 和異步 IO 的區別是顯而易見的。阻塞 IO 是一種成功的設計,它能夠保證 IO 的可靠和簡單。但在這種模式下,每個 IO 都需要單獨的一個線程來處理。在 JVM 的默認參數下,32 bit 系統的一個棧為 320kB ,64 bit 下更達到了 1MB ,在高并發情況下這是完全無法接受的。線程池是解決這個問題的一種途徑,但當我們面臨大量低速長鏈接的時候,問題仍然沒有被徹底解決。而這正是大規模互聯網應用的常態。因此,異步 IO 成為了必然的選擇。異步 IO 的最典型特征是,每一次檢查不再是阻塞或獲得整塊數據,而是0或數據。這雖然解決了多線程的問題,卻帶來了另外一些需要解決的問題。
異步 IO 首先要解決的問題是,怎樣用一個線程處理許多連接。于是,我們有了 Selector ,使用一個 Selector 來處理許多連接,以實現“阻塞直到有一個”的效果,而不需要去處理那些尚未讀到數據的連接。于是,線程資源被充分地利用起來。
第二個問題是,由于所有的操作都被立即返回,異步 IO 下讀到的數據不總是完整的。甚至,在連續傳輸的情況下,幾乎總是不完整的。于是,我們需要做兩件事:判斷當前的數據是否是完整的
將不完整的數據暫存起來,以備下一次傳輸時拼合起來。
于是,我們在 Selector 與 Channel 之間加入一個 Message Reader,用來處理這些工作。在工程化的實踐中,我們可能希望這套系統能夠處理各種不同的協議。因此,可能會接收一個 Message Reader 的工廠作為參數,以進行依賴注入。
Message Reader 的實現
前面我們看到,Message Reader 需要能夠在內部的一個 Buffer 中存儲不完整的 Message 。顯而易見,這個 buffer 的大小應該等于消息的最大值。但這時我們又遇到了之前說的內存不足的問題:百萬級別的 1 MB buffer 意味著 1 TB 的 RAM 空間。因此,我們需要在這里使用可伸縮的(flexible)buffer 。
拷貝擴容
一種常見的方法是熟悉的拷貝擴容,也就是當 buffer 已滿后將所有內容復制到一個更大的數組中去。在這種方式下, threshold 的 選取就是一個重要的問題。例如,假設一個系統的請求消息不大于 4 kB ,傳輸的文件通常不大于 128 kB ,更大的文件則沒有規律性。那么,我們就會將 threshold 設置為 4kB 和 128 kB ,將最終的內存占用控制在 GB 級別。
追加擴容
另一種常見的方式是追加(append)擴容,方法是用一個列表將所有小的 buffer 片段集合起來,或者將一個大的數組分片,再用列表來管理分片。后者在內存模型上會更有利一些,但需要對并發量的準確判斷。追加擴容的缺點也很明顯,維護和讀取都比較復雜。
使用 TLV 消息
許多協議,包括 HTTP/2 在內,開始使用 TLV 格式的消息。TLV 指的是 Type-Length-Value 的元組。對于這類消息,我們可以在一開始就知道消息的長度,并為其開辟好內存空間,避免了上面的方式中對內存資源的浪費。
當然,TLV 格式也有其缺點。對于很長的 TLV 消息,我們就需要很大的內存空間的預開辟,這也為 DoS 攻擊提供了空間。一種解決方案是使用分段 TLV 的消息格式,但這并不能徹底解決問題。另一種方式是為消息設置超時時間。這樣,服務器至少能夠在一段時間的無響應后恢復。
寫不完整的消息
前面已經提到,非阻塞模式的通道并不能對一次 write() 實際寫入的數據量做出保證,而是將寫入的數據的字節數返回給調用者。于是,為了進一步解耦和提高效率,我們還需要在數據處理者和 Channel 同樣準備一個 Message Writer ,用來處理這個不穩定的輸出過程。
回過頭來想,我們在這里并不想為每個連接都維護一個線程。因此,我們只希望對有消息可寫的 Writer 進行處理。因此,我們使用這樣一個過程:
當 Message Writer 有消息可寫時,才將其對應的 Channel 注冊到 Selector 。然后,服務器在空閑時檢查 Selector 來獲取可寫的 Channel ,并尋找其對應的 Writer 以寫入數據。在 Writer 已經沒有數據可寫時,將 Channel 從 Selector 上解綁。
集成
現在我們已經理清了輸入和輸出兩個部分,現在我們從整個服務器的角度來思考。總的來說,一個服務器會執行這樣一個循環:
從 ServerSocket 中獲取 Socket => Select 讀事件 => 將接受的數據交給 Reader 來處理 => 在核心部分處理 Reader 傳來的完整數據 => 將處理后的數據交給 Writer => Select 寫事件
當然,這些功能還可以在多個線程內完成。