Java NIO 深度实战——Buffer、Channel、Selector,比你想象的难用
Java NIO 深度实战——Buffer、Channel、Selector,比你想象的难用
适读人群:有一定 Java 基础,想深入理解 NIO 或使用过 NIO 被坑过的开发者 | 阅读时长:约 17 分钟 | 核心价值:真实理解 NIO 的核心抽象,不被 flip/clear/compact 这些绕死人的方法搞混
我第一次认真写 NIO 代码是在 2019 年,当时组里想把一个文件传输服务从 BIO 改成 NIO,理由是"NIO 更高效"。
我花了两天时间写出来了,然后用了半天时间修 bug。问题是 ByteBuffer.flip() 的时机搞错了,数据读出来是乱的,偶尔还能正常工作(最可怕的 bug 类型)。
事后我老老实实把 NIO 的官方文档和《Java NIO》这本书啃了一遍。这篇文章把我认为最容易搞错的地方整理出来。
先说结论:除非你在写网络框架或者底层 IO 工具,一般业务开发不要直接用 NIO。用 Netty。NIO 的 API 设计得令人抓狂,出了错很难调试。 但是理解 NIO 是理解 Netty 的前提,所以这篇文章还是值得读。
Buffer:最反直觉的地方在 flip()
ByteBuffer 有三个关键属性:
position:当前读/写位置limit:可读/写的上限capacity:缓冲区总容量
向 Buffer 写入数据时,position 前进;读数据时,position 也前进。
问题来了:同一个 position 变量,写入时用来记录写到哪了,读取时用来记录读到哪了。 从写模式切换到读模式,必须调用 flip(),否则读出来的不是你写进去的数据。
package com.example.nio;
import java.nio.ByteBuffer;
public class BufferDemo {
public static void main(String[] args) {
ByteBuffer buffer = ByteBuffer.allocate(1024);
// 写模式:往 buffer 里写数据
buffer.put("Hello NIO".getBytes());
// 此时:position=9, limit=1024, capacity=1024
System.out.println("写完后 position=" + buffer.position() + " limit=" + buffer.limit());
// 切换到读模式!flip() 做了两件事:
// 1. limit = position(把 limit 设成刚才写了多少)
// 2. position = 0(从头开始读)
buffer.flip();
System.out.println("flip后 position=" + buffer.position() + " limit=" + buffer.limit());
// 现在:position=0, limit=9, capacity=1024
byte[] data = new byte[buffer.limit()];
buffer.get(data); // 读 9 个字节
System.out.println("读出来的内容: " + new String(data)); // Hello NIO
// 如果要再次写入,要么 clear()(position=0, limit=capacity),
// 要么 compact()(把未读的数据移到头部,position 指向已读位置之后)
buffer.clear(); // 丢弃所有数据,准备重新写
// 或者 buffer.compact(); // 保留未读数据,适合半包问题
}
}我犯过的错误: 写完数据之后忘了调 flip(),直接把 buffer 传给 Channel 写出去。position 是 9,limit 是 1024,channel.write(buffer) 从 position=9 开始写到 limit=1024,写出去的是 9 之后的 1015 个字节垃圾数据,完全错误。
而且这个错误不会报异常,数据就是悄悄地写错了。NIO 最让我头疼的地方就是这个:很多状态错误不会立刻报错。
Channel:FileChannel 的 transferTo 是真好用
Channel 相对于 Buffer 更好理解:它就是一个可读可写的 IO 通道。常用的是 FileChannel、SocketChannel、ServerSocketChannel。
FileChannel 有一个很好用的方法 transferTo,可以实现零拷贝文件传输:
package com.example.nio;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.channels.FileChannel;
/**
* 使用 FileChannel.transferTo 实现零拷贝文件传输
* 零拷贝:数据不需要经过用户态,直接在内核态从源文件复制到目标文件
* 对于大文件传输,比传统 InputStream + OutputStream 快很多
*/
public class ZeroCopyFileTransfer {
public static void transferFile(String srcPath, String destPath) throws IOException {
try (FileInputStream fis = new FileInputStream(srcPath);
FileOutputStream fos = new FileOutputStream(destPath);
FileChannel srcChannel = fis.getChannel();
FileChannel destChannel = fos.getChannel()) {
long fileSize = srcChannel.size();
long transferred = 0;
// transferTo 单次调用不一定能传完,需要循环
// 这是很多教程没有提到的细节,直接用一次 transferTo 在某些 OS 上会传不完
while (transferred < fileSize) {
long count = srcChannel.transferTo(
transferred, // 从哪里开始
fileSize - transferred, // 传多少
destChannel // 传到哪
);
if (count <= 0) {
// 防止无限循环(理论上不应该出现,加个保护)
break;
}
transferred += count;
}
}
}
}这比传统的 while (in.read(buffer) != -1) { out.write(buffer); } 方式快很多,特别是大文件。这是因为 transferTo 底层使用了 sendfile 系统调用,数据不需要在内核态和用户态之间来回复制。
踩坑实录一:SocketChannel 的非阻塞模式和 read() 返回 0
SocketChannel 在非阻塞模式下,read() 方法可能返回 0,意思是"现在没有数据,但连接还在"。返回 -1 才是"连接已关闭"。
很多人看到 0 就处理成错误了:
// 错误:把返回 0 当成错误处理
int bytesRead = socketChannel.read(buffer);
if (bytesRead <= 0) { // 这里的 <= 0 把 0 也包进去了,不对
// 关闭连接
socketChannel.close();
}正确处理:
package com.example.nio;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.SocketChannel;
public class NonBlockingReadDemo {
public static void readFromChannel(SocketChannel channel, ByteBuffer buffer)
throws IOException {
while (true) {
buffer.clear();
int bytesRead = channel.read(buffer);
if (bytesRead == -1) {
// 连接关闭,正常退出
System.out.println("连接已关闭");
channel.close();
break;
} else if (bytesRead == 0) {
// 非阻塞模式:当前没有数据,不是错误,继续等
// 在 Selector 模型里,这里直接 break 出循环,等 Selector 下次通知
break;
} else {
// 有数据,处理
buffer.flip();
processData(buffer);
}
}
}
private static void processData(ByteBuffer buffer) {
// 处理数据
byte[] data = new byte[buffer.limit()];
buffer.get(data);
System.out.println("收到数据: " + new String(data));
}
}Selector:多路复用的正确用法
Selector 是 NIO 实现高并发的核心:一个线程通过 Selector 监控多个 Channel,哪个 Channel 有事件(可读、可写、连接建立)就处理哪个。
package com.example.nio;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.util.Iterator;
import java.util.Set;
/**
* 使用 Selector 的简单服务器示例
* 单线程处理多个连接——这就是 NIO 和 BIO 的本质区别
*/
public class NioServer {
public static void main(String[] args) throws IOException {
ServerSocketChannel serverChannel = ServerSocketChannel.open();
serverChannel.configureBlocking(false); // 必须设置为非阻塞!
serverChannel.bind(new InetSocketAddress(8080));
Selector selector = Selector.open();
// 把 ServerSocketChannel 注册到 Selector,监听 ACCEPT 事件
serverChannel.register(selector, SelectionKey.OP_ACCEPT);
ByteBuffer buffer = ByteBuffer.allocate(4096);
System.out.println("服务器启动,监听 8080");
while (true) {
// 阻塞,直到有至少一个 Channel 准备好
// 超时版:selector.select(1000),最多等 1 秒
int readyCount = selector.select();
if (readyCount == 0) continue;
Set<SelectionKey> selectedKeys = selector.selectedKeys();
Iterator<SelectionKey> iterator = selectedKeys.iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
// 处理完之后必须从 selectedKeys 里移除!
// 忘记这一步,下次 select() 时这个 key 还在,会重复处理
iterator.remove();
if (key.isAcceptable()) {
// 有新连接
ServerSocketChannel server = (ServerSocketChannel) key.channel();
SocketChannel client = server.accept();
client.configureBlocking(false);
// 注册 READ 事件,监听这个客户端的数据
client.register(selector, SelectionKey.OP_READ);
System.out.println("新连接: " + client.getRemoteAddress());
} else if (key.isReadable()) {
// 有数据可读
SocketChannel client = (SocketChannel) key.channel();
buffer.clear();
int bytesRead = client.read(buffer);
if (bytesRead == -1) {
System.out.println("连接关闭: " + client.getRemoteAddress());
key.cancel(); // 取消注册
client.close();
} else if (bytesRead > 0) {
buffer.flip();
byte[] data = new byte[buffer.limit()];
buffer.get(data);
System.out.println("收到: " + new String(data));
// echo 回去(实际业务里这里处理请求)
buffer.flip();
client.write(buffer);
}
}
}
}
}
}踩坑实录二:忘了从 selectedKeys 移除处理过的 key
上面代码里我特别注释了 iterator.remove()。这是最常见的 NIO 使用错误之一。
Selector 的 selectedKeys() 返回的集合是 Selector 内部维护的,它不会自动移除你已经处理过的 key。如果不移除,下次 select() 结束后,这个旧的 key 还在 selectedKeys 里,会被重复处理,导致各种奇怪的问题。
我当时的症状是:一个连接发了一次数据,服务端处理了几十次,发了一大堆重复响应。而且 CPU 占用莫名其妙地飙高,因为在不停地处理同一个"已就绪"的 key。
踩坑实录三:DirectBuffer 忘记释放,堆外内存泄漏
ByteBuffer.allocateDirect() 分配的是堆外内存(直接内存),不受 GC 管理。这种内存在 NIO 场景下很常用,因为在写入 Channel 时不需要把数据从堆复制到堆外。
但堆外内存的释放是个问题:
ByteBuffer directBuffer = ByteBuffer.allocateDirect(1024 * 1024); // 分配 1MB 堆外内存
// 用完之后...
// GC 无法回收!这块内存只有在 DirectBuffer 对象被 GC 回收时才会触发清理
// 但 DirectBuffer 对象很小,可能长时间不被 GC在高并发场景下,如果不断分配 DirectBuffer 而不释放,堆外内存会一直涨,最终 OutOfMemoryError: Direct buffer memory。
解决方案:
package com.example.nio;
import java.lang.reflect.Method;
import java.nio.ByteBuffer;
/**
* 主动释放 DirectBuffer 的工具方法
* 注意:这个方法用了内部 API,Java 9+ 需要配置 --add-opens
*/
public class DirectBufferUtils {
public static void release(ByteBuffer buffer) {
if (buffer == null || !buffer.isDirect()) return;
try {
// Java 8 的方式
// ((sun.nio.ch.DirectBuffer) buffer).cleaner().clean();
// 更通用的方式,避免直接引用内部类
Method cleanerMethod = buffer.getClass().getMethod("cleaner");
cleanerMethod.setAccessible(true);
Object cleaner = cleanerMethod.invoke(buffer);
if (cleaner != null) {
Method cleanMethod = cleaner.getClass().getMethod("clean");
cleanMethod.setAccessible(true);
cleanMethod.invoke(cleaner);
}
} catch (Exception e) {
// 释放失败,等 GC 处理
}
}
}实际上在生产中,我更倾向于用 Netty 的池化 ByteBuf,它的内存管理比手动管理 DirectBuffer 友好得多。
我的最终建议
NIO 的 API 是一套面向底层实现的 API,不是面向应用开发者的 API。Buffer 的 flip/clear/compact 状态机很容易用错,Selector 的事件处理也有多个地方容易踩坑。
如果你要做网络编程:用 Netty。
如果你要做高性能文件操作:FileChannel 的 transferTo 值得用,但也就这一个方法。
理解 NIO 的意义在于:让你理解 Netty 为什么这样设计,为什么 Netty 比自己写 NIO 更可靠。Netty 本质上是在 NIO 上加了一层合理的抽象,把那些容易出错的状态管理封装起来了。
