Java NIO之選擇器
1.簡介
前面的文章說了緩衝區,說了通道,本文就來說說 NIO 中另一個重要的實現,即選擇器 Selector。在更早的文章中,我簡述了幾種 IO 模型。如果大家看過之前的文章,並動手寫過程式碼的話。再看 Java 的選擇器大概就會知道它是什麼了,以及怎麼用了。選擇器是 Java 多路複用模型的一個實現,可以同時監控多個非阻塞套接字通道。示意圖大致如下:
如果大家瞭解過多路複用模型,那應該也會知道幾種複用模型的實現。比如 select,poll 以及 Linux 下的 epoll 和 BSD 下的 kqueue。Java 的選擇器並非憑空創造,而是在底層作業系統提供的介面的基礎上封裝而來。相關的細節,我隨後會進行分析。
關於 Java 選擇器的簡介這裡先說到這,接下來進入正題。
2.基本操作及實現
本章我將對 Selector 的建立,通道的註冊,Selector 的選擇過程進行分析。內容篇幅較大,希望大家耐心看完。由於 Selector 相關類在不同作業系統下的實現是不同的,加之個人對 Linux epoll 更為熟悉,所以本文所分析的原始碼也是和 epoll 相關的。好了,進入正題吧。
2.1 建立選擇器
選擇器 Selector 是一個抽象類,所以不能直接建立。Selector 提供了一個 open 方法,通過 open 方法既可以建立選擇器例項。示例程式碼如下:
|
|
上面的程式碼比較簡單,只有一行。不過不要被表象迷惑,這行程式碼僅是完整實現的冰山一角,更復雜的邏輯則隱藏在水面之下。
在簡介一節,我已經說了 Java 選擇器是對底層多路複用介面的一個包裝,這裡的 open 方法也不例外。假設我們的 Java 執行在 Linux 平臺下,那麼 open 最終所做的事情應該是呼叫作業系統的epoll_create
函式,用於建立 epoll 例項。真實情況是不是如此呢?答案就在冰山深處,接下來就讓我們一起去求索吧。下面我們將沿著 open 方法一路走下去,如下:
|
|
以上程式碼時 Java 層面的,Java 層呼叫棧最下面的類是 EPollArrayWrapper(原始碼路徑可以在附錄中查詢)。EPollArrayWrapper 是一個重要的實現,起著承上啟下的作用。上層是 Java 程式碼,下層是 C 程式碼。上層的程式碼看完了,接下來看看冰山深處的 C 程式碼:
|
|
上面的程式碼很簡單,僅做了建立 epoll 例項這一件事。看到這裡,答案就明瞭了。最後在附一張時序圖幫助大家理清程式碼呼叫順序,如下:
2.2 選擇鍵
2.2.1 幾種事件
選擇鍵 SelectionKey 包含4種事件,分別是:
|
|
事件之間可以通過或運算進行組合,比如:
|
|
2.2.2 兩種事件集合:interestOps 和 readyOps
interestOps 即感興趣的事件集合,通道呼叫 register 方法註冊時會設定此值,interestOps 可通過 SelectionKey interestOps() 方法獲取。readyOps 是就緒事件集合,可通過 SelectionKey readyOps() 獲取。
interestOps 和 readyOps 被宣告在 SelectionKey 子類 SelectionKeyImpl 中,程式碼如下:
|
|
接下來再來看看與 readyOps 事件集合相關的幾個方法,如下:
|
|
以上方法從字面意思上就可以知道有什麼用,這裡就不解釋了。接下來以 isReadable 方法為例,簡單看一下這個方法是如何實現。
|
|
上面說到可以通過或運算組合事件,這裡則是通過與運算來測試某個事件是否在事件集合中。比如
|
|
readyOps & OP_READ != 0
,所以 OP_READ 在事件集合中。readyOps & OP_CONNECT == 0
,所以 OP_CONNECT 不在事件集合中。
2.2.3 attach 方法
attach 是一個好用的方法,通過這個方法,可以將物件暫存在 SelectionKey 中,待需要的時候直接取出來即可。比如本文對應的練習程式碼實現了一個簡單的 HTTP 伺服器,在讀取使用者請求資料後(即 selectionKey.isReadable() 為 true),會去解析請求頭,然後將請求頭資訊通過 attach 方法放入 selectionKey 中。待通道可寫後,再從 selectionKey 中取出請求頭,並根據請求頭回復客戶端不同的訊息。當然,這只是一個應用場景,attach 可能還有其他的應用場景,比如標識通道。不過其他的場景我沒使用過,就不說了。attach 使用方式如下:
|
|
2.3 通道註冊
通道註冊即將感興趣的事件告知 Selector,待事件發生時,Selector 即可返回就緒事件,我們就可以去做後續的事情了。比如 ServerSocketChannel 通道通常對 OP_ACCEPT 事件感興趣,那麼我們就可以把這個事件註冊給 Selector。待事件發生,即服務端接受客戶端連線後,我們即可獲取這個就緒的事件並做相應的操作。通道註冊的示例程式碼如下:
|
|
起初我以為通道註冊操作會呼叫作業系統的 epoll_ctl 函式,但最終通過看原始碼,發現自己的理解是錯的。既然通道註冊階段不呼叫 epoll_ctl 函式。那麼,epoll_ctl 什麼時候才會被呼叫呢?如果不呼叫 epoll_ctl,那麼註冊過程都幹了什麼事情呢?關於第一個問題,本節還無法解答,不過第二個問題則可以說說。接下來讓我們深入通道類 register 方法的呼叫棧中去探尋答案吧。
|
|
到 setUpdateEvents 這個方法,整個呼叫棧就結束了。但是我們並未在呼叫棧中看到呼叫 epoll_ctl 函式的地方,也就是說,通道註冊時,並不會立即呼叫 epoll_ctl,而是先將事件集合 events 存放在 eventsLow。至於 epoll_ctl 函式何時呼叫的,需要大家繼續往下看了。
2.4 選擇過程
2.4.1 選擇方法
Selector 包含3種不同功能的選擇方法,分別如下:
- int select()
- int select(long timeout)
- int selectNow()
select() 是一個阻塞方法,僅在至少一個通道處於就緒狀態時才返回。
select(long timeout) 同樣也是阻塞方法,不過可對該方法設定超時時間(timeout > 0),使得執行緒不會被一直阻塞。如果 timeout = 0,會一直阻塞執行緒。
selectNow() 為非阻塞方法,呼叫後立即返回。
以上3個方法均返回 int 型別值,表示每次呼叫 select 或 selectNow 方法後,新就緒通道的數量。如果某個通道在上一次呼叫 select 方法時就已經處於就緒狀態,但並未將該通道對應的 SelectionKey 物件從 selectedKeys 集合中移除。假設另一個的通道在本次呼叫 select 期間處於就緒狀態,此時,select 返回1,而不是2。
2.4.2 選擇過程
選擇方法用起來雖然簡單,但方法之下隱藏的邏輯還是比較複雜的。大致分為下面幾個步驟:
- 檢查已取消鍵集合 cancelledKeys 是否為空,不為空則將 cancelledKeys 的鍵從 keys 和 selectedKeys 中移除,並將鍵和通道登出。
- 呼叫作業系統的 epoll_ctl 函式將通道感興趣的事件註冊到 epoll 例項中
- 呼叫作業系統的 epoll_wait 函式監聽事件
- 再次執行步驟1
- 更新 selectedKeys 集合,並返回就緒通道數量
上面五個步驟對應於 EPollSelectorImpl 類中 doSelect 方法的邏輯,如下:
|
|
接下來,我們按照上面的步驟順序去分析程式碼實現。先來看看步驟1對應的程式碼:
|
|
上面的程式碼程式碼邏輯不是很複雜,首先是獲取 cancelledKeys 集合,然後遍歷集合,並對每個選擇鍵及其對應的通道執行登出操作。接下來再來看看步驟2和3對應的程式碼,如下:
|
|
看到 updateRegistrations 方法的實現,大家現在知道 epoll_ctl 這個函式是在哪裡呼叫的了。在 3.2 節通道註冊的結尾給大家埋了一個疑問,這裡就是答案了。註冊通道實際上只是先將事件收集起來,等呼叫 select 方法時,在一起通過 epoll_ctl 函式將事件註冊到 epoll 例項中。
上面 epollCtl 和 epollWait 方法是 native 型別的,接下來我們再來看看這兩個方法是如何實現的。如下:
|
|
上面的C程式碼沒什麼複雜的邏輯,這裡就不多說了。如果大家對 epoll_ctl 和 epoll_wait 函式不瞭解,可以參考 Linux man-page。關於 epoll 的示例,也可以參考我的另一篇文章“基於epoll實現簡單的web伺服器”。
說完步驟2和3對應的程式碼,接下來再來說說步驟4和5。由於步驟4和步驟1是一樣的,這裡不再贅述。最後再來說說步驟5的邏輯。程式碼如下:
|
|
上面就是步驟5的邏輯了,簡單總結一下。首先是獲取就緒通道數量,然後再獲取這些就緒通道對應的檔案描述符 fd,以及就緒事件集合 rOps。之後呼叫 translateAndSetReadyOps 轉換並設定就緒事件集合。最後,將選擇鍵新增到 selectedKeys 集合中,並累加 numKeysUpdated 值,之後返回該值。
以上就是選擇過程的程式碼講解,貼了不少程式碼,可能不太好理解。Java NIO 和作業系統介面關聯比較大,所以在學習 NIO 相關原理時,也應該去了解諸如 epoll 等系統呼叫的知識。沒有這些背景知識,很多東西看起來不太好懂。好了,本節到此結束。
2.5 模板程式碼
使用 NIO 選擇器程式設計時,主幹程式碼的結構一般比較固定。所以把主幹程式碼寫好後,就可以往裡填業務程式碼了。下面貼一個服務端的模板程式碼,如下:
|
|
2.6 例項演示
原本打算將示例演示的程式碼放在本節中展示,奈何文章篇幅已經很大了,所以決定把本節的內容獨立成文。在下一篇文章中,我將會演示使用 Java NIO 完成一個簡單的 HTTP 伺服器。這裡先貼張效果圖,如下:
3.總結
到這裡,本文差不多就要結束了。原本只是打算簡單說說 Selector 的用法,然後再寫一份例項程式碼。但是後來發現這樣寫顯得比較空洞,沒什麼深度。所以後來翻了一下 Selector 的原始碼,大致理解了 Selector 的邏輯,然後就有了上面的分析。不過 Selector 的邏輯並不止我上面所說的那些,還有一些內容我現在還沒看,所以就沒有講。對於已寫出來的分析,由於我個人水平有限,難免會有錯誤。如果有錯誤,也歡迎大家指出來,共同進步!
好了,本文到此結束,感謝大家的閱讀。
參考
附錄
文中貼的一些程式碼是沒有包含在 JDK src.zip 包裡的,這裡單獨列舉出來,方便大家查詢。
檔名 | 路徑 |
---|---|
DefaultSelectorProvider.java | jdk/src/solaris/classes/sun/nio/ch/DefaultSelectorProvider.java |
EPollSelectorProvider.java | jdk/src/solaris/classes/sun/nio/ch/EPollSelectorProvider.java |
SelectorImpl.java | jdk/src/share/classes/sun/nio/ch/SelectorImpl.java |
EPollSelectorImpl.java | jdk/src/solaris/classes/sun/nio/ch/EPollSelectorImpl.java |
EPollArrayWrapper.java | jdk/src/solaris/classes/sun/nio/ch/EPollArrayWrapper.java |
SelectionKeyImpl.java | jdk/src/share/classes/sun/nio/ch/SelectionKeyImpl.java |
SocketChannelImpl.java | jdk/src/share/classes/sun/nio/ch/SocketChannelImpl.java |
EPollArrayWrapper.c | jdk/src/solaris/native/sun/nio/ch/EPollArrayWrapper.c |
from: http://www.tianxiaobo.com/2018/04/03/Java-NIO%E4%B9%8B%E9%80%89%E6%8B%A9%E5%99%A8/