認真帶你深入理解 一次JAVA 中的 NIO

傳統的 IO 流還是有很多缺陷的,尤其它的阻塞性加上磁盤讀寫本來就慢,會導致 CPU 使用效率大大降低。

所以,jdk 1.4 發佈了 NIO 包,NIO 的文件讀寫設計顛覆了傳統 IO 的設計,採用通道+緩存區使得新式的 IO 操作直接面向緩存區,並且是非阻塞的,對於效率的提升真不是一點兩點,我們一起來看看。

通道 Channel

我們說過,NIO 的核心就是通道和緩存區,所以它們的工作模式是這樣的:

認真帶你深入理解 一次JAVA 中的 NIO

通道有點類似 IO 中的流,但不同的是,同一個通道既允許讀也允許寫,而任意一個流要麼是讀流要麼是寫流。

但是你要明白一點,通道和流一樣都是需要基於物理文件的,而每個流或者通道都通過文件指針操作文件,這裡說的「通道是雙向的」也是有前提的,那就是通道基於隨機訪問文件『RandomAccessFile』的可讀可寫文件指針。

『RandomAccessFile』是既可讀又可寫的,所以基於它的通道是雙向的,所以,「通道是雙向的」這句話是有前提的,不能斷章取義。

基本的通道類型有如下一些:

FileChannel

DatagramChannel

SocketChannel

ServerSocketChannel

FileChannel 是基於文件的通道,SocketChannel 和 ServerSocketChannel 用於網絡 TCP 套接字數據報讀寫,DatagramChannel 是用於網絡 UDP 套接字數據報讀寫。

通道不能單獨存在,它永遠需要綁定一個緩存區,所有的數據只會存在於緩存區中,無論你是寫或是讀,必然是緩存區通過通道到達磁盤文件,或是磁盤文件通過通道到達緩存區。

即緩存區是數據的「起點」,也是「終點」,具體這些通道到底有哪些不同以及該如何使用,基本實現如何,我們介紹完『緩存區』概念後,再做詳細學習。

緩存區 Buffer

Buffer 是所有具體緩存區的基類,是一個抽象類,它的實現類有很多,包含各種類型數據的緩存。

ByteBuffer

CharBuffer

ShortBuffer

IntBuffer

LongBuffer

FloatBuffer

DoubleBuffer

MappedByteBuffer

我們以 ByteBuffer 為例進行學習,其餘的緩存區也都是基於字節緩存區的,只不過多了一步字節轉換過程而已,MappedByteBuffer 是一個特殊的緩存方式,我們會單獨介紹。

Buffer 中有幾個重要的成員屬性,我們瞭解一下:

認真帶你深入理解 一次JAVA 中的 NIO

mark 屬性我們已經不陌生了,用於重複讀。capacity 描述緩存區容量,即整個緩存區最大能存儲多少數據量。address 用於操作直接內存,區別於 jvm 內存,這一點待會說明。

而 position 和 limit 我想用一張圖結合解釋:

認真帶你深入理解 一次JAVA 中的 NIO

由於緩存區是讀寫共存的,所以不同的模式下,這兩個變量的值也具有不同的意義。

寫模式下,所謂寫模式就是將緩存區中的內容寫入通道。position 代表下一個字節應該被寫出去的字節在緩存區中的位置,limit 表示最後一個待寫字節在緩存區的位置。

讀模式下,所謂讀模式就是從通道讀取數據到緩存區。position 代表下一個讀出來的字節應當存儲在緩存區的位置,limit 等於 capacity。

相關的讀寫操作細節,待會會和大家一起看源碼,以加深對通道和緩存區協作工作的原理,這裡我們先討論一個大家可能沒怎麼關注過的一個問題。

JVM 內存劃分為棧和堆,這是大家深入腦海的知識,但是其實劃分給 JVM 的還有一塊堆外內存,也就是直接內存,很多人不知道這塊內存是幹什麼用的。

這是一塊物理內存,專門用於 JVM 和 IO 設備打交道,Java 底層使用 C 語言的 API 調用操作系統與 IO 設備進行交互。

例如,Java 內存中有一個字節數組,現在調用流將它寫入磁盤文件,那麼 JVM 首先會將這個字節數組先拷貝一份到堆外內存中,然後調用 C 語言 API 指明將某個連續地址範圍的數據寫入磁盤。

