Java NIO 入門
本文主要記錄 Java 中 NIO 相關的基礎知識點,以及基本的使用方式。
一、回顧傳統的 I/O
剛接觸 Java 中的 I/O 時,使用的傳統的 BIO 的 API。由於 BIO 設計的類實在太多,至今我仍然不能信手拈來的寫出完成的 BIO 的代碼。不過它基本的特點和分類,我還是記得一二的。
- 從方向上看,分為輸入流和輸出流;
- 從類別上看,分為字節流和字符流;
- 從緩沖去上看,分為帶緩沖的流和不帶緩沖的流。
一個便於使用的的流對象的構建,一般都是由相對底層的流逐漸構建出相對高級的流。通常我都是用 BIO 來做一些本地文件的 I/O 操作 。在網絡編程方面,BIO 因為其阻塞的原因,大家使用的都比較少,一般都使用 NIO,尤其是在服務端的網絡開發。強大的 Netty 正是基於 Java NIO 的基礎而開發出來的高性能框架,在學習 Netty 之前,很有必要去掌握 NIO 的基本使用。
二、NIO 底層 API
NIO 相關的核心概念有 3 個,Channel、Buffer 和 Selector。
2.1 Channel
Java 中傳統的 BIO 分為輸入流和輸出流,在同一個 Socket 連接或者文件的 IO 中,需要同時使用這兩種流才能進行數據的交互。而 NIO 則使用了 Channel 的概念,可以對 Channel 進行雙向操作。我們可以將數據寫入到 Channel,也可以從 Channel 中讀取數據。
Channel 的主要實現有以下 4 類:
- FileChannel
- DatagramChannel
- SocketChannel
- ServerSocketChannel
從名稱上看,就能知道這些類分別對應了文件、UDP、TCP(Client、Server)。
2.2 Buffer
Buffer 也就是緩沖區。它負責將 Channel 中的數據取出來(讀數據),或者將用戶程序的數據放入 Channel(寫數據)。如果將 Channel 比作是一架飛機及其航線,那麽 Buffer 就是航站樓與飛機之
間的擺渡車。下圖就是 Buffer 和 Channel 之間的交互:
Buffer 是用戶程序與 Channel 進行數據交互的工具。ByteBuffer 是 NIO 中最底層的實現,在此基礎上,還有 CharBuffer、DoubleBuffer 等。
ByteBuffer 實質上是維護了一個字節數組,它包含了一個幾個特殊的屬性:
- capacity:緩沖區的長度;
- position:下一個要操作元素的索引;
- limit:當前可操作元素的最大索引;
- mark:標記當前 position 的前 1 位。
具體怎麽用,可以查詢 JDK API,只要直到其他只屬性即可。
2.3 Selector
在學習了 Channel 和 Buffer 之後,已經可以使用這兩個類了進行阻塞式的 I/O 操作了。但是 Selector 才是 Java NIO 的核心優勢點。只有 ScoketChannel 才能設置為非阻塞模式,所以 Selector 只能在網絡 I/O 中才能使用。
Selector 的核心方法就是 select()方法,該方法會一直阻塞,直到註冊在該選擇器上的通道有用戶所感興趣的事件準備就緒了才會返回。這裏面又涉及到 Selector 與 Channel 之間的映射,這個關系用 SelectionKey 來表示。在調用選擇器的 select()方法前,用戶可以使用 Channel 的 register()方法,將其註冊到選擇器上,同時表明用戶對該通道的哪些操作感興趣。註意,register()方法返回的就是 SelectionKey 對象。
三、客戶端示例
3.1 客戶端示例:
1 public class Client { 2 private static final int REMOTE_PORT = 8888; 3 private static final String REMOTE_HOST = "127.0.0.1"; 4 private static final int BUFF_SIZE = 1024; 5 6 public static void main(String[] args) throws IOException { 7 Selector selector = Selector.open(); 8 SocketChannel socketChannel = 9 SocketChannel.open(new InetSocketAddress(REMOTE_HOST, REMOTE_PORT)); 10 //將套接字通道設置為非阻塞模式 11 socketChannel.configureBlocking(false); 12 //將通道註冊到 Selector 中,第二個參數為該通道感興趣的事件,此處為讀事件 13 //註冊方法會返回一個 SelectionKey 對象,它代表通道與選擇器之間的映射關系 14 socketChannel.register(selector, 15 SelectionKey.OP_READ, ByteBuffer.allocateDirect(BUFF_SIZE)); 16 if (!socketChannel.isConnected()) { 17 socketChannel.finishConnect(); 18 } 19 for (; ; ) { 20 //選擇器的 select()方法會阻塞到有通道所感興趣的事件已經就緒 21 if (selector.select() > 0) { 22 //調用選擇器的 selectedKeys() 方法會返回,本次所有就緒通道對應的 SelectionKey 集合 23 Iterator<SelectionKey> it = selector.selectedKeys().iterator(); 24 while (it.hasNext()) { 25 SelectionKey key = it.next(); 26 //通過叠代去刪除掉本次將要處理的 key 27 //如果不刪除,下次 select 還會返回該 key 28 it.remove(); 29 if (key.isReadable()) { 30 ByteBuffer bf = (ByteBuffer) key.attachment(); 31 bf.clear(); 32 SocketChannel sc = (SocketChannel) key.channel(); 33 int i = sc.read(bf); 34 if (i < 0) { 35 key.cancel(); 36 sc.close(); 37 return; 38 } 39 bf.flip(); 40 byte[] ret = new byte[bf.remaining()]; 41 bf.get(ret); 42 for (byte b : ret) { 43 System.out.print(b); 44 } 45 System.out.println(); 46 } 47 } 48 } 49 } 50 } 51 }
關於該示例的一些說明:
- 示例中將通道註冊到選擇器時,沒有註冊寫事件(15 行)。原因是當操作系統中發送數據的緩沖區未滿時,寫操作一般都是可用的。如果註冊了寫事件,由於寫事件一直是就緒的,那麽 select()方法會立刻返回,這就會導致程序的 CPU 使用率一路飆升;
- 示例中未包含向通道寫入數據的演示。 寫入數據可以直接將數據寫入通道,也可以使用通道註冊時返回的 key 來獲取通道,然後寫入數據到通道;
- 示例代碼中的 31 行,當讀取到的字節數為 -1 時,會讓人產生疑惑,既然這個通道被 select 出來了,那麽為什麽沒有數據可讀呢?有一種情況就是對端關閉了套接字連接,此時客戶端的 select()方法每次都會立刻返回,導致空輪詢。並且每次都能 select 出這個對端已經關閉了的通道。如果我們不關閉該通道,稍後可能就會拋出 IOException:遠程主機強迫關閉了一個現有的連接!JAVA NIO客戶端主動關閉連接,導致服務器空輪詢 - SegmentFault 思否
對於上面的第 3 點,之前遇到過一個問題,對端如果發現連接在指定的間隔內沒有數據通訊,就會關閉掉連接,這個時候我們也需要關閉對應的通道。下圖是抓包的結果:
3.2 服務端示例:
1 public class Server { 2 3 private static final int PORT = 8888; 4 5 public static void main(String[] args) throws IOException { 6 Selector selector = Selector.open(); 7 ServerSocketChannel ssc = ServerSocketChannel.open(); 8 ssc.configureBlocking(false); 9 ssc.socket().bind(new InetSocketAddress(PORT)); 10 //對可連接事件感興趣 11 ssc.register(selector, SelectionKey.OP_ACCEPT); 12 for (; ; ) { 13 if (selector.select() > 0) { 14 Iterator<SelectionKey> it = selector.selectedKeys().iterator(); 15 while (it.hasNext()) { 16 SelectionKey key = it.next(); 17 it.remove(); 18 if (key.isAcceptable()) { 19 //處理客戶端的連接 20 SocketChannel sc = ssc.accept(); 21 sc.configureBlocking(false); 22 sc.register(selector, SelectionKey.OP_READ); 23 } 24 if (key.isReadable()) { 25 //通過該 key 處理對應的讀事件 26 } 27 } 28 } 29 } 30 } 31 }
基於 Selector 的 NIO 模式,可以使用一個線程來處理大量的連接,優勢十分明顯。
四、總結
以上只是 Java 中 NIO 的粗略介紹,仍需進一步熟悉各個 API 的使用方法。在熟悉 NIO 之後,下一步準備學習一下 Netty 框架的使用。
準備使用 Netty 的原因:
- BIO 和 NIO 的 API 都很繁雜,使用起來十分的不優雅。尤其是在 BIO 和 NIO 之間切換的時候,幾乎是推到重建;
- NIO 還有空輪詢的 BUG;
- 實現穩定可靠的網絡 I/O 程序是一個極具挑戰的任務,這裏面涉及了很多的知識:計算機網絡、操作系統、程序設計等。
五、參考資料
- 攻破JAVA NIO技術壁壘 - CSDN博客 基礎知識介紹的很全面
- Java NIO服務器:遠程主機強迫關閉了一個現有的連接 - CSDN博客
- SocketChannel---各種註意點 - CSDN博客
Java NIO 入門