網絡編程四-原生JDK的NIO及其應用

目錄

一、NIO介紹

1.1 什麼是NIO?

1.2 NIO和BIO的區別

1.3 適用場景

二、NIO的核心組成

2.1 Selector

2.2 Channels

2.3 buffer緩衝區

2.3.1 buffer重要屬性

2.3.2 Buffer的分配

2.3.3 Buffer的讀寫

2.3.4 buffer其他常用方法

三、NIO之Reactor模式

3.1 單線程Reactor模式流程

3.2 單線程Reactor,工作者線程池

3.3 多Reactor線程模式

3.4 和觀察者模式的區別

四、NIO使用舉例

4.1 NioServerHandle

4.2 NioServer

4.3 NioClientHandlenio

4.4 NioClient

4.5 效果演示


一、NIO介紹

1.1 什麼是NIO?

NIO 庫是在 JDK 1.4 中引入的。NIO 彌補了原來的 I/O 的不足,它在標準 Java 代碼中提供了高速的、面向塊的 I/O。NIO翻譯成 no-blocking io 或者 new io都說得通。

1.2 NIO和BIO的區別

面向流與面向緩衝

Java NIO和IO之間第一個最大的區別是,IO是面向流的,NIO是面向緩衝區的。 Java IO面向流意味着每次從流中讀一個或多個字節,直至讀取所有字節,它們沒有被緩存在任何地方。此外,它不能前後移動流中的數據。如果需要前後移動從流中讀取的數據,需要先將它緩存到一個緩衝區。 Java NIO的緩衝導向方法略有不同。數據讀取到一個它稍後處理的緩衝區,需要時可在緩衝區中前後移動。這就增加了處理過程中的靈活性。但是,還需要檢查是否該緩衝區中包含所有需要處理的數據。而且,需確保當更多的數據讀入緩衝區時,不要覆蓋緩衝區裏尚未處理的數據。

阻塞與非阻塞IO

Java IO的各種流是阻塞的。這意味着,當一個線程調用read() 或 write()時,該線程被阻塞,直到有一些數據被讀取,或數據完全寫入。該線程在此期間不能再幹任何事情了。

 Java NIO的非阻塞模式,使一個線程從某通道發送請求讀取數據,但是它僅能得到目前可用的數據,如果目前沒有數據可用時,就什麼都不會獲取。而不是保持線程阻塞,所以直至數據變的可以讀取之前,該線程可以繼續做其他的事情。 非阻塞寫也是如此。一個線程請求寫入一些數據到某通道,但不需要等待它完全寫入,這個線程同時可以去做別的事情。 線程通常將非阻塞IO的空閒時間用於在其它通道上執行IO操作,所以一個單獨的線程現在可以管理多個輸入和輸出通道(channel)。、

對於阻塞非阻塞,同步和異步相關的區別,大家可以看下我的網絡編程二-LINUX網絡IO模型這篇文章

選擇器(Selectors)

Java NIO的選擇器允許一個單獨的線程來監視多個輸入通道,你可以註冊多個通道使用一個選擇器,然後使用一個單獨的線程來“選擇”通道:這些通道里已經有可以處理的輸入,或者選擇已準備寫入的通道。這種選擇機制,使得一個單獨的線程很容易來管理多個通道。而BIO中是一個線程一個連接,在高併發情況下,可能會導致線程被鏈接耗光而進入阻塞的情況。

1.3 適用場景

NIO適用場景

服務器需要支持超大量的長時間連接。並且每個客戶端並不會頻繁地發送太多數據。Jetty、Mina、Netty、ZooKeeper,dubbo等都是基於NIO方式實現。

BIO適用場景

適用於連接數目比較小,並且一次發送大量數據的場景,這種方式對服務器資源要求比較高,併發侷限於應用中。

因此,不一定是NIO一定性能就高。選擇合適的場景纔是最重要的。如果使用方式不對,可能不僅不會增加服務吞吐,反而使單個接口響應時間變長。
 

二、NIO的核心組成

NIO主要有三個核心部分組成:

buffer緩衝區、Channel管道、Selector選擇器,他們的關係圖如下

 

2.1 Selector

Selector的英文含義是“選擇器”,也可以稱爲爲“輪詢代理器”、“事件訂閱器”、“channel容器管理機”都行。

應用程序將向Selector對象註冊需要它關注的Channel,以及具體的某一個Channel會對哪些IO事件感興趣。Selector中也會維護一個“已經註冊的Channel”的容器。

操作類型 SelectionKey

SelectionKey是一個抽象類,表示selectableChannel在Selector中註冊的標識.每個Channel向Selector註冊時,都將會創建一個selectionKey。選擇鍵將Channel與Selector建立了關係,並維護了channel事件。

可以通過cancel方法取消鍵,取消的鍵不會立即從selector中移除,而是添加到cancelledKeys中,在下一次select操作時移除它.所以在調用某個key時,需要使用isValid進行校驗.

在向Selector對象註冊感興趣的事件時,JAVA NIO共定義了四種:OP_READ、OP_WRITE、OP_CONNECT、OP_ACCEPT(定義在SelectionKey中),分別對應讀、寫、請求連接、接受連接等網絡Socket操作。

ServerSocketChannel和SocketChannel可以註冊自己感興趣的操作類型,當對應操作類型的就緒條件滿足時OS會通知channel,下表描述各種Channel允許註冊的操作類型,Y表示允許註冊,N表示不允許註冊,其中服務器SocketChannel指由服務器ServerSocketChannel.accept()返回的對象。

 

 

OP_READ

OP_WRITE

OP_CONNECT

OP_ACCEPT

服務器ServerSocketChannel

 

 

 

Y

服務器SocketChannel

Y

Y

 

 

客戶端SocketChannel

Y

Y

Y

 

 

服務器啓動ServerSocketChannel,關注OP_ACCEPT事件,

