mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
1061 字
3 分钟
Java NIO详解
2026-02-06

NIO的Selector模型是我第一次感受到了单线程处理万级连接的魅力。相比传统IO的阻塞读写,Channel加Buffer的组合简直是为高性能网络编程量身定做。不过说实话,NIO的API设计得确实不太友好,入门门槛有点高。这篇文章带你一步步搞懂它。

Java NIO详解#

NIO与传统IO有啥区别?#

用过传统IO的都知道,一个线程读文件的时候啥也干不了,只能干等。NIO的核心思想就是非阻塞,一个线程可以同时管理多个连接。

特性传统IONIO
操作方式阻塞式非阻塞式
数据单位字节流/字符流缓冲区
处理模式面向流面向通道
并发处理多线程单线程处理多个连接

NIO的三个核心组件#

1. Buffer(缓冲区)#

Buffer就是一块连续的内存,所有的NIO操作都围绕着它转。理解Buffer的几个属性是关键——positionlimitcapacity,搞不清楚这三个,代码跑出来全是乱码。

常用的Buffer类型:

  • ByteBufferCharBufferIntBufferLongBuffer

Buffer的核心属性:

  • capacity:容量,创建后不能改
  • position:当前位置,下一个要读写的位置
  • limit:限制位置,不能读写超过这个位置
  • mark:标记位置,可以用reset()跳回来
// 创建ByteBuffer
ByteBuffer buffer = ByteBuffer.allocate(1024); // 堆内缓冲区
ByteBuffer directBuffer = ByteBuffer.allocateDirect(1024); // 直接缓冲区(堆外)
// 写入数据
buffer.put((byte) 1);
buffer.put(new byte[]{2, 3, 4});
// 切换到读模式——这个最容易忘!
buffer.flip();
// 读取数据
byte b1 = buffer.get();
byte[] bytes = new byte[3];
buffer.get(bytes);
// 清空缓冲区,切回写模式
buffer.clear();
// 压缩缓冲区——保留未读的数据
buffer.compact();
// 标记和重置
buffer.mark();
// 读了一些数据...
buffer.reset(); // 回到标记位置

划重点flip()clear()是最容易搞混的。我刚开始用的时候,经常忘记flip(),结果读出来全是空数据,debug了半天才发现。

2. Channel(通道)#

Channel是双向的,既可以读也可以写,这点跟传统IO的流不一样(InputStream只能读,OutputStream只能写)。

常用的Channel类型:

  • FileChannel:文件操作
  • SocketChannel:TCP网络
  • ServerSocketChannel:服务端监听
  • DatagramChannel:UDP通信
// FileChannel 读写示例
RandomAccessFile file = new RandomAccessFile("data.txt", "rw");
FileChannel fileChannel = file.getChannel();
ByteBuffer buffer = ByteBuffer.allocate(1024);
// 从文件读到缓冲区
int bytesRead = fileChannel.read(buffer);
while (bytesRead != -1) {
buffer.flip();
while (buffer.hasRemaining()) {
System.out.print((char) buffer.get());
}
buffer.clear();
bytesRead = fileChannel.read(buffer);
}
// 写入数据到文件
buffer.put("Hello, NIO!".getBytes());
buffer.flip();
fileChannel.write(buffer);
fileChannel.close();
file.close();

3. Selector(选择器)#

Selector是NIO最牛的地方——一个线程管几千个连接。它通过事件驱动的方式,轮询检查哪些通道有数据可读/可写。

// 创建选择器
Selector selector = Selector.open();
// 打开ServerSocketChannel,设置为非阻塞
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.bind(new InetSocketAddress(8080));
serverSocketChannel.configureBlocking(false);
// 注册到选择器,关注ACCEPT事件
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
// 阻塞直到有事件发生
int readyChannels = selector.select();
if (readyChannels == 0) continue;
Set<SelectionKey> selectionKeys = selector.selectedKeys();
Iterator<SelectionKey> iterator = selectionKeys.iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
if (key.isAcceptable()) {
// 新连接来了
ServerSocketChannel serverChannel = (ServerSocketChannel) key.channel();
SocketChannel socketChannel = serverChannel.accept();
socketChannel.configureBlocking(false);
socketChannel.register(selector, SelectionKey.OP_READ);
} else if (key.isReadable()) {
// 有数据可读
SocketChannel socketChannel = (SocketChannel) key.channel();
ByteBuffer buffer = ByteBuffer.allocate(1024);
int bytesRead = socketChannel.read(buffer);
if (bytesRead == -1) {
socketChannel.close();
key.cancel();
} else if (bytesRead > 0) {
buffer.flip();
byte[] data = new byte[buffer.remaining()];
buffer.get(data);
System.out.println("收到: " + new String(data));
}
}
// 重要!要移除已处理的key
iterator.remove();
}
}

