詳解 Java NIO

文件的抽象化表示,字節流以及字符流的文件操做等屬於傳統 IO 的相關內容,咱們已經在前面的文章進行了較爲深入的學習了。java

可是傳統的 IO 流仍是有不少缺陷的,尤爲它的阻塞性加上磁盤讀寫原本就慢,會致使 CPU 使用效率大大下降。git

因此,jdk 1.4 發佈了 NIO 包,NIO 的文件讀寫設計顛覆了傳統 IO 的設計,採用『通道』+『緩存區』使得新式的 IO 操做直接面向緩存區,而且是非阻塞的,對於效率的提高真不是一點兩點,咱們一塊兒來看看。github

通道 Channel

咱們說過,NIO 的核心就是通道和緩存區,因此它們的工做模式是這樣的:編程

image

通道有點相似 IO 中的流,但不一樣的是,同一個通道既容許讀也容許寫,而任意一個流要麼是讀流要麼是寫流。小程序

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

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

基本的通道類型有以下一些:緩存

  • FileChannel
  • DatagramChannel
  • SocketChannel
  • ServerSocketChannel

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

通道不能單獨存在,它永遠須要綁定一個緩存區,全部的數據只會存在於緩存區中,不管你是寫或是讀,必然是緩存區經過通道到達磁盤文件,或是磁盤文件經過通道到達緩存區。服務器

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

緩存區 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
相關文章
相關標籤/搜索