NIO 基础三大核心组件
一、三大核心组件1.1 Channel Buffer核心本质Channel通道是操作系统内核 IO 缓冲区的 Java 抽象比传统的InputStream/OutputStream更底层。它是双向通道既可以读也可以写数据必须通过 Buffer 中转不能直接读写 Channel。Buffer缓冲区一块连续的内存区域所有 NIO 的读写操作都围绕 Buffer 进行。数据先从 Channel 读入 Buffer或从 Buffer 写入 Channel避免了传统 IO 逐字节操作的性能损耗。常见 Channel 分类与用途Channel 类型对应协议核心用途是否支持非阻塞FileChannel文件 IO本地文件的读写、传输❌ 仅阻塞SocketChannelTCPTCP 客户端与服务端的数据读写✅ServerSocketChannelTCPTCP 服务端监听新连接✅DatagramChannelUDPUDP 协议的数据收发✅注意只有网络 Channel 支持非阻塞模式FileChannel只能工作在阻塞模式无法配合 Selector 使用。常见 Buffer 分类Java 为所有基本类型都提供了对应 Buffer其中ByteBuffer是网络编程的核心网络传输的本质是字节流。按内存位置划分ByteBuffer 独有HeapByteBuffer堆内内存底层是 Java 字节数组受 JVM GC 管理分配快但 IO 读写有额外拷贝开销DirectByteBuffer堆外内存操作系统内核内存不受 GC 影响内存地址固定IO 性能更高但分配 / 回收成本高MappedByteBuffer文件内存映射 Buffer直接将磁盘文件映射到用户态内存随机读写性能极高按数据类型划分ShortBuffer、IntBuffer、LongBuffer、FloatBuffer、DoubleBuffer、CharBuffer本质都是对字节的封装。1.2 Selector 多路复用器Selector 是 NIO 实现高并发的核心用 1 个线程管理成百上千个 Channel避免了多线程模型下的内存占用与上下文切换开销是经典的 C10K 问题解决方案。服务器模型演化对比模型实现逻辑核心缺点适用场景多线程版每来一个连接新建一个线程处理线程内存占用高、上下文切换成本大10 万连接就会耗尽内存连接数极少100的场景线程池版用线程池复用线程处理连接阻塞模式下 1 个线程同时只能处理 1 个连接长连接会占满线程池短连接、低并发场景Selector 版单线程 Selector 监听所有连接只有事件发生时才处理单线程处理业务不适合重计算场景连接数多、流量低low traffic的高并发场景工作原理大白话Selector 就像一个 “前台接待”所有 Channel 都注册到 Selector 上告诉它 “我关心读 / 写 / 连接事件”线程调用select()方法就会 “休息”直到有 Channel 的事件就绪事件发生后线程醒来批量处理所有就绪的 Channel全程线程大部分时间在等待不会空转浪费 CPU1 个线程就能扛住几万连接二、ByteBuffer 深度详解ByteBuffer 是 NIO 最核心、最容易用错的组件本质是一个带指针状态机的字节数组通过控制指针位置实现读写模式切换。2.1 核心属性与不变式ByteBuffer 内部有 4 个关键指针始终满足不变式0 ≤ mark ≤ position ≤ limit ≤ capacity属性含义capacity容量Buffer 总大小分配后永远不变position当前读写位置每读 / 写 1 个字节position 自动后移 1 位limit读写上限写模式下等于 capacity读模式下等于最后一次写的 positionmark标记位调用mark()记录当前 position调用reset()回到该位置状态流转完整示例capacity10我们用表格直观展示每个操作后指针的变化操作positionlimit模式说明初始化allocate(10)010写模式空 Buffer从第 0 位开始写写入 4 个字节后410写模式已写 4 字节下一个字节写到第 4 位调用flip()后04读模式limit 设为原来的 position4position 归零最多读 4 字节读取 2 个字节后24读模式已读 2 字节还剩 2 字节可读调用compact()后210写模式未读的 2 字节移到 Buffer 开头position 移到 2后面可以继续写新数据调用clear()后010写模式逻辑清空指针复位原来的数据还在但会被新数据覆盖2.2 正确使用四步流程标准读写循环必须严格遵循否则会出现读不到数据、数据覆盖等 Bug写模式调用channel.read(buffer)或buffer.put()向 Buffer 写入数据position 自动后移切换读模式调用flip()将 limit 设为当前 positionposition 重置为 0读取数据调用buffer.get()或channel.write(buffer)读取数据position 自动后移切换回写模式clear()全部读完时用position 置 0limit 置 capacity逻辑清空所有数据compact()数据没读完、还要继续写新数据时用把未读完的部分前移保留未读数据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 核心方法详解一、内存分配// 分配堆内 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。二、写入数据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);三、读取数据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()limitposition; position0; mark-1切换为读模式写完数据准备读取rewind()position0; mark-1; limit 不变重置读指针重读数据同一份数据需要读多次clear()position0; limitcapacity; mark-1切换为写模式逻辑清空数据全部读完重新写新数据compact()未读数据前移position 剩余长度limitcapacity切换为写模式保留未读数据半包场景数据没读完还要继续写✅ 可运行 Demo对比 clear 和 compactimport 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); // 方式1clear 切换写模式 ByteBuffer buf1 buf.duplicate(); buf1.clear(); System.out.println( clear 后 ); debugAll(buf1); // 方式2compact 切换写模式 ByteBuffer buf2 buf.duplicate(); buf2.compact(); System.out.println( compact 后 ); debugAll(buf2); } }五、mark reset用来标记位置随时回到标记点适合需要回退读取的场景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(); // 标记 position2 的位置 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 互转本质是编码字符串→字节和解码字节→字符串必须指定字符集避免乱码。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适合协议头 协议体拆分读取减少内存拷贝。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 的数据按顺序写入通道适合拼接多个数据块发送减少系统调用次数。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作为消息边界从错乱的字节流中拆分出完整消息。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\nIm 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(); } }代码逐行解释source.flip()切换到读模式才能读取里面的数据遍历查找\n找到就说明有一条完整消息临时修改limit控制source.put(target)只读到换行符位置不会多读target.put(source)利用 Buffer 间的批量拷贝比循环 get 高效source.compact()核心把没读完的半包移到 Buffer 开头下次接收新数据时拼在一起解决半包问题