標籤:成員 ref 網路編程 讀寫 伺服器 bool code oat 數值
檔案的抽象化表示,位元組流以及字元流的檔案操作等屬於傳統 IO 的相關內容,我們已經在前面的文章進行了較為深刻的學習了。
但是傳統的 IO 流還是有很多缺陷的,尤其它的阻塞性加上磁碟讀寫本來就慢,會導致 CPU 使用效率大大降低。
所以,jdk 1.4 發布了 NIO 包,NIO 的檔案讀寫設計顛覆了傳統 IO 的設計,採用『通道』+『緩衝區』使得新式的 IO 操作直接面向緩衝區,並且是非阻塞的,對於效率的提升真不是一點兩點,我們一起來看看。
通道 Channel
我們說過,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 中有幾個重要的成員屬性,我們瞭解一下:
private int mark = -1;private int position = 0;private int limit;private int capacity;long address;
mark 屬性我們已經不陌生了,用於重複讀。capacity 描述緩衝區容量,即整個緩衝區最大能儲存多少資料量。address 用於操作直接記憶體,區別於 jvm 記憶體,這一點待會說明。
而 position 和 limit 我想用一張圖結合解釋:
由於緩衝區是讀寫共存的,所以不同的模式下,這兩個變數的值也具有不同的意義。
寫入模式下,所謂寫入模式就是將緩衝區中的內容寫入通道。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 管理,所以用完一定得記得釋放。
理解這一個小知識是看懂源碼實現的前提,不然你可能不知道代碼實現者在做什麼。好了,那我們就先來看看讀操作的基本使用與源碼實現。
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();
我們看這麼一段代碼,這段代碼我大致分成了四個部分,第一部分用於擷取檔案通道,第二部分用於分配緩衝區並完成讀操作,第三部分用於將緩衝區中資料進行列印,第四部分為關閉通道串連。
第一部分:
getChannel 方法用於擷取一個檔案相關的通道執行個體,具體實現如下:
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 方法會調用 FileChannelImpl 的Factory 方法構建一個 FileChannelImpl 執行個體,FileChannelImpl 是抽象類別 FileChannel 的一個子類實現。
構成 FileChannelImpl 執行個體所需的必要參數有,該檔案的檔案指標,該檔案的完整路徑,讀寫權限等。
第二部分:
Buffer 的基本結構我們上述已經簡單介紹了,這裡不再贅述了,所謂的緩衝區,本質上就是位元組數組。
public static ByteBuffer allocate(int capacity) { if (capacity < 0) throw new IllegalArgumentException(); return new HeapByteBuffer(capacity, capacity);}
ByteBuffer 執行個體的構建是通過原廠模式產生的,必須指定參數 capacity 作為內部位元組數組的容量。HeapByteBuffer 是虛擬機器的堆上記憶體,所有資料都將儲存在堆空間,我們不久將會介紹它的一個兄弟,DirectByteBuffer,它被分配在堆外記憶體中,具體的一會說。
這個 HeapByteBuffer 的構造情況我們不妨跟進去看看:
HeapByteBuffer(int cap, int lim) { super(-1, 0, lim, cap, new byte[cap], 0);}
調用父類的構造方法,初始化我們在 ByteBuffer 中提過的一些屬性值,如 position,capacity,mark,limit,offset 以及位元組數組 hb。
接著,我們看看這個 read 方法的調用鏈。
這個 read 方法是子類 FileChannelImpl 對父類 FileChannel read 方法的重寫。這個方法不是讀操作的核心,我們簡單概括一下,該方法首先會拿到當前通道執行個體的鎖,如果沒有被其他線程佔有,那麼佔有該鎖,並調用 IOUtil 的 read 方法。
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 的檔案讀寫是非阻塞的,明明就是阻塞的。
建立一個選取器一般是通過 Selector 的Factory 方法,Selector.open :
Selector selector = Selector.open();
而一個通道想要註冊到某個選取器中,必須調整模式為非阻塞模式,例如:
//建立一個 TCP 通訊端通道SocketChannel channel = SocketChannel.open();//調整通道為非阻塞模式channel.configureBlocking(false);//向選取器註冊一個通道SelectionKey key = channel.register(selector, SelectionKey.OP_READ);
以上代碼是註冊一個通道到選取器中的最簡單版本,支援註冊選取器的通道都有一個 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 方法將返回當前選擇感興趣當前通道中事件中準備就緒的事件集合,依然返回的一個整型數值,也就是一個二進位掩碼。
例如:
int readySet = selectionKey.readyOps();
假如 readySet 的值為 13,二進位 「0000 1101」,從後向前數,第一位為 1,第三位為 1,第四位為 1,那麼說明選取器關聯的通道,讀就緒、寫就緒,串連就緒。
所以,當我們註冊一個通道到選取器之後,就可以通過返回的 SelectionKey 執行個體監聽該通道的各種事件。
當然,一旦某個選取器中註冊了多個通道,我們不可能一個一個的記錄它們註冊時返回的 SelectionKey 執行個體來監聽通道事件,選取器應當有方法返回所有註冊成功的通道相關的 SelectionKey 執行個體。
Set<SelectionKey> keys = selector.selectedKeys();
selectedKeys 方法會返回選取器中註冊成功的所有通道的 SelectionKey 執行個體集合。我們通過這個集合的 SelectionKey 執行個體,可以得到所有通道的事件就緒情況並進行相應的處理操作。
下面我們以一個簡單的用戶端服務端串連通訊的執行個體應用一下上述理論知識:
服務端代碼:
這段小程式的啟動並執行實際效果是這樣的,用戶端建立請求到服務端,待請求完全建立,用戶端會去檢查服務端是否有資料寫回,而服務端的任務就很簡單了,接受任意用戶端的請求串連並為它寫回一段資料。
別看整個過程很簡單,但只要你有一點模糊的地方,你這個功能就不可能實現,不信你試試,尤其是加了選取器的用戶端代碼,更值得大家一行一行分析。提醒一點的是,大家應更多的關注於哪些方法是阻塞的,哪些是非阻塞的,這會有助於分析代碼。
這其實也算一個最最簡單的伺服器用戶端請求模型了,理解了這一點相信會有助於理解瀏覽器與 Web 服務器的工作原理的,這裡我就不再帶大家分析了,有任何不同看法的也歡迎給我留言,咱們一起學習探討。
想必你也能發現,加了選取器的代碼會複雜很多,也並不一定高效於原來的代碼,這其實是因為你的功能比較簡單,並不涉及大量通道處理,邏輯一旦複雜起來,選取器給你帶來的好處會非常明顯。
其實,NIO 中還有一塊 AIO ,也就是非同步 IO 並沒有介紹,因為非同步 IO 涉及到很多其他方面知識,這裡暫時不做介紹,後續文章將單獨介紹非同步任務等相關內容。
文章中的所有代碼、圖片、檔案都雲端儲存在我的 GitHub 上:
(https://github.com/SingleYam/overview_java)
歡迎關注公眾號:撲在代碼上的高爾基,所有文章都將同步在公眾號上。
詳解 Java NIO