SelectionKey的事件类型

  • OP_ACCEPT:接受连接
  • OP_CONNECT:连接完成
  • OP_READ:可读
  • OP_WRITE:可写

NIO的实用技巧#

1. 非阻塞IO#

设置configureBlocking(false)后,读写操作不会阻塞,没数据就直接返回0或-1:

SocketChannel socketChannel = SocketChannel.open();
socketChannel.configureBlocking(false);
// 非阻塞连接
boolean connected = socketChannel.connect(new InetSocketAddress("localhost", 8080));
if (!connected) {
while (!socketChannel.finishConnect()) {
// 可以做其他事情
System.out.println("连接中...");
}
}
// 非阻塞读取
ByteBuffer buffer = ByteBuffer.allocate(1024);
int bytesRead = socketChannel.read(buffer);
// bytesRead可能是0(没数据)、-1(连接关闭)、>0(有数据)

2. 直接缓冲区(Direct Buffer)#

直接缓冲区分配在堆外内存,避免了数据在堆内和堆外之间的拷贝,大文件场景下性能提升明显:

ByteBuffer directBuffer = ByteBuffer.allocateDirect(1024);

但要注意:创建和销毁直接缓冲区的成本比堆内缓冲区高,要复用,不要频繁创建。

3. 内存映射文件#

大文件操作的神器——把文件映射到内存,直接操作内存就像操作文件一样:

FileChannel fileChannel = new RandomAccessFile("data.txt", "rw").getChannel();
MappedByteBuffer mappedBuffer = fileChannel.map(
FileChannel.MapMode.READ_WRITE, 0, fileChannel.size());
// 直接修改内存中的数据
for (int i = 0; i < mappedBuffer.limit(); i++) {
byte b = mappedBuffer.get(i);
mappedBuffer.put(i, (byte) (b + 1));
}
fileChannel.close();

4. 分散/聚集IO#

一次性读写多个缓冲区,适合处理协议数据(比如头部和体部分开):

// 分散读取
ByteBuffer header = ByteBuffer.allocate(100);
ByteBuffer body = ByteBuffer.allocate(1024);
ByteBuffer[] buffers = {header, body};
socketChannel.read(buffers);
// 聚集写入
ByteBuffer headerBuf = ByteBuffer.wrap("HTTP/1.1 200 OK\r\n".getBytes());
ByteBuffer bodyBuf = ByteBuffer.wrap("Hello, World!\r\n".getBytes());
socketChannel.write(new ByteBuffer[]{headerBuf, bodyBuf});

实战贴士#

  1. Buffer用完记得 flip():写入数据后要切换到读模式,这个步骤最容易忘
  2. Selector的key要手动移除iterator.remove()不调用的话,下次轮询还会处理同一个事件
  3. 直接缓冲区要复用:创建成本高,不要频繁allocateDirect
  4. 非阻塞IO不是万能的:简单场景用传统IO更省事,NIO的复杂度不一定值得
  5. 内存映射文件不能超过物理内存:否则会OOM

常见坑#

  • Buffer状态管理不当:忘记flip()clear()compact(),导致数据错乱。我刚开始写NIO的时候,经常读出来一堆乱码,debug半天才发现是position没搞对。
  • Selector key重复处理:没有在循环里iterator.remove(),同一个事件处理了多次,导致各种奇怪的问题。
  • 通道没关闭:资源泄漏,特别是在处理网络连接时,连接数一多就炸了。
  • 直接缓冲区频繁创建:堆外内存泄漏,GC管不了堆外内存,只能自己注意。
分享

如果这篇文章对你有帮助,欢迎分享给更多人!

部分信息可能已经过时

目录