在部落客不長的工作經曆中,NIO用的并不多,由于使用原生的Java NIO程式設計的複雜性,大多數時候我們會選擇Netty,mina等開源架構,但了解NIO的原理就不重要了嗎?恰恰相反,了解NIO底層機制是了解這一切的基礎,由此我總結一下當初學習NIO時的筆記,以便後續複習。
以下是我了解的Java原生NIO開發大緻流程:
上圖大緻描述的是服務端的NIO操作。
第一步,綁定一個服務的端口
這與傳統阻塞IO中的ServerSocket類似,沒什麼好說的
第二步,打開通道管理器Selector并在Selector上注冊一個事件
當注冊的事件發生時,Selector.select()會傳回,否則一直阻塞。這一步很有意思,也是NIO第一個與傳統IO不同的地方,NIO通過一個Selector線程可以管理大量用戶端連接配接,反之傳統IO一個用戶端連接配接進來必須建立一個新的線程為它服務(當然你可以使用連接配接池),我們知道線程對服務端來說是十分寶貴的資源,一個服務端程序所包含的線程是有 限的;此外,每個線程會占用一定的記憶體空間,過多的線程可能導緻記憶體溢出,這種情況下你可能會到想對虛拟機進行調優,比如通過修改參數-Xss限制單個線程大小,但這又可 能導緻StackOverFlow;另外,線程排程需要切換上下文,對于作業系統,它需要通過TCB(線程控制塊)來對線程進行排程,過多的上下文切換浪費了CPU時間,降低了系統效 率。
第三步,輪循通路Selector,當注冊的事件到達時,方法傳回
下面的代碼可以看到,方法整體是一個死循環,輪詢通路Selector,發生某些已經注冊在Selector上的事件時,該方法傳回。可以通過selector.selectedKeys擷取發生的事件,
該方法傳回的是一個泛型集合Set<SelectionKey>,周遊這個集合與周遊普通集合沒有什麼不同,這裡我們通過疊代器疊代的原因是我們需要删除已經處理的Key,避免重複處理:
public void listen() throws IOException {// 輪詢通路selector
while (true) {
// 當注冊的事件到達時,方法傳回;否則,該方法會一直阻塞
selector.select();
// 獲得selector中選中的項的疊代器,選中的項為注冊的事件
Iterator<?> ite = this.selector.selectedKeys().iterator();
while (ite.hasNext()) {
SelectionKey key = (SelectionKey) ite.next();
//删除已選的key,以防重複處理
ite.remove();
//這裡可以寫我們自己的處理邏輯
handle(key);
}
}
}
在第二步時,已經在Selector上注冊了Accept事件,當這裡的selector.select()傳回時,代表用戶端已經可以連接配接了,在handle方法裡可以處理這個事件:
public void handle(SelectionKey key) throws IOException {
// 用戶端請求連接配接事件
if (key.isAcceptable()){
//從Key裡可以很友善的取到注冊這個事件的Channel
ServerSocketChannel server = (ServerSocketChannel) key.channel();
// 獲得和用戶端連接配接的通道
SocketChannel channel = server.accept();
// 設定成非阻塞
channel.configureBlocking(false);
logger.info("用戶端已經連接配接!");
//用戶端連接配接在通道管理器Selector上注冊讀事件
channel.register(this.selector, SelectionKey.OP_READ);
}
}
上面的代碼很簡單,我們通過key擷取到注冊它的那個Channel,在這裡是ServerSocketChannel,通過server.accept()擷取用戶端連接配接,這裡同樣可以類比到傳統的阻塞
IO,在阻塞IO中我們可以通過ServerSocket.accept擷取到socket,唯一不同的是,阻塞IO中的accept方法是阻塞操作,而NIO中是非阻塞的。
當然,僅僅是連接配接到用戶端并沒有什麼用處,服務端需要有讀寫資料的能力,比如你可以用NIO實作一個Http伺服器(當然最佳實踐使用Netty等架構)。是以我們需要在Selector
上注冊讀事件,同樣,當讀事件發生時,執行我們自己的業務邏輯。下面是修改後的代碼:
public void handle(SelectionKey key) throws IOException {
if (key.isAcceptable()){
ServerSocketChannel server = (ServerSocketChannel) key.channel();
SocketChannel channel = server.accept();
channel.configureBlocking(false);
logger.info("用戶端已經連接配接!");
channel.register(this.selector, SelectionKey.OP_READ);
} else if(key.isReadable()){
SocketChannel channel = (SocketChannel) key.channel();
// 建立讀取緩沖
ByteBuffer buffer = ByteBuffer.allocate(1024);
//讀取到Buffer中
int read = channel.read(buffer);
if(read > 0){
byte[] data = buffer.array();
String msg = new String(data).trim();
logger.info("receive msg: {}",msg)
//回寫資料
ByteBuffer outBuffer = ByteBuffer.wrap("OK".getBytes());
channel.write(outBuffer);
}else{
logger.info("client closed!!!");
key.cancel();
}
}
}
總結
本文大緻講述了使用NIO進行伺服器端開發的大緻流程,但代碼顯然仍然存在問題,其一是我們隻使用了一個線程執行所有操作,包括接收用戶端連接配接,讀取資料,傳回資料,對于這個簡單的Demo來說已經足夠了,但在實際的伺服器開發中,例如你想使用NIO開發自己的HTTP伺服器,伺服器本地需要做大量操作,包括解析使用者請求,根據請求路由到某一個Action執行業務邏輯,這其中又很可能某些資料從資料庫讀取,渲染模闆等操作,十分耗時,這無疑又稱為系統的瓶頸,再者,使用單一線程不能充分利用多核CPU提供的計算能力。下一篇中會看到,在基于Reactor模型的Netty中,會使用一個Boss線程接收用戶端請求,使用多個Worker線程執行具體的業務邏輯。