深入學習Netty(一)NIO基礎篇

神祕傑克發表於2021-12-30

NIO 基礎

什麼是 NIO

  1. Java NIO 全稱 Java non-blocking IO,指的是 JDK 提供的新 API。從 JDK 1.4 開始,Java 提供了一系列改進的輸入/輸出的新特性,被統稱為 NIO,即 New IO,是同步非阻塞的。
  2. NIO 相關類都放在 java.nio 包下,並對原 java.io 包中很多類進行了改寫。
  3. NIO 有三大核心部分:Channel(管道)Buffer(緩衝區)Selector(選擇器)
  4. NIO 是面向緩衝區程式設計的。資料讀取到了一個它稍微處理的緩衝區,需要時可在緩衝區中前後移動,這就增加了處理過程中的靈活性,使用它可以提供非阻塞的高伸縮性網路。
  5. Java NIO 的非阻塞模式,使一個執行緒從某通道傳送請求讀取資料,但是它僅能得到目前可用資料,如果目前沒有可用資料時,則說明不會獲取,而不是保持執行緒阻塞,所以直到資料變為可以讀取之前,該執行緒可以做其他事情。非阻塞寫入同理。

三大核心元件

概覽

Channel 的基本介紹

NIO 的通道類似於流,但有如下區別:

  1. 通道是雙向的可以進行讀寫,而流是單向的只能讀,或者寫
  2. 通道可以實現非同步讀寫資料
  3. 通道可以從緩衝區讀取資料,也可以寫入資料到緩衝區

四種通道:

  • 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 的基本介紹

  1. Java 的 NIO 使用了非阻塞的 I/O 方式。可以用一個執行緒處理若干個客戶端連線,就會使用到 Selector(選擇器)
  2. Selector 能夠檢測到多個註冊通道上是否有事件發生(多個 Channel 以事件的形式註冊到同一個 selector),如果有事件發生,便獲取事件然後針對每個事件進行相應的處理
  3. 只有在連線真正有讀寫事件發生時,才會進行讀寫,減少了系統開銷,並且不必為每個連線都建立一個執行緒,不用維護多個執行緒
  4. 避免了多執行緒之間上下文切換導致的開銷

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()方法後,則迴歸最原始狀態。

clear()

當呼叫 compact()方法時,需要注意:此方法為 ByteBuffer 的方法,而不是 Buffer 的方法

  • compact 會把未讀完的資料向前壓縮,然後切換到寫模式
  • 資料前移後,原位置的值並未清零,寫時會覆蓋之前的值

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

出現原因

粘包

傳送方在傳送資料時,並不是一條一條地傳送資料,而是將資料整合在一起,當資料達到一定的數量後再一起傳送。這就會導致多條資訊被放在一個緩衝區中被一起傳送出去。

半包

接收方的緩衝區的大小是有限的,當接收方的緩衝區滿了以後,就需要將資訊截斷,等緩衝區空了以後再繼續放入資料。這就會發生一段完整的資料最後被截斷的現象。

解決辦法

  1. 通過get(index)方法遍歷 ByteBuffer,當遇到\n後進行處理。
  2. 記錄從 position 到 index 的資料長度,申請對應大小的緩衝區。
  3. 將緩衝區的資料通過get()獲取寫入到 target 緩衝區中。
  4. 最後,呼叫 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 操作,常見的方法有:

  1. public int read(ByteBuffer dst) :從通道中讀取資料到緩衝區中。
  2. public int write(ByteBuffer src):把緩衝區中的資料寫入到通道中。
  3. public long transferFrom(ReadableByteChannel src,long position,long count):從目標通道中複製資料到當前通道。
  4. 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 的注意事項

  1. ByteBuffer 支援型別化的 put 和 get,put 放入什麼資料型別,get 就應該使用相應的資料型別來取出,否則可能會產生 ByteUnderflowException 異常。
  2. 可以將一個普通的 Buffer 轉換為只讀的 Buffer:asReadOnlyBuffer()方法
  3. NIO 提供了 MapperByteBuffer,可以讓檔案直接在記憶體(堆外記憶體)中進行修改,而如何同步到檔案由 NIO 來完成。
  4. 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 阻塞。

    image-20211229202541691

  • 在客戶端和伺服器建立連線後,客戶端傳送訊息前:伺服器端因通道為空被阻塞。

    image-20211229202703313

  • 客戶端傳送資料後,伺服器處理通道中的資料。之後再次進入迴圈時,再次被 accept 阻塞。

    image-20211229202810523

  • 之前的客戶端再次傳送訊息,伺服器端因為被 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

基本介紹

  1. Java 的 NIO 使用了非阻塞的 I/O 方式。可以用一個執行緒處理若干個客戶端連線,就會使用到 Selector(選擇器)。
  2. Selector 能夠檢測到多個註冊通道上是否有事件發生(多個 Channel 以事件的形式註冊到同一個 selector),如果有事件發生,便獲取事件然後針對每個事件進行相應的處理。
  3. 只有在連線真正有讀寫事件發生時,才會進行讀寫,減少了系統開銷,並且不必為每個連線都建立一個執行緒,不用維護多個執行緒。
  4. 避免了多執行緒之間上下文切換導致的開銷。

特點

單執行緒可以配合 Selector 完成對多個 Channel 可讀寫事件的監控,這稱為多路複用

  • 多路複用僅針對網路 IO,普通檔案 IO 無法利用多路複用
  • 如果不用 Selector 的非阻塞模式,執行緒大部分時間都在做無用功,而 Selector 能夠保證

    • 有可連線事件時才去連線
    • 有可讀事件才去讀取
    • 有可寫事件才去寫入
限於網路傳輸能力,Channel 未必隨時可寫,一旦 Channel 可寫,會觸發 Selector 的可寫事件進行寫入。

Selector 相關方法說明

  • selector.select()://若未監聽到註冊管道中有事件,則持續阻塞
  • selector.select(1000)://阻塞 1000 毫秒,1000 毫秒後返回
  • selector.wakeup()://喚醒 selector
  • selector.selectNow(): //不阻塞,立即返回

NIO 非阻塞網路程式設計過程分析

  1. 當客戶端連線時,會通過 SeverSocketChannel 得到對應的 SocketChannel。
  2. Selector 進行監聽,呼叫 select()方法,返回註冊該 Selector 的所有通道中有事件發生的通道個數。
  3. 將 SocketChannel 註冊到 Selector 上,public final SelectionKey register(Selector sel, int ops),一個 Selector 上可以註冊多個 SocketChannel。
  4. 註冊後返回一個 SelectionKey,會和該 Selector 關聯(以集合的形式)。
  5. 進一步得到各個 SelectionKey,有事件發生。
  6. 再通過 SelectionKey 反向獲取 SocketChannel,使用 channnel()方法。
  7. 可以通過得到的 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);
}

image-20211230201625204

改進後的程式碼如下

/**
 * @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 中,這時便需要分多次寫入,具體步驟如下:

  1. 執行一次寫操作,向將 buffer 中的內容寫入到 SocketChannel 中,然後判斷 Buffer 中是否還有資料
  2. 若 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();
        }
    }
}

執行結果:

服務端

客戶端

相關文章