詳解 Java NIO

YangAM發表於2018-06-14

檔案的抽象化表示,位元組流以及字元流的檔案操作等屬於傳統 IO 的相關內容,我們已經在前面的文章進行了較為深刻的學習了。

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

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

通道 Channel

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

image

通道有點類似 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 我想用一張圖結合解釋:

image

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

寫模式下,所謂寫模式就是將快取區中的內容寫入通道。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 的工廠方法構建一個 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 方法的呼叫鏈。

image

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

image

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 的檔案讀寫是非阻塞的,明明就是阻塞的。

image

建立一個選擇器一般是通過 Selector 的工廠方法,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 例項,可以得到所有通道的事件就緒情況並進行相應的處理操作。

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

image

服務端程式碼:

image

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

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

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

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

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


文章中的所有程式碼、圖片、檔案都雲端儲存在我的 GitHub 上:

(https://github.com/SingleYam/overview_java)

歡迎關注微信公眾號:撲在程式碼上的高爾基,所有文章都將同步在公眾號上。

image

相關文章