1. 程式人生 > >Java中的NIO與Netty框架

Java中的NIO與Netty框架

前言

  隨著移動網際網路的爆發性增長,小明公司的電子商務系統訪問量越來越大,由於現有系統是個單體的巨型應用,已經無法滿足海量的併發請求,拆分勢在必行。

  在微服務的大潮之中, 架構師通常會把系統拆分成了多個服務,根據需要部署在多個機器上,這些服務非常靈活,可以隨著訪問量彈性擴充套件。

  世界上沒有免費的午餐, 拆分成多個“微服務”以後雖然增加了彈性,但也帶來了一個巨大的挑戰:各個服務之間互相呼叫的開銷。

  比如說:原來使用者下一個訂單需要登入,瀏覽產品詳情,加入購物車,支付,扣庫存等一系列操作,在單體應用的時候它們都在一臺機器的同一個程序中,說白了就是模組之間的函式呼叫,效率超級高。

  現在好了,服務被安置到了不同的伺服器上,一個訂單流程,幾乎每個操作都要越網路,都是遠端過程呼叫(RPC), 那執行時間、執行效率可遠遠比不上以前了。

  遠端過程呼叫的第一版實現使用了HTTP協議,也就是說各個服務對外提供HTTP介面。HTTP協議雖然簡單明瞭,但是太過繁瑣太多,僅僅是給伺服器發個簡單的訊息都會附帶一大堆無用資訊:

GET /orders/1 HTTP/1.1                                                                                             
Host: order
.myshop.com User-Agent: Mozilla/5.0 (Windows NT 6.1; ) Accept: text/html; Accept-Language: en-US,en; Accept-Encoding: gzip Connection: keep-alive

  看看那User-Agent,Accept-Language ,這個協議明顯是為瀏覽器而生的!對於各個應用程式之間的呼叫,用HTTP協議得不償失。

  能不能自定義一個精簡的協議? 在這個協議中我們只需要把要呼叫方法名和引數發給伺服器即可,根本不用這麼多亂七八糟的額外資訊。

  但是自定義協議客戶端和伺服器端就得直接使用“低階”的Socket了,尤其是伺服器端,得能夠處理高併發的訪問請求才行。

阻塞IO與非阻塞IO

  至於伺服器端的socket程式設計,最早的Java是所謂的阻塞IO(Blocking IO), 想處理多個socket的連線的話需要建立多個執行緒, 一個執行緒對應一個。

  這種方式寫起來倒是挺簡單的,但是連線(socket)多了就受不了了,如果真的有成千上萬個執行緒同時處理成千上萬個socket,佔用大量的空間不說,光是執行緒之間的切換就是一個巨大的開銷。

  更重要的是,雖然有大量的socket,但是真正需要處理的(可以讀寫資料的socket)卻不多,大量的執行緒處於等待資料狀態(這也是為什麼叫做阻塞的原因),資源浪費得讓人心疼。

  後來Java為了解決這個問題,又搞了一個非阻塞IO(NIO:Non-Blocking IO,有人也叫做New IO), 改變了一下思路:通過多路複用的方式讓一個執行緒去處理多個Socket。

  這樣一來,只需要使用少量的執行緒就可以搞定多個socket了,執行緒只需要通過Selector去查一下它所管理的socket集合,哪個Socket的資料準備好了,就去處理哪個Socket,一點兒都不浪費。

  這樣一來,只需要使用少量的執行緒就可以搞定多個socket了,執行緒只需要通過Selector去查一下它所管理的socket集合,哪個Socket的資料準備好了,就去處理哪個Socket,一點兒都不浪費。

  Java NIO 由三個核心元件元件:
  Buffer
  Channel
  Selector

  緩衝區和通道是NIO中的核心物件,通道Channel是對原IO中流的模擬,所有資料都要通過通道進行傳輸;Buffer實質上是一個容器物件,傳送給通道的所有物件都必須首先放到一個緩衝區中。

  Buffer是一個數據物件,它包含要寫入或者剛讀出的資料。這是NIO與IO的一個重要區別,我們可以把它理解為固定數量的資料的容器,它包含一些要寫入或者讀出的資料。在面向流的I/O中你將資料直接寫入或者將資料直接讀到stream中,在Java NIO中,任何時候訪問NIO中的資料,都需要通過緩衝區(Buffer)進行操作。讀取資料時,直接從緩衝區中讀取,寫入資料時,寫入至緩衝區。緩衝區實質上是一個數組。通常它是一個位元組陣列,但是也可以使用其他種類的陣列。但是一個緩衝區不僅僅是一個數組。緩衝區提供了對資料的結構化訪問,而且還可以跟蹤系統的讀/寫程序。簡單的說Buffer是:一塊連續的記憶體塊,是NIO資料讀或寫的中轉地。NIO最常用的緩衝區則是ByteBuffer。下圖是 Buffer 繼承關係圖:

  從類圖可以看出NIO為所有的原始資料型別都實現了Buffer快取的支援。並且看JDK_API可以得知除了ByteBuffer中的方法有所不同之外,其它類中的方法基本相同。

  Channel 是一個物件,可以通過它讀取和寫入資料。拿 NIO 與原來的 I/O 做個比較,通道就像是流。正如前面提到的,所有資料都通過 Buffer 物件來處理。你永遠不會將位元組直接寫入通道中,相反,你是將資料寫入包含一個或者多個位元組的緩衝區。同樣,你不會直接從通道中讀取位元組,而是將資料從通道讀入緩衝區,再從緩衝區獲取這個位元組。簡單的說Channel是:資料的源頭或者資料的目的地,用於向buffer提供資料或者讀取buffer資料,並且對I/O提供非同步支援

  下圖是 Channel 的類圖

  Channel 為最頂層介面,所有子 Channel 都實現了該介面,它主要用於 I/O 操作的連線。定義如下:

public interface Channel extends Closeable {
    /**
     * 判斷此通道是否處於開啟狀態。 
     */
    public boolean isOpen();
    /**
     *關閉此通道。
     */
    public void close() throws IOException;
}

  最為重要的Channel實現類為:
  FileChannel:一個用來寫、讀、對映和操作檔案的通道
  DatagramChannel:能通過UDP讀寫網路中的資料
  SocketChannel: 能通過TCP讀寫網路中的資料
  ServerSocketChannel:可以監聽新進來的 TCP 連線,像 Web 伺服器那樣。對每一個新進來的連線都會建立一個SocketChannel

  使用以下三個方法可以得到一個FileChannel的例項

FileInputStream.getChannel()
FileOutputStream.getChannel()
RandomAccessFile.getChannel()

  上面提到Channel是資料的源頭或者資料的目的地,用於向bufer提供資料或者從buffer讀取資料。那麼在實現了該介面的子類中應該有相應的read和write方法。

  在FileChannel中有以下方法可以使用:

/**
 * 讀取一串資料到緩衝區
 */
public long read(ByteBuffer[] dsts)
/**
 * 將緩衝區中指定位置的一串資料寫入到通道
 */
public long write(ByteBuffer[] srcs)

  多路複用器 Selector,它是 Java NIO 程式設計的基礎,它提供了選擇已經就緒的任務的能力。從底層來看,Selector 提供了詢問通道是否已經準備好執行每個 I/O 操作的能力。簡單來講,Selector 會不斷地輪詢註冊在其上的 Channel,如果某個 Channel 上面發生了讀或者寫事件,這個 Channel 就處於就緒狀態,會被 Selector 輪詢出來,然後通過 SelectionKey 可以獲取就緒 Channel 的集合,進行後續的 I/O 操作

  Selector 就是你註冊對各種 I/O 事件的地方,而且當那些事件發生時,就是這個物件告訴你所發生的事件。Selector 允許一個執行緒處理多個 Channel ,也就是說只要一個執行緒複雜 Selector 的輪詢,就可以處理成千上萬個 Channel ,相比於多執行緒來處理勢必會減少執行緒的上下文切換問題

