NIO 基础三大核心组件

一、三大核心组件

1.1 Channel & Buffer

核心本质
  • Channel(通道) :是操作系统内核 IO 缓冲区的 Java 抽象,比传统的 InputStream/OutputStream 更底层。它是双向通道,既可以读也可以写,数据必须通过 Buffer 中转,不能直接读写 Channel。
  • Buffer(缓冲区):一块连续的内存区域,所有 NIO 的读写操作都围绕 Buffer 进行。数据先从 Channel 读入 Buffer,或从 Buffer 写入 Channel,避免了传统 IO 逐字节操作的性能损耗。
常见 Channel 分类与用途
Channel 类型 对应协议 核心用途 是否支持非阻塞
FileChannel 文件 IO 本地文件的读写、传输 ❌ 仅阻塞
SocketChannel TCP TCP 客户端与服务端的数据读写
ServerSocketChannel TCP TCP 服务端监听新连接
DatagramChannel UDP UDP 协议的数据收发

注意:只有网络 Channel 支持非阻塞模式,FileChannel 只能工作在阻塞模式,无法配合 Selector 使用。

常见 Buffer 分类

Java 为所有基本类型都提供了对应 Buffer,其中 ByteBuffer 是网络编程的核心(网络传输的本质是字节流)。

  • 按内存位置划分(ByteBuffer 独有)
    • HeapByteBuffer:堆内内存,底层是 Java 字节数组,受 JVM GC 管理,分配快但 IO 读写有额外拷贝开销
    • DirectByteBuffer:堆外内存(操作系统内核内存),不受 GC 影响,内存地址固定,IO 性能更高,但分配 / 回收成本高
    • MappedByteBuffer:文件内存映射 Buffer,直接将磁盘文件映射到用户态内存,随机读写性能极高
  • 按数据类型划分:ShortBufferIntBufferLongBufferFloatBufferDoubleBufferCharBuffer,本质都是对字节的封装。

1.2 Selector 多路复用器

Selector 是 NIO 实现高并发的核心:用 1 个线程管理成百上千个 Channel,避免了多线程模型下的内存占用与上下文切换开销,是经典的 C10K 问题解决方案。

服务器模型演化对比
模型 实现逻辑 核心缺点 适用场景
多线程版 每来一个连接,新建一个线程处理 线程内存占用高、上下文切换成本大,10 万连接就会耗尽内存 连接数极少(<100)的场景
线程池版 用线程池复用线程处理连接 阻塞模式下 1 个线程同时只能处理 1 个连接,长连接会占满线程池 短连接、低并发场景
Selector 版 单线程 + Selector 监听所有连接,只有事件发生时才处理 单线程处理业务,不适合重计算场景 连接数多、流量低(low traffic)的高并发场景
工作原理大白话

Selector 就像一个 "前台接待":

  • 所有 Channel 都注册到 Selector 上,告诉它 "我关心读 / 写 / 连接事件"
  • 线程调用 select() 方法就会 "休息",直到有 Channel 的事件就绪
  • 事件发生后,线程醒来,批量处理所有就绪的 Channel
  • 全程线程大部分时间在等待,不会空转浪费 CPU,1 个线程就能扛住几万连接

二、ByteBuffer 深度详解

ByteBuffer 是 NIO 最核心、最容易用错的组件,本质是一个带指针状态机的字节数组,通过控制指针位置实现读写模式切换。

2.1 核心属性与不变式

ByteBuffer 内部有 4 个关键指针,始终满足不变式: 0 ≤ mark ≤ position ≤ limit ≤ capacity

属性 含义
capacity 容量,Buffer 总大小,分配后永远不变
position 当前读写位置,每读 / 写 1 个字节,position 自动后移 1 位
limit 读写上限:写模式下等于 capacity;读模式下等于最后一次写的 position
mark 标记位,调用 mark() 记录当前 position,调用 reset() 回到该位置
状态流转完整示例(capacity=10)

我们用表格直观展示每个操作后指针的变化:

