天天看點

JDK NIO程式設計

我們首先需要澄清一個概念:NIO到底是什麼的簡稱?有人稱之為New I/O,因為它相對于之前的I/O類庫是新增的,是以被稱為New I/O,這是它的官方叫法。但是,由于之前老的I/O類庫是阻塞I/O,New I/O類庫的目标就是要讓Java支援非阻塞I/O,是以,更多的人喜歡稱之為非阻塞I/O(Non-block I/O),由于非阻塞I/O更能夠展現NIO的特點。

與Socket類和ServerSocket類相對應,NIO也提供了SocketChannel和ServerSocketChannel兩種不同的套接字通道實作。這兩種新增的通道支援阻塞和非阻塞兩種模式。阻塞模式使用非常簡單,但是性能和可靠性都不好,非阻塞模式則正好相反。開發人員一般可以根據自己的需要來選擇合适的模式,一般來說,低負載、低并發的應用程式可以選擇同步阻塞I/O以降低程式設計複雜度,但是對于高負載、高并發的網絡應用,需要使用NIO的非阻塞模式進行開發。

新的輸入/輸出(NIO)庫是在JDK 1.4中引入的。NIO彌補了原來同步阻塞I/O的不足,它在标準Java代碼中提供了高速的、面向塊的I/O。通過定義包含資料的類,以及通過以塊的形式處理這些資料,NIO不用使用本機代碼就可以利用低級優化,這是原來的I/O包所無法做到的。

1.緩沖區Buffer

我們首先介紹緩沖區(Buffer)的概念,Buffer是一個對象,它包含一些要寫入或者要讀出的資料。在NIO類庫中加入Buffer對象,展現了新庫與原I/O的一個重要差別。在面向流的I/O中,可以将資料直接寫入或者将資料直接讀到Stream對象中。

在NIO庫中,所有資料都是用緩沖區處理的。在讀取資料時,它是直接讀到緩沖區中的;在寫入資料時,寫入到緩沖區中。任何時候通路NIO中的資料,都是通過緩沖區進行操作。

緩沖區實質上是一個數組。通常它是一個位元組數組(ByteBuffer),也可以使用其他種類的數組。但是一個緩沖區不僅僅是一個數組,緩沖區提供了對資料的結構化通路以及維護讀寫位置(limit)等資訊。

最常用的緩沖區是ByteBuffer,一個ByteBuffer提供了一組功能用于操作byte數組。除了ByteBuffer,還有其他的一些緩沖區,事實上,每一種Java基本類型(除了Boolean類型)都對應有一種緩沖區,具體如下:

ByteBuffer:位元組緩沖區

CharBuffer:字元緩沖區

ShortBuffer:短整型緩沖區

IntBuffer:整形緩沖區

LongBuffer:長整形緩沖區

FloatBuffer:浮點型緩沖區

DoubleBuffer:雙精度浮點型緩沖區

JDK NIO程式設計

每一個Buffer類都是Buffer接口的一個子執行個體。除了ByteBuffer,每一個 Buffer類都有完全一樣的操作,隻是它們所處理的資料類型不一樣。因為大多數标準I/O操作都使用ByteBuffer,是以它除了具有一般緩沖區的操作之外還提供一些特有的操作,友善網絡讀寫。

2.通道Channel

Channel是一個通道,可以通過它讀取和寫入資料,它就像自來水管一樣,網絡資料通過Channel讀取和寫入。通道與流的不同之處在于通道是雙向的,流隻是在一個方向上移動(一個流必須是InputStream或者OutputStream的子類),而且通道可以用于讀、寫或者同時用于讀寫。

因為Channel是全雙工的,是以它可以比流更好地映射底層作業系統的API。特别是在UNIX網絡程式設計模型中,底層作業系統的通道都是全雙工的,同時支援讀寫操作。

JDK NIO程式設計

自頂向下看,前三層主要是Channel接口,用于定義它的功能,後面是一些具體的功能類(抽象類),從類圖可以看出,實際上Channel可以分為兩大類:分别是用于網絡讀寫的SelectableChannel和用于檔案操作的FileChannel。