客戶端啓動SocketChannel,連接服務器,關注OP_CONNECT事件

服務器接受連接,啓動一個服務器的SocketChannel,這個SocketChannel可以關注OP_READ、OP_WRITE事件,一般連接建立後會直接關注OP_READ事件

客戶端這邊的客戶端SocketChannel發現連接建立後,可以關注OP_READ、OP_WRITE事件,一般是需要客戶端需要發送數據了才關注OP_READ事件

連接建立後客戶端與服務器端開始相互發送消息(讀寫),根據實際情況來關注OP_READ、OP_WRITE事件。

 

我們可以看看每個操作類型的就緒條件。

操作類型

就緒條件及說明

OP_READ

當操作系統讀緩衝區有數據可讀時就緒。並非時刻都有數據可讀,所以一般需要註冊該操作,僅當有就緒時才發起讀操作,有的放矢,避免浪費CPU。

OP_WRITE

當操作系統寫緩衝區有空閒空間時就緒。一般情況下寫緩衝區都有空閒空間,小塊數據直接寫入即可,沒必要註冊該操作類型,否則該條件不斷就緒浪費CPU;但如果是寫密集型的任務,比如文件下載等,緩衝區很可能滿,註冊該操作類型就很有必要,同時注意寫完後取消註冊。

OP_CONNECT

當SocketChannel.connect()請求連接成功後就緒。該操作只給客戶端使用。

OP_ACCEPT

當接收到一個客戶端連接請求時就緒。該操作只給服務器使用。3.1

2.2 Channels

通道,被建立的一個應用程序和操作系統交互事件、傳遞內容的渠道(注意是連接到操作系統)。那麼既然是和操作系統進行內容的傳遞,那麼說明應用程序可以通過通道讀取數據,也可以通過通道向操作系統寫數據,而且可以同時進行讀寫。

  • 所有被Selector(選擇器)註冊的通道,只能是繼承了SelectableChannel類的子類。
  • ServerSocketChannel:應用服務器程序的監聽通道。只有通過這個通道,應用程序才能向操作系統註冊支持“多路複用IO”的端口監聽。同時支持UDP協議和TCP協議。
  • ScoketChannel:TCP Socket套接字的監聽通道,一個Socket套接字對應了一個客戶端IP:端口 到 服務器IP:端口的通信連接。

通道中的數據總是要先讀到一個Buffer,或者總是要從一個Buffer中寫入。

2.3 buffer緩衝區

Buffer用於和NIO通道進行交互。數據是從通道讀入緩衝區,從緩衝區寫入到通道中的。以寫爲例,應用程序都是將數據寫入緩衝,再通過通道把緩衝的數據發送出去,讀也是一樣,數據總是先從通道讀到緩衝,應用程序再讀緩衝的數據。

緩衝區本質上是一塊可以寫入數據,然後可以從中讀取數據的內存( 其實就是數組)。這塊內存被包裝成NIO Buffer對象,並提供了一組方法,用來方便的訪問該塊內存。

Buffer用於和NIO通道進行交互。數據是從通道讀入緩衝區,從緩衝區寫入到通道中的。以寫爲例,應用程序都是將數據寫入緩衝,再通過通道把緩衝的數據發送出去,讀也是一樣,數據總是先從通道讀到緩衝,應用程序再讀緩衝的數據。

緩衝區本質上是一塊可以寫入數據,然後可以從中讀取數據的內存( 其實就是數組)。這塊內存被包裝成NIO Buffer對象,並提供了一組方法,用來方便的訪問該塊內存。

2.3.1 buffer重要屬性

緩衝區本質上是一塊可以寫入數據,然後可以從中讀取數據的內存。這塊內存被包裝成NIO Buffer對象,並提供了一組方法,用來方便的訪問該塊內存。

爲了理解Buffer的工作原理,需要熟悉它的三個屬性:

  • capacity
  • position
  • limit

position和limit的含義取決於Buffer處在讀模式還是寫模式。不管Buffer處在什麼模式,capacity的含義總是一樣的。

這裏有一個關於capacity,position和limit在讀寫模式中的說明,詳細的解釋在插圖後面。

 

capacity

作爲一個內存塊,Buffer有一個固定的大小值,也叫“capacity”.你只能往裏寫capacity個byte、long,char等類型。一旦Buffer滿了,需要將其清空(通過讀數據或者清除數據)才能繼續寫數據往裏寫數據。

position

當你寫數據到Buffer中時,position表示當前的位置。初始的position值爲0.當一個byte、long等數據寫到Buffer後, position會向前移動到下一個可插入數據的Buffer單元。position最大可爲capacity – 1.

當讀取數據時,也是從某個特定位置讀。當將Buffer從寫模式切換到讀模式,position會被重置爲0. 當從Buffer的position處讀取數據時,position向前移動到下一個可讀的位置。

limit

在寫模式下,Buffer的limit表示你最多能往Buffer裏寫多少數據。 寫模式下,limit等於Buffer的capacity。

當切換Buffer到讀模式時, limit表示你最多能讀到多少數據。因此,當切換Buffer到讀模式時,limit會被設置成寫模式下的position值。換句話說,你能讀到之前寫入的所有數據(limit被設置成已寫數據的數量,這個值在寫模式下就是position)

2.3.2 Buffer的分配

堆內內存

要想獲得一個Buffer對象首先要進行分配。 每一個Buffer類都有allocate方法(可以在堆上分配,也可以在直接內存上分配)。

分配48字節capacity的ByteBuffer的例子:ByteBuffer buf = ByteBuffer.allocate(48);

分配一個可存儲1024個字符的CharBuffer:CharBuffer buf = CharBuffer.allocate(1024);

wrap方法:把一個byte數組或byte數組的一部分包裝成ByteBuffer:

ByteBuffer wrap(byte [] array)

