最近看zookeeper源碼,發現底層的通信使用到了NIO和netty,接下來的系列記錄下NIO和Netty的學習,記錄完接着zookeeper源碼的學習。
java.io
中最爲核心的概念是流(stream),是面向流的編程,一個流要麼是輸入流,要麼是輸出流,不可能同時即是輸入流又是輸出流;而java.nio
是面向塊(block)或面向緩衝區(buffer)編程,塊或者緩衝區既可以作爲輸入也可以作爲輸出,nio
中有三個核心的概念,即Selector
、Channel
、Buffer
。
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的基本使用分爲四個步驟,如下圖所示:
- 首先從Channel或我們的代碼中往Buffer寫入數據;
- 然後調用flip方法切換到讀模式;
- 將數據從Buffer讀取到我們的代碼中或讀取後寫入到Channel中;
- 調用clear方法爲下一次向Buffer寫入數據做準備;
使用到了Buffer
的clear
、compact
、flip
等方法,在瞭解這些方法之前我們先了解Buffer中維護緩存數據的幾個屬性: position
、limit
、capacity
和mark
.
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提供了幾個常用的方法來修改上述幾個屬性的值,包括clear
、flip
、rewind
、slice
、duplicate
等。
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是共享的,但position
、limit
、capacity
等屬性是獨立於舊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端口進行測試。