/**
 * 第一步:建立一個Selector
 */
Selector selector = Selector.open();
/**
 * 第二步:開啟一個遠端連線
 */
InetSocketAddress socketAddress =
new InetSocketAddress("www.baidu.com", 80);
SocketChannel sc = SocketChannel.open(socketAddress);
sc.configureBlocking(false);
/**
 * 第三步:選擇鍵,註冊
 */
SelectionKey key = sc.register(selector, SelectionKey.OP_CONNECT);
/**
 * 註冊時第一個引數總是當前的這個selector。
 * 註冊讀事件
 * 註冊寫事件
 */
SelectionKey key = sc.register(selector, SelectionKey.OP_READ);
SelectionKey key = sc.register(selector, SelectionKey.OP_WRITE);
/**
 * 第四步:內部迴圈處理
 */
int num = selector.select();
Set selectedKeys = selector.selectedKeys();
Iterator<SelectionKey> iterator = selectionKeys.iterator();
while (iterator.hasNext()) {
    SelectionKey key = (SelectionKey)it.next();
    SelectionKey selectionKey = iterator.next();
    iterator.remove();
    //handleKey(selectionKey);
    // ... deal with I/O event ...
}

  首先,我們呼叫 Selector 的 select() 方法。這個方法會阻塞,直到至少有一個已註冊的事件發生。當一個或者更多的事件發生時, select() 方法將返回所發生的事件的數量。該方法必須首先執行

  接下來,我們呼叫 Selector 的 selectedKeys() 方法,它返回發生了事件的 SelectionKey 物件的一個集合。SelectionKey中共定義了四種事件,OP_ACCEPT(socket accept)、OP_CONNECT(socket connect)、OP_READ(read)、OP_WRITE(write)。我們通過迭代 SelectionKeys 並依次處理每個 SelectionKey 來處理事件。對於每一個 SelectionKey,您必須確定發生的是什麼 I/O 事件,以及這個事件影響哪些 I/O 物件

  在處理 SelectionKey 之後,我們幾乎可以返回主迴圈了。但是我們必須首先將處理過的 SelectionKey 從選定的鍵集合中刪除。如果我們沒有刪除處理過的鍵,那麼它仍然會在主集合中以一個啟用的鍵出現,這會導致我們嘗試再次處理它。我們呼叫迭代器的 remove() 方法來刪除處理過的 SelectionKey

基於NIO的高併發RPC框架

  開發一個具有較好的穩定性和可靠性的 NIO 程式還是挺有難度的。於是 Netty 出現,把我們從水深火熱當中解救出來。說說Netty到底是何方神聖, 要解決什麼問題吧。回到前言中提到的例子,如果使用Java NIO來自定義一個高效能的RPC框架,呼叫協議,資料的格式和次序都是自己定義的,現有的HTTP根本玩不轉,那使用Netty就是絕佳的選擇。

  使用Netty的開源框架,可以快速地開發高效能的面向協議的伺服器和客戶端。 易用、健壯、安全、高效,你可以在Netty上輕鬆實現各種自定義的協議!其實遊戲領域是個更好的例子,長連線,自定義協議,高併發,Netty就是絕配。

  因為Netty本身就是一個基於NIO的網路框架, 封裝了Java NIO那些複雜的底層細節,給你提供簡單好用的抽象概念來程式設計。

  注意幾個關鍵詞,首先它是個框架,是個“半成品”,不能開箱即用,你必須得拿過來做點定製,利用它開發出自己的應用程式,然後才能執行(就像使用Spring那樣)。一個更加知名的例子就是阿里巴巴的Dubbo了,這個RPC框架的底層用的就是Netty。 另外一個關鍵詞是高效能,如果你的應用根本沒有高併發的壓力,那就不一定要用Netty了。


參考資料:https://blog.csdn.net/bjweimengshu/article/details/78786315