ByteBuffer wrap(byte [] array, int offset, int length)

直接內存(堆外內存)

HeapByteBuffer與DirectByteBuffer,在原理上,前者可以看出分配的buffer是在heap區域的,其實真正flush到遠程的時候會先拷貝到直接內存,再做下一步操作;在NIO的框架下,很多框架會採用DirectByteBuffer來操作,這樣分配的內存不再是在java heap上,而是在操作系統的C heap上,經過性能測試,可以得到非常快速的網絡交互,在大量的網絡交互下,一般速度會比HeapByteBuffer要快速好幾倍。

直接內存(Direct Memory)並不是虛擬機運行時數據區的一部分,也不是Java虛擬機規範中定義的內存區域,但是這部分內存也被頻繁地使用,而且也可能導致OutOfMemoryError 異常出現。 

NIO可以使用Native 函數庫直接分配堆外內存,然後通過一個存儲在Java 堆裏面的DirectByteBuffer 對象作爲這塊內存的引用進行操作。這樣能在一些場景中顯著提高性能,因爲避免了在Java 堆和Native 堆中來回複製數據。

堆內內存和對外內存分配code:

/**
 * @author DarkKing
 * 類說明:Buffer的分配
 */
public class AllocateBuffer {
    public static void main(String[] args) {
        System.out.println("----------Test allocate--------");
        System.out.println("before alocate:"
                + Runtime.getRuntime().freeMemory());

        //堆上分配
        ByteBuffer buffer = ByteBuffer.allocate(1024000);
        System.out.println("buffer = " + buffer);
        System.out.println("after alocate:"
                + Runtime.getRuntime().freeMemory());

        // 直接內存分配
        ByteBuffer directBuffer = ByteBuffer.allocateDirect(102400);
        System.out.println("directBuffer = " + directBuffer);
        System.out.println("after direct alocate:"
                + Runtime.getRuntime().freeMemory());

        System.out.println("----------Test wrap--------");
        byte[] bytes = new byte[32];
        buffer = ByteBuffer.wrap(bytes);
        System.out.println(buffer);

        buffer = ByteBuffer.wrap(bytes, 10, 10);
        System.out.println(buffer);
    }
}

堆外內存的優點和缺點

堆外內存,其實就是不受JVM控制的內存。相比於堆內內存有幾個優勢: 
  1 減少了垃圾回收的工作,因爲垃圾回收會暫停其他的工作(可能使用多線程或者時間片的方式,根本感覺不到) 
  2 加快了複製的速度。因爲堆內在flush到遠程時,會先複製到直接內存(非堆內存),然後在發送;而堆外內存相當於省略掉了這個工作。(零拷貝原理) 
而福之禍所依,自然也有不好的一面: 
  1 堆外內存難以控制,如果內存泄漏,那麼很難排查 
  2 堆外內存相對來說,不適合存儲很複雜的對象。一般簡單的對象或者扁平化的比較適合。

直接內存(堆外內存)與堆內存比較

直接內存申請空間耗費更高的性能,當頻繁申請到一定量時尤爲明顯

直接內存IO讀寫的性能要優於普通的堆內存,在多次讀寫操作的情況下差異明顯

性能測試


/**
 * @author DarkKing
 * 類說明:
 */
public class ByteBufferCompare {
    public static void main(String[] args) {
        allocateCompare();   //分配比較
        operateCompare();    //讀寫比較
    }

    /**
     * 直接內存 和 堆內存的 分配空間比較
     * 結論: 在數據量提升時,直接內存相比非直接內的申請,有很嚴重的性能問題
     */
    public static void allocateCompare() {
        int time = 10000000;    //操作次數


        long st = System.currentTimeMillis();
        for (int i = 0; i < time; i++) {

            //ByteBuffer.allocate(int capacity)   分配一個新的字節緩衝區。
            ByteBuffer buffer = ByteBuffer.allocate(2);      //非直接內存分配申請
        }
        long et = System.currentTimeMillis();

        System.out.println("在進行" + time + "次分配操作時,堆內存 分配耗時:" + (et - st) + "ms");

        long st_heap = System.currentTimeMillis();
        for (int i = 0; i < time; i++) {
            //ByteBuffer.allocateDirect(int capacity) 分配新的直接字節緩衝區。
            ByteBuffer buffer = ByteBuffer.allocateDirect(2); //直接內存分配申請
        }
        long et_direct = System.currentTimeMillis();

        System.out.println("在進行" + time + "次分配操作時,直接內存 分配耗時:" + (et_direct - st_heap) + "ms");

    }

    /**
     * 直接內存 和 堆內存的 讀寫性能比較
     * 結論:直接內存在直接的IO 操作上,在頻繁的讀寫時 會有顯著的性能提升
     */
    public static void operateCompare() {
        int time = 100000000;

        ByteBuffer buffer = ByteBuffer.allocate(2 * time);
        long st = System.currentTimeMillis();
        for (int i = 0; i < time; i++) {

            //  putChar(char value) 用來寫入 char 值的相對 put 方法
            buffer.putChar('a');
        }
        buffer.flip();
        for (int i = 0; i < time; i++) {
            buffer.getChar();
        }
        long et = System.currentTimeMillis();

        System.out.println("在進行" + time + "次讀寫操作時,非直接內存讀寫耗時:" + (et - st) + "ms");

        ByteBuffer buffer_d = ByteBuffer.allocateDirect(2 * time);
        long st_direct = System.currentTimeMillis();
        for (int i = 0; i < time; i++) {

            //  putChar(char value) 用來寫入 char 值的相對 put 方法
            buffer_d.putChar('a');
        }
        buffer_d.flip();
        for (int i = 0; i < time; i++) {
            buffer_d.getChar();
        }
        long et_direct = System.currentTimeMillis();

        System.out.println("在進行" + time + "次讀寫操作時,直接內存讀寫耗時:" + (et_direct - st_direct) + "ms");
    }

}

