NIO 基礎
什麼是 NIO
- Java NIO 全稱 Java non-blocking IO,指的是 JDK 提供的新 API。從 JDK 1.4 開始,Java 提供了一系列改進的輸入/輸出的新特性,被統稱為 NIO,即 New IO,是
同步非阻塞
的。 - NIO 相關類都放在 java.nio 包下,並對原 java.io 包中很多類進行了改寫。
- NIO 有三大核心部分:
Channel(管道)
、Buffer(緩衝區)
、Selector(選擇器)
。 - NIO 是面向
緩衝區
程式設計的。資料讀取到了一個它稍微處理的緩衝區,需要時可在緩衝區中前後移動,這就增加了處理過程中的靈活性,使用它可以提供非阻塞的高伸縮性網路。 - Java NIO 的非阻塞模式,使一個執行緒從某通道傳送請求讀取資料,但是它僅能得到目前可用資料,如果目前沒有可用資料時,則說明不會獲取,而不是保持執行緒阻塞,所以直到資料變為可以讀取之前,該執行緒可以做其他事情。非阻塞寫入同理。
三大核心元件
Channel 的基本介紹
NIO 的通道類似於流,但有如下區別:
- 通道是雙向的可以進行讀寫,而流是單向的只能讀,或者寫
- 通道可以實現非同步讀寫資料
- 通道可以從緩衝區讀取資料,也可以寫入資料到緩衝區
四種通道:
- FileChannel :從檔案中讀寫資料
- DatagramChannel:通過 UDP 協議,讀寫網路中的資料
- SocketChannel:能通過 TCP 協議來讀寫網路中資料,常用於客戶端
- ServerSocketChannel:監聽 TCP 連線,對每個新的連線會建立一個 SocketChannel
Buffer(緩衝區)基本介紹
NIO 中的 Buffer 用於 NIO 通道(Channel)進行互動。
緩衝區本質上是一個可以讀寫資料的記憶體塊,可以理解為是一個容器物件(含陣列)
,該物件提供了一組方法
,可以更輕鬆地使用記憶體塊,緩衝區物件內建了一些機制,能夠跟蹤和記錄緩衝區的狀態變化情況。
當向 Buffer 寫入資料時,Buffer 會記錄下寫了多少資料,一旦要讀取資料,需要通過flip()
方法將 Buffer 從寫模式切換到讀模式。在讀模式下,可以讀取之前寫入到 Buffer 的所有資料。
當讀完了所有資料,就需要清空快取區,讓它可以再次被寫入。有兩種方式能清空緩衝區,呼叫clear()
或者compact()
方法。
clear()方法會清空整個緩衝區。compact()方法只會清除已經讀過的資料。任何未讀的資料都被移到緩衝區的起始處,新寫入的資料將放到緩衝區未讀資料的後面。
Channel 提供從檔案、網路讀取資料的渠道,但是讀取或者都必須經過 Buffer。在 Buffer 子類中維護著一個對應型別的陣列,用來存放資料。
Selector 的基本介紹
- Java 的 NIO 使用了非阻塞的 I/O 方式。可以用一個執行緒處理若干個客戶端連線,就會使用到 Selector(選擇器)
- Selector 能夠檢測到多個註冊通道上是否有事件發生(多個 Channel 以事件的形式註冊到同一個 selector),如果有事件發生,便獲取事件然後針對每個事件進行相應的處理
- 只有在連線真正有讀寫事件發生時,才會進行讀寫,減少了系統開銷,並且不必為每個連線都建立一個執行緒,不用維護多個執行緒
- 避免了多執行緒之間上下文切換導致的開銷
Selector 的特點
Netty 的 I/O 執行緒 NioEventLoop 聚合了 Selector(選擇器 / 多路複用器),可以併發處理成百上千個客戶端連線。
當執行緒從某客戶端 Socket 通道進行讀寫時,若沒有資料可用,該執行緒可以進行其他任務。
執行緒通常將非阻塞 I/O 的空閒時間用於其他通道上執行 I/O 操作,所以單獨的執行緒可以管理多個輸入輸出通道。
由於讀寫操作都是非阻塞的,就可以充分提高 I/O 執行緒的執行效率,避免由於頻繁 I/O 阻塞導致的執行緒掛起。
一個 I/O 執行緒可以併發處理 N 個客戶端連線和讀寫操作,這從根本上解決了傳統同步阻塞 I/O 一連線一執行緒模型,架構效能、彈性伸縮能力和可靠性都得到極大地提升。
ByteBuffer 的基本使用
核心依賴
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>4.1.36.Final</version>
</dependency>
/**
* @author 神祕傑克
* 公眾號: Java菜鳥程式設計師
* @date 2021/12/28
* @Description ByteBuffer基本使用,讀取檔案內容並列印
*/
public class ByteBufferTest {
public static void main(String[] args) {
//獲取channel
try (FileChannel channel = new FileInputStream("data.txt").getChannel()) {
//建立ByteBuffer
final ByteBuffer buffer = ByteBuffer.allocate(1024);
//讀取檔案內容,並存入buffer
channel.read(buffer);
//切換為讀模式
buffer.flip();
while (buffer.hasRemaining()) {
System.out.print((char) buffer.get());
}
//清空緩衝區,並重置為寫模式
buffer.clear();
} catch (IOException e) {
e.printStackTrace();
}
}
}
輸出結果:
1234567890abc
ByteBuffer 的結構
Buffer 中定義了四個屬性來提供所其包含的資料元素。
// Invariants: mark <= position <= limit <= capacity
private int mark = -1;
private int position = 0;
private int limit;
private int capacity;
- capacity:緩衝區的容量。通過建構函式賦予,一旦設定,無法更改
- limit:緩衝區的界限。位於 limit 後的資料不可讀寫。緩衝區的限制不能為負,並且不能大於其容量
- position:下一個讀寫位置的索引(類似 PC)。緩衝區的位置不能為負,並且不能大於 limit
- mark:記錄當前 position 的值。position 被改變後,可以通過呼叫 reset() 方法恢復到 mark 的位置
在一開始的情況下,position 指向第一位寫入位置,limit 和 capacity 則等於緩衝區的容量。
在寫模式下,position 是寫入位置,limit 等於容量,下圖表示寫入 4 個元素後的狀態。
當呼叫flip()
方法切換為讀模式後,position 切換為讀取位置,limit 切換為讀取限制。
當讀取到 limit 位置後,則不可以繼續讀取。
當呼叫clear()
方法後,則迴歸最原始狀態。
當呼叫 compact()方法時,需要注意:此方法為 ByteBuffer 的方法,而不是 Buffer 的方法。
- compact 會把未讀完的資料向前壓縮,然後切換到寫模式
- 資料前移後,原位置的值並未清零,寫時會覆蓋之前的值
ByteBuffer 的常見方法
分配空間:allocate()
//java.nio.HeapByteBuffer java堆記憶體,讀寫效率較低,受到gc影響
System.out.println(ByteBuffer.allocate(1024).getClass());
//java.nio.DirectByteBuffer 直接記憶體,讀寫效率較高(少一次拷貝),不會受gc影響,分配記憶體效率較低,使用不當則可能會發生記憶體洩漏
System.out.println(ByteBuffer.allocateDirect(1024).getClass());
flip()
- flip()方法會切換對緩衝區的操作模式,由寫->讀 / 讀->寫
put()
- put()方法可以將一個資料放入到緩衝區中。
- 進行該操作後,postition 的值會+1,指向下一個可以放入的位置。
get()
- get()方法會讀取緩衝區中的一個值
- 進行該操作後,position 會+1,如果超過了 limit 則會丟擲異常
注意:get(i)方法不會改變 position 的值。
rewind()
- 該方法只能在讀模式下使用
- rewind()方法後,會恢復 position、limit 和 capacity 的值,變為進行 get()前的值
clear()
- clear()方法會將緩衝區中的各個屬性恢復為最初的狀態,position = 0, capacity = limit
- 此時緩衝區的資料依然存在,處於“被遺忘”狀態,下次進行寫操作時會覆蓋這些資料
mark()和 reset()
- mark()方法會將 postion 的值儲存到 mark 屬性中
- reset()方法會將 position 的值改為 mark 中儲存的值
字串和 ByteBuffer 相互轉換
引入工具類:
import io.netty.util.internal.MathUtil;
import io.netty.util.internal.StringUtil;
import java.nio.ByteBuffer;
/**
* @author 神祕傑克
* 公眾號: Java菜鳥程式設計師
* @date 2021/12/28
* @Description 工具類
*/
public class ByteBufferUtil {
private static final char[] BYTE2CHAR = new char[256];
private static final char[] HEXDUMP_TABLE = new char[256 * 4];
private static final String[] HEXPADDING = new String[16];
private static final String[] HEXDUMP_ROWPREFIXES = new String[65536 >>> 4];
private static final String[] BYTE2HEX = new String[256];
private static final String[] BYTEPADDING = new String[16];
static {
final char[] DIGITS = "0123456789abcdef".toCharArray();
for (int i = 0; i < 256; i++) {
HEXDUMP_TABLE[i << 1] = DIGITS[i >>> 4 & 0x0F];
HEXDUMP_TABLE[(i << 1) + 1] = DIGITS[i & 0x0F];
}
int i;
// Generate the lookup table for hex dump paddings
for (i = 0; i < HEXPADDING.length; i++) {
int padding = HEXPADDING.length - i;
StringBuilder buf = new StringBuilder(padding * 3);
for (int j = 0; j < padding; j++) {
buf.append(" ");
}
HEXPADDING[i] = buf.toString();
}
// Generate the lookup table for the start-offset header in each row (up to 64KiB).
for (i = 0; i < HEXDUMP_ROWPREFIXES.length; i++) {
StringBuilder buf = new StringBuilder(12);
buf.append(StringUtil.NEWLINE);
buf.append(Long.toHexString(i << 4 & 0xFFFFFFFFL | 0x100000000L));
buf.setCharAt(buf.length() - 9, '|');
buf.append('|');
HEXDUMP_ROWPREFIXES[i] = buf.toString();
}
// Generate the lookup table for byte-to-hex-dump conversion
for (i = 0; i < BYTE2HEX.length; i++) {
BYTE2HEX[i] = ' ' + StringUtil.byteToHexStringPadded(i);
}
// Generate the lookup table for byte dump paddings
for (i = 0; i < BYTEPADDING.length; i++) {
int padding = BYTEPADDING.length - i;
StringBuilder buf = new StringBuilder(padding);
for (int j = 0; j < padding; j++) {
buf.append(' ');
}
BYTEPADDING[i] = buf.toString();
}
// Generate the lookup table for byte-to-char conversion
for (i = 0; i < BYTE2CHAR.length; i++) {
if (i <= 0x1f || i >= 0x7f) {
BYTE2CHAR[i] = '.';
} else {
BYTE2CHAR[i] = (char) i;
}
}
}
/**
* 列印所有內容
*
* @param buffer
*/
public static void debugAll(ByteBuffer buffer) {
int oldlimit = buffer.limit();
buffer.limit(buffer.capacity());
StringBuilder origin = new StringBuilder(256);
appendPrettyHexDump(origin, buffer, 0, buffer.capacity());
System.out.println("+--------+-------------------- all ------------------------+----------------+");
System.out.printf("position: [%d], limit: [%d]\n", buffer.position(), oldlimit);
System.out.println(origin);
buffer.limit(oldlimit);
}
/**
* 列印可讀取內容
*
* @param buffer
*/
public static void debugRead(ByteBuffer buffer) {
StringBuilder builder = new StringBuilder(256);
appendPrettyHexDump(builder, buffer, buffer.position(), buffer.limit() - buffer.position());
System.out.println("+--------+-------------------- read -----------------------+----------------+");
System.out.printf("position: [%d], limit: [%d]\n", buffer.position(), buffer.limit());
System.out.println(builder);
}
private static void appendPrettyHexDump(StringBuilder dump, ByteBuffer buf, int offset, int length) {
if (MathUtil.isOutOfBounds(offset, length, buf.capacity())) {
throw new IndexOutOfBoundsException(
"expected: " + "0 <= offset(" + offset + ") <= offset + length(" + length
+ ") <= " + "buf.capacity(" + buf.capacity() + ')');
}
if (length == 0) {
return;
}
dump.append(
" +-------------------------------------------------+" +
StringUtil.NEWLINE + " | 0 1 2 3 4 5 6 7 8 9 a b c d e f |" +
StringUtil.NEWLINE + "+--------+-------------------------------------------------+----------------+");
final int startIndex = offset;
final int fullRows = length >>> 4;
final int remainder = length & 0xF;
// Dump the rows which have 16 bytes.
for (int row = 0; row < fullRows; row++) {
int rowStartIndex = (row << 4) + startIndex;
// Per-row prefix.
appendHexDumpRowPrefix(dump, row, rowStartIndex);
// Hex dump
int rowEndIndex = rowStartIndex + 16;
for (int j = rowStartIndex; j < rowEndIndex; j++) {
dump.append(BYTE2HEX[getUnsignedByte(buf, j)]);
}
dump.append(" |");
// ASCII dump
for (int j = rowStartIndex; j < rowEndIndex; j++) {
dump.append(BYTE2CHAR[getUnsignedByte(buf, j)]);
}
dump.append('|');
}
// Dump the last row which has less than 16 bytes.
if (remainder != 0) {
int rowStartIndex = (fullRows << 4) + startIndex;
appendHexDumpRowPrefix(dump, fullRows, rowStartIndex);
// Hex dump
int rowEndIndex = rowStartIndex + remainder;
for (int j = rowStartIndex; j < rowEndIndex; j++) {
dump.append(BYTE2HEX[getUnsignedByte(buf, j)]);
}
dump.append(HEXPADDING[remainder]);
dump.append(" |");
// Ascii dump
for (int j = rowStartIndex; j < rowEndIndex; j++) {
dump.append(BYTE2CHAR[getUnsignedByte(buf, j)]);
}
dump.append(BYTEPADDING[remainder]);
dump.append('|');
}
dump.append(StringUtil.NEWLINE +
"+--------+-------------------------------------------------+----------------+");
}
private static void appendHexDumpRowPrefix(StringBuilder dump, int row, int rowStartIndex) {
if (row < HEXDUMP_ROWPREFIXES.length) {
dump.append(HEXDUMP_ROWPREFIXES[row]);
} else {
dump.append(StringUtil.NEWLINE);
dump.append(Long.toHexString(rowStartIndex & 0xFFFFFFFFL | 0x100000000L));
dump.setCharAt(dump.length() - 9, '|');
dump.append('|');
}
}
public static short getUnsignedByte(ByteBuffer buffer, int index) {
return (short) (buffer.get(index) & 0xFF);
}
}
測試類:
/**
* @author 神祕傑克
* 公眾號: Java菜鳥程式設計師
* @date 2021/12/28
* @Description 字串和ByteBuffer相互轉換
*/
public class TranslateTest {
public static void main(String[] args) {
String str1 = "hello";
String str2;
String str3;
// 通過StandardCharsets的encode方法獲得ByteBuffer
// 此時獲得的ByteBuffer為讀模式,無需通過flip切換模式
ByteBuffer buffer = StandardCharsets.UTF_8.encode(str1);
//也可以使用wrap方法實現,無需通過flip切換模式
ByteBuffer wrap = ByteBuffer.wrap(str1.getBytes());
ByteBufferUtil.debugAll(wrap);
ByteBufferUtil.debugAll(buffer);
// 將緩衝區中的資料轉化為字串
// 通過StandardCharsets解碼,獲得CharBuffer,再通過toString獲得字串
str2 = StandardCharsets.UTF_8.decode(buffer).toString();
System.out.println(str2);
str3 = StandardCharsets.UTF_8.decode(wrap).toString();
System.out.println(str3);
}
}
執行結果:
+--------+-------------------- all ------------------------+----------------+
position: [0], limit: [5]
+-------------------------------------------------+
| 0 1 2 3 4 5 6 7 8 9 a b c d e f |
+--------+-------------------------------------------------+----------------+
|00000000| 68 65 6c 6c 6f |hello |
+--------+-------------------------------------------------+----------------+
+--------+-------------------- all ------------------------+----------------+
position: [0], limit: [5]
+-------------------------------------------------+
| 0 1 2 3 4 5 6 7 8 9 a b c d e f |
+--------+-------------------------------------------------+----------------+
|00000000| 68 65 6c 6c 6f |hello |
+--------+-------------------------------------------------+----------------+
hello
hello
粘包與半包
現象
網路上有多條資料傳送給服務端,資料之間使用 \n 進行分隔。
但由於某種原因這些資料在接收時,被進行了重新組合,例如原始資料有 3 條為:
- Hello,world\n
- I’m Jack\n
- How are you?\n
變成了下面的兩個 byteBuffer (粘包,半包)
- Hello,world\nI’m Jack\nHo
- w are you?\n
出現原因
粘包
傳送方在傳送資料時,並不是一條一條地傳送資料,而是將資料整合在一起,當資料達到一定的數量後再一起傳送。這就會導致多條資訊被放在一個緩衝區中被一起傳送出去。
半包
接收方的緩衝區的大小是有限的,當接收方的緩衝區滿了以後,就需要將資訊截斷,等緩衝區空了以後再繼續放入資料。這就會發生一段完整的資料最後被截斷的現象。
解決辦法
- 通過
get(index)
方法遍歷 ByteBuffer,當遇到\n
後進行處理。 - 記錄從 position 到 index 的資料長度,申請對應大小的緩衝區。
- 將緩衝區的資料通過
get()
獲取寫入到 target 緩衝區中。 - 最後,呼叫 compact()方法切換為寫模式,因為緩衝區中可能還存在未讀取的資料。
/**
* @author 神祕傑克
* 公眾號: Java菜鳥程式設計師
* @date 2021/12/29
* @Description 解決黏包和半包
*/
public class ByteBufferTest {
public static void main(String[] args) {
ByteBuffer buffer = ByteBuffer.allocate(32);
//模擬黏包和半包
buffer.put("Hello,world\nI'm Jack\nHo".getBytes(StandardCharsets.UTF_8));
split(buffer);
buffer.put("w are you?\n".getBytes(StandardCharsets.UTF_8));
split(buffer);
}
private static void split(ByteBuffer buffer) {
//切換讀模式
buffer.flip();
for (int i = 0; i < buffer.limit(); i++) {
//找到完整訊息
if (buffer.get(i) == '\n') {
int length = i + 1 - buffer.position();
final ByteBuffer target = ByteBuffer.allocate(length);
//從buffer中讀取,寫入 target
for(int j = 0; j < length; j++) {
// 將buffer中的資料寫入target中
target.put(buffer.get());
}
// 列印檢視結果
ByteBufferUtil.debugAll(target);
}
}
//清空已讀部分,並切換寫模式
buffer.compact();
}
}
執行結果:
+--------+-------------------- all ------------------------+----------------+
position: [12], limit: [12]
+-------------------------------------------------+
| 0 1 2 3 4 5 6 7 8 9 a b c d e f |
+--------+-------------------------------------------------+----------------+
|00000000| 48 65 6c 6c 6f 2c 77 6f 72 6c 64 0a |Hello,world. |
+--------+-------------------------------------------------+----------------+
+--------+-------------------- all ------------------------+----------------+
position: [9], limit: [9]
+-------------------------------------------------+
| 0 1 2 3 4 5 6 7 8 9 a b c d e f |
+--------+-------------------------------------------------+----------------+
|00000000| 49 27 6d 20 4a 61 63 6b 0a |I'm Jack. |
+--------+-------------------------------------------------+----------------+
+--------+-------------------- all ------------------------+----------------+
position: [13], limit: [13]
+-------------------------------------------------+
| 0 1 2 3 4 5 6 7 8 9 a b c d e f |
+--------+-------------------------------------------------+----------------+
|00000000| 48 6f 77 20 61 72 65 20 79 6f 75 3f 0a |How are you?. |
+--------+-------------------------------------------------+----------------+
檔案程式設計
FileChannel
工作模式
?:FileChannel 只能工作在阻塞模式下!
獲取
不能直接開啟 FileChannel,必須通過 FileInputStream、FileOutputStream 或者 RandomAccessFile 來獲取 FileChannel,它們都有 getChannel()
方法。
- 通過 FileInputStream 獲取的 channel 只能讀
- 通過 FileOutputStream 獲取的 channel 只能寫
- 通過 RandomAccessFile 是否能讀寫根據構造 RandomAccessFile 時的讀寫模式決定
讀取
通過read()
方法將資料填充到 ByteBuffer 中,返回值表示讀到了多少位元組,-1
表示讀到了檔案末尾。
int readBytes = channel.read(buffer);
寫入
因為 channel 是有寫入上限的,所以 write() 方法並不能保證一次將 buffer 中的內容全部寫入 channel。必須按照以下規則進行寫入。
// 通過hasRemaining()方法檢視緩衝區中是否還有資料未寫入到通道中
while(buffer.hasRemaining()) {
channel.write(buffer);
}
關閉
Channel 必須關閉,不過呼叫 FileInputStream、FileOutputStream、 RandomAccessFile 的close()
方法時也會間接的呼叫 Channel 的 close()方法。
位置
channel 也擁有一個儲存讀取資料位置的屬性,即 position。
long pos = channel.position();
可以通過 position(int pos)設定 channel 中 position 的值。
long newPos = 10;
channel.position(newPos);
設定當前位置時,如果設定為檔案的末尾:
- 這時讀取會返回 -1
- 這時寫入,會追加內容,但要注意如果 position 超過了檔案末尾,再寫入時在新內容和原末尾之間會有空洞(00)
強制寫入
作業系統出於效能的考慮,會將資料快取,不是立刻寫入磁碟,而是等到快取滿了以後將所有資料一次性的寫入磁碟。可以呼叫 force(true)
方法將檔案內容和後設資料(檔案的許可權等資訊)立刻寫入磁碟。
常見方法
FileChannel 主要用來對本地檔案進行 IO 操作,常見的方法有:
- public int read(ByteBuffer dst) :從通道中讀取資料到緩衝區中。
- public int write(ByteBuffer src):把緩衝區中的資料寫入到通道中。
- public long transferFrom(ReadableByteChannel src,long position,long count):從目標通道中複製資料到當前通道。
- public long transferTo(long position,long count,WriteableByteChannel target):把資料從當前通道複製給目標通道。
使用 FileChannel 寫入文字檔案
/**
* @author 神祕傑克
* 公眾號: Java菜鳥程式設計師
* @date 2021/12/29
* @Description FileChannel測試寫入檔案
*/
public class FileChannelTest {
public static void main(String[] args) {
try (final FileChannel channel = new FileOutputStream("data1.txt").getChannel()) {
String msg = "Hello World!!!";
final ByteBuffer buffer = ByteBuffer.allocate(16);
buffer.put(msg.getBytes(StandardCharsets.UTF_8));
buffer.flip();
channel.write(buffer);
} catch (IOException e) {
e.printStackTrace();
}
}
}
使用 FileChannel 讀取文字檔案
/**
* @author 神祕傑克
* 公眾號: Java菜鳥程式設計師
* @date 2021/12/29
* @Description FileChannel測試讀取檔案
*/
public class FileChannelTest {
public static void main(String[] args) {
try (final FileChannel channel = new FileInputStream("data1.txt").getChannel()) {
final ByteBuffer buffer = ByteBuffer.allocate(16);
channel.read(buffer);
buffer.flip();
while (buffer.hasRemaining()) {
System.out.print((char) buffer.get());
}
//清空緩衝區,並重置為寫模式
buffer.clear();
} catch (IOException e) {
e.printStackTrace();
}
}
}
使用 FileChannel 進行資料傳輸
/**
* @author 神祕傑克
* 公眾號: Java菜鳥程式設計師
* @date 2021/12/29
* @Description FileChannel測試檔案傳輸
*/
public class FileChannelTest {
public static void main(String[] args){
try (final FileChannel from = new FileInputStream("data.txt").getChannel();
final FileChannel to = new FileOutputStream("data1.txt").getChannel()) {
// 引數:inputChannel的起始位置,傳輸資料的大小,目的channel
// 返回值為傳輸的資料的位元組數
// transferTo一次只能傳輸2G的資料
from.transferTo(0, from.size(), to);
} catch (IOException e) {
e.printStackTrace();
}
}
}
transferTo()方法對應的還有 transferFrom()方法。
雖然 transferTo()方法傳輸效率較高,底層利用作業系統的零拷貝進行優化,但是 transferTo 方法一次只能傳輸 2G 的資料。
解決方法:可以根據 transferTo()的返回值來判斷,返回值代表傳輸了多少,通過 from 的 size()大小來每次減去即可。
long size = from.size();
for (long left = size; left > 0; ) {
left -= from.transferTo(size - left, size, to);
}
Channel 和 Buffer 的注意事項
- ByteBuffer 支援型別化的 put 和 get,put 放入什麼資料型別,get 就應該使用相應的資料型別來取出,否則可能會產生 ByteUnderflowException 異常。
- 可以將一個普通的 Buffer 轉換為只讀的 Buffer:asReadOnlyBuffer()方法。
- NIO 提供了 MapperByteBuffer,可以讓檔案直接在記憶體(堆外記憶體)中進行修改,而如何同步到檔案由 NIO 來完成。
NIO 還支援通過多個 Buffer(即 Buffer 陣列)完成讀寫操作,即Scattering(分散)和 Gathering(聚集)。
Scattering(分散)
:在向緩衝區寫入資料時,可以使用 Buffer 陣列依次寫入,一個 Buffer 陣列寫滿後,繼續寫入下一個 Buffer 陣列。Gathering(聚集)
:從緩衝區讀取資料時,可以依次讀取,讀完一個 Buffer 再按順序讀取下一個。
網路程式設計
阻塞 vs 非阻塞
阻塞
- 在沒有資料可讀時,包括資料複製過程中,執行緒必須阻塞等待,不會佔用 CPU,但執行緒相當於閒置狀態
- 32 位 JVM 一個執行緒 320k,64 位 JVM 一個執行緒 1024k,為了減少執行緒數量,需要採用執行緒池技術
- 但即使使用執行緒池,如果有很多連線建立,但長時間 inactive,會阻塞執行緒池中所有執行緒
非阻塞
- 在某個 Channel 沒有可讀事件時,執行緒不必阻塞,它可以去處理其它有可讀事件的 Channel
- 資料複製過程中,執行緒實際還是阻塞的(AIO 改進的地方)
- 寫資料時,執行緒只是等待資料寫入 Channel 即可,無需等待 Channel 通過網路把資料傳送出去
阻塞案例程式碼
服務端程式碼:
/**
* @author 神祕傑克
* 公眾號: Java菜鳥程式設計師
* @date 2021/12/29
* @Description 使用NIO來理解阻塞模式-服務端
*/
public class Server {
public static void main(String[] args) {
//1. 建立伺服器
try (ServerSocketChannel ssc = ServerSocketChannel.open()) {
final ByteBuffer buffer = ByteBuffer.allocate(16);
//2. 繫結監聽埠
ssc.bind(new InetSocketAddress(7777));
//3. 存放建立連線的集合
List<SocketChannel> channels = new ArrayList<>();
while (true) {
System.out.println("建立連線...");
//4. accept 建立客戶端連線 , 用來和客戶端之間通訊
final SocketChannel socketChannel = ssc.accept();
System.out.println("建立連線完成...");
channels.add(socketChannel);
//5. 接收客戶端傳送的資料
for (SocketChannel channel : channels) {
System.out.println("正在讀取資料...");
channel.read(buffer);
buffer.flip();
ByteBufferUtil.debugRead(buffer);
buffer.clear();
System.out.println("資料讀取完成...");
}
}
} catch (IOException e) {
System.out.println("出現異常...");
}
}
}
客戶端程式碼:
/**
* @author 神祕傑克
* 公眾號: Java菜鳥程式設計師
* @date 2021/12/29
* @Description 使用NIO來理解阻塞模式-客戶端
*/
public class Client {
public static void main(String[] args) {
try (SocketChannel socketChannel = SocketChannel.open()) {
// 建立連線
socketChannel.connect(new InetSocketAddress("localhost", 7777));
final ByteBuffer buffer = ByteBuffer.allocate(10);
buffer.put("hello".getBytes(StandardCharsets.UTF_8));
buffer.flip();
socketChannel.write(buffer);
} catch (IOException e) {
e.printStackTrace();
}
}
}
執行結果:
在剛開始伺服器執行後:伺服器端因 accept 阻塞。
在客戶端和伺服器建立連線後,客戶端傳送訊息前:伺服器端因通道為空被阻塞。
客戶端傳送資料後,伺服器處理通道中的資料。之後再次進入迴圈時,再次被 accept 阻塞。
- 之前的客戶端再次傳送訊息,伺服器端因為被 accept 阻塞,就無法處理之前客戶端再次傳送到通道中的資訊了。
非阻塞
- 通過 ServerSocketChannel 的
configureBlocking(false)
方法將獲得連線設定為非阻塞的。此時若沒有連線,accept 會返回 null - 通過 SocketChannel 的
configureBlocking(false)
方法將從通道中讀取資料設定為非阻塞的。若此時通道中沒有資料可讀,read 會返回-1
/**
* @author 神祕傑克
* 公眾號: Java菜鳥程式設計師
* @date 2021/12/29
* @Description 使用NIO來理解阻塞模式-服務端
*/
public class Server {
public static void main(String[] args) {
//1. 建立伺服器
try (ServerSocketChannel ssc = ServerSocketChannel.open()) {
final ByteBuffer buffer = ByteBuffer.allocate(16);
//2. 繫結監聽埠
ssc.bind(new InetSocketAddress(7777));
//3. 存放建立連線的集合
List<SocketChannel> channels = new ArrayList<>();
//設定非阻塞!!
ssc.configureBlocking(false);
while (true) {
System.out.println("建立連線...");
//4. accept 建立客戶端連線 , 用來和客戶端之間通訊
final SocketChannel socketChannel = ssc.accept();
//設定非阻塞!!
socketChannel.configureBlocking(false);
System.out.println("建立連線完成...");
channels.add(socketChannel);
//5. 接收客戶端傳送的資料
for (SocketChannel channel : channels) {
System.out.println("正在讀取資料...");
channel.read(buffer);
buffer.flip();
ByteBufferUtil.debugRead(buffer);
buffer.clear();
System.out.println("資料讀取完成...");
}
}
} catch (IOException e) {
System.out.println("出現異常...");
}
}
}
因為設定為了非阻塞,會一直執行while(true)
中的程式碼,CPU 一直處於忙碌狀態,會使得效能變低,所以實際情況中不使用這種方法處理請求。
Selector
基本介紹
- Java 的 NIO 使用了非阻塞的 I/O 方式。可以用一個執行緒處理若干個客戶端連線,就會使用到 Selector(選擇器)。
- Selector 能夠檢測到多個註冊通道上是否有事件發生(多個 Channel 以事件的形式註冊到同一個 selector),如果有事件發生,便獲取事件然後針對每個事件進行相應的處理。
- 只有在連線真正有讀寫事件發生時,才會進行讀寫,減少了系統開銷,並且不必為每個連線都建立一個執行緒,不用維護多個執行緒。
- 避免了多執行緒之間上下文切換導致的開銷。
特點
單執行緒可以配合 Selector 完成對多個 Channel 可讀寫事件的監控,這稱為多路複用。
- 多路複用僅針對網路 IO,普通檔案 IO 無法利用多路複用
如果不用 Selector 的非阻塞模式,執行緒大部分時間都在做無用功,而 Selector 能夠保證
- 有可連線事件時才去連線
- 有可讀事件才去讀取
- 有可寫事件才去寫入
限於網路傳輸能力,Channel 未必隨時可寫,一旦 Channel 可寫,會觸發 Selector 的可寫事件進行寫入。
Selector 相關方法說明
selector.select()
://若未監聽到註冊管道中有事件,則持續阻塞selector.select(1000)
://阻塞 1000 毫秒,1000 毫秒後返回selector.wakeup()
://喚醒 selectorselector.selectNow()
: //不阻塞,立即返回
NIO 非阻塞網路程式設計過程分析
- 當客戶端連線時,會通過 SeverSocketChannel 得到對應的 SocketChannel。
- Selector 進行監聽,呼叫
select()
方法,返回註冊該 Selector 的所有通道中有事件發生的通道個數。 - 將 SocketChannel 註冊到 Selector 上,public final SelectionKey register(Selector sel, int ops),一個 Selector 上可以註冊多個 SocketChannel。
- 註冊後返回一個 SelectionKey,會和該 Selector 關聯(以集合的形式)。
- 進一步得到各個 SelectionKey,有事件發生。
- 再通過 SelectionKey 反向獲取 SocketChannel,使用 channnel()方法。
- 可以通過得到的 channel,完成業務處理。
SelectionKey 中定義了四個操作標誌位:OP_READ
表示通道中發生讀事件;OP_WRITE
—表示通道中發生寫事件;OP_CONNECT
—表示建立連線;OP_ACCEPT
—請求新連線。
SelectionKey 的相關方法
方法 | 描述 |
---|---|
public abstract Selector selector(); | 得到與之關聯的 Selector 物件 |
public abstract SelectableChannel channel(); | 得到與之關聯的通道 |
public final Object attachment() | 得到與之關聯的共享資料 |
public abstract SelectionKey interestOps(int ops); | 設定或改變監聽的事件型別 |
public final boolean isReadable(); | 通道是否可讀 |
public final boolean isWritable(); | 通道是否可寫 |
public final boolean isAcceptable(); | 是否可以建立連線 ACCEPT |
Selector 基本使用及 Accpet 事件
接下來我們使用 Selector 實現多路複用,對服務端程式碼進行改進。
/**
* @author 神祕傑克
* 公眾號: Java菜鳥程式設計師
* @date 2021/12/29
* @Description Selector基本使用-服務端
*/
public class Server {
public static void main(String[] args) {
try (ServerSocketChannel ssc = ServerSocketChannel.open();
final Selector selector = Selector.open()) {//建立selector 管理多個channel
ssc.bind(new InetSocketAddress(7777));
ssc.configureBlocking(false);
// 將通道註冊到選擇器中,並設定感興趣的事件
ssc.register(selector, SelectionKey.OP_ACCEPT);
ByteBuffer buffer = ByteBuffer.allocate(16);
while (true) {
// 如果事件就緒,執行緒會被阻塞,反之不會被阻塞。從而避免了CPU空轉
// 返回值為就緒的事件個數
int ready = selector.select();
System.out.println("selector就緒總數: " + ready);
// 獲取所有事件
Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
while (iterator.hasNext()) {
final SelectionKey key = iterator.next();
//判斷key的事件型別
if (key.isAcceptable()) {
ServerSocketChannel serverSocketChannel = (ServerSocketChannel) key.channel();
final SocketChannel socketChannel = serverSocketChannel.accept();
System.out.println("獲取到客戶端連線...");
}
// 處理完畢後移除
iterator.remove();
}
}
} catch (IOException e) {
System.out.println("出現異常...");
}
}
}
事件發生後,要麼處理,要麼使用 key.cancel()方法取消,不能什麼都不做,否則下次該事件仍會觸發,這是因為 nio 底層使用的是水平觸發。
當選擇器中的通道對應的事件發生後,SelectionKey 會被放到另一個集合中,但是selecionKey 不會自動移除,所以需要我們在處理完一個事件後,通過迭代器手動移除其中的 selecionKey。否則會導致已被處理過的事件再次被處理,就會引發錯誤。
Read 事件
/**
* @author 神祕傑克
* 公眾號: Java菜鳥程式設計師
* @date 2021/12/29
* @Description Read事件-服務端
*/
public class Server {
public static void main(String[] args) {
try (ServerSocketChannel ssc = ServerSocketChannel.open();
final Selector selector = Selector.open()) {//建立selector 管理多個channel
ssc.bind(new InetSocketAddress(7777));
ssc.configureBlocking(false);
// 將通道註冊到選擇器中,並設定感興趣的事件
ssc.register(selector, SelectionKey.OP_ACCEPT);
ByteBuffer buffer = ByteBuffer.allocate(16);
while (true) {
// 如果事件就緒,執行緒會被阻塞,反之不會被阻塞。從而避免了CPU空轉
// 返回值為就緒的事件個數
int ready = selector.select();
System.out.println("selector就緒總數: " + ready);
// 獲取所有事件
Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
while (iterator.hasNext()) {
final SelectionKey key = iterator.next();
//判斷key的事件型別
if (key.isAcceptable()) {
ServerSocketChannel serverSocketChannel = (ServerSocketChannel) key.channel();
final SocketChannel socketChannel = serverSocketChannel.accept();
System.out.println("獲取到客戶端連線...");
// 設定為非阻塞模式,同時將連線的通道也註冊到選擇其中
socketChannel.configureBlocking(false);
socketChannel.register(selector, SelectionKey.OP_READ);
} else if (key.isReadable()) { //讀事件
SocketChannel channel = (SocketChannel) key.channel();
channel.read(buffer);
buffer.flip();
ByteBufferUtil.debugRead(buffer);
buffer.clear();
}
// 處理完畢後移除
iterator.remove();
}
}
} catch (IOException e) {
System.out.println("出現異常...");
}
}
}
斷開處理
當客戶端與伺服器之間的連線斷開時,會給伺服器端傳送一個讀事件,對異常斷開
和正常斷開
需要不同的方式進行處理:
正常斷開
- 正常斷開時,伺服器端的 channel.read(buffer)方法的返回值為-1,所以當結束到返回值為-1 時,需要呼叫 key 的 cancel()方法取消此事件,並在取消後移除該事件
異常斷開
- 異常斷開時,會丟擲 IOException 異常, 在 try-catch 的catch 塊中捕獲異常並呼叫 key 的 cancel()方法即可
訊息邊界
⚠️ 不處理訊息邊界存在的問題
將緩衝區的大小設定為 4 個位元組,傳送 2 個漢字(你好),通過 decode 解碼並列印時,會出現亂碼
ByteBuffer buffer = ByteBuffer.allocate(4);
// 解碼並列印
System.out.println(StandardCharsets.UTF_8.decode(buffer));
你�
��
這是因為 UTF-8 字符集下,1 個漢字佔用 3 個位元組,此時緩衝區大小為 4 個位元組,一次讀時間無法處理完通道中的所有資料,所以一共會觸發兩次讀事件。這就導致 你好
的 好
字被拆分為了前半部分和後半部分傳送,解碼時就會出現問題。
? 處理訊息邊界
傳輸的文字可能有以下三種情況:
- 文字大於緩衝區大小,此時需要將緩衝區進行擴容
- 發生半包現象
- 發生粘包現象
解決方案:
- 固定訊息長度,資料包大小一樣,伺服器按預定長度讀取,當傳送的資料較少時,需要將資料進行填充,直到長度與訊息規定長度一致。缺點是浪費頻寬
- 另一種思路是按分隔符拆分,缺點是效率低,需要一個一個字元地去匹配分隔符
- TLV 格式,即 Type 型別、Length 長度、Value 資料(也就是在訊息開頭用一些空間存放後面資料的長度),如 HTTP 請求頭中的 Content-Type 與Content-Length。型別和長度已知的情況下,就可以方便獲取訊息大小,分配合適的 buffer,缺點是 buffer 需要提前分配,如果內容過大,則影響 server 吞吐量
下面演示第二種解決方案,按分隔符拆分:
我們需要在 Accept 事件發生後,將通道註冊到 Selector 中時,對每個通道新增一個 ByteBuffer 附件,讓每個通道發生讀事件時都使用自己的通道,避免與其他通道發生衝突而導致問題。
ByteBuffer buffer = ByteBuffer.allocate(16);
// 新增通道對應的Buffer附件
socketChannel.register(selector, SelectionKey.OP_READ, buffer);
當 Channel 中的資料大於緩衝區時,需要對緩衝區進行擴容操作。此程式碼中的擴容的判定方法:Channel 呼叫 compact 方法後,的 position 與 limit 相等,說明緩衝區中的資料並未被讀取(容量太小),此時建立新的緩衝區,其大小擴大為兩倍。同時還要將舊緩衝區中的資料拷貝到新的緩衝區中,同時呼叫 SelectionKey 的 attach 方法將新的緩衝區作為新的附件放入 SelectionKey 中。
// 如果緩衝區太小,就進行擴容
if (buffer.position() == buffer.limit()) {
ByteBuffer newBuffer = ByteBuffer.allocate(buffer.capacity()*2);
// 將舊buffer中的內容放入新的buffer中
buffer.flip();
newBuffer.put(buffer);
// 將新buffer放到key中作為附件
key.attach(newBuffer);
}
改進後的程式碼如下:
/**
* @author 神祕傑克
* 公眾號: Java菜鳥程式設計師
* @date 2021/12/29
* @Description Read事件完整版-服務端
*/
public class Server {
public static void main(String[] args) {
try (ServerSocketChannel ssc = ServerSocketChannel.open();
final Selector selector = Selector.open()) {//建立selector 管理多個channel
ssc.bind(new InetSocketAddress(7777));
ssc.configureBlocking(false);
// 將通道註冊到選擇器中,並設定感興趣的事件
ssc.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
// 如果事件就緒,執行緒會被阻塞,反之不會被阻塞。從而避免了CPU空轉
// 返回值為就緒的事件個數
int ready = selector.select();
System.out.println("selector就緒總數: " + ready);
// 獲取所有事件
Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
while (iterator.hasNext()) {
final SelectionKey key = iterator.next();
//判斷key的事件型別
if (key.isAcceptable()) {
ServerSocketChannel serverSocketChannel = (ServerSocketChannel) key.channel();
final SocketChannel socketChannel = serverSocketChannel.accept();
System.out.println("獲取到客戶端連線...");
socketChannel.configureBlocking(false);
ByteBuffer byteBuffer = ByteBuffer.allocate(16);
//註冊到Selector並且設定讀事件,設定附件bytebuffer
socketChannel.register(selector, SelectionKey.OP_READ, byteBuffer);
} else if (key.isReadable()) { //讀事件
try {
SocketChannel channel = (SocketChannel) key.channel();
// 通過key獲得附件
ByteBuffer buffer = (ByteBuffer) key.attachment();
int read = channel.read(buffer);
if (read == -1) {
key.cancel();
channel.close();
} else {
// 通過分隔符來分隔buffer中的資料
split(buffer);
// 如果緩衝區太小,就進行擴容
if (buffer.position() == buffer.limit()) {
ByteBuffer newBuffer = ByteBuffer.allocate(buffer.capacity() * 2);
// 將舊buffer中的內容放入新的buffer中
buffer.flip();
newBuffer.put(buffer);
// 將新buffer放到key中作為附件
key.attach(newBuffer);
}
}
} catch (IOException e) {
//異常斷開,取消事件
key.cancel();
}
}
// 處理完畢後移除
iterator.remove();
}
}
} catch (IOException e) {
System.out.println("出現異常...");
}
}
private static void split(ByteBuffer buffer) {
buffer.flip();
for (int i = 0; i < buffer.limit(); i++) {
//找到一條完成資料
if (buffer.get(i) == '\n') {
// 緩衝區長度
int length = i + 1 - buffer.position();
ByteBuffer target = ByteBuffer.allocate(length);
// 將前面的內容寫入target緩衝區
for (int j = 0; j < length; j++) {
// 將buffer中的資料寫入target中
target.put(buffer.get());
}
ByteBufferUtil.debugAll(target);
}
}
// 切換為寫模式,但是緩衝區可能未讀完,這裡需要使用compact
buffer.compact();
}
}
/**
* @author 神祕傑克
* 公眾號: Java菜鳥程式設計師
* @date 2021/12/29
* @Description Read事件完整版-客戶端
*/
public class Client {
public static void main(String[] args) {
try (SocketChannel socketChannel = SocketChannel.open()) {
// 建立連線
socketChannel.connect(new InetSocketAddress("localhost", 7777));
final ByteBuffer buffer = ByteBuffer.allocate(32);
buffer.put("01234567890abcdef3333\n".getBytes(StandardCharsets.UTF_8));
buffer.flip();
socketChannel.write(buffer);
} catch (IOException e) {
e.printStackTrace();
}
}
}
ByteBuffer 的大小分配
- 每個 channel 都需要記錄可能被切分的訊息,因為 ByteBuffer 不能被多個 channel 共同使用,因此需要為每個 channel 維護一個獨立的 ByteBuffer
- ByteBuffer 不能太大,比如一個 ByteBuffer 1Mb 的話,要支援百萬連線就要 1Tb 記憶體,因此需要設計大小可變的 ByteBuffer
分配思路:
- 一種思路是首先分配一個較小的 buffer,例如 4k,如果發現資料不夠,再分配 8k 的 buffer,將 4k buffer 內容拷貝至 8k buffer,優點是訊息連續容易處理,缺點是資料拷貝耗費效能
- 另一種思路是用多個陣列組成 buffer,一個陣列不夠,把多出來的內容寫入新的陣列,與前面的區別是訊息儲存不連續解析複雜,優點是避免了拷貝引起的效能損耗
Write 事件
伺服器通過 Buffer 通道中寫入資料時,可能因為通道容量小於 Buffer 中的資料大小,導致無法一次性將 Buffer 中的資料全部寫入到 Channel 中,這時便需要分多次寫入,具體步驟如下:
- 執行一次寫操作,向將 buffer 中的內容寫入到 SocketChannel 中,然後判斷 Buffer 中是否還有資料
- 若 Buffer 中還有資料,則需要將 SockerChannel 註冊到 Seletor 中,並關注寫事件,同時將未寫完的 Buffer 作為附件一起放入到 SelectionKey 中。
/**
* @author 神祕傑克
* 公眾號: Java菜鳥程式設計師
* @date 2021/12/29
* @Description Write事件-服務端
*/
public class Server {
public static void main(String[] args) {
try (ServerSocketChannel ssc = ServerSocketChannel.open();
final Selector selector = Selector.open()) {
ssc.bind(new InetSocketAddress(7777));
ssc.configureBlocking(false);
ssc.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
int ready = selector.select();
Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
while (iterator.hasNext()) {
final SelectionKey key = iterator.next();
//判斷key的事件型別
if (key.isAcceptable()) {
final SocketChannel socketChannel = ssc.accept();
socketChannel.configureBlocking(false);
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 3000000; i++) {
sb.append("a");
}
final ByteBuffer buffer = Charset.defaultCharset().encode(sb.toString());
final int write = socketChannel.write(buffer);
System.out.println("accept事件器寫入.."+write);
// 判斷是否還有剩餘內容
if (buffer.hasRemaining()) {
// 註冊到Selector中,關注可寫事件,並將buffer新增到key的附件中
socketChannel.register(selector, SelectionKey.OP_WRITE, buffer);
}
}else if (key.isWritable()) {
SocketChannel socket = (SocketChannel) key.channel();
// 獲得事件
ByteBuffer buffer = (ByteBuffer) key.attachment();
int write = socket.write(buffer);
System.out.println("write事件器寫入.."+write);
// 如果已經完成了寫操作,需要移除key中的附件,同時不再對寫事件感興趣
if (!buffer.hasRemaining()) {
key.attach(null);
key.interestOps(0);
}
}
// 處理完畢後移除
iterator.remove();
}
}
} catch (IOException e) {
System.out.println("出現異常...");
}
}
}
/**
* @author 神祕傑克
* 公眾號: Java菜鳥程式設計師
* @date 2021/12/29
* @Description Write事件-客戶端
*/
public class Client {
public static void main(String[] args) {
try (SocketChannel socketChannel = SocketChannel.open()) {
// 建立連線
socketChannel.connect(new InetSocketAddress("localhost", 7777));
int count = 0;
while (true) {
final ByteBuffer buffer = ByteBuffer.allocate(1024 * 1024);
count += socketChannel.read(buffer);
System.out.println("客戶端接受了.."+count);
buffer.clear();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
執行結果: