本文將介紹 Java NIO 中三大組件 Buffer、Channel、Selector 的使用。
Buffer
一個 Buffer 本質上是內存中的一塊,我們可以將數據寫入這塊內存,之後從這塊內存獲取數據。
java.nio 定義了以下幾個 Buffer 的實現。
image
其實核心是最後的 ByteBuffer,前面的一大串類只是包裝了一下它而已,我們使用最多的通常也是 ByteBuffer。
我們應該將 Buffer 理解為一個數組,IntBuffer、CharBuffer、DoubleBuffer 等分別對應 int[]、char[]、double[] 等。
MappedByteBuffer 用於實現內存映射文件,也不是本文關注的重點。
我覺得操作 Buffer 和操作數組、類集差不多,只不過大部分時候我們都把它放到了 NIO 的場景裡面來使用而已。下面介紹 Buffer 中的幾個重要屬性和幾個重要方法。
position、limit、capacity
就像數組有數組容量,每次訪問元素要指定下標,Buffer 中也有幾個重要屬性:position、limit、capacity。
image
最好理解的當然是 capacity,它代表這個緩衝區的容量,一旦設定就不可以更改。比如 capacity 為 1024 的 IntBuffer,代表其一次可以存放 1024 個 int 類型的值。一旦 Buffer 的容量達到 capacity,需要清空 Buffer,才能重新寫入值。
position 和 limit 是變化的,我們分別看下讀和寫操作下,它們是如何變化的。
position 的初始值是 0,每往 Buffer 中寫入一個值,position 就自動加 1,代表下一次的寫入位置。讀操作的時候也是類似的,每讀一個值,position 就自動加 1。
從寫操作模式到讀操作模式切換的時候(flip),position 都會歸零,這樣就可以從頭開始讀寫了。
Limit:寫操作模式下,limit 代表的是最大能寫入的數據,這個時候 limit 等於 capacity。寫結束後,切換到讀模式,此時的 limit 等於 Buffer 中實際的數據大小,因為 Buffer 不一定被寫滿了。
image
初始化 Buffer
每個 Buffer 實現類都提供了一個靜態方法 allocate(int capacity) 幫助我們快速實例化一個 Buffer。如:
另外,我們經常使用 wrap 方法來初始化一個 Buffer。
填充 Buffer
各個 Buffer 類都提供了一些 put 方法用於將數據填充到 Buffer 中,如 ByteBuffer 中的幾個 put 方法:
上述這些方法需要自己控制 Buffer 大小,不能超過 capacity,超過會拋
java.nio.BufferOverflowException 異常。
對於 Buffer 來說,另一個常見的操作中就是,我們要將來自 Channel 的數據填充到 Buffer 中,在系統層面上,這個操作我們稱為讀操作,因為數據是從外部(文件或網絡等)讀到內存中。
上述方法會返回從 Channel 中讀入到 Buffer 的數據大小。
提取 Buffer 中的值
前面介紹了寫操作,每寫入一個值,position 的值都需要加 1,所以 position 最後會指向最後一次寫入的位置的後面一個,如果 Buffer 寫滿了,那麼 position 等於 capacity(position 從 0 開始)。
如果要讀 Buffer 中的值,需要切換模式,從寫入模式切換到讀出模式。注意,通常在說 NIO 的讀操作的時候,我們說的是從 Channel 中讀數據到 Buffer 中,對應的是對 Buffer 的寫入操作,初學者需要理清楚這個。
調用 Buffer 的 flip() 方法,可以進行模式切換。其實這個方法也就是設置了一下 position 和 limit 值罷了。
對應寫入操作的一系列 put 方法,讀操作提供了一系列的 get 方法:
附一個經常使用的方法:
<code>new String(buffer.array()).trim(); /<code>
當然了,除了將數據從 Buffer 取出來使用,更常見的操作是將我們寫入的數據傳輸到 Channel 中,如通過 FileChannel 將數據寫入到文件中,通過 SocketChannel 將數據寫入網絡發送到遠程機器等。對應的,這種操作,我們稱之為寫操作。
<code>int num = channel.write(buf); /<code>
mark() & reset()
除了 position、limit、capacity 這三個基本的屬性外,還有一個常用的屬性就是 mark。
mark 用於臨時保存 position 的值,每次調用 mark() 方法都會將 mark 設值為當前的 position,便於後續需要的時候使用。
<code>public final Buffer mark() { mark = position; return this; } /<code>
那到底什麼時候用呢?考慮以下場景,我們在 position 為 5 的時候,先 mark() 一下,然後繼續往下讀,讀到第 10 的時候,我想重新回到 position 為 5 的地方重新來一遍,那隻要調一下 reset() 方法,position 就回到 5 了。
<code>public final Buffer reset() { int m = mark; if (m < 0) throw new InvalidMarkException(); position = m; return this; } /<code>
rewind() & clear() & compact()
rewind():會重置 position 為 0,通常用於重新從頭讀寫 Buffer。
<code>public final Buffer rewind() { position = 0; mark = -1; return this; } /<code>
clear():有點重置 Buffer 的意思,相當於重新實例化了一樣。
通常,我們會先填充 Buffer,然後從 Buffer 讀取數據,之後我們再重新往裡填充新的數據,我們一般在重新填充之前先調用 clear()。
<code>public final Buffer clear() { position = 0; limit = capacity; mark = -1; return this; } /<code>
compact():和 clear() 一樣的是,它們都是在準備往 Buffer 填充新的數據之前調用。
前面說的 clear() 方法會重置幾個屬性,但是我們要看到,clear() 方法並不會將 Buffer 中的數據清空,只不過後續的寫入會覆蓋掉原來的數據,也就相當於清空了數據了。
而 compact() 方法有點不一樣,調用這個方法以後,會先處理還沒有讀取的數據,也就是 position 到 limit 之間的數據(還沒有讀過的數據),先將這些數據移到左邊,然後在這個基礎上再開始寫入。很明顯,此時 limit 還是等於 capacity,position 指向原來數據的右邊。
Channel
所有的 NIO 操作始於通道,通道是數據來源或數據寫入的目的地,主要地,我們將關心 java.nio 包中實現的以下幾個 Channel:
image
FileChannel:文件通道,用於文件的讀和寫 DatagramChannel:用於 UDP 連接的接收和發送 SocketChannel:把它理解為 TCP 連接通道,簡單理解就是 TCP 客戶端 ServerSocketChannel:TCP 對應的服務端,用於監聽某個端口進來的請求
這裡不是很理解這些也沒關係,後面介紹了代碼之後就清晰了。還有,我們最應該關注,也是後面將會重點介紹的是 SocketChannel 和 ServerSocketChannel。
Channel 經常翻譯為通道,類似 IO 中的流,用於讀取和寫入。它與前面介紹的 Buffer 打交道,讀操作的時候將 Channel 中的數據填充到 Buffer 中,而寫操作時將 Buffer 中的數據寫入到 Channel 中。
image
至少讀者應該記住一點,這兩個方法都是 channel 實例的方法。
FileChannel
我想文件操作對於大家來說應該是最熟悉的,不過我們在說 NIO 的時候,其實 FileChannel 並不是關注的重點。而且後面我們說非阻塞的時候會看到,FileChannel 是不支持非阻塞的。
這裡算是簡單介紹下常用的操作吧,感興趣的讀者瞄一眼就是了。
初始化:
<code>FileInputStream inputStream = new FileInputStream(new File("/data.txt")); FileChannel fileChannel = inputStream.getChannel(); /<code>
當然了,我們也可以從 RandomAccessFile#getChannel 來得到 FileChannel。
讀取文件內容:
<code>ByteBuffer buffer = ByteBuffer.allocate(1024); int num = fileChannel.read(buffer); /<code>
前面我們也說了,所有的 Channel 都是和 Buffer 打交道的。
寫入文件內容:
<code>ByteBuffer buffer = ByteBuffer.allocate(1024); buffer.put("隨機寫入一些內容到 Buffer 中".getBytes()); // Buffer 切換為讀模式 buffer.flip(); while(buffer.hasRemaining()) { // 將 Buffer 中的內容寫入文件 fileChannel.write(buffer); } /<code>
SocketChannel
我們前面說了,我們可以將 SocketChannel 理解成一個 TCP 客戶端。雖然這麼理解有點狹隘,因為我們在介紹 ServerSocketChannel 的時候會看到另一種使用方式。
打開一個 TCP 連接:
<code>SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress("https://www.javadoop.com", 80)); /<code>
當然了,上面的這行代碼等價於下面的兩行:
<code>// 打開一個通道 SocketChannel socketChannel = SocketChannel.open(); // 發起連接 socketChannel.connect(new InetSocketAddress("https://www.javadoop.com", 80)); /<code>
SocketChannel 的讀寫和 FileChannel 沒什麼區別,就是操作緩衝區。
<code>// 讀取數據 socketChannel.read(buffer); // 寫入數據到網絡連接中 while(buffer.hasRemaining()) { socketChannel.write(buffer); } /<code>
不要在這裡停留太久,先繼續往下走。
ServerSocketChannel
之前說 SocketChannel 是 TCP 客戶端,這裡說的 ServerSocketChannel 就是對應的服務端。
ServerSocketChannel 用於監聽機器端口,管理從這個端口進來的 TCP 連接。
<code>// 實例化 ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); // 監聽 8080 端口 serverSocketChannel.socket().bind(new InetSocketAddress(8080)); while (true) { // 一旦有一個 TCP 連接進來,就對應創建一個 SocketChannel 進行處理 SocketChannel socketChannel = serverSocketChannel.accept(); } /<code>
這裡我們可以看到 SocketChannel 的第二個實例化方式
到這裡,我們應該能理解 SocketChannel 了,它不僅僅是 TCP 客戶端,它代表的是一個網絡通道,可讀可寫。
ServerSocketChannel 不和 Buffer 打交道了,因為它並不實際處理數據,它一旦接收到請求後,實例化 SocketChannel,之後在這個連接通道上的數據傳遞它就不管了,因為它需要繼續監聽端口,等待下一個連接。
DatagramChannel
UDP 和 TCP 不一樣,DatagramChannel 一個類處理了服務端和客戶端。
科普一下,UDP 是面向無連接的,不需要和對方握手,不需要通知對方,就可以直接將數據包投出去,至於能不能送達,它是不知道的
監聽端口:
<code>DatagramChannel channel = DatagramChannel.open(); channel.socket().bind(new InetSocketAddress(9090)); ByteBuffer buf = ByteBuffer.allocate(48); buf.clear(); /<code>
channel.receive(buf);
發送數據:
<code>String newData = "New String to write to file..." + System.currentTimeMillis(); ByteBuffer buf = ByteBuffer.allocate(48); buf.clear(); buf.put(newData.getBytes()); buf.flip(); int bytesSent = channel.send(buf, new InetSocketAddress("jenkov.com", 80)); /<code>
Selector
NIO 三大組件就剩 Selector 了,Selector 建立在非阻塞的基礎之上,大家經常聽到的 多路複用 在 Java 世界中指的就是它,用於實現一個線程管理多個 Channel。
讀者在這一節不能消化 Selector 也沒關係,因為後續在介紹非阻塞 IO 的時候還得說到這個,這裡先介紹一些基本的接口操作。
首先,我們開啟一個 Selector。你們愛翻譯成選擇器也好,多路複用器也好。
<code>Selector selector = Selector.open(); /<code>
將 Channel 註冊到 Selector 上。前面我們說了,Selector 建立在非阻塞模式之上,所以註冊到 Selector 的 Channel 必須要支持非阻塞模式,FileChannel 不支持非阻塞,我們這裡討論最常見的 SocketChannel 和 ServerSocketChannel。
register 方法的第二個 int 型參數(使用二進制的標記位)用於表明需要監聽哪些感興趣的事件,共以下四種事件:
SelectionKey.OP_READ 對應 00000001,通道中有數據可以進行讀取
SelectionKey.OP_WRITE 對應 00000100,可以往通道中寫入數據
SelectionKey.OP_CONNECT 對應 00001000,成功建立 TCP 連接
SelectionKey.OP_ACCEPT 對應 00010000,接受 TCP 連接
我們可以同時監聽一個 Channel 中的發生的多個事件,比如我們要監聽 ACCEPT 和 READ 事件,那麼指定參數為二進制的 00010001 即十進制數值 17 即可。
註冊方法返回值是 SelectionKey 實例,它包含了 Channel 和 Selector 信息,也包括了一個叫做 Interest Set 的信息,即我們設置的我們感興趣的正在監聽的事件集合。
調用 select() 方法獲取通道信息。用於判斷是否有我們感興趣的事件已經發生了。 Selector 的操作就是以上 3 步,這裡來一個簡單的示例,大家看一下就好了。之後在介紹非阻塞 IO 的時候,會演示一份可執行的示例代碼。
總結
到此為止,介紹了 Buffer、Channel 和 Selector 的常見接口。
Buffer 和數組差不多,它有 position、limit、capacity 幾個重要屬性。put() 一下數據、flip() 切換到讀模式、然後用 get() 獲取數據、clear() 一下清空數據、重新回到 put() 寫入數據。
Channel 基本上只和 Buffer 打交道,最重要的接口就是 channel.read(buffer) 和 channel.write(buffer)。
Selector 用於實現非阻塞 IO,這裡僅僅介紹接口使用,後續請關注非阻塞 IO 的介紹。
關鍵字: SocketChannel buffer limit