[NIO和Netty] NIO和Netty系列(一): NIO中selector、channel和buffer

最近看zookeeper源碼,發現底層的通信使用到了NIO和netty,接下來的系列記錄下NIO和Netty的學習,記錄完接着zookeeper源碼的學習。

java.io中最爲核心的概念是流(stream),是面向流的編程,一個流要麼是輸入流,要麼是輸出流,不可能同時即是輸入流又是輸出流;而java.nio是面向塊(block)或面向緩衝區(buffer)編程,塊或者緩衝區既可以作爲輸入也可以作爲輸出,nio中有三個核心的概念,即SelectorChannelBuffer

Channel

NIO中的Channel類似於IO中Stream,但與Stream又有所不同:

  • IO中一個Stream要麼是輸入流,要麼是輸出流,不能同時即是輸入流又是輸出流,而NIO中既可以從Channel中讀取數據,也可以向同一個Channel中寫數據;
  • Channel可以異步的讀寫,而Stream是同步的;
  • Channel總數向Buffer寫入數據,或者從Buffer讀取數據,在NIO編程中不能繞過Buffer直接與Channel讀寫數據;

如下圖所示爲NIO中的數據流向:
在這裏插入圖片描述
在NIO中一個Channel代表一個連接,例如連接到一個設備、文件、網絡socket等,Channel定義在java.nio.channels.Channel中,有如下幾個重要的實現類:

  • FileChannel:從文件讀取或向文件寫入數據,還可以將文件映射到一塊內存中(通過java.nio.channels.FileChannel#map方法);
  • DatagramChannel:通過UDP協議讀寫網絡數據;
  • SocketChannel:通過TCP協議讀寫網絡數據;
  • ServerSocketChannel:用來監聽TCP連接,一旦有連接進來通過調用java.nio.channels.ServerSocketChannel#accept方法獲取SocketChannel連接;

如下示例爲通過FileChannel將文件內容讀取到Buffer中:

    RandomAccessFile aFile = new RandomAccessFile("data/nio-data.txt", "rw");
    // 獲取FileChannel
    FileChannel inChannel = aFile.getChannel();

	 // 分配一個48字節大小的buffer,內部其實就是一個byte數組
    ByteBuffer buf = ByteBuffer.allocate(48);

	 // 通過FileChanne將文件內容讀取到buffer中,返回的bytesRead是讀取的字節數
    int bytesRead = inChannel.read(buf);
    while (bytesRead != -1) {

      System.out.println("Read " + bytesRead);
      // 將buffer切換到讀取模式,之前FileChannel寫入buffer是寫入模式,在Buffer相關內容會學習這個方法的作用
      buf.flip();

      // 將buffer中的數據一個一個字節讀取出來
      while(buf.hasRemaining()){
          System.out.print((char) buf.get());
      }
      
      // 相當於復位,爲下次將文件內容寫入buffer做準備buffer內容中會學習該方法作用
      buf.clear();
      // 繼續通過FileChannel將文件內容讀取到buffer
      bytesRead = inChannel.read(buf);
    }
    aFile.close();

Buffer

顧名思義,NIO中的Buffer就是緩衝區,數據都是從Channel讀取到Buffer,或者從Buffer寫入到Channel,Buffer就是我們編寫代碼和Channel之間的一個緩衝區。Buffer內部緩存數據的載體通常是一個特定類型的數組(ByteBuffer、IntBuffer…)或一塊內存(DirectByteBuffer…)。

buffer的基本使用分爲四個步驟,如下圖所示:
在這裏插入圖片描述

  1. 首先從Channel或我們的代碼中往Buffer寫入數據;
  2. 然後調用flip方法切換到讀模式;
  3. 將數據從Buffer讀取到我們的代碼中或讀取後寫入到Channel中;
  4. 調用clear方法爲下一次向Buffer寫入數據做準備;

使用到了Bufferclearcompactflip等方法,在瞭解這些方法之前我們先了解Buffer中維護緩存數據的幾個屬性: positionlimitcapacitymark.

Buffer中的Position、Limit、Capacity和Mark

Buffer中維護瞭如下幾個屬性:

  • Capacity:Buffer的容量,即Buffer最大能緩存的元素個數,這個值是在各個子類創建Buffer的allocate(size)方法中指定的,一旦Buffer創建好該值不能更改;
  • Limit:第一個不能讀取或寫入的位置的索引,也就是一個Buffer最大能讀取或寫入的位置索引是(Limit - 1),Limit總是小於或等於Capacity;
  • Position:下一個能讀取或寫入的位置的索引,Position的值總是要小於或等於Limit;

此外Buffer中還維護了Mark屬性,Mark顧名思義就是標記,舉個例子:假設要往Buffer中放入10個數字,當放到第五個數字時調用Buffer的mark()方法標記下,此時Buffer中mark屬性的值就是4(從0開始),接着繼續往Buffer中放入剩下的數字,此時position值爲9,若此時調用Buffer的reset方法,則position被重置爲mark的值(4),可以繼續往Buffer中放入數據,覆蓋mark位置之後的數據。

Buffer中幾個重要的操作

Buffer提供了幾個常用的方法來修改上述幾個屬性的值,包括clearfliprewindsliceduplicate等。

clear操作

clear方法的源代碼如下:

    public Buffer clear() {
        position = 0;
        limit = capacity;
        mark = -1;
        return this;
    }

可見clear方法只是將position重置爲0,將limit重置爲capacity,相當於將Buffer恢復到初始狀態,但Buffer裏面保存的數據並未清除,當下次向Buffer中寫入數據時會纔會將這些數據清除。

flip操作

flip操作的源碼如下:

    public Buffer flip() {
        limit = position;
        position = 0;
        mark = -1;
        return this;
    }

可見flip方法只是將limit重置爲position,將position值重置爲0,mark重置爲-1。那麼爲什麼在切換讀寫buffer模式時需要調用flip方法呢,如下圖所示:
在這裏插入圖片描述
灰色部分代表該位置實際不存在,可見調用flip方法的作用,將position指向第0個位置,limit指向可以讀取的最後一個數據的下一個位置,這樣限定了從Buffer讀取數據的首末位置(讀取position到limit之間的數據)。每次由向Buffer寫入數據切換到從Buffer讀取數據前必須調用flip方法,否則讀取的數據內容不確定

rewind操作

rewind操作源碼如下:

    public Buffer rewind() {
        position = 0;
        mark = -1;
        return this;
    }

rewind會將position重新置爲0,通常用在這樣希望重複讀取Buffer的場景,比如已經向Buffer中寫入了數據並且調用flip限定讀取範圍後讀取了Buffer一次,然後又想再次讀取Buffer的內容,就可以調用rewind方法將position重置爲0(limit位置不變),重新讀取數據。

slice操作

使用當前Buffer創建一個新的Buffer,新Buffer和原來的Buffer使用同一個數據載體(同一個數組或同一塊內存),因此,當修改舊Buffer中數據時,會影響到新Buffer中的數據,新Buffer中的數據時舊Buffer中position到limit之間的數據,slice操作過程如下圖所示:
在這裏插入圖片描述
舊Buffer如果是隻讀的,那麼slice返回的Buffer也是隻讀的。新Buffer數據載體和舊Buffer是共享的,但positionlimitcapacity等屬性是獨立於舊Buffer的。

DirectByteBuffer和MappedByteBuffer

DirectByteBuffer用於管理(分配、回收、讀寫數據)堆外內存。堆外內存是相對於JVM堆內內存來說的,堆內內存是jvm所管控的Java進程內存,我們平時在Java中創建的對象都處於堆內內存中,並且它們遵循jvm的內存管理機制,jvm會採用GC回收機制統一管理堆內內存。堆外內存就是存在於jvm管控之外的一塊內存區域,因此它是不受jvm的管控。

DirectByteBuffer是Java用於實現堆外內存的一個重要類,我們可以通過該類實現堆外內存的創建、使用和銷燬。

使用如下代碼即可分配一塊大小爲1024的堆外內存:

ByteBuffer directBuffer = ByteBuffer.allocateDirect(1024);

DirectByteBuffer提供了putXXX和getXXX系列方法來使用堆外內存,例如java.nio.DirectByteBuffer#putShort(short)方法將一個short值放入到堆外內存當中,java.nio.DirectByteBuffer#getShort(long)方法用於將內外內存的short數據取回來。

MappedByteBuffer是DirectByteBuffer的父類,MappedByteBuffer通過java.nio.channels.FileChannel#map將一個文件映射到一塊堆外內存區域,本質上是通過反射構造了一個DirectByteBuffer對象,並持有一個指向該文件的文件描述符(FileDescriptor,非文件映射的堆外內存該描述符爲null)。使用MappedByteBuffer減少了一次文件數據由內核空間拷貝到用戶進程空間的過程。更多詳細信息將會在零拷貝相關文章中分析。

Selector

Selector用語檢測一個或多個NIO Channel是否可讀、可寫、可連接等狀態,使用Selector可以實現用一個線程來管理多個Channel,從而減少線程的數量。如下示例爲向Selector註冊Channel:

// 打開一個監聽連接的socket
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
// 設置該socket以非阻塞模式監聽
serverSocketChannel.configureBlocking(false);
// 綁定 地址+端口
ServerSocket serverSocket = serverSocketChannel.socket();
serverSocket.bind(new InetSocketAddress(8899));

// 打開一個Selector
Selector selector = Selector.open();
// 向Selector註冊監聽連接的Channel,並傳入感興趣的操作,返回一個SelectionKey
SelectionKey listenKey = serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);

