Java IO之NIO

三分青年發表於2018-03-25

上篇說了最基礎的五種IO模型,相信大家對IO相關的概念應該有了一定的瞭解,這篇文章主要講講基於多路複用IO的Java NIO。

背景

Java誕生至今,有好多種IO模型,從最早的Java IO到後來的Java NIO以及最新的Java AIO,每種IO模型都有它自己的特點,詳情請看我的上篇文章Java IO初探,而其中的的Java NIO應用非常廣泛,尤其是在高併發領域,比如我們常見的Netty,Mina等框架,都是基於它實現的,相信大家都有所瞭解,下面讓我們來看看Java NIO的具體架構。

Java NIO架構

其實Java NIO模型相對來說也還是比較簡單的,它的核心主要有三個,分別是:Selector、Channel和Buffer,我們先來看看它們之間的關係:

java-nio

它們之間的關係很清晰,一個執行緒對應著一個Selector,一個Selector對應著多個Channel,一個Channel對應著一個Buffer,當然這只是通常的做法,一個Channel也可以對應多個Selector,一個Channel對應著多個Buffer。

Selector

個人認為Selector是Java NIO的最大特點,之前我們說過,傳統的Java IO在面對大量IO請求的時候有心無力,因為每個維護每一個IO請求都需要一個執行緒,這帶來的問題就是,系統資源被極度消耗,吞吐量直線下降,引起系統相關問題,那麼Java NIO是如何解決這個問題的呢?答案就是Selector,簡單來說它對應著多路IO複用中的監管角色,它負責統一管理IO請求,監聽相應的IO事件,並通知對應的執行緒進行處理,這種模式下就無需為每個IO請求單獨分配一個執行緒,另外也減少執行緒大量阻塞,資源利用率下降的情況,所以說Selector是Java NIO的精髓,在Java中我們可以這麼寫:

// 開啟伺服器套接字通道
ServerSocketChannel ssc = ServerSocketChannel.open();
// 伺服器配置為非阻塞
ssc.configureBlocking(false);
// 進行服務的繫結
ssc.bind(new InetSocketAddress("localhost", 8001));

// 通過open()方法找到Selector
Selector selector = Selector.open();
// 註冊到selector,等待連線
ssc.register(selector, SelectionKey.OP_ACCEPT);
...
複製程式碼

Channel

Channel本意是通道的意思,簡單來說,它在Java NIO中表現的就是一個資料通道,但是這個通道有一個特點,那就是它是雙向的,也就是說,我們可以從通道里接收資料,也可以向通道里寫資料,不用像Java BIO那樣,讀資料和寫資料需要不同的資料通道,比如最常見的Inputstream和Outputstream,但是它們都是單向的,Channel作為一種全新的設計,它幫助系統以相對小的代價來保持IO請求資料傳輸的處理,但是它並不真正存放資料,它總是結合著快取區(Buffer)一起使用,另外Channel主要有以下四種:

  • FileChannel:讀寫檔案時使用的通道
  • DatagramChannel:傳輸UDP連線資料時的通道,與Java IO中的DatagramSocket對應
  • SocketChannel:傳輸TCP連線資料時的通道,與Java IO中的Socket對應
  • ServerSocketChannel: 監聽套接詞連線時的通道,與Java IO中的ServerSocket對應

當然其中最重要以及最常用的就是SocketChannel和ServerSocketChannel,也是Java NIO的精髓,ServerSocketChannel可以設定成非阻塞模式,然後結合Selector就可以實現多路複用IO,使用一個執行緒管理多個Socket連線,具體使用可以引數上面的程式碼。

Buffer

顧名思義,Buffer的含義是緩衝區,它在Java NIO中的主要作用就是作為資料的緩衝區域,Buffer對應著某一個Channel,從Channel中讀取資料或者向Channel中寫資料,Buffer與陣列很類似,但是它提供了更多的特性,方便我們對Buffer中的資料進行操作,後面我也會主要分析它的三個屬性capacity,position和limit,我們先來看一下Buffer分配時的類別(這裡不是指Buffer的具體資料型別)即Direct Buffer和Heap Buffer,那麼為什麼要有這兩種類別的Buffer呢?我們先來看看它們的特性:

Direct Buffer:

  • 直接分配在系統記憶體中;
  • 不需要花費將資料庫從記憶體拷貝到Java記憶體中的成本;
  • 雖然Direct Buffer是直接分配中系統記憶體中的,但當它被重複利用時,只有真正需要資料的那一頁資料會被裝載到真是的記憶體中,其它的還存在在虛擬記憶體中,不會造成實際記憶體的資源浪費;
  • 可以結合特定的機器碼,一次可以有順序的讀取多位元組單元;
  • 因為直接分配在系統記憶體中,所以它不受Java GC管理,不會自動回收;
  • 建立以及銷燬的成本比較高;

Heap Buffer:

  • 分配在Java Heap,受Java GC管理生命週期,不需要額外維護;
  • 建立成本相對較低;

根據它們的特性,我們可以大致總結出它們的適用場景:

如果這個Buffer可以重複利用,而且你也想多個位元組操作,亦或者你對效能要求很高,可以選擇使用Direct Buffer,但其編碼相對來說會比較複雜,需要注意的點也更多,反之則用Heap Buffer,Buffer的相應建立方法:

//建立Heap Buffer
ByteBuffer heapBuffer = ByteBuffer.allocate(1024);

//建立Direct Buffer
ByteBuffer directBuffer = ByteBuffer.allocateDirect(1024);
複製程式碼

下面我們來看看它的三個屬性:

  • Capacity:顧名思義它的含義是容量,代表著Buffer的最大容量,與陣列的Size很類似,初始化不可更改,除非你改變的Buffer的結構;
  • Limit:顧名思義它的含義是界限,代表著Buffer的目前可使用的最大限制,寫模式下,一般Limit等於Capacity,讀模式下需要你自己控制它的值結合position讀取想要的資料;
  • Position:顧名思義它的含義是位置,代表著Buffer目前操作的位置,通俗來說,就是你下次對Buffer進行操作的起始位置;

接下來我會用一個圖解的列子幫助大家理解,現在我們假設有一個容量為10的Buffer,我們先往裡面寫入一定位元組的資料,然後再根據編碼規則從其中讀取我們需要的資料:

1.初始Buffer:

ByteBuffer buffer = ByteBuffer.allocate(10);
複製程式碼

init-buffer

2.向Buffer中寫入兩個位元組:

buffer.put("my".getBytes());
複製程式碼

write-buffer-1

3.再Buffer中寫入四個位元組:

buffer.put("blog".getBytes());
複製程式碼

write-buffer-2

4.現在我們需要從Buffer中獲取資料,首先我們先將寫模式轉換為讀模式:

  buffer.flip();
複製程式碼

我們來看看flip()方法到底做了什麼事?

public final Buffer flip() {
    limit = position;
    position = 0;
    mark = -1;
    return this;
}
複製程式碼

從原始碼中可以看出,flip方法根據Buffer目前的相應屬性來修改對應的屬性,所以flip()方法之後,Buffer目前的狀態:

read-buffer

5.接著我們從Buffer中讀取資料

從Buffer中讀取資料有多種方式,比如get(),get(byte [])等,相關的具體方法使用可以參考Buffer的官方API文件,這裡我們用最簡單的get()來獲取資料:

  byte a = buffer.get();
  byte b = buffer.get();
複製程式碼

此時Buffer的狀態如下圖所示:

read-buffer-2

我們可以按照這種方式讀取完我們所需資料,最終呼叫clear()方法將Buffer置為初始狀態。

總結

這篇文章主要講解了Java NIO中重要的三個組成部分,在實際使用過程也是比較重要的,掌握它們之間的關係,可以讓你對Java NIO的整個架構更加熟悉,理解相對來說也會更加深刻,並分析了這種模式是如何與多路複用IO模型的對映,瞭解Java NIO在高併發場景下優勢的原因。

相關文章