執行程序後

可以看到,

1、內存分配方面,在數據量提升時,直接內存相比非直接內的申請,有很嚴重的性能問題。

2、IO讀寫方面,直接內存在直接的IO 操作上,在頻繁的讀寫時 會有顯著的性能提升

2.3.3 Buffer的讀寫

從Buffer中寫數據

寫數據到Buffer有兩種方式:

  1.  讀取Channel寫到Buffer。
  2.  通過Buffer的put()方法寫到Buffer裏。

從Channel寫到Buffer的例子 int bytesRead = inChannel.read(buf); //read into buffer.

通過put方法寫Buffer的例子:buf.put(127);

put方法有很多版本,允許你以不同的方式把數據寫入到Buffer中。例如, 寫到一個指定的位置,或者把一個字節數組寫入到Buffer。 更多Buffer實現的細節參考JavaDoc。

flip()方法

flip方法將Buffer從寫模式切換到讀模式。調用flip()方法會將position設回0,並將limit設置成之前position的值。

換句話說,position現在用於標記讀的位置,limit表示之前寫進了多少個byte、char等 —— 現在能讀取多少個byte、char等。

從Buffer中讀取數據

從Buffer中讀取數據有兩種方式:

  1. 從Buffer讀取數據寫入到Channel。
  2. 使用get()方法從Buffer中讀取數據。

從Buffer讀取數據到Channel的例子:int bytesWritten = inChannel.write(buf);

使用get()方法從Buffer中讀取數據的例子:byte aByte = buf.get();

get方法有很多版本,允許你以不同的方式從Buffer中讀取數據。例如,從指定position讀取,或者從Buffer中讀取數據到字節數組。更多Buffer實現的細節參考JavaDoc。

使用Buffer讀寫數據常見步驟:

  1. 寫入數據到Buffer
  2. 調用flip()方法
  3. 從Buffer中讀取數據
  4. 調用clear()方法或者compact()方法

當向buffer寫入數據時,buffer會記錄下寫了多少數據。一旦要讀取數據,需要通過flip()方法將Buffer從寫模式切換到讀模式。在讀模式下,可以讀取之前寫入到buffer的所有數據。

一旦讀完了所有的數據,就需要清空緩衝區,讓它可以再次被寫入。有兩種方式能清空緩衝區:調用clear()或compact()方法。clear()方法會清空整個緩衝區。compact()方法只會清除已經讀過的數據。任何未讀的數據都被移到緩衝區的起始處,新寫入的數據將放到緩衝區未讀數據的後面。

2.3.4 buffer其他常用方法

rewind()方法

Buffer.rewind()將position設回0,所以你可以重讀Buffer中的所有數據。limit保持不變,仍然表示能從Buffer中讀取多少個元素(byte、char等)。

clear()compact()方法

一旦讀完Buffer中的數據,需要讓Buffer準備好再次被寫入。可以通過clear()或compact()方法來完成。

如果調用的是clear()方法,position將被設回0,limit被設置成 capacity的值。換句話說,Buffer 被清空了。Buffer中的數據並未清除,只是這些標記告訴我們可以從哪裏開始往Buffer裏寫數據。

如果Buffer中有一些未讀的數據,調用clear()方法,數據將“被遺忘”,意味着不再有任何標記會告訴你哪些數據被讀過,哪些還沒有。

如果Buffer中仍有未讀的數據,且後續還需要這些數據,但是此時想要先先寫些數據,那麼使用compact()方法。

compact()方法將所有未讀的數據拷貝到Buffer起始處。然後將position設到最後一個未讀元素正後面。limit屬性依然像clear()方法一樣,設置成capacity。現在Buffer準備好寫數據了,但是不會覆蓋未讀的數據。

mark()reset()方法

通過調用Buffer.mark()方法,可以標記Buffer中的一個特定position。之後可以通過調用Buffer.reset()方法恢復到這個position。例如:

buffer.mark();//call buffer.get() a couple of times, e.g. during parsing.

buffer.reset(); //set position back to mark.

equals()compareTo()方法

可以使用equals()和compareTo()方法兩個Buffer。

equals()

當滿足下列條件時,表示兩個Buffer相等:

  1. 有相同的類型(byte、char、int等)。
  2. Buffer中剩餘的byte、char等的個數相等。
  3. Buffer中所有剩餘的byte、char等都相同。

如你所見,equals只是比較Buffer的一部分,不是每一個在它裏面的元素都比較。實際上,它只比較Buffer中的剩餘元素。

compareTo()方法

compareTo()方法比較兩個Buffer的剩餘元素(byte、char等), 如果滿足下列條件,則認爲一個Buffer“小於”另一個Buffer:

  1. 第一個不相等的元素小於另一個Buffer中對應的元素 。
  2. 所有元素都相等,但第一個Buffer比另一個先耗盡(第一個Buffer的元素個數比另一個少)。

Buffer方法總結

limit(), limit(10)等

其中讀取和設置這4個屬性的方法的命名和jQuery中的val(),val(10)類似,一個負責get,一個負責set

reset()

把position設置成mark的值,相當於之前做過一個標記,現在要退回到之前標記的地方

clear()

position = 0;limit = capacity;mark = -1;  有點初始化的味道,但是並不影響底層byte數組的內容

flip()

limit = position;position = 0;mark = -1;  翻轉,也就是讓flip之後的position到limit這塊區域變成之前的0到position這塊,翻轉就是將一個處於存數據狀態的緩衝區變爲一個處於準備取數據的狀態

rewind()

把position設爲0,mark設爲-1,不改變limit的值

remaining()

return limit - position;返回limit和position之間相對位置差

hasRemaining()

return position < limit返回是否還有未讀內容

compact()