操作 position limit 模式 说明
初始化 allocate(10) 0 10 写模式 空 Buffer,从第 0 位开始写
写入 4 个字节后 4 10 写模式 已写 4 字节,下一个字节写到第 4 位
调用 flip() 0 4 读模式 limit 设为原来的 position(4),position 归零,最多读 4 字节
读取 2 个字节后 2 4 读模式 已读 2 字节,还剩 2 字节可读
调用 compact() 2 10 写模式 未读的 2 字节移到 Buffer 开头,position 移到 2,后面可以继续写新数据
调用 clear() 0 10 写模式 逻辑清空,指针复位,原来的数据还在,但会被新数据覆盖

2.2 正确使用四步流程

标准读写循环必须严格遵循,否则会出现读不到数据、数据覆盖等 Bug:

  1. 写模式 :调用 channel.read(buffer)buffer.put() 向 Buffer 写入数据,position 自动后移
  2. 切换读模式 :调用 flip(),将 limit 设为当前 position,position 重置为 0
  3. 读取数据 :调用 buffer.get()channel.write(buffer) 读取数据,position 自动后移
  4. 切换回写模式
    • clear():全部读完时用,position 置 0,limit 置 capacity,逻辑清空所有数据
    • compact():数据没读完、还要继续写新数据时用,把未读完的部分前移,保留未读数据
java 复制代码
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.RandomAccessFile;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;

public class ChannelDemo1 {
    private static final Logger log = LoggerFactory.getLogger(ChannelDemo1.class);

