一、寫在開頭
我們在上一篇博文中提到了Java IO中常見得三大模型(BIO,NIO,AIO),其中NIO是我們在日常開發中使用比較多的一種IO模型,我們今天就一起來詳細的學習一下。
在傳統的IO中,多以這種同步阻塞的IO模型為主,程式發起IO請求後,處理執行緒處於阻塞狀態,直到請求的IO資料從核心空間複製到使用者空間。如下圖可以直觀的體現整個流程(圖源:沉默王二)。
如果發起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)
雖然在應用發起IO請求時,之多多次發起,無須阻塞。但在核心將資料複製到使用者空間時,還是會阻塞的,為了保證資料的準確性和系統的安全穩定。
二、NIO的三大元件
在計算機與外部通訊過程中,並非所有場景下NIO的效能都會好,對於連線少,併發地的應用系統中傳統的BIO效能反而更好,因為在NIO中應用程式需要不斷進行 I/O 系統呼叫輪詢資料是否已經準備好的過程是十分消耗 CPU 資源的。
為了更好的熟悉和掌握NIO,我們這裡從NIO的三大元件入手,這也是很多大廠面試官在面試時會問到的點,雖然頻率不高,但一定得會!
三個核心元件:
- Selector(選擇器): 一種基於事件驅動的I/O多路複用模型,允許一個執行緒處理多個Channel,多個Channel註冊到一個Selector上,然後由Selector進行輪詢監聽每一個Channel的變化。
- Channel(通道): 是一個雙向的,可讀可寫的資料傳輸管道,透過它來實現資料的輸入與輸出工作,它只負責運輸資料,不負責處理資料,處理資料在Buffer中。一般將管道分為檔案通道和套接字通道。
- Buffer(緩衝區): NIO中資料的操作都是在緩衝區中完成的。讀操作是將Channel中運輸過來的資料填充到Buffer中;寫操作是將Buffer中的資料寫入到Channel中。
為了更好的理解NIO基於三大核心元件的執行流程,畫了一個思維導圖,如下:
三、元件詳解
下面,我們針對上一章總結的三大元件,進行一個個的詳細介紹。
3.1 Buffer(緩衝區)
在傳統的BIO中,資料的讀寫操作是基於流的,寫入採用輸入位元組流或字元流,而寫出採用都的是輸出位元組流或者字元流,本質上都是基於位元組的資料操作。而NIO庫中,採用的是緩衝區,無論是寫入還是寫出資料,都不會進入到緩衝區裡,由緩衝區進行下一步的操作。
上圖是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;
}
這四個成員變數的具體含義如下:
- 容量(capacity):Buffer可以儲存的最大資料量,Buffer建立時設定且不可改變;
- 界限(limit):Buffer 中可以讀/寫資料的邊界。寫模式下,limit 代表最多能寫入的資料,一般等於 capacity(可以透過limit(int newLimit)方法設定);讀模式下,limit 等於 Buffer 中實際寫入的資料大小;
- 位置(position):下一個可以被讀寫的資料的位置(索引)。從寫操作模式到讀操作模式切換的時候(flip),position 都會歸零,這樣就可以從頭開始讀寫了;
- 標記(mark):Buffer允許將位置直接定位到該標記處,這是一個可選屬性。
並且,上述變數滿足如下的關係:0 <= mark <= position <= limit <= capacity
。
這裡我們需要注意一點,Buffer擁有讀和寫兩種模式。Buffer被建立後,預設是寫模式 ,呼叫flip()可以切換到讀模式,再呼叫clear()或者compact()方法切換為寫模式。
1️⃣ Buffer的例項化
Buffer無法透過呼叫構造方法來建立物件,而是需要透過靜態方法進行例項化。我們以ByteBuffer為例:
// 分配堆記憶體,將緩衝區建立在JVM的記憶體中
public static ByteBuffer allocate(int capacity);
// 分配直接記憶體,將緩衝區建立在實體記憶體中,可以提交效率。但這裡的資料不會被垃圾回收,容易導致記憶體溢位。
public static ByteBuffer allocateDirect(int capacity);
2️⃣ Buffer的核心方法
Buffer中我們常用的方法有:
- get : 讀取緩衝區的資料;
- put :向緩衝區寫入資料;
- flip :將緩衝區從寫模式切換到讀模式,它會將 limit 的值設定為當前 position 的值,將 position 的值設定為 0;
- clear: 清空緩衝區,將緩衝區從讀模式切換到寫模式,並將 position 的值設定為 0,將 limit 的值設定為 capacity 的值。
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 中。甚至還可以同時讀寫!
Channel 的子類如下圖所示。
這裡雖然有很多通道類,但我們在日常生活中常用的,無非是 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的核心方法
- read :讀取資料並寫入到 Buffer 中;
- 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()));
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的幾個重要的元件介紹完啦,肯定不能面面俱到,大家想更多瞭解的,還是要多翻看不同的書籍。同時,後面我們將基於這部分內容,寫一個小型的聊天室。