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. 選擇器核心思想:使用一個(或者少數個)選擇器管理所有的可選擇通道,每個選擇器使用一個執行緒監控就緒狀態,使用一個工作執行緒池處理就緒通道的數據讀寫