把從position到limit中的內容移到0到limit-position的區域內,position和limit的取值也分別變成limit-position、capacity。如果先將positon設置到limit,再compact,那麼相當於clear()

get()

相對讀,從position位置讀取一個byte,並將position+1,爲下次讀寫作準備

get(int index)

絕對讀,讀取byteBuffer底層的bytes中下標爲index的byte,不改變position

get(byte[] dst, int offset, int length)

從position位置開始相對讀,讀length個byte,並寫入dst下標從offset到offset+length的區域

put(byte b)

相對寫,向position的位置寫入一個byte,並將postion+1,爲下次讀寫作準備

put(int index, byte b)

絕對寫,向byteBuffer底層的bytes中下標爲index的位置插入byte b,不改變position

put(ByteBuffer src)

用相對寫,把src中可讀的部分(也就是position到limit)寫入此byteBuffer

put(byte[] src, int offset, int length)

從src數組中的offset到offset+length區域讀取數據並使用相對寫寫入此byteBuffer

buffer方法演示


/**
 * @author DarkKing
 * 類說明:Buffer方法演示
 */
public class BufferMethod {
    public static void main(String[] args) {

        System.out.println("------Test get-------------");
        ByteBuffer buffer = ByteBuffer.allocate(32);
        buffer.put((byte) 'a')//0
                .put((byte) 'b')//1
                .put((byte) 'c')//2
                .put((byte) 'd')//3
                .put((byte) 'e')//4
                .put((byte) 'f');//5
        System.out.println("before flip()" + buffer);
        /* 轉換爲讀取模式*/
        buffer.flip();
        System.out.println("before get():" + buffer);
        System.out.println((char) buffer.get());
        System.out.println("after get():" + buffer);

        /* get(index)不影響position的值*/
        System.out.println((char) buffer.get(2));
        System.out.println("after get(index):" + buffer);
        byte[] dst = new byte[10];

        /* position移動兩位*/
        buffer.get(dst, 0, 2);
        /*這裏的buffer是 abcdef[pos=3 lim=6 cap=32]*/
        System.out.println("after get(dst, 0, 2):" + buffer);
        System.out.println("dst:" + new String(dst));

        System.out.println("--------Test put-------");
        ByteBuffer bb = ByteBuffer.allocate(32);
        System.out.println("before put(byte):" + bb);
        System.out.println("after put(byte):" + bb.put((byte) 'z'));
        // put(2,(byte) 'c')不改變position的位置
        bb.put(2, (byte) 'c');
        System.out.println("after put(2,(byte) 'c'):" + bb);
        System.out.println(new String(bb.array()));

        // 這裏的buffer是 abcdef[pos=3 lim=6 cap=32]
        bb.put(buffer);
        System.out.println("after put(buffer):" + bb);
        System.out.println(new String(bb.array()));

        System.out.println("--------Test reset----------");
        buffer = ByteBuffer.allocate(20);
        System.out.println("buffer = " + buffer);
        buffer.clear();
        buffer.position(5);//移動position到5
        buffer.mark();//記錄當前position的位置
        buffer.position(10);//移動position到10
        System.out.println("before reset:" + buffer);
        buffer.reset();//復位position到記錄的地址
        System.out.println("after reset:" + buffer);

        System.out.println("--------Test rewind--------");
        buffer.clear();
        buffer.position(10);//移動position到10
        buffer.limit(15);//限定最大可寫入的位置爲15
        System.out.println("before rewind:" + buffer);
        buffer.rewind();//將position設回0
        System.out.println("before rewind:" + buffer);

        System.out.println("--------Test compact--------");
        buffer.clear();
        //放入4個字節,position移動到下個可寫入的位置,也就是4
        buffer.put("abcd".getBytes());
        System.out.println("before compact:" + buffer);
        System.out.println(new String(buffer.array()));
        buffer.flip();//將position設回0,並將limit設置成之前position的值
        System.out.println("after flip:" + buffer);
        //從Buffer中讀取數據的例子,每讀一次,position移動一次
        System.out.println((char) buffer.get());
        System.out.println((char) buffer.get());
        System.out.println((char) buffer.get());
        System.out.println("after three gets:" + buffer);
        System.out.println(new String(buffer.array()));
        //compact()方法將所有未讀的數據拷貝到Buffer起始處。
        // 然後將position設到最後一個未讀元素正後面。
        buffer.compact();
        System.out.println("after compact:" + buffer);
        System.out.println(new String(buffer.array()));


    }
}

三、NIO之Reactor模式

“反應”器名字中”反應“的由來:

“反應”即“倒置”,“控制逆轉”,具體事件處理程序不調用反應器,而向反應器註冊一個事件處理器,表示自己對某些事件感興趣,有時間來了,具體事件處理程序通過事件處理器對某個指定的事件發生做出反應;這種控制逆轉又稱爲“好萊塢法則”(不要調用我,讓我來調用你)

NIO爲實現Reactor模式提供了基礎,上面的NIO圖示其實就是Reactor模式的雛形,只是Reactor以OO的方式抽象出了幾個概念,使得職責劃分更加明確。

  • Reactor:Reactor是IO事件的派發者,對應NIO的Selector;
  • Acceptor:Acceptor接受client連接,建立對應client的Handler,並向Reactor註冊此Handler,對應NIO中註冊Channel和事件觸發時的判斷分支(上述NIO服務端示例代碼的38-46行);
  • Handler:IO處理類,對應NIO中Channel[使用socket]操作Buffer的過程。

 