讀操作也是類似,而 JVM 額外做的拷貝工作也是有意義的,因為 JVM 是基於自動垃圾回收機制運行的,所有內存中的數據會在 GC 時不停的被移動,如果你調用系統 API 告訴操作系統將內存某某位置的內存寫入磁盤,而此時發生 GC 移動了該部分數據,GC 結束後操作系統是不是就寫錯數據了。

所以,JVM 對於與外圍 IO 設備交互的情況下,都會將內存數據複製一份到堆外內存中,然後調用系統 API 間接的寫入磁盤,讀也是類似的。由於堆外內存不受 GC 管理,所以用完一定得記得釋放。

理解這一個小知識是看懂源碼實現的前提,不然你可能不知道代碼實現者在做什麼。好了,那我們就先來看看讀操作的基本使用與源碼實現。

認真帶你深入理解 一次JAVA 中的 NIO

我們看這麼一段代碼,這段代碼我大致分成了四個部分,第一部分用於獲取文件通道,第二部分用於分配緩存區並完成讀操作,第三部分用於將緩存區中數據進行打印,第四部分為關閉通道連接。

第一部分:

getChannel 方法用於獲取一個文件相關的通道實例,具體實現如下:

認真帶你深入理解 一次JAVA 中的 NIO

認真帶你深入理解 一次JAVA 中的 NIO

getChannel 方法會調用 FileChannelImpl 的工廠方法構建一個 FileChannelImpl 實例,FileChannelImpl 是抽象類 FileChannel 的一個子類實現。

構成 FileChannelImpl 實例所需的必要參數有,該文件的文件指針,該文件的完整路徑,讀寫權限等。

第二部分:

Buffer 的基本結構我們上述已經簡單介紹了,這裡不再贅述了,所謂的緩存區,本質上就是字節數組。

認真帶你深入理解 一次JAVA 中的 NIO

ByteBuffer 實例的構建是通過工廠模式產生的,必須指定參數 capacity 作為內部字節數組的容量。HeapByteBuffer 是虛擬機的堆上內存,所有數據都將存儲在堆空間,我們不久將會介紹它的一個兄弟,DirectByteBuffer,它被分配在堆外內存中,具體的一會說。

這個 HeapByteBuffer 的構造情況我們不妨跟進去看看:

認真帶你深入理解 一次JAVA 中的 NIO

調用父類的構造方法,初始化我們在 ByteBuffer 中提過的一些屬性值,如 position,capacity,mark,limit,offset 以及字節數組 hb。

接著,我們看看這個 read 方法的調用鏈。

認真帶你深入理解 一次JAVA 中的 NIO

這個 read 方法是子類 FileChannelImpl 對父類 FileChannel read 方法的重寫。這個方法不是讀操作的核心,我們簡單概括一下,該方法首先會拿到當前通道實例的鎖,如果沒有被其他線程佔有,那麼佔有該鎖,並調用 IOUtil 的 read 方法。

認真帶你深入理解 一次JAVA 中的 NIO

IOUtil 的 read 方法內部也調用了很多方法,有的甚至是本地方法,這裡只簡單介紹一下整個 read 方法的大體邏輯,具體細節留待大家自行學習。

首先判斷我們的 ByteBuffer 實例是不是一個 DirectBuffer,也就是判斷當前的 ByteBuffer 實例是不是被分配在直接內存中,如果是,那麼將調用readIntoNativeBuffer 方法從磁盤讀取數據直接放入 ByteBuffer 實例所在的直接內存中。

否則,虛擬機將在直接內存區域分配一塊內存,該內存區域的首地址存儲在 var5 實例的 address 屬性中。

接著從磁盤讀取數據放入 var5 所代表的直接內存區域中。

最後,put 方法會將 var5 所代表的直接內存區域中的數據寫入到 var1 所代表的堆內緩存區並釋放臨時創建的直接內存空間。

這樣,我們傳入的緩存區中就成功的被讀入了數據。寫操作是相反的,大家可以自行類比,反正堆內數據想要到達磁盤就必定要經過堆外內存的複製過程。

第三第四部分比較簡單,這裡不再贅述了。提醒一下,想要更好的使用這個通道和緩存區進行文件讀寫操作,你就一定得對緩存區的幾個變量的值時刻把握住,position 和 limit 當前的值是什麼,大致什麼位置,一定得清晰,否則這個讀寫共存的緩存區可能會讓你暈頭轉向。

選擇器 Selector

Selector 是 Java NIO 的一個組件,它用於監聽多個 Channel 的各種狀態,用於管理多個 Channel。但本質上由於 FileChannel 不支持註冊選擇器,所以 Selector 一般被認為是服務於網絡套接字通道的。

而大家口中的「NIO 是非阻塞的」,準確來說,指的是網絡編程中客戶端與服務端連接交換數據的過程是非阻塞的。普通的文件讀寫依然是阻塞的,和 IO 是一樣的,這一點可能很多初學者會懵,包括我當時也總想不通為什麼說 NIO 的文件讀寫是非阻塞的,明明就是阻塞的。

認真帶你深入理解 一次JAVA 中的 NIO

創建一個選擇器一般是通過 Selector 的工廠方法,Selector.open :

認真帶你深入理解 一次JAVA 中的 NIO

而一個通道想要註冊到某個選擇器中,必須調整模式為非阻塞模式,例如:

認真帶你深入理解 一次JAVA 中的 NIO

以上代碼是註冊一個通道到選擇器中的最簡單版本,支持註冊選擇器的通道都有一個 register 方法,該方法就是用於註冊當前實例通道到指定選擇器的。

該方法的第一個參數就是目標選擇器,第二個參數其實是一個二進制掩碼,它指明當前選擇器感興趣當前通道的哪些事件。以枚舉類型提供了以下幾種取值:

int OP_READ = 1 << 0;

int OP_WRITE = 1 << 2;

int OP_CONNECT = 1 << 3;

int OP_ACCEPT = 1 << 4;

這種用二進制掩碼來表示某些狀態的機制,我們在講述虛擬機類類文件結構的時候也遇到過,它就是用一個二進制位來描述一種狀態。

register 方法會返回一個 SelectionKey 實例,該實例代表的就是選擇器與通道的一個關聯關係。你可以調用它的 selector 方法返回當前相關聯的選擇器實例,也可以調用它的 channel 方法返回當前關聯關係中的通道實例。

除此之外,SelectionKey 的 readyOps 方法將返回當前選擇感興趣當前通道中事件中準備就緒的事件集合,依然返回的一個整型數值,也就是一個二進制掩碼。

例如:

認真帶你深入理解 一次JAVA 中的 NIO

假如 readySet 的值為 13,二進制 「0000 1101」,從後向前數,第一位為 1,第三位為 1,第四位為 1,那麼說明選擇器關聯的通道,讀就緒、寫就緒,連接就緒。

所以,當我們註冊一個通道到選擇器之後,就可以通過返回的 SelectionKey 實例監聽該通道的各種事件。

當然,一旦某個選擇器中註冊了多個通道,我們不可能一個一個的記錄它們註冊時返回的 SelectionKey 實例來監聽通道事件,選擇器應當有方法返回所有註冊成功的通道相關的 SelectionKey 實例。

認真帶你深入理解 一次JAVA 中的 NIO

selectedKeys 方法會返回選擇器中註冊成功的所有通道的 SelectionKey 實例集合。我們通過這個集合的 SelectionKey 實例,可以得到所有通道的事件就緒情況並進行相應的處理操作。

下面我們以一個簡單的客戶端服務端連接通訊的實例應用一下上述理論知識:

認真帶你深入理解 一次JAVA 中的 NIO

服務端代碼:

這段小程序的運行的實際效果是這樣的,客戶端建立請求到服務端,待請求完全建立,客戶端會去檢查服務端是否有數據寫回,而服務端的任務就很簡單了,接受任意客戶端的請求連接併為它寫回一段數據。

別看整個過程很簡單,但只要你有一點模糊的地方,你這個功能就不可能實現,不信你試試,尤其是加了選擇器的客戶端代碼,更值得大家一行一行分析。提醒一點的是,大家應更多的關注於哪些方法是阻塞的,哪些是非阻塞的,這會有助於分析代碼。

這其實也算一個最最簡單的服務器客戶端請求模型了,理解了這一點相信會有助於理解瀏覽器與 Web 服務器的工作原理的,這裡我就不再帶大家分析了,有任何不同看法的也歡迎給我留言,咱們一起學習探討。

想必你也能發現,加了選擇器的代碼會複雜很多,也並不一定高效於原來的代碼,這其實是因為你的功能比較簡單,並不涉及大量通道處理,邏輯一旦複雜起來,選擇器給你帶來的好處會非常明顯。

其實,NIO 中還有一塊 AIO ,也就是異步 IO 並沒有介紹,因為異步 IO 涉及到很多其他方面知識,這裡暫時不做介紹,後續文章將單獨介紹異步任務等相關內容。


分享到:


相關文章: