Java NIO:選擇器

油多壞不了菜 發表於 2020-10-18

最近打算把Java網路程式設計相關的知識深入一下(IO、NIO、Socket程式設計、Netty)

Java NIO主要需要理解緩衝區、通道、選擇器三個核心概念,作為對Java I/O的補充, 以提升大批量資料傳輸的效率。

學習NIO之前最好能有基礎的網路程式設計知識

Java I/O流

Java 網路程式設計

Java NIO:緩衝區

Java NIO:通道

傳統監控多個Socket的Java解決方案是為每一個Socket建立一個執行緒並使執行緒阻塞在read呼叫處, 直到資料可讀。這種方式在系統併發不高時可以正常執行,如果是併發很高的系統就需要建立很多的執行緒(每個連線需要一個執行緒)。過多的執行緒會導致頻繁的上下文切換、且執行緒是系統資源,可建立最大執行緒數是有限制的且遠小於可以建立的網路連線數。

NIO的選擇器就是為了解決這個問題, 選擇器提供了同時詢問多個通道是否準備好執行I/O的能力,比如SocketChannel物件是否還有更多的位元組待讀取, ServerSocketChannel是否有已經到達的客戶端連線。

通過使用選擇器,我們可以在一個執行緒裡監聽多個通道的就緒狀態!

核心概念

選擇器 :管理可選擇通道集合&更新可選擇通道的就緒狀態

可選擇通道:所有繼承了SelectableChannel的通道, Socket都是可選擇的,而檔案通道不是,只有可選擇通道可以註冊到選擇器上。

選擇鍵:可選擇通道註冊到選擇器後返回選擇鍵, 所以選擇鍵其實是通道與選擇器註冊關係的一個封裝

三者之間的關係:可選擇通道註冊到選擇器上,返回選擇鍵

選擇器使用

使用選擇器的步驟一般是:

  1. 構造選擇器

  2. 可選擇通道註冊到選擇器

  3. 選擇器選擇(選擇出就緒通道)

  4. 對就緒通道進行讀寫操作

  5. 重複 2 ~ 4

    下面從這幾步進行講解

構造選擇器

使用靜態工廠方法構造(底層使用SelectorProvider建立Selector例項, SelectorProvider支援java spi擴充套件)

Selector selector = Selector.open();	

可選擇通道註冊到選擇器

只有執行在非阻塞模式下的通道可以註冊到選擇器上

註冊的方法定義在SelectableChannel類中, 註冊時需帶上可選擇的操作(四種可選擇操作,定義在SelectionKey中),也可以帶上附件

註冊成功返回選擇鍵,具體API如下:

//引數 選擇器 + 可選擇操作
public final SelectionKey register(Selector sel, int ops)
//帶附件的版本
public abstract SelectionKey register(Selector sel, int ops, Object att)

選擇器選擇

select方法選擇出就緒的通道,把該就緒通道關聯的SelectionKey放到選擇器的selectedKeys集合中(通道就緒指底層Socket已經就緒,執行連線或者讀寫操作時不會阻塞)

對就緒通道進行讀寫操作

對就緒通道的讀寫操作見下面Demo

Demo

寫了一個demo把上面幾步整合起來,程式碼主要兩部分:可選擇通道註冊&選擇器選擇 和 對就緒通道進行讀寫操作

可選擇通道註冊&選擇器選擇

  //構造選擇器
  Selector selector = Selector.open();
  //初始化ServerSocketChannel繫結本地埠並設定為非阻塞模式
  ServerSocketChannel ch = ServerSocketChannel.open();
  ch.bind(new InetSocketAddress("127.0.0.1", 7001));
  ch.configureBlocking(false);
  //通道註冊到選擇器,並且關心ACCEPT操作(因為是Server)
  ch.register(selector, SelectionKey.OP_ACCEPT);
  //一直迴圈, 進行通道就緒狀態的選擇	
  while (true) {
    int n = selector.select();
    if (n == 0) {
      continue;
    }
    Iterator<SelectionKey> it = selector.selectedKeys().iterator();
    //遍歷所有就緒的通道
    while (it.hasNext()) {
      SelectionKey key = it.next();
      //如果就緒操作是ACCEPT, 接受客戶端連線並註冊到選擇器。
      if (key.isAcceptable()) {
        try {
          SocketChannel cch = ch.accept();
          cch.configureBlocking(false);
          //客戶端通道註冊到選擇選擇器,並關心READ操作
          cch.register(selector, SelectionKey.OP_READ, ByteBuffer.allocate(1024));
        } catch (IOException e) {
          e.printStackTrace();
        }
      } else {
        //如果是其他就緒操作, 提交到執行緒池處理
        pool.submit(() -> handle(key));
      }
      //移除該選擇鍵
      it.remove();
    }
  }

對就緒通道進行讀寫操作

public static void handle(SelectionKey key) {
    //如果通道是讀就緒的
    if (key.isReadable()) {
        key.interestOps(key.interestOps() & (~SelectionKey.OP_READ));	
        SocketChannel ch = (SocketChannel) key.channel();
        ByteBuffer bf = (ByteBuffer) key.attachment();
        try {
            int n = ch.read(bf);
          	//讀取資料(ascii),並簡單輸出
            if (n > 0) {
                bf.flip();
                StringBuilder builder = new StringBuilder();
                while (bf.hasRemaining()) {
                    builder.append((char) bf.get());
                }
                System.out.print(builder.toString());
                bf.clear();
                key.interestOps(key.interestOps() | SelectionKey.OP_READ);
                key.selector().wakeup();
            } else if (n < -1) { //關閉連線
                ch.close();
            }
        } catch (IOException e) {
            //
        }
    }
}

選擇器深入

可選擇的操作

一共有四種可選擇的操作(OP_READ、OP_WRITE、OP_ACCEPT、OP_ACCEPT),下為Socket通道對這四種可選擇操作的支援情況

OP_READ OP_WRITE OP_ACCEPT OP_CONNECT
SocketChannel 支援 支援 不支援 支援
SeverScoketChannel 支援 支援 支援 不支援
DatagramChannel 支援 支援 不支援 不支援

概括來說:客戶端Socket通道不關心ACCEPT操作, 服務端Socket通道不關心CONNECT操作,資料包Socket通道只關心READ和WRITE操作

選擇鍵(SelectionKey)

可選擇的通道註冊到選擇器,然後返回一個選擇鍵物件,即選擇鍵代表通道和選擇器的一個關聯關係。主要需要了解他的幾個屬性

  • interestOps:感興趣的可選擇操作集合,可選擇通道註冊到選取器的時候初始化,可以修改

  • readyOps: 就緒的可選擇操作集合, 選擇器選擇的時候會對該集合更新, 客戶端不能修改。

  • attachment:選擇鍵可以關聯一個物件,叫做附件(比如關聯一個緩衝區物件)

選擇器選擇過程

在瞭解具體選擇過程之前,我們先了解選擇器中三個鍵集合的含義

  • 已註冊的鍵的集合,通過keys方法返回。通道註冊的時候會把對應的選擇鍵加入該集合

  • 已選擇的鍵的集合,選擇器選擇的時候會把就緒的通道對應的選擇鍵放到該集合中

  • 已取消的鍵的集合,選擇鍵的cancel方法被呼叫後該選擇鍵會加入該集合

具體選擇過程:

  1. 如果已取消的鍵的集合非空, 將每個已取消的鍵從其他兩個集合中移除,並將相關的通道登出,最後將已取消鍵的集合清空。
  2. 詢問已註冊鍵的集合中通道的就緒狀態(系統呼叫),更新已選擇鍵的集合以及鍵的readyOps集合。(如果一個鍵在該次選擇操作之前就已在已選擇鍵的集合中,則更新該鍵的readyOps集合;否則把鍵加入到已選擇的集合中,並重置該鍵的readyOps集合)

最佳實踐

我們通常使用一個選擇器管理所有的可選擇通道,並將就緒通道的服務委託給其他執行緒,只需要一個執行緒監控通道的就緒狀態並使用一個協調好的工作執行緒池來讀寫資料

在這種方式下,進行相關操作前需要先將操作位從interestOps集合中移除,避免下次selector選擇時再將選擇鍵放到已選擇集合中(造成一次就緒多次處理的問題),相關操作結束後再把操作加入到interestOps集合中並且喚醒selector進行下一次選擇

下面是讀操作簡單偽碼

//讀就緒
if (key.isReadable()) {
  	//把READ從interestOps中移除
         key.interestOps(key.interestOps() & (~SelectionKey.OP_READ));
	//......資料讀取處理
  	//把READ加入到interestOps中
 	key.interestOps(key.interestOps() | SelectionKey.OP_READ);
  	//喚醒selector,重新選擇(因為鍵的interestOps變化了)
 	key.selector().wakeup();
}

總結

  1. 可選擇通道註冊到選擇器上,返回選擇鍵
  2. 選擇器核心思想:使用一個(或者少數個)選擇器管理所有的可選擇通道,每個選擇器使用一個執行緒監控就緒狀態,使用一個工作執行緒池處理就緒通道的資料讀寫