3.1 單線程Reactor模式流程

  1. 服務器端的Reactor是一個線程對象,該線程會啓動事件循環,並使用Selector(選擇器)來實現IO的多路複用。註冊一個Acceptor事件處理器到Reactor中,Acceptor事件處理器所關注的事件是ACCEPT事件,這樣Reactor會監聽客戶端向服務器端發起的連接請求事件(ACCEPT事件)。
  2.  客戶端向服務器端發起一個連接請求,Reactor監聽到了該ACCEPT事件的發生並將該ACCEPT事件派發給相應的Acceptor處理器來進行處理。Acceptor處理器通過accept()方法得到與這個客戶端對應的連接(SocketChannel),然後將該連接所關注的READ事件以及對應的READ事件處理器註冊到Reactor中,這樣一來Reactor就會監聽該連接的READ事件了。
  3.  當Reactor監聽到有讀或者寫事件發生時,將相關的事件派發給對應的處理器進行處理。比如,讀處理器會通過SocketChannel的read()方法讀取數據,此時read()操作可以直接讀取到數據,而不會堵塞與等待可讀的數據到來。
  4. 每當處理完所有就緒的感興趣的I/O事件後,Reactor線程會再次執行select()阻塞等待新的事件就緒並將其分派給對應處理器進行處理。

注意,Reactor的單線程模式的單線程主要是針對於I/O操作而言,也就是所有的I/O的accept()、read()、write()以及connect()操作都在一個線程上完成的。

 

但在目前的單線程Reactor模式中,不僅I/O操作在該Reactor線程上,連非I/O的業務操作也在該線程上進行處理了,這可能會大大延遲I/O請求的響應。所以我們應該將非I/O的業務邏輯操作從Reactor線程上卸載,以此來加速Reactor線程對I/O請求的響應。

 

3.2 單線程Reactor,工作者線程池

與單線程Reactor模式不同的是,添加了一個工作者線程池,並將非I/O操作從Reactor線程中移出轉交給工作者線程池來執行。這樣能夠提高Reactor線程的I/O響應,不至於因爲一些耗時的業務邏輯而延遲對後面I/O請求的處理。

使用線程池的優勢:

  1.  通過重用現有的線程而不是創建新線程,可以在處理多個請求時分攤在線程創建和銷燬過程產生的巨大開銷。
  2.  另一個額外的好處是,當請求到達時,工作線程通常已經存在,因此不會由於等待創建線程而延遲任務的執行,從而提高了響應性。
  3.  通過適當調整線程池的大小,可以創建足夠多的線程以便使處理器保持忙碌狀態。同時還可以防止過多線程相互競爭資源而使應用程序耗盡內存或失敗。

改進的版本中,所以的I/O操作依舊由一個Reactor來完成,包括I/O的accept()、read()、write()以及connect()操作。

對於一些小容量應用場景,可以使用單線程模型。但是對於高負載、大併發或大數據量的應用場景卻不合適,主要原因如下:

  1.  一個NIO線程同時處理成百上千的鏈路,性能上無法支撐,即便NIO線程的CPU負荷達到100%,也無法滿足海量消息的讀取和發送;
  2. 當NIO線程負載過重之後,處理速度將變慢,這會導致大量客戶端連接超時,超時之後往往會進行重發,這更加重了NIO線程的負載,最終會導致大量消息積壓和處理超時,成爲系統的性能瓶頸;

3.3 多Reactor線程模式

 

Reactor線程池中的每一Reactor線程都會有自己的Selector、線程和分發的事件循環邏輯。

mainReactor可以只有一個,但subReactor一般會有多個。mainReactor線程主要負責接收客戶端的連接請求,然後將接收到的SocketChannel傳遞給subReactor,由subReactor來完成和客戶端的通信。

流程:

  1. 註冊一個Acceptor事件處理器到mainReactor中,Acceptor事件處理器所關注的事件是ACCEPT事件,這樣mainReactor會監聽客戶端向服務器端發起的連接請求事件(ACCEPT事件)。啓動mainReactor的事件循環。
  2.  客戶端向服務器端發起一個連接請求,mainReactor監聽到了該ACCEPT事件並將該ACCEPT事件派發給Acceptor處理器來進行處理。Acceptor處理器通過accept()方法得到與這個客戶端對應的連接(SocketChannel),然後將這個SocketChannel傳遞給subReactor線程池。
  3. subReactor線程池分配一個subReactor線程給這個SocketChannel,即,將SocketChannel關注的READ事件以及對應的READ事件處理器註冊到subReactor線程中。當然你也註冊WRITE事件以及WRITE事件處理器到subReactor線程中以完成I/O寫操作。Reactor線程池中的每一Reactor線程都會有自己的Selector、線程和分發的循環邏輯。
  4. 當有I/O事件就緒時,相關的subReactor就將事件派發給響應的處理器處理。注意,這裏subReactor線程只負責完成I/O的read()操作,在讀取到數據後將業務邏輯的處理放入到線程池中完成,若完成業務邏輯後需要返回數據給客戶端,則相關的I/O的write操作還是會被提交回subReactor線程來完成。

注意,所以的I/O操作(包括,I/O的accept()、read()、write()以及connect()操作)依舊還是在Reactor線程(mainReactor線程 或 subReactor線程)中完成的。Thread Pool(線程池)僅用來處理非I/O操作的邏輯。

多Reactor線程模式將“接受客戶端的連接請求”和“與該客戶端的通信”分在了兩個Reactor線程來完成。mainReactor完成接收客戶端連接請求的操作,它不負責與客戶端的通信,而是將建立好的連接轉交給subReactor線程來完成與客戶端的通信,這樣一來就不會因爲read()數據量太大而導致後面的客戶端連接請求得不到即時處理的情況。並且多Reactor線程模式在海量的客戶端併發請求的情況下,還可以通過實現subReactor線程池來將海量的連接分發給多個subReactor線程,在多核的操作系統中這能大大提升應用的負載和吞吐量。

3.4 和觀察者模式的區別