註冊完得到一個對SelectionKey.OP_ACCEPT操作感興趣的SelectionKey。

SelectionKey

與Selector密切相關的另一組件是SelectionKey。當向Selector註冊一個Channel時會返回一個SelectionKey。一個SelectionKey在以下任何一種情況都會失效:

  • 調用SelectionKey的cancel方法;
  • 關閉該SelectionKey對應的Channel;
  • 關閉該SelectionKey對應的selector;

Selector的實現中會維護三個SelectionKey的集合:

  • key set:該集合保存了當前向Selector註冊的所有Channel對應的SelectionKey的集合,每當向Selector註冊Channel時就會向key set集合添加一個SelectionKey;
  • selected-key set:Selector會檢測所有註冊的Channel,一旦該Channel發生了某個事件,並且對應的SelectionKey剛好對這個事件感興趣,那麼該SelectionKey就被選中放到selected-key set集合中;
  • cancelled-key:當一個SelectionKey已經被取消,但對應的Channel沒有被註銷,則該SelectionKey被放入到cancelled-key,被cancel的key將會在下一次select操作時從key set刪除,對應的Channel也會從Selector註銷;

SelectionKey的實現中包含兩個操作集合:

  • interest set:註冊Channel時指定對應SelectionKey感興趣的操作集合;
  • ready set:當Channel發生的操作在interest set集合中時,該SelectionKey會被選中放到selected-key set集合當中,並且該操作會加入到ready set,表示該操作已經就緒;