    public static void main(String[] args) {
        // 项目根目录下新建 data.txt,写入 1234567890abcd
        try (RandomAccessFile file = new RandomAccessFile("data.txt", "rw");
             FileChannel channel = file.getChannel()) {

            ByteBuffer buffer = ByteBuffer.allocate(10);
            do {
                // 1. 通道数据写入 buffer
                int len = channel.read(buffer);
                log.debug("读到字节数:{}", len);
                if (len == -1) {
                    break;
                }
                // 2. 切换读模式
                buffer.flip();
                // 3. 读取 buffer 数据
                while (buffer.hasRemaining()) {
                    log.debug("{}", (char) buffer.get());
                }
                // 4. 切换写模式,准备下一次读取
                buffer.clear();
            } while (true);

        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

2.4 核心方法详解

一、内存分配
java 复制代码
// 分配堆内 Buffer,底层是 byte[],受 GC 管理
ByteBuffer heapBuf = ByteBuffer.allocate(16);
// 分配堆外 Buffer,直接使用操作系统内存,IO 性能更高
ByteBuffer directBuf = ByteBuffer.allocateDirect(16);
// 用已有字节数组包装,修改数组会影响 Buffer,反之亦然
byte[] arr = {1, 2, 3};
ByteBuffer wrapBuf = ByteBuffer.wrap(arr);

选型建议:短生命周期、小 Buffer 用 allocate;长生命周期、频繁 IO 操作的 Buffer 用 allocateDirect

二、写入数据
java 复制代码
ByteBuffer buf = ByteBuffer.allocate(10);

// 1. 写入单个字节
buf.put((byte) 97);
// 2. 写入字节数组
buf.put(new byte[]{98, 99, 100});
// 3. 绝对位置写入(不移动 position)
buf.put(0, (byte) 65);

// 4. 从通道读取数据写入 buffer(最常用)
// int readBytes = channel.read(buf);
三、读取数据
java 复制代码
buf.flip(); // 先切换读模式

// 1. 读取单个字节,position 后移
byte b = buf.get();
// 2. 读取到字节数组
byte[] arr = new byte[3];
buf.get(arr);
// 3. 绝对位置读取(不移动 position)
byte b2 = buf.get(0);

// 4. 将 buffer 数据写入通道(最常用)
// int writeBytes = channel.write(buf);
四、模式切换核心方法对比

这是最容易混淆的知识点,整理成对比表一目了然:

方法 对指针的操作 用途 适用场景
flip() limit=position; position=0; mark=-1 切换为读模式 写完数据,准备读取
rewind() position=0; mark=-1; limit 不变 重置读指针,重读数据 同一份数据需要读多次
clear() position=0; limit=capacity; mark=-1 切换为写模式,逻辑清空 数据全部读完,重新写新数据
compact() 未读数据前移;position = 剩余长度;limit=capacity 切换为写模式,保留未读数据 半包场景,数据没读完还要继续写

✅ 可运行 Demo:对比 clear 和 compact

java 复制代码
import java.nio.ByteBuffer;
import static ByteBufferUtil.debugAll;

public class BufferModeDemo {
    public static void main(String[] args) {
        ByteBuffer buf = ByteBuffer.allocate(10);
        // 写入 4 个字节
        buf.put(new byte[]{1, 2, 3, 4});
        System.out.println("=== 写入4字节后 ===");
        debugAll(buf);

        // 切换读模式,读 2 个字节
        buf.flip();
        buf.get();
        buf.get();
        System.out.println("=== flip后读取2字节 ===");
        debugAll(buf);

        // 方式1:clear 切换写模式
        ByteBuffer buf1 = buf.duplicate();
        buf1.clear();
        System.out.println("=== clear 后 ===");
        debugAll(buf1);

        // 方式2:compact 切换写模式
        ByteBuffer buf2 = buf.duplicate();
        buf2.compact();
        System.out.println("=== compact 后 ===");
        debugAll(buf2);
    }
}
五、mark & reset

用来标记位置,随时回到标记点,适合需要回退读取的场景:

java 复制代码
import java.nio.ByteBuffer;

public class MarkResetDemo {
    public static void main(String[] args) {
        ByteBuffer buf = ByteBuffer.allocate(10);
        buf.put(new byte[]{1, 2, 3, 4, 5});
        buf.flip();

        // 读 2 个字节,打标记
        System.out.println(buf.get()); // 1
        System.out.println(buf.get()); // 2
        buf.mark(); // 标记 position=2 的位置

        System.out.println(buf.get()); // 3
        System.out.println(buf.get()); // 4

        // 回到标记位置
        buf.reset();
        System.out.println("reset 后读取:" + buf.get()); // 3
    }
}

注意:flip()rewind()clear() 都会清除 mark 标记,只有 compact() 会保留。

六、字符串与 ByteBuffer 互转

本质是编码(字符串→字节)和解码(字节→字符串),必须指定字符集,避免乱码。

java 复制代码
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.charset.StandardCharsets;
import static ByteBufferUtil.debugAll;

public class StringConvertDemo {
    public static void main(String[] args) {
        // 1. 字符串 → ByteBuffer(编码,自动切换为读模式)
        ByteBuffer buf1 = StandardCharsets.UTF_8.encode("你好");
        ByteBuffer buf2 = StandardCharsets.UTF_8.encode("Hello World");

        System.out.println("中文编码后:");
        debugAll(buf1);

        // 2. ByteBuffer → 字符串(解码)
        CharBuffer charBuf = StandardCharsets.UTF_8.decode(buf1);
        System.out.println("解码结果:" + charBuf.toString());

        // 易错点:自己 put 的字符串要手动 flip 才能解码
        ByteBuffer buf3 = ByteBuffer.allocate(16);
        buf3.put("测试".getBytes(StandardCharsets.UTF_8));
        // buf3.flip(); // 不加这行解码为空!
        System.out.println("未flip解码:" + StandardCharsets.UTF_8.decode(buf3));
    }
}

坑点提醒:半包场景下不能直接解码中文,否则会出现乱码(一个中文占 3 字节,拆成两半就解码失败)。

七、线程安全

Buffer 是非线程安全的,多线程同时调用 put/get 会导致 position 混乱、数据错乱。

  • 网络编程中,一个 Channel 对应一个 Buffer,在单线程中处理,天然线程安全
  • 如果多线程共享一个 Buffer,必须加锁保护

2.5 Scattering Reads(分散读)

一次读取操作将数据按顺序填充到多个 Buffer,适合协议头 + 协议体拆分读取,减少内存拷贝。

java 复制代码
import java.io.RandomAccessFile;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import static ByteBufferUtil.debugAll;

public class ScatteringReadDemo {
    public static void main(String[] args) {
        // 新建 3parts.txt,写入 onetwothree
        try (RandomAccessFile file = new RandomAccessFile("3parts.txt", "rw");
             FileChannel channel = file.getChannel()) {

            // 三个 Buffer,分别装 3、3、5 字节
            ByteBuffer a = ByteBuffer.allocate(3);
            ByteBuffer b = ByteBuffer.allocate(3);
            ByteBuffer c = ByteBuffer.allocate(5);

            // 一次读取,按顺序填满三个 Buffer
            channel.read(new ByteBuffer[]{a, b, c});

            a.flip();
            b.flip();
            c.flip();

            System.out.println("Buffer a:");
            debugAll(a);
            System.out.println("Buffer b:");
            debugAll(b);
            System.out.println("Buffer c:");
            debugAll(c);

        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

2.6 Gathering Writes(聚集写)

一次写入操作将多个 Buffer 的数据按顺序写入通道,适合拼接多个数据块发送,减少系统调用次数。

java 复制代码
import java.io.RandomAccessFile;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import static ByteBufferUtil.debugAll;

public class GatheringWriteDemo {
    public static void main(String[] args) {
        try (RandomAccessFile file = new RandomAccessFile("3parts.txt", "rw");
             FileChannel channel = file.getChannel()) {

            ByteBuffer d = ByteBuffer.allocate(4);
            ByteBuffer e = ByteBuffer.allocate(4);
            // 移动文件指针到末尾
            channel.position(11);

            d.put(new byte[]{'f', 'o', 'u', 'r'});
            e.put(new byte[]{'f', 'i', 'v', 'e'});
            d.flip();
            e.flip();

            // 一次写入,把两个 Buffer 的数据全部写入通道
            channel.write(new ByteBuffer[]{d, e});
            System.out.println("写入完成,文件大小:" + channel.size());

        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

2.7 黏包半包问题

为什么会有黏包半包?

TCP 是面向流的协议,没有消息边界。操作系统会根据缓冲区状态、网络拥塞情况对数据进行拆分和合并,所以接收方收到的数据和发送方的发送次数不是一一对应的:

  • 黏包:多条小消息被合并成一次发送
  • 半包:一条大消息被拆成多次发送
解决方案:按分隔符拆分

我们用 \n 作为消息边界,从错乱的字节流中拆分出完整消息。

java 复制代码
import java.nio.ByteBuffer;
import static ByteBufferUtil.debugAll;

public class ByteBufferStickyDemo {

    public static void main(String[] args) {
        ByteBuffer source = ByteBuffer.allocate(32);

        // 模拟第一次接收:两条完整消息 + 第三条的前半段(半包)
        source.put("Hello,world\nI'm zhangsan\nHo".getBytes());
        System.out.println("=== 第一次接收后拆分 ===");
        split(source);

        // 模拟第二次接收:第三条的后半段 + 第四条完整消息
        source.put("w are you?\nhaha!\n".getBytes());
        System.out.println("=== 第二次接收后拆分 ===");
        split(source);
    }

    /**
     * 按 \n 拆分完整消息
     * @param source 源缓冲区,包含可能不完整的消息
     */
    private static void split(ByteBuffer source) {
        // 1. 切换为读模式
        source.flip();
        // 保存原始 limit,后面修改 limit 后需要恢复
        int oldLimit = source.limit();

        // 2. 遍历每个字节,找换行符
        for (int i = source.position(); i < oldLimit; i++) {
            if (source.get(i) == '\n') {
                // 计算这条完整消息的长度
                int length = i + 1 - source.position();
                // 分配对应大小的新 Buffer
                ByteBuffer target = ByteBuffer.allocate(length);

                // 3. 临时修改 limit 到换行符位置,从 source 读取完整消息
                source.limit(i + 1);
                target.put(source); // 批量读取,position 自动后移

                // 4. 打印完整消息
                target.flip();
                System.out.println("拆分出一条完整消息:");
                debugAll(target);

                // 5. 恢复原始 limit,继续找下一个换行符
                source.limit(oldLimit);
            }
        }

        // 6. 压缩:把未读完的半包数据移到 Buffer 开头,切换为写模式
        // 下次接收新数据时,会接在半包后面
        source.compact();
    }
}
代码逐行解释
  1. source.flip():切换到读模式,才能读取里面的数据
  2. 遍历查找 \n:找到就说明有一条完整消息
  3. 临时修改 limit:控制 source.put(target) 只读到换行符位置,不会多读
  4. target.put(source):利用 Buffer 间的批量拷贝,比循环 get 高效
  5. source.compact():核心!把没读完的半包移到 Buffer 开头,下次接收新数据时拼在一起,解决半包问题