觀察者模式:
  也可以稱爲爲 發佈-訂閱 模式,主要適用於多個對象依賴某一個對象的狀態並,當某對象狀態發生改變時,要通知其他依賴對象做出更新。是一種一對多的關係。當然,如果依賴的對象只有一個時,也是一種特殊的一對一關係。通常,觀察者模式適用於消息事件處理,監聽者監聽到事件時通知事件處理者對事件進行處理(這一點上面有點像是回調,容易與反應器模式和前攝器模式的回調搞混淆)。
Reactor模式:
  reactor模式,即反應器模式,是一種高效的異步IO模式,特徵是回調,當IO完成時,回調對應的函數進行處理。這種模式並非是真正的異步,而是運用了異步的思想,當IO事件觸發時,通知應用程序作出IO處理。模式本身並不調用系統的異步IO函數。

reactor模式與觀察者模式有點像。不過,觀察者模式與單個事件源關聯,而反應器模式則與多個事件源關聯 。當一個主體發生改變時,所有依屬體都得到通知。

四、NIO使用舉例

4.1、NioServerHandle

Nio通信服務端處理器

/**
 * @author DarkKing
 * 類說明:nio通信服務端處理器
 */
public class NioServerHandle implements Runnable {
    private Selector selector;
    private ServerSocketChannel serverChannel;
    private volatile boolean started;

    /**
     * 構造方法
     *
     * @param port 指定要監聽的端口號
     */
    public NioServerHandle(int port) {
        try {
            //創建選擇器
            selector = Selector.open();
            //打開監聽通道
            serverChannel = ServerSocketChannel.open();
            //如果爲 true,則此通道將被置於阻塞模式;
            // 如果爲 false,則此通道將被置於非阻塞模式
            serverChannel.configureBlocking(false);//開啓非阻塞模式
            serverChannel.socket().bind(new InetSocketAddress(port));
            serverChannel.register(selector, SelectionKey.OP_ACCEPT);

            //標記服務器已開啓
            started = true;
            System.out.println("服務器已啓動,端口號:" + port);
        } catch (IOException e) {
            e.printStackTrace();
            System.exit(1);
        }
    }

    public void stop() {
        started = false;
    }

    @Override
    public void run() {
        //循環遍歷selector
        while (started) {
            try {
                //阻塞,只有當至少一個註冊的事件發生的時候纔會繼續.
                selector.select();
                Set<SelectionKey> keys = selector.selectedKeys();
                Iterator<SelectionKey> it = keys.iterator();
                SelectionKey key = null;
                while (it.hasNext()) {
                    key = it.next();
                    it.remove();
                    try {
                        handleInput(key);
                    } catch (Exception e) {
                        if (key != null) {
                            key.cancel();
                            if (key.channel() != null) {
                                key.channel().close();
                            }
                        }
                    }
                }
            } catch (Throwable t) {
                t.printStackTrace();
            }
        }
        //selector關閉後會自動釋放裏面管理的資源
        if (selector != null)
            try {
                selector.close();
            } catch (Exception e) {
                e.printStackTrace();
            }
    }

    private void handleInput(SelectionKey key) throws IOException {
        if (key.isValid()) {
            //處理新接入的請求消息
            if (key.isAcceptable()) {
                ServerSocketChannel ssc = (ServerSocketChannel) key.channel();
                SocketChannel sc = ssc.accept();
                System.out.println("=======建立連接===");
                sc.configureBlocking(false);
                sc.register(selector, SelectionKey.OP_READ);
            }

            //讀消息
            if (key.isReadable()) {
                System.out.println("======socket channel 數據準備完成," +
                        "可以去讀==讀取=======");
                SocketChannel sc = (SocketChannel) key.channel();
                //創建ByteBuffer,並開闢一個1M的緩衝區
                ByteBuffer buffer = ByteBuffer.allocate(1024);
                //讀取請求碼流,返回讀取到的字節數
                int readBytes = sc.read(buffer);
                //讀取到字節,對字節進行編解碼
                if (readBytes > 0) {
                    //將緩衝區當前的limit設置爲position,position=0,
                    // 用於後續對緩衝區的讀取操作
                    buffer.flip();
                    //根據緩衝區可讀字節數創建字節數組
                    byte[] bytes = new byte[buffer.remaining()];
                    //將緩衝區可讀字節數組複製到新建的數組中
                    buffer.get(bytes);
                    String message = new String(bytes, "UTF-8");
                    System.out.println("服務器收到消息:" + message);
                    //處理數據
                    String result = Const.response(message);
                    //發送應答消息
                    doWrite(sc, result);
                }
                //鏈路已經關閉,釋放資源
                else if (readBytes < 0) {
                    key.cancel();
                    sc.close();
                }
            }
        }
    }

    //發送應答消息
    private void doWrite(SocketChannel channel, String response)
            throws IOException {
        //將消息編碼爲字節數組
        byte[] bytes = response.getBytes();
        //根據數組容量創建ByteBuffer
        ByteBuffer writeBuffer = ByteBuffer.allocate(bytes.length);
        //將字節數組複製到緩衝區
        writeBuffer.put(bytes);
        //flip操作
        writeBuffer.flip();
        //發送緩衝區的字節數組
        channel.write(writeBuffer);
    }

}

4.2、NioServer

nio通信服務端

/**
 * @author DarkKing
 * 類說明:nio通信服務端
 */
public class NioServer {
    private static NioServerHandle nioServerHandle;

    public static void start() {
        if (nioServerHandle != null)
            nioServerHandle.stop();
        nioServerHandle = new NioServerHandle(Const.DEFAULT_PORT);
        new Thread(nioServerHandle, "Server").start();
    }

    public static void main(String[] args) {
        start();
    }

}

4.3NioClientHandlenio

通信客戶端處理器


/**
 * @author DarkKing
 * 類說明:nio通信客戶端處理器
 */
public class NioClientHandle implements Runnable {
    private String host;
    private int port;
    private volatile boolean started;
    private Selector selector;
    private SocketChannel socketChannel;


