原文鏈接:深入理解 JAVA 中的 NIO
作者:OSC_爛豬皮
轉載請私信,不經(jīng)授權不得轉載
傳統(tǒng)的 IO 流還是有很多缺陷的庶近,尤其它的阻塞性加上磁盤讀寫本來就慢,會導致 CPU 使用效率大大降低。
所以博秫,jdk 1.4 發(fā)布了 NIO 包边灭,NIO 的文件讀寫設計顛覆了傳統(tǒng) IO 的設計异希,采用通道+緩存區(qū)使得新式的 IO 操作直接面向緩存區(qū),并且是非阻塞的绒瘦,對于效率的提升真不是一點兩點称簿,我們一起來看看扣癣。
通道 Channel
我們說過,NIO 的核心就是通道和緩存區(qū)予跌,所以它們的工作模式是這樣的:
通道有點類似 IO 中的流搏色,但不同的是,同一個通道既允許讀也允許寫券册,而任意一個流要么是讀流要么是寫流频轿。
但是你要明白一點,通道和流一樣都是需要基于物理文件的航邢,而每個流或者通道都通過文件指針操作文件,這里說的「通道是雙向的」也是有前提的岔激,那就是通道基于隨機訪問文件『RandomAccessFile』的可讀可寫文件指針。
『RandomAccessFile』是既可讀又可寫的匾七,所以基于它的通道是雙向的,所以夺颤,「通道是雙向的」這句話是有前提的,不能斷章取義案疲。
基本的通道類型有如下一些:
FileChannel
DatagramChannel
SocketChannel
ServerSocketChannel
FileChannel 是基于文件的通道备畦,SocketChannel 和 ServerSocketChannel 用于網(wǎng)絡 TCP 套接字數(shù)據(jù)報讀寫糕档,DatagramChannel 是用于網(wǎng)絡 UDP 套接字數(shù)據(jù)報讀寫。
通道不能單獨存在,它永遠需要綁定一個緩存區(qū)坞淮,所有的數(shù)據(jù)只會存在于緩存區(qū)中啡直,無論你是寫或是讀舷丹,必然是緩存區(qū)通過通道到達磁盤文件,或是磁盤文件通過通道到達緩存區(qū)诅蝶。
即緩存區(qū)是數(shù)據(jù)的「起點」筐眷,也是「終點」照棋,具體這些通道到底有哪些不同以及該如何使用,基本實現(xiàn)如何武翎,我們介紹完『緩存區(qū)』概念后烈炭,再做詳細學習。
緩存區(qū) Buffer
Buffer 是所有具體緩存區(qū)的基類宝恶,是一個抽象類符隙,它的實現(xiàn)類有很多,包含各種類型數(shù)據(jù)的緩存垫毙。
ByteBuffer
CharBuffer
ShortBuffer
IntBuffer
LongBuffer
FloatBuffer
DoubleBuffer
MappedByteBuffer
我們以 ByteBuffer 為例進行學習霹疫,其余的緩存區(qū)也都是基于字節(jié)緩存區(qū)的,只不過多了一步字節(jié)轉換過程而已综芥,MappedByteBuffer 是一個特殊的緩存方式丽蝎,我們會單獨介紹。
Buffer 中有幾個重要的成員屬性膀藐,我們了解一下:
mark 屬性我們已經(jīng)不陌生了屠阻,用于重復讀。capacity 描述緩存區(qū)容量额各,即整個緩存區(qū)最大能存儲多少數(shù)據(jù)量国觉。address 用于操作直接內(nèi)存,區(qū)別于 jvm 內(nèi)存虾啦,這一點待會說明蛉加。
而 position 和 limit 我想用一張圖結合解釋:
由于緩存區(qū)是讀寫共存的蚜枢,所以不同的模式下,這兩個變量的值也具有不同的意義针饥。
寫模式下厂抽,所謂寫模式就是將緩存區(qū)中的內(nèi)容寫入通道。position 代表下一個字節(jié)應該被寫出去的字節(jié)在緩存區(qū)中的位置丁眼,limit 表示最后一個待寫字節(jié)在緩存區(qū)的位置筷凤。
讀模式下,所謂讀模式就是從通道讀取數(shù)據(jù)到緩存區(qū)苞七。position 代表下一個讀出來的字節(jié)應當存儲在緩存區(qū)的位置藐守,limit 等于 capacity。
相關的讀寫操作細節(jié)蹂风,待會會和大家一起看源碼卢厂,以加深對通道和緩存區(qū)協(xié)作工作的原理,這里我們先討論一個大家可能沒怎么關注過的一個問題惠啄。
JVM 內(nèi)存劃分為棧和堆慎恒,這是大家深入腦海的知識,但是其實劃分給 JVM 的還有一塊堆外內(nèi)存撵渡,也就是直接內(nèi)存融柬,很多人不知道這塊內(nèi)存是干什么用的。
這是一塊物理內(nèi)存趋距,專門用于 JVM 和 IO 設備打交道粒氧,Java 底層使用 C 語言的 API 調(diào)用操作系統(tǒng)與 IO 設備進行交互。
例如节腐,Java 內(nèi)存中有一個字節(jié)數(shù)組外盯,現(xiàn)在調(diào)用流將它寫入磁盤文件,那么 JVM 首先會將這個字節(jié)數(shù)組先拷貝一份到堆外內(nèi)存中翼雀,然后調(diào)用 C 語言 API 指明將某個連續(xù)地址范圍的數(shù)據(jù)寫入磁盤饱苟。
讀操作也是類似,而 JVM 額外做的拷貝工作也是有意義的锅纺,因為 JVM 是基于自動垃圾回收機制運行的,所有內(nèi)存中的數(shù)據(jù)會在 GC 時不停的被移動肋殴,如果你調(diào)用系統(tǒng) API 告訴操作系統(tǒng)將內(nèi)存某某位置的內(nèi)存寫入磁盤囤锉,而此時發(fā)生 GC 移動了該部分數(shù)據(jù),GC 結束后操作系統(tǒng)是不是就寫錯數(shù)據(jù)了护锤。
所以官地,JVM 對于與外圍 IO 設備交互的情況下,都會將內(nèi)存數(shù)據(jù)復制一份到堆外內(nèi)存中烙懦,然后調(diào)用系統(tǒng) API 間接的寫入磁盤驱入,讀也是類似的。由于堆外內(nèi)存不受 GC 管理,所以用完一定得記得釋放亏较。
理解這一個小知識是看懂源碼實現(xiàn)的前提莺褒,不然你可能不知道代碼實現(xiàn)者在做什么。好了雪情,那我們就先來看看讀操作的基本使用與源碼實現(xiàn)遵岩。
我們看這么一段代碼,這段代碼我大致分成了四個部分巡通,第一部分用于獲取文件通道尘执,第二部分用于分配緩存區(qū)并完成讀操作,第三部分用于將緩存區(qū)中數(shù)據(jù)進行打印宴凉,第四部分為關閉通道連接誊锭。
第一部分:
getChannel 方法用于獲取一個文件相關的通道實例,具體實現(xiàn)如下:
getChannel 方法會調(diào)用 FileChannelImpl 的工廠方法構建一個 FileChannelImpl 實例弥锄,F(xiàn)ileChannelImpl 是抽象類 FileChannel 的一個子類實現(xiàn)丧靡。
構成 FileChannelImpl 實例所需的必要參數(shù)有,該文件的文件指針叉讥,該文件的完整路徑窘行,讀寫權限等。
第二部分:
Buffer 的基本結構我們上述已經(jīng)簡單介紹了图仓,這里不再贅述了罐盔,所謂的緩存區(qū),本質(zhì)上就是字節(jié)數(shù)組救崔。
ByteBuffer 實例的構建是通過工廠模式產(chǎn)生的惶看,必須指定參數(shù) capacity 作為內(nèi)部字節(jié)數(shù)組的容量。HeapByteBuffer 是虛擬機的堆上內(nèi)存六孵,所有數(shù)據(jù)都將存儲在堆空間纬黎,我們不久將會介紹它的一個兄弟,DirectByteBuffer劫窒,它被分配在堆外內(nèi)存中本今,具體的一會說。
這個 HeapByteBuffer 的構造情況我們不妨跟進去看看:
調(diào)用父類的構造方法主巍,初始化我們在 ByteBuffer 中提過的一些屬性值冠息,如 position,capacity孕索,mark逛艰,limit,offset 以及字節(jié)數(shù)組 hb搞旭。
接著散怖,我們看看這個 read 方法的調(diào)用鏈菇绵。
這個 read 方法是子類 FileChannelImpl 對父類 FileChannel read 方法的重寫。這個方法不是讀操作的核心镇眷,我們簡單概括一下咬最,該方法首先會拿到當前通道實例的鎖,如果沒有被其他線程占有偏灿,那么占有該鎖丹诀,并調(diào)用 IOUtil 的 read 方法。
IOUtil 的 read 方法內(nèi)部也調(diào)用了很多方法翁垂,有的甚至是本地方法铆遭,這里只簡單介紹一下整個 read 方法的大體邏輯,具體細節(jié)留待大家自行學習沿猜。
首先判斷我們的 ByteBuffer 實例是不是一個 DirectBuffer枚荣,也就是判斷當前的 ByteBuffer 實例是不是被分配在直接內(nèi)存中,如果是啼肩,那么將調(diào)用readIntoNativeBuffer 方法從磁盤讀取數(shù)據(jù)直接放入 ByteBuffer 實例所在的直接內(nèi)存中橄妆。
否則,虛擬機將在直接內(nèi)存區(qū)域分配一塊內(nèi)存祈坠,該內(nèi)存區(qū)域的首地址存儲在 var5 實例的 address 屬性中害碾。
接著從磁盤讀取數(shù)據(jù)放入 var5 所代表的直接內(nèi)存區(qū)域中。
最后赦拘,put 方法會將 var5 所代表的直接內(nèi)存區(qū)域中的數(shù)據(jù)寫入到 var1 所代表的堆內(nèi)緩存區(qū)并釋放臨時創(chuàng)建的直接內(nèi)存空間慌随。
這樣,我們傳入的緩存區(qū)中就成功的被讀入了數(shù)據(jù)躺同。寫操作是相反的阁猜,大家可以自行類比,反正堆內(nèi)數(shù)據(jù)想要到達磁盤就必定要經(jīng)過堆外內(nèi)存的復制過程蹋艺。
第三第四部分比較簡單剃袍,這里不再贅述了。提醒一下捎谨,想要更好的使用這個通道和緩存區(qū)進行文件讀寫操作民效,你就一定得對緩存區(qū)的幾個變量的值時刻把握住,position 和 limit 當前的值是什么涛救,大致什么位置畏邢,一定得清晰,否則這個讀寫共存的緩存區(qū)可能會讓你暈頭轉向州叠。
選擇器 Selector
Selector 是 Java NIO 的一個組件棵红,它用于監(jiān)聽多個 Channel 的各種狀態(tài)凶赁,用于管理多個 Channel咧栗。但本質(zhì)上由于 FileChannel 不支持注冊選擇器逆甜,所以 Selector 一般被認為是服務于網(wǎng)絡套接字通道的。
而大家口中的「NIO 是非阻塞的」致板,準確來說交煞,指的是網(wǎng)絡編程中客戶端與服務端連接交換數(shù)據(jù)的過程是非阻塞的。普通的文件讀寫依然是阻塞的斟或,和 IO 是一樣的素征,這一點可能很多初學者會懵,包括我當時也總想不通為什么說 NIO 的文件讀寫是非阻塞的萝挤,明明就是阻塞的御毅。
創(chuàng)建一個選擇器一般是通過 Selector 的工廠方法,Selector.open :
而一個通道想要注冊到某個選擇器中怜珍,必須調(diào)整模式為非阻塞模式端蛆,例如:
以上代碼是注冊一個通道到選擇器中的最簡單版本,支持注冊選擇器的通道都有一個 register 方法酥泛,該方法就是用于注冊當前實例通道到指定選擇器的今豆。
該方法的第一個參數(shù)就是目標選擇器,第二個參數(shù)其實是一個二進制掩碼柔袁,它指明當前選擇器感興趣當前通道的哪些事件呆躲。以枚舉類型提供了以下幾種取值:
int OP_READ = 1 << 0;
int OP_WRITE = 1 << 2;
int OP_CONNECT = 1 << 3;
int OP_ACCEPT = 1 << 4;
這種用二進制掩碼來表示某些狀態(tài)的機制,我們在講述虛擬機類類文件結構的時候也遇到過捶索,它就是用一個二進制位來描述一種狀態(tài)插掂。
register 方法會返回一個 SelectionKey 實例,該實例代表的就是選擇器與通道的一個關聯(lián)關系情组。你可以調(diào)用它的 selector 方法返回當前相關聯(lián)的選擇器實例燥筷,也可以調(diào)用它的 channel 方法返回當前關聯(lián)關系中的通道實例。
除此之外院崇,SelectionKey 的 readyOps 方法將返回當前選擇感興趣當前通道中事件中準備就緒的事件集合肆氓,依然返回的一個整型數(shù)值,也就是一個二進制掩碼底瓣。
例如:
假如 readySet 的值為 13谢揪,二進制 「0000 1101」,從后向前數(shù)捐凭,第一位為 1拨扶,第三位為 1,第四位為 1茁肠,那么說明選擇器關聯(lián)的通道患民,讀就緒、寫就緒垦梆,連接就緒匹颤。
所以仅孩,當我們注冊一個通道到選擇器之后,就可以通過返回的 SelectionKey 實例監(jiān)聽該通道的各種事件印蓖。
當然辽慕,一旦某個選擇器中注冊了多個通道,我們不可能一個一個的記錄它們注冊時返回的 SelectionKey 實例來監(jiān)聽通道事件赦肃,選擇器應當有方法返回所有注冊成功的通道相關的 SelectionKey 實例溅蛉。
selectedKeys 方法會返回選擇器中注冊成功的所有通道的 SelectionKey 實例集合。我們通過這個集合的 SelectionKey 實例他宛,可以得到所有通道的事件就緒情況并進行相應的處理操作船侧。
下面我們以一個簡單的客戶端服務端連接通訊的實例應用一下上述理論知識:
服務端代碼:
這段小程序的運行的實際效果是這樣的,客戶端建立請求到服務端厅各,待請求完全建立勺爱,客戶端會去檢查服務端是否有數(shù)據(jù)寫回,而服務端的任務就很簡單了讯检,接受任意客戶端的請求連接并為它寫回一段數(shù)據(jù)琐鲁。
別看整個過程很簡單,但只要你有一點模糊的地方人灼,你這個功能就不可能實現(xiàn)围段,不信你試試,尤其是加了選擇器的客戶端代碼投放,更值得大家一行一行分析奈泪。提醒一點的是,大家應更多的關注于哪些方法是阻塞的灸芳,哪些是非阻塞的涝桅,這會有助于分析代碼。
這其實也算一個最最簡單的服務器客戶端請求模型了烙样,理解了這一點相信會有助于理解瀏覽器與 Web 服務器的工作原理的冯遂,這里我就不再帶大家分析了,有任何不同看法的也歡迎給我留言谒获,咱們一起學習探討蛤肌。
想必你也能發(fā)現(xiàn),加了選擇器的代碼會復雜很多批狱,也并不一定高效于原來的代碼裸准,這其實是因為你的功能比較簡單,并不涉及大量通道處理赔硫,邏輯一旦復雜起來炒俱,選擇器給你帶來的好處會非常明顯。
其實,NIO 中還有一塊 AIO 权悟,也就是異步 IO 并沒有介紹恼蓬,因為異步 IO 涉及到很多其他方面知識,這里暫時不做介紹僵芹,后續(xù)文章將單獨介紹異步任務等相關內(nèi)容。