3.多路複用器Selector

多路複用器Selector,它是Java NIO程式設計的基礎,熟練地掌握Selector對于掌握NIO程式設計至關重要。多路複用器提供選擇已經就緒的任務的能力。簡單來講,Selector會不斷地輪詢注冊在其上的Channel,如果某個Channel上面有新的TCP連接配接接入、讀和寫事件,這個Channel就處于就緒狀态,會被Selector輪詢出來,然後通過SelectionKey可以擷取就緒Channel的集合,進行後續的I/O操作。

一個多路複用器Selector可以同時輪詢多個Channel,由于JDK使用了epoll()代替傳統的select實作,是以它并沒有最大連接配接句柄1024/2048的限制。這也就意味着隻需要一個線程負責Selector的輪詢,就可以接入成千上萬的用戶端,這确實是個非常巨大的進步。

JDK NIO程式設計

下面,我們對NIO服務端的主要建立過程進行講解和說明,作為NIO的基礎入門,我們将忽略掉一些在生産環境中部署所需要的一些特性和功能。

步驟一:打開ServerSocketChannel,用于監聽用戶端的連接配接,它是所有用戶端連接配接的父管道,代碼示例如下。

ServerSocketChannel acceptorSvr = ServerSocketChannel.open();

步驟二:綁定監聽端口,設定連接配接為非阻塞模式,示例代碼如下。

acceptorSvr.socket().bind(new InetSocketAddress(InetAddress.getByName(“IP”), port));

acceptorSvr.configureBlocking(false);

步驟三:建立Reactor線程,建立多路複用器并啟動線程,代碼如下。

Selector selector = Selector.open();

new Thread(new ReactorTask()).start();

步驟四:将ServerSocketChannel注冊到Reactor線程的多路複用器Selector上,監聽ACCEPT事件,代碼如下。

SelectionKey key = acceptorSvr.register( selector, SelectionKey.OP_ACCEPT, ioHandler);

步驟五:多路複用器線上程run方法的無限循環體内輪詢準備就緒的Key,代碼如下。

int num = selector.select();

Set selectedKeys = selector.selectedKeys();

Iterator it = selectedKeys.iterator();

while (it.hasNext()) {

SelectionKey key = (SelectionKey)it.next();

// ... deal with I/O event ...

}

步驟六:多路複用器監聽到有新的用戶端接入,處理新的接入請求,完成TCP三向交握,建立實體鍊路,代碼示例如下。

SocketChannel channel = svrChannel.accept();

步驟七:設定用戶端鍊路為非阻塞模式,示例代碼如下。

channel.configureBlocking(false);

channel.socket().setReuseAddress(true);

......

步驟八:将新接入的用戶端連接配接注冊到Reactor線程的多路複用器上,監聽讀操作,用來讀取用戶端發送的網絡消息,代碼如下。

SelectionKey key = socketChannel.register( selector, SelectionKey.OP_READ, ioHandler);

步驟九:異步讀取用戶端請求消息到緩沖區,示例代碼如下。

int readNumber = channel.read(receivedBuffer);

步驟十:對ByteBuffer進行編解碼,如果有半包消息指針reset,繼續讀取後續的封包,将解碼成功的消息封裝成Task,投遞到業務線程池中,進行業務邏輯編排。

Object message = null;

while(buffer.hasRemain())