    public NioClientHandle(String ip, int port) {
        this.host = ip;
        this.port = port;
        try {
            /*創建選擇器*/
            this.selector = Selector.open();
            /*打開監聽通道*/
            socketChannel = SocketChannel.open();
            /*如果爲 true,則此通道將被置於阻塞模式;
             * 如果爲 false,則此通道將被置於非阻塞模式
             * 缺省爲true*/
            socketChannel.configureBlocking(false);
            started = true;
        } catch (IOException e) {
            e.printStackTrace();
            System.exit(-1);
        }
    }

    public void stop() {
        started = false;
    }


    @Override
    public void run() {
        //連接服務器
        try {
            doConnect();
        } catch (IOException e) {
            e.printStackTrace();
            System.exit(-1);
        }
        /*循環遍歷selector*/
        while (started) {
            try {
                /*阻塞方法,當至少一個註冊的事件發生的時候就會繼續*/
                selector.select();
                /*獲取當前有哪些事件可以使用*/
                Set<SelectionKey> keys = selector.selectedKeys();
                /*轉換爲迭代器*/
                Iterator<SelectionKey> it = keys.iterator();
                SelectionKey key = null;
                while (it.hasNext()) {
                    key = it.next();
                    /*我們必須首先將處理過的 SelectionKey 從選定的鍵集合中刪除。
                    如果我們沒有刪除處理過的鍵,那麼它仍然會在事件集合中以一個激活
                    的鍵出現,這會導致我們嘗試再次處理它。*/
                    it.remove();
                    try {
                        handleInput(key);
                    } catch (Exception e) {
                        if (key != null) {
                            key.cancel();
                            if (key.channel() != null) {
                                key.channel().close();
                            }
                        }
                    }
                }

            } catch (IOException e) {
                e.printStackTrace();
                System.exit(-1);
            }
        }

        if (selector != null) {
            try {
                selector.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }

    }

    /*具體的事件處理方法*/
    private void handleInput(SelectionKey key) throws IOException {
        if (key.isValid()) {
            /*獲得關心當前事件的channel*/
            SocketChannel sc = (SocketChannel) key.channel();
            /*處理連接就緒事件
             * 但是三次握手未必就成功了,所以需要等待握手完成和判斷握手是否成功*/
            if (key.isConnectable()) {
                /*finishConnect的主要作用就是確認通道連接已建立,
                方便後續IO操作(讀寫)不會因連接沒建立而
                導致NotYetConnectedException異常。*/
                if (sc.finishConnect()) {
                    /*連接既然已經建立,當然就需要註冊讀事件,
                    寫事件一般是不需要註冊的。*/
                    socketChannel.register(selector, SelectionKey.OP_READ);
                } else System.exit(-1);
            }

            /*處理讀事件,也就是當前有數據可讀*/
            if (key.isReadable()) {
                /*創建ByteBuffer,並開闢一個1k的緩衝區*/
                ByteBuffer buffer = ByteBuffer.allocate(1024);
                /*將通道的數據讀取到緩衝區,read方法返回讀取到的字節數*/
                int readBytes = sc.read(buffer);
                if (readBytes > 0) {
                    buffer.flip();
                    byte[] bytes = new byte[buffer.remaining()];
                    buffer.get(bytes);
                    String result = new String(bytes, "UTF-8");
                    System.out.println("客戶端收到消息:" + result);
                }
                /*鏈路已經關閉,釋放資源*/
                else if (readBytes < 0) {
                    key.cancel();
                    sc.close();
                }

            }
        }
    }

    /*進行連接*/
    private void doConnect() throws IOException {
        /*如果此通道處於非阻塞模式,則調用此方法將啓動非阻塞連接操作。
        如果連接馬上建立成功,則此方法返回true。
        否則,此方法返回false,
        因此我們必須關注連接就緒事件,
        並通過調用finishConnect方法完成連接操作。*/
        if (socketChannel.connect(new InetSocketAddress(host, port))) {
            /*連接成功,關注讀事件*/
            socketChannel.register(selector, SelectionKey.OP_READ);
        } else {
            socketChannel.register(selector, SelectionKey.OP_CONNECT);
        }
    }

    /*寫數據對外暴露的API*/
    public void sendMsg(String msg) throws IOException {
        doWrite(socketChannel, msg);
    }

    private void doWrite(SocketChannel sc, String request) throws IOException {
        byte[] bytes = request.getBytes();
        ByteBuffer writeBuffer = ByteBuffer.allocate(bytes.length);
        writeBuffer.put(bytes);
        writeBuffer.flip();
        sc.write(writeBuffer);
    }
}

4.4、NioClient

nio通信客戶端

/**
 * @author DarkKing
 * 類說明:nio通信客戶端
 */
public class NioClient {
    private static NioClientHandle nioClientHandle;

    public static void start() {
        if (nioClientHandle != null)
            nioClientHandle.stop();
        nioClientHandle = new NioClientHandle(Const.DEFAULT_SERVER_IP, Const.DEFAULT_PORT);
        new Thread(nioClientHandle, "Server").start();
    }

    //向服務器發送消息
    public static boolean sendMsg(String msg) throws Exception {
        nioClientHandle.sendMsg(msg);
        return true;
    }

    public static void main(String[] args) throws Exception {
        start();
        Scanner scanner = new Scanner(System.in);
        while (NioClient.sendMsg(scanner.next())) ;

    }

}

4.5 效果演示

執行Nioserver服務

執行Nioclient,啓動客戶端,服務端打印

 

客戶端輸入

服務端接收客戶端數據,並返回消息

致此,JAVA IO相關的已經介紹的差不多了。當然還有AIO,但是因爲linux系統還不支持異步IO,顧暫時不做多講,下一節開始講NIO的應用。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章