SelectionKey中定義瞭如下幾種操作(OP_WRITE和OP_CONNECT還不是很理解,但貌似大多數情況下使用OP_READ和OP_ACCEPT):

  • OP_READ:如果Selector檢測到該SelectionKey對應的Channel準備好了可以讀、或者達到了讀stream的末端、或者被遠端關閉讀、或者有錯誤發生,並且該SelectionKey的interest set包含OP_READ,則會將OP_READ添加到ready set當中;
  • OP_WRITE:如果Selector檢測到該SelectionKey對應的Channel準備好了可以寫、或者被遠端關閉寫、或者有錯誤發生,並且該SelectionKey的interest set包含OP_WRITE,則會將OP_WRITE添加到ready set當中;
  • OP_CONNECT:如果Selector檢測到該SelectionKey對應的Channel準備好了完成這次連接、或者有錯誤發生,並且該SelectionKey的interest set包含OP_CONNECT,則會將OP_CONNECT添加到ready set當中;
  • OP_ACCEPT:如果Selector檢測到該SelectionKey對應的Channel準備好了接收另外一個連接、或者有錯誤發生,並且該SelectionKey的interest set包含OP_ACCEPT,則會將OP_ACCEPT添加到ready set當中;

此外SelectionKey提供了java.nio.channels.SelectionKey#attach方法可以在註冊Channel時將一個對象保存到SelectionKey當中,在select操作中SelectionKey被選中的話在調用java.nio.channels.SelectionKey#attachment將該對象取出來,這樣可以傳遞一些上下文信息。

select系列操作

Selector提供了若干個個select相關的方法:

// select本身是阻塞操作,直到有Channel準備好了對應的感興趣操作,纔會返回,返回值爲選中的Channel個數
public abstract int select() throws IOException;
// select操作等待timeout時間
public abstract int select(long timeout) throws IOException;
// select立即返回,若沒有ready的Channel,則返回0
public abstract int selectNow() throws IOException;
// jdk11提供,等待Channel ready並在action中消費SelectionKey,超時時間爲timeout
public int select(Consumer<SelectionKey> action, long timeout) throws IOException
public int select(Consumer<SelectionKey> action) throws IOException
public int selectNow(Consumer<SelectionKey> action) throws IOException

select系列方法一旦返回並且返回值大於0的話則有Channel已經準備號(ready),接下來可以調用java.nio.channels.Selector#selectedKeys方法獲取已經ready的SelectionKey的集合。接着遍歷SelectionKey從調用SelectionKey的如下方法獲取對應的Channel和selector:

public abstract SelectableChannel channel();
public abstract Selector selector();

Selector示例代碼

如下示例爲使用Selector + Channel + Buffer實現一個簡化版聊天程序的服務端服務端:

import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.nio.charset.Charset;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.UUID;

public class ChatServer {
	 // 用於保存所有客戶端
    private static Map<SocketChannel, String> clientMap = new HashMap<>();

    public static void main(String[] args) throws Exception {
		 // 獲取一個監聽連接的Channel
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        // 在selector編程中一定要設置爲非阻塞模式
        serverSocketChannel.configureBlocking(false);
        // 綁定ip地址和端口
        ServerSocket serverSocket = serverSocketChannel.socket();
        serverSocket.bind(new InetSocketAddress(8899));

		 // 獲取selector
        Selector selector = Selector.open();
        // 將監聽Channel註冊到selector中,對應SelectionKey感興趣的操作爲OP_ACCEPT,即接收連接
        serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);

        while (true) {
        	  // select阻塞直到有ready的Channel
            selector.select();
            // 獲取ready的SelectionKey集合
            Set<SelectionKey> selectionKeys = selector.selectedKeys();
			  // 遍歷SelectionKey集合針對不同的操作做相應的處理
            selectionKeys.forEach(selectionKey -> {
                try {
                    final SocketChannel client;
                    final String key;
                    if (selectionKey.isAcceptable()) {
                    	   // SelectionKey ready的操作爲接收連接,獲取Channel,接收連接(客戶端和服務端accept後的Channel通信)
                        client = ((ServerSocketChannel)selectionKey.channel()).accept();
                        // 設置爲非阻塞模式
                        client.configureBlocking(false);
                        // 註冊Channel到selector中,對應SelectionKey感興趣的操作爲OP_READ
                        client.register(selector, SelectionKey.OP_READ);
                        key = "[" + UUID.randomUUID() + "]";
                        // 保存客戶端用於羣發消息
                        clientMap.put(client, key);
                    } else if (selectionKey.isReadable()) {
                    		// SelectionKey ready的操作爲讀取數據,同樣先獲取到Channel(與客戶端通信的那個SocketChannel)
                        client =  (SocketChannel)selectionKey.channel();
                        ByteBuffer readBuffer = ByteBuffer.allocate(1024);
                        key = clientMap.get(client);
							// 從Channel將數據讀取到ByteBuffer中
                        int count = client.read(readBuffer);
                        if (count <= 0) {
                            return;
                        }
                        readBuffer.flip();
                        Charset charset = Charset.forName("utf-8");
                        String sendMsg = key + ":" + String.valueOf(charset.decode(readBuffer).array());
                        // 構造羣發消息
                        ByteBuffer sendBuffer = charset.encode(sendMsg);
                        // 遍歷所有Channel將消息發送給所有客戶端
                        for (SocketChannel channel : clientMap.keySet()) {
                            channel.write(sendBuffer);
                            // 因爲需要重複的從sendBuffer讀取數據到Channel,因此將sendBuffer的position屬性置爲0
                            sendBuffer.rewind();
                        }
                    }
                } catch (Exception ex) {
                    ex.printStackTrace();
                }
            });
            selectionKeys.clear();
        }
    }
}

客戶端可以開啓多個telnet連接localhost 8899端口進行測試。

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