{

  byteBuffer.mark();

  Object message = decode(byteBuffer);

  if (message == null)

  {

    byteBuffer.reset();

    break;

  }

  messageList.add(message );

if (!byteBuffer.hasRemain())

  byteBuffer.clear();

else

  byteBuffer.compact();

if (messageList != null & !messageList.isEmpty())

  for(Object messageE : messageList)

    handlerTask(messageE);

步驟十一:将POJO對象encode成ByteBuffer,調用SocketChannel的異步write接口,将消息異步發送給用戶端,示例代碼如下。

socketChannel.write(buffer);

注意:如果發送區TCP緩沖區滿,會導緻寫半包,此時,需要注冊監聽寫操作位,循環寫,直到整包消息寫入TCP緩沖區。

JDK NIO程式設計

步驟一:打開SocketChannel,綁定用戶端本地位址(可選,預設系統會随機配置設定一個可用的本地位址),示例代碼如下。

SocketChannel clientChannel = SocketChannel.open();

步驟二:設定SocketChannel為非阻塞模式,同時設定用戶端連接配接的TCP參數,示例代碼如下。

clientChannel.configureBlocking(false);

socket.setReuseAddress(true);

socket.setReceiveBufferSize(BUFFER_SIZE);

socket.setSendBufferSize(BUFFER_SIZE);

步驟三:異步連接配接服務端,示例代碼如下。

boolean connected=clientChannel.connect(new InetSocketAddress(“ip”,port));

步驟四:判斷是否連接配接成功,如果連接配接成功,則直接注冊讀狀态位到多路複用器中,如果目前沒有連接配接成功(異步連接配接,傳回false,說明用戶端已經發送sync包,服務端沒有傳回ack包,實體鍊路還沒有建立),示例代碼如下。

if (connected)

  clientChannel.register( selector, SelectionKey.OP_READ, ioHandler);

  clientChannel.register( selector, SelectionKey.OP_CONNECT, ioHandler);

步驟五:向Reactor線程的多路複用器注冊OP_CONNECT狀态位,監聽服務端的TCP ACK應答,示例代碼如下。

步驟六:建立Reactor線程,建立多路複用器并啟動線程,代碼如下。

  Selector selector = Selector.open();

  new Thread(new ReactorTask()).start();

步驟七:多路複用器線上程run方法的無限循環體内輪詢準備就緒的Key,代碼如下。

  SelectionKey key = (SelectionKey)it.next();

  // ... deal with I/O event ...

步驟八:接收connect事件進行處理,示例代碼如下。

if (key.isConnectable())

  handlerConnect();

步驟九:判斷連接配接結果,如果連接配接成功,注冊讀事件到多路複用器,示例代碼如下。

if (channel.finishConnect())

  registerRead();

步驟十:注冊讀事件到多路複用器,示例代碼如下。

clientChannel.register( selector, SelectionKey.OP_READ, ioHandler);

步驟十一:異步讀用戶端請求消息到緩沖區,示例代碼如下。

步驟十二:對ByteBuffer進行編解碼,如果有半包消息接收緩沖區Reset,繼續讀取後續的封包,将解碼成功的消息封裝成Task,投遞到業務線程池中,進行業務邏輯編排,示例代碼如下。

步驟十三:将POJO對象encode成ByteBuffer,調用SocketChannel的異步write接口,将消息異步發送給用戶端,示例代碼如下。

我們發現NIO程式設計難度确實比同步阻塞BIO大很多,我們的NIO例程并沒有考慮“半包讀”和“半包寫”,如果加上這些,代碼将會更加複雜。NIO代碼既然這麼複雜,為什麼它的應用卻越來越廣泛呢,使用NIO程式設計的優點總結如下。

(1)用戶端發起的連接配接操作是異步的,可以通過在多路複用器注冊OP_CONNECT等待後續結果,不需要像之前的用戶端那樣被同步阻塞。

(2)SocketChannel的讀寫操作都是異步的,如果沒有可讀寫的資料它不會同步等待,直接傳回,這樣I/O通信線程就可以處理其他的鍊路,不需要同步等待這個鍊路可用。

(3)線程模型的優化:由于JDK的Selector在Linux等主流作業系統上通過epoll實作,它沒有連接配接句柄數的限制(隻受限于作業系統的最大句柄數或者對單個程序的句柄限制),這意味着一個Selector線程可以同時處理成千上萬個用戶端連接配接,而且性能不會随着用戶端的增加而線性下降,是以,它非常适合做高性能、高負載的網絡伺服器。

JDK1.7更新了NIO類庫,更新後的NIO類庫被稱為NIO2.0,引人注目的是,Java正式提供了異步檔案I/O操作,同時提供了與UNIX網絡程式設計事件驅動I/O對應的AIO。