详解 Java NIO
詳解 Java NIO
文件的抽象化表示,字節(jié)流以及字符流的文件操作等屬于傳統(tǒng) IO 的相關(guān)內(nèi)容,我們已經(jīng)在前面的文章進(jìn)行了較為深刻的學(xué)習(xí)了。
但是傳統(tǒng)的 IO 流還是有很多缺陷的,尤其它的阻塞性加上磁盤讀寫本來就慢,會(huì)導(dǎo)致 CPU 使用效率大大降低。
所以,jdk 1.4 發(fā)布了 NIO 包,NIO 的文件讀寫設(shè)計(jì)顛覆了傳統(tǒng) IO 的設(shè)計(jì),采用『通道』+『緩存區(qū)』使得新式的 IO 操作直接面向緩存區(qū),并且是非阻塞的,對(duì)于效率的提升真不是一點(diǎn)兩點(diǎn),我們一起來看看。
通道 Channel
我們說過,NIO 的核心就是通道和緩存區(qū),所以它們的工作模式是這樣的:
通道有點(diǎn)類似 IO 中的流,但不同的是,同一個(gè)通道既允許讀也允許寫,而任意一個(gè)流要么是讀流要么是寫流。
但是你要明白一點(diǎn),通道和流一樣都是需要基于物理文件的,而每個(gè)流或者通道都通過文件指針操作文件,這里說的「通道是雙向的」也是有前提的,那就是通道基于隨機(jī)訪問文件『RandomAccessFile』的可讀可寫文件指針。
『RandomAccessFile』是既可讀又可寫的,所以基于它的通道是雙向的,所以,「通道是雙向的」這句話是有前提的,不能斷章取義。
基本的通道類型有如下一些:
- FileChannel
- DatagramChannel
- SocketChannel
- ServerSocketChannel
FileChannel 是基于文件的通道,SocketChannel 和 ServerSocketChannel 用于網(wǎng)絡(luò) TCP 套接字?jǐn)?shù)據(jù)報(bào)讀寫,DatagramChannel 是用于網(wǎng)絡(luò) UDP 套接字?jǐn)?shù)據(jù)報(bào)讀寫。
通道不能單獨(dú)存在,它永遠(yuǎn)需要綁定一個(gè)緩存區(qū),所有的數(shù)據(jù)只會(huì)存在于緩存區(qū)中,無論你是寫或是讀,必然是緩存區(qū)通過通道到達(dá)磁盤文件,或是磁盤文件通過通道到達(dá)緩存區(qū)。
即緩存區(qū)是數(shù)據(jù)的「起點(diǎn)」,也是「終點(diǎn)」,具體這些通道到底有哪些不同以及該如何使用,基本實(shí)現(xiàn)如何,我們介紹完『緩存區(qū)』概念后,再做詳細(xì)學(xué)習(xí)。
緩存區(qū) Buffer
Buffer 是所有具體緩存區(qū)的基類,是一個(gè)抽象類,它的實(shí)現(xiàn)類有很多,包含各種類型數(shù)據(jù)的緩存。
- ByteBuffer
- CharBuffer
- ShortBuffer
- IntBuffer
- LongBuffer
- FloatBuffer
- DoubleBuffer
- MappedByteBuffer
我們以 ByteBuffer 為例進(jìn)行學(xué)習(xí),其余的緩存區(qū)也都是基于字節(jié)緩存區(qū)的,只不過多了一步字節(jié)轉(zhuǎn)換過程而已,MappedByteBuffer 是一個(gè)特殊的緩存方式,我們會(huì)單獨(dú)介紹。
Buffer 中有幾個(gè)重要的成員屬性,我們了解一下:
private int mark = -1; private int position = 0; private int limit; private int capacity; long address;mark 屬性我們已經(jīng)不陌生了,用于重復(fù)讀。capacity 描述緩存區(qū)容量,即整個(gè)緩存區(qū)最大能存儲(chǔ)多少數(shù)據(jù)量。address 用于操作直接內(nèi)存,區(qū)別于 jvm 內(nèi)存,這一點(diǎn)待會(huì)說明。
而 position 和 limit 我想用一張圖結(jié)合解釋:
由于緩存區(qū)是讀寫共存的,所以不同的模式下,這兩個(gè)變量的值也具有不同的意義。
寫模式下,所謂寫模式就是將緩存區(qū)中的內(nèi)容寫入通道。position 代表下一個(gè)字節(jié)應(yīng)該被寫出去的字節(jié)在緩存區(qū)中的位置,limit 表示最后一個(gè)待寫字節(jié)在緩存區(qū)的位置。
讀模式下,所謂讀模式就是從通道讀取數(shù)據(jù)到緩存區(qū)。position 代表下一個(gè)讀出來的字節(jié)應(yīng)當(dāng)存儲(chǔ)在緩存區(qū)的位置,limit 等于 capacity。
相關(guān)的讀寫操作細(xì)節(jié),待會(huì)會(huì)和大家一起看源碼,以加深對(duì)通道和緩存區(qū)協(xié)作工作的原理,這里我們先討論一個(gè)大家可能沒怎么關(guān)注過的一個(gè)問題。
JVM 內(nèi)存劃分為棧和堆,這是大家深入腦海的知識(shí),但是其實(shí)劃分給 JVM 的還有一塊堆外內(nèi)存,也就是直接內(nèi)存,很多人不知道這塊內(nèi)存是干什么用的。
這是一塊物理內(nèi)存,專門用于 JVM 和 IO 設(shè)備打交道,Java 底層使用 C 語言的 API 調(diào)用操作系統(tǒng)與 IO 設(shè)備進(jìn)行交互。
例如,Java 內(nèi)存中有一個(gè)字節(jié)數(shù)組,現(xiàn)在調(diào)用流將它寫入磁盤文件,那么 JVM 首先會(huì)將這個(gè)字節(jié)數(shù)組先拷貝一份到堆外內(nèi)存中,然后調(diào)用 C 語言 API 指明將某個(gè)連續(xù)地址范圍的數(shù)據(jù)寫入磁盤。
讀操作也是類似,而 JVM 額外做的拷貝工作也是有意義的,因?yàn)?JVM 是基于自動(dòng)垃圾回收機(jī)制運(yùn)行的,所有內(nèi)存中的數(shù)據(jù)會(huì)在 GC 時(shí)不停的被移動(dòng),如果你調(diào)用系統(tǒng) API 告訴操作系統(tǒng)將內(nèi)存某某位置的內(nèi)存寫入磁盤,而此時(shí)發(fā)生 GC 移動(dòng)了該部分?jǐn)?shù)據(jù),GC 結(jié)束后操作系統(tǒng)是不是就寫錯(cuò)數(shù)據(jù)了。
所以,JVM 對(duì)于與外圍 IO 設(shè)備交互的情況下,都會(huì)將內(nèi)存數(shù)據(jù)復(fù)制一份到堆外內(nèi)存中,然后調(diào)用系統(tǒng) API 間接的寫入磁盤,讀也是類似的。由于堆外內(nèi)存不受 GC 管理,所以用完一定得記得釋放。
理解這一個(gè)小知識(shí)是看懂源碼實(shí)現(xiàn)的前提,不然你可能不知道代碼實(shí)現(xiàn)者在做什么。好了,那我們就先來看看讀操作的基本使用與源碼實(shí)現(xiàn)。
RandomAccessFile file = new RandomAccessFile("C:\\Users\\yanga\\Desktop\\note.txt","rw"); FileChannel channel = file.getChannel();ByteBuffer buffer = ByteBuffer.allocate(1024); channel.read(buffer);buffer.flip(); byte[] res = new byte[1024]; buffer.get(res,0,buffer.limit()); System.out.println(new String(res));channel.close();我們看這么一段代碼,這段代碼我大致分成了四個(gè)部分,第一部分用于獲取文件通道,第二部分用于分配緩存區(qū)并完成讀操作,第三部分用于將緩存區(qū)中數(shù)據(jù)進(jìn)行打印,第四部分為關(guān)閉通道連接。
第一部分:
getChannel 方法用于獲取一個(gè)文件相關(guān)的通道實(shí)例,具體實(shí)現(xiàn)如下:
public final FileChannel getChannel() {synchronized (this) {if (channel == null) {channel = FileChannelImpl.open(fd, path, true, rw, this);}return channel;} } public static FileChannel open (FileDescriptor var0, String var1, boolean var2, boolean var3, Object var4) {return new FileChannelImpl(var0, var1, var2, var3, false, var4); }getChannel 方法會(huì)調(diào)用 FileChannelImpl 的工廠方法構(gòu)建一個(gè) FileChannelImpl 實(shí)例,FileChannelImpl 是抽象類 FileChannel 的一個(gè)子類實(shí)現(xiàn)。
構(gòu)成 FileChannelImpl 實(shí)例所需的必要參數(shù)有,該文件的文件指針,該文件的完整路徑,讀寫權(quán)限等。
第二部分:
Buffer 的基本結(jié)構(gòu)我們上述已經(jīng)簡單介紹了,這里不再贅述了,所謂的緩存區(qū),本質(zhì)上就是字節(jié)數(shù)組。
public static ByteBuffer allocate(int capacity) {if (capacity < 0)throw new IllegalArgumentException();return new HeapByteBuffer(capacity, capacity); }ByteBuffer 實(shí)例的構(gòu)建是通過工廠模式產(chǎn)生的,必須指定參數(shù) capacity 作為內(nèi)部字節(jié)數(shù)組的容量。HeapByteBuffer 是虛擬機(jī)的堆上內(nèi)存,所有數(shù)據(jù)都將存儲(chǔ)在堆空間,我們不久將會(huì)介紹它的一個(gè)兄弟,DirectByteBuffer,它被分配在堆外內(nèi)存中,具體的一會(huì)說。
這個(gè) HeapByteBuffer 的構(gòu)造情況我們不妨跟進(jìn)去看看:
HeapByteBuffer(int cap, int lim) {super(-1, 0, lim, cap, new byte[cap], 0); }調(diào)用父類的構(gòu)造方法,初始化我們?cè)?ByteBuffer 中提過的一些屬性值,如 position,capacity,mark,limit,offset 以及字節(jié)數(shù)組 hb。
接著,我們看看這個(gè) read 方法的調(diào)用鏈。
這個(gè) read 方法是子類 FileChannelImpl 對(duì)父類 FileChannel read 方法的重寫。這個(gè)方法不是讀操作的核心,我們簡單概括一下,該方法首先會(huì)拿到當(dāng)前通道實(shí)例的鎖,如果沒有被其他線程占有,那么占有該鎖,并調(diào)用 IOUtil 的 read 方法。
IOUtil 的 read 方法內(nèi)部也調(diào)用了很多方法,有的甚至是本地方法,這里只簡單介紹一下整個(gè) read 方法的大體邏輯,具體細(xì)節(jié)留待大家自行學(xué)習(xí)。
首先判斷我們的 ByteBuffer 實(shí)例是不是一個(gè) DirectBuffer,也就是判斷當(dāng)前的 ByteBuffer 實(shí)例是不是被分配在直接內(nèi)存中,如果是,那么將調(diào)用 readIntoNativeBuffer 方法從磁盤讀取數(shù)據(jù)直接放入 ByteBuffer 實(shí)例所在的直接內(nèi)存中。
否則,虛擬機(jī)將在直接內(nèi)存區(qū)域分配一塊內(nèi)存,該內(nèi)存區(qū)域的首地址存儲(chǔ)在 var5 實(shí)例的 address 屬性中。
接著從磁盤讀取數(shù)據(jù)放入 var5 所代表的直接內(nèi)存區(qū)域中。
最后,put 方法會(huì)將 var5 所代表的直接內(nèi)存區(qū)域中的數(shù)據(jù)寫入到 var1 所代表的堆內(nèi)緩存區(qū)并釋放臨時(shí)創(chuàng)建的直接內(nèi)存空間。
這樣,我們傳入的緩存區(qū)中就成功的被讀入了數(shù)據(jù)。寫操作是相反的,大家可以自行類比,反正堆內(nèi)數(shù)據(jù)想要到達(dá)磁盤就必定要經(jīng)過堆外內(nèi)存的復(fù)制過程。
第三第四部分比較簡單,這里不再贅述了。提醒一下,想要更好的使用這個(gè)通道和緩存區(qū)進(jìn)行文件讀寫操作,你就一定得對(duì)緩存區(qū)的幾個(gè)變量的值時(shí)刻把握住,position 和 limit 當(dāng)前的值是什么,大致什么位置,一定得清晰,否則這個(gè)讀寫共存的緩存區(qū)可能會(huì)讓你暈頭轉(zhuǎn)向。
選擇器 Selector
Selector 是 Java NIO 的一個(gè)組件,它用于監(jiān)聽多個(gè) Channel 的各種狀態(tài),用于管理多個(gè) Channel。但本質(zhì)上由于 FileChannel 不支持注冊(cè)選擇器,所以 Selector 一般被認(rèn)為是服務(wù)于網(wǎng)絡(luò)套接字通道的。
而大家口中的「NIO 是非阻塞的」,準(zhǔn)確來說,指的是網(wǎng)絡(luò)編程中客戶端與服務(wù)端連接交換數(shù)據(jù)的過程是非阻塞的。普通的文件讀寫依然是阻塞的,和 IO 是一樣的,這一點(diǎn)可能很多初學(xué)者會(huì)懵,包括我當(dāng)時(shí)也總想不通為什么說 NIO 的文件讀寫是非阻塞的,明明就是阻塞的。
創(chuàng)建一個(gè)選擇器一般是通過 Selector 的工廠方法,Selector.open :
Selector selector = Selector.open();而一個(gè)通道想要注冊(cè)到某個(gè)選擇器中,必須調(diào)整模式為非阻塞模式,例如:
//創(chuàng)建一個(gè) TCP 套接字通道 SocketChannel channel = SocketChannel.open(); //調(diào)整通道為非阻塞模式 channel.configureBlocking(false); //向選擇器注冊(cè)一個(gè)通道 SelectionKey key = channel.register(selector, SelectionKey.OP_READ);以上代碼是注冊(cè)一個(gè)通道到選擇器中的最簡單版本,支持注冊(cè)選擇器的通道都有一個(gè) register 方法,該方法就是用于注冊(cè)當(dāng)前實(shí)例通道到指定選擇器的。
該方法的第一個(gè)參數(shù)就是目標(biāo)選擇器,第二個(gè)參數(shù)其實(shí)是一個(gè)二進(jìn)制掩碼,它指明當(dāng)前選擇器感興趣當(dāng)前通道的哪些事件。以枚舉類型提供了以下幾種取值:
- int OP_READ = 1 << 0;
- int OP_WRITE = 1 << 2;
- int OP_CONNECT = 1 << 3;
- int OP_ACCEPT = 1 << 4;
這種用二進(jìn)制掩碼來表示某些狀態(tài)的機(jī)制,我們?cè)谥v述虛擬機(jī)類類文件結(jié)構(gòu)的時(shí)候也遇到過,它就是用一個(gè)二進(jìn)制位來描述一種狀態(tài)。
register 方法會(huì)返回一個(gè) SelectionKey 實(shí)例,該實(shí)例代表的就是選擇器與通道的一個(gè)關(guān)聯(lián)關(guān)系。你可以調(diào)用它的 selector 方法返回當(dāng)前相關(guān)聯(lián)的選擇器實(shí)例,也可以調(diào)用它的 channel 方法返回當(dāng)前關(guān)聯(lián)關(guān)系中的通道實(shí)例。
除此之外,SelectionKey 的 readyOps 方法將返回當(dāng)前選擇感興趣當(dāng)前通道中事件中準(zhǔn)備就緒的事件集合,依然返回的一個(gè)整型數(shù)值,也就是一個(gè)二進(jìn)制掩碼。
例如:
int readySet = selectionKey.readyOps();假如 readySet 的值為 13,二進(jìn)制 「0000 1101」,從后向前數(shù),第一位為 1,第三位為 1,第四位為 1,那么說明選擇器關(guān)聯(lián)的通道,讀就緒、寫就緒,連接就緒。
所以,當(dāng)我們注冊(cè)一個(gè)通道到選擇器之后,就可以通過返回的 SelectionKey 實(shí)例監(jiān)聽該通道的各種事件。
當(dāng)然,一旦某個(gè)選擇器中注冊(cè)了多個(gè)通道,我們不可能一個(gè)一個(gè)的記錄它們注冊(cè)時(shí)返回的 SelectionKey 實(shí)例來監(jiān)聽通道事件,選擇器應(yīng)當(dāng)有方法返回所有注冊(cè)成功的通道相關(guān)的 SelectionKey 實(shí)例。
Set<SelectionKey> keys = selector.selectedKeys();selectedKeys 方法會(huì)返回選擇器中注冊(cè)成功的所有通道的 SelectionKey 實(shí)例集合。我們通過這個(gè)集合的 SelectionKey 實(shí)例,可以得到所有通道的事件就緒情況并進(jìn)行相應(yīng)的處理操作。
下面我們以一個(gè)簡單的客戶端服務(wù)端連接通訊的實(shí)例應(yīng)用一下上述理論知識(shí):
服務(wù)端代碼:
這段小程序的運(yùn)行的實(shí)際效果是這樣的,客戶端建立請(qǐng)求到服務(wù)端,待請(qǐng)求完全建立,客戶端會(huì)去檢查服務(wù)端是否有數(shù)據(jù)寫回,而服務(wù)端的任務(wù)就很簡單了,接受任意客戶端的請(qǐng)求連接并為它寫回一段數(shù)據(jù)。
別看整個(gè)過程很簡單,但只要你有一點(diǎn)模糊的地方,你這個(gè)功能就不可能實(shí)現(xiàn),不信你試試,尤其是加了選擇器的客戶端代碼,更值得大家一行一行分析。提醒一點(diǎn)的是,大家應(yīng)更多的關(guān)注于哪些方法是阻塞的,哪些是非阻塞的,這會(huì)有助于分析代碼。
這其實(shí)也算一個(gè)最最簡單的服務(wù)器客戶端請(qǐng)求模型了,理解了這一點(diǎn)相信會(huì)有助于理解瀏覽器與 Web 服務(wù)器的工作原理的,這里我就不再帶大家分析了,有任何不同看法的也歡迎給我留言,咱們一起學(xué)習(xí)探討。
想必你也能發(fā)現(xiàn),加了選擇器的代碼會(huì)復(fù)雜很多,也并不一定高效于原來的代碼,這其實(shí)是因?yàn)槟愕墓δ鼙容^簡單,并不涉及大量通道處理,邏輯一旦復(fù)雜起來,選擇器給你帶來的好處會(huì)非常明顯。
其實(shí),NIO 中還有一塊 AIO ,也就是異步 IO 并沒有介紹,因?yàn)楫惒?IO 涉及到很多其他方面知識(shí),這里暫時(shí)不做介紹,后續(xù)文章將單獨(dú)介紹異步任務(wù)等相關(guān)內(nèi)容。
創(chuàng)作挑戰(zhàn)賽新人創(chuàng)作獎(jiǎng)勵(lì)來咯,堅(jiān)持創(chuàng)作打卡瓜分現(xiàn)金大獎(jiǎng)總結(jié)
以上是生活随笔為你收集整理的详解 Java NIO的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 第三届山东大数据-威海赛区-民宿空置预测
- 下一篇: 开源python-文档撰写