跳到主要内容
约 12 分钟3 张卡

Java NIO

Java NIO 用 Buffer 保存数据,用 Channel 在缓冲区与文件或 Socket 之间读写。需要管理多个非阻塞连接时,再由 Selector 集中等待通道的就绪事件。

本文先介绍缓冲区的位置状态,再沿着通道读写和就绪通知解释事件循环。最后说明怎样保存连接状态、协调业务线程,以及区分阻塞、非阻塞和异步 I/O。

1. 面试核心​

先记住这几条,再读正文
  • 写后读的切换。 flip() 将原 position 设为 limit,再把 position 归零,让读取范围对应刚写入的数据。
  • 保留与清理。 compact() 保留未读字节并为继续写入准备空间,clear() 只重置位置状态,原有字节仍保留。
  • 组件分工。 Selector 让一个线程集中等待多个非阻塞通道的就绪事件,应用再通过 Channel 与 Buffer 读写数据。卡 · Buffer
  • 就绪与完成。 就绪通知后仍需应用调用读写,一次读取可以只有半条消息,一次写入也可以只发送部分数据。卡 · 就绪事件的含义
  • 缓冲区视图。 slice() 和 duplicate() 与原缓冲区共享内容,但各自维护 position、limit 和 mark。卡 · 缓冲区视图与位置重置

2. Buffer​

读写数据时,需要知道哪些字节有效,以及下一次从哪里继续。Buffer 用以下四个位置属性管理这些状态,应用无需自己维护一组数组下标:

  • capacity:缓冲区总容量,创建后不会改变。
  • position:下一次相对读写的位置,读写后会向前移动。
  • limit:当前可读写范围的上界,可访问下标必须小于它。
  • mark:可选的位置标记,调用 mark() 保存,再用 reset() 返回该位置。

已设置标记时,四者满足 0 <= mark <= position <= limit <= capacity。若把位置或上界调到标记之前,旧标记就会失效。flip、clear、rewind 和 compact 也会丢弃标记。

创建一个字节缓冲区:

ByteBuffer buffer = ByteBuffer.allocate(1024);

这个缓冲区容量为 1024 字节,初始可写范围覆盖整个缓冲区:

position = 0
limit = capacity = 1024

2.1 flip​

数据写入 Buffer 后,读取应从开头开始,并停在已写数据的末尾。flip() 用来设置这两个位置:

buffer.put((byte) 'J');
buffer.put((byte) 'a');
buffer.put((byte) 'v');
buffer.put((byte) 'a');

buffer.flip();

while (buffer.hasRemaining()) {
System.out.print((char) buffer.get());
}

写入四个字节后,position 是 4。flip() 把它设为新的 limit,再把 position 归零。因此,循环只读取下标 0 到 3,输出 Java,无需扫描容量中尚未写入的部分。

2.2 clear 与 compact​

准备复用缓冲区接收下一批数据时,要先判断当前数据是否已全部处理。clear() 和 compact() 分别处理全部消费与保留剩余数据两种情况:

  • clear() 把 position 设为 0、limit 设为容量,适合已有数据全部消费完后从头写入。
  • compact() 将未读数据移到开头,把 position 设为剩余数据长度,并将 limit 设为容量。下一次写入就会接在这些数据后面。

clear() 只重置位置,后续写入才会覆盖旧内容。因此,若需要擦除敏感字节,应显式清零缓冲区内容。

channel.read(buffer);
buffer.flip();

consumeCompleteFrames(buffer);
buffer.compact(); // 未完成的一帧留到下一次读取

一次网络读取可以只得到半条消息,也可以得到多条消息。示例中的 consumeCompleteFrames 只消费完整消息,并把 position 留在未完成消息的开头。随后 compact() 保留剩余字节,下一次读取再接着拼出完整消息。

3. Channel​

Channel 表示与文件、Socket 等对象之间的 I/O 通道,具体类型可以支持读、写或两者。使用缓冲区通道 API 时,数据显式经过 Buffer,所以应用要管理每次读写后的有效范围:

try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) {
ByteBuffer buffer = ByteBuffer.allocate(8192);

while (channel.read(buffer) != -1) {
buffer.flip();
consume(buffer); // 消费本次读出的全部字节
buffer.clear();
}
}

示例每次最多读取 8192 字节,consume 消费本次全部字节后,再用 clear() 准备下一次读取。读到文件末尾时,read 返回 -1,循环结束并关闭通道。

理解调用时,要分清通道与缓冲区的数据方向:

  • channel.read(buffer) 从通道读取数据,写入缓冲区。
  • channel.write(buffer) 从缓冲区读取数据,写入通道。

3.1 部分写入​

非阻塞 SocketChannel.write 会立即返回本次写出的字节数。发送空间不足时,它可以只写出部分数据,甚至返回 0:

while (buffer.hasRemaining()) {
int written = channel.write(buffer);
if (written == 0) {
break;
}
}

每次成功写入后,Buffer 的 position 会前移,剩余部分仍留在缓冲区中。返回 0 时退出循环,能避免持续重试占满 CPU。应用应把这个缓冲区保存在连接状态中,等通道再次可写时继续发送。若直接丢弃,响应就会被截断。

文件通道、Socket 通道和数据报通道有各自的读写语义。FileChannel 采用阻塞文件 I/O,无法切换成供 Selector 使用的非阻塞模式。因此,通道怎样等待和返回,要看具体 API。

4. Selector​

大量连接都在等待数据时,可以用一个 Selector 集中等待它们的就绪事件。它与可注册的 SelectableChannel 配合,通道需要先切换为非阻塞模式,再登记要关注的事件:

Selector selector = Selector.open();
ServerSocketChannel server = ServerSocketChannel.open();

server.configureBlocking(false);
server.bind(new InetSocketAddress(8080));
server.register(selector, SelectionKey.OP_ACCEPT);

示例监听 8080 端口,并用 OP_ACCEPT 关注接受新连接的就绪事件。register 返回的 SelectionKey 表示通道与 Selector 的注册关系。

事件循环通过 select() 等待就绪,再遍历本次选中的键。wakeup() 或线程中断也会让等待返回,因此仍要检查实际选中的键:

while (!Thread.currentThread().isInterrupted()) {
selector.select();

Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
iterator.remove();

if (!key.isValid()) {
continue;
}
if (key.isAcceptable()) {
accept(key);
}
if (key.isValid() && key.isReadable()) {
read(key);
}
if (key.isValid() && key.isWritable()) {
write(key);
}
}
}

这里用 iterator.remove() 从 selected-key 集合取走待处理键,然后按事件调用对应处理函数。处理一个事件时,连接可能被关闭,所以后续分支再次检查键是否有效。Selector 会保留选中的键,应用处理时需要主动移除,否则后续循环还会看到旧记录。

4.1 就绪事件的含义​

就绪通知表示可以尝试相应操作,操作结果仍以实际调用为准。正常情况下,Socket 的 OP_READ 表示数据可读,OP_WRITE 表示内核发送缓冲区有空间。连接关闭或发生错误时,也会出现相应就绪事件。

非阻塞 read 的返回值需要区分三种结果:

  • 正数表示本次读到的字节数。
  • 0 表示本次没有读到数据,保留连接状态后等待后续事件。
  • -1 表示输入结束,即 EOF,需要按协议处理关闭。

读到正数字节也可能只有半条消息。写入后同样需要检查 Buffer 是否还有剩余,并处理调用抛出的异常。Selector 负责通知就绪,实际 read 和 write 仍由应用线程调用。

4.2 OP_WRITE 的注册时机​

应用只需在确实有剩余数据待发送时关注写就绪。Socket 的发送缓冲区有空间时就处于可写状态,即使应用没有数据要发也是如此。若始终关注 OP_WRITE,select() 就会不断立即返回,事件循环反复检查空队列,造成空转。

因此,OP_WRITE 的注册与取消应跟随待发送数据变化:

  1. 平时只关注读取。
  2. 写操作未能发送完时,把剩余缓冲区保存到连接状态,并增加 OP_WRITE。
  3. 待发送数据全部写完后,取消对 OP_WRITE 的关注。

5. 连接状态​

非阻塞调用会在请求完整前返回,下一次事件到来时还要接着处理。因此,每个连接需要保存尚未解析的输入和等待发送的输出:

final class ConnectionState {
final ByteBuffer input = ByteBuffer.allocate(8192);
final Deque<ByteBuffer> output = new ArrayDeque<>();
}

这里用一个输入缓冲区保存读取到的字节,用输出队列保存尚未发完的响应。注册通道后,可以将这份状态附加到 SelectionKey:

SelectionKey key = channel.register(selector, SelectionKey.OP_READ);
key.attach(new ConnectionState());

后续事件通过同一个键取得连接状态,就能继续拼接或发送。完整实现还需管理以下连接数据与生命周期:

  • 处理半包和粘包。半包指一次只读到消息的一部分,粘包指一次读到多条消息的字节。
  • 管理待发送队列与背压。背压指处理或发送跟不上时,限制上游继续产生数据。
  • 连接关闭或异常时,释放通道、缓冲区和未完成任务占用的资源。
  • 记录当前处于读取、解析还是业务处理阶段,保证下一次事件从正确位置继续。
  • 检查空闲超时、消息长度等协议限制,防止连接长期占用资源。

集中等待减少了线程数量,这些连接状态则需要由应用显式维护。连接越多,越要及时清理结束的连接,并限制单个连接每轮占用的处理时间,让其他连接也有执行机会。

6. 事件循环与业务线程​

一个事件循环管理多个连接时,每个处理函数都占用同一个线程。长时间计算、阻塞数据库调用或远程请求,会推迟这个循环中所有其他连接的读写。因此,耗时业务应交给有界执行器,由它限制工作线程或排队任务数量。

可以按以下读写与业务分工安排执行:

  1. 事件循环负责接收、读取、协议拆帧和排队写出。
  2. 有界业务执行器处理阻塞或耗时的业务。
  3. 业务结果回到对应事件循环,加入连接的输出队列。

业务线程完成后,先把结果放入线程安全队列,再调用 selector.wakeup()。后者使阻塞中的 select() 及时返回,事件循环就能处理新结果。新增输出或修改兴趣集,都可以交回这个线程完成。兴趣集就是该连接当前关注的事件集合。

若发送速度低于业务生成响应的速度,输出队列会持续增长。无限队列只是把等待转成内存占用,最终会耗尽内存。此时应暂停读取或限流,并为单连接队列设置容量上限。超过上限时,再按协议拒绝请求或关闭连接。

7. BIO、NIO 与异步 I/O​

区分 I/O 模型时,要看调用在哪里等待,以及通知表示就绪还是完成。下表比较三种模型中的等待方式和状态保存位置:

模型等待方式调用结果状态主要由谁维护
阻塞流/通道每个调用等待数据或完成调用返回时已有结果线程调用栈
非阻塞通道 + Selector一个线程等待多个通道就绪就绪后应用继续读写事件循环与连接状态
异步通道提交操作后由回调或 Future 接收完成通知表示操作完成异步 API 与回调状态

阻塞调用把等待保留在调用栈中,Selector 模型则把未完成的输入输出放进连接状态。异步通道在操作提交后,通过回调或 Future 交付完成结果。

java.nio 包同时提供不同模式的 API。FileChannel 使用阻塞文件操作,SocketChannel 也可以保持阻塞模式。把可选择通道设为非阻塞并注册到 Selector 后,才进入这里讨论的就绪驱动模型。

8. 缓冲区视图与位置重置​

需要用不同位置读取同一份数据时,可以创建缓冲区视图。视图共享底层字节,但独立维护自己的读写位置,因此省去了复制数据的成本。

slice() 对应原缓冲区的剩余区域,视图的下标从 0 开始。duplicate() 则覆盖整个内容区,并复制创建时的位置状态。之后,各视图分别维护 position、limit 和 mark:

ByteBuffer source = ByteBuffer.wrap(new byte[] {10, 20, 30});
source.position(1);
ByteBuffer view = source.slice();
view.put(0, (byte) 99);
System.out.println(source.get(1)); // 99
System.out.println(source.position()); // 1
System.out.println(view.position()); // 0

这里原缓冲区从下标 1 开始切片,所以视图下标 0 对应原缓冲区下标 1。写入 99 后,原缓冲区读到的值也变成 99。带绝对下标的 get、put 保持位置不变,因此另外两个输出仍是 1 和 0。

重新读取时,可用 rewind() 把 position 归零并保留 limit。它适合重读原有范围,而 flip() 用当前写入位置重新确定读取上界。

由于视图共享内容,并发写入仍需同步。只读视图只是禁止通过该视图写入,原缓冲区仍可修改同一批字节,所以只读视图中的内容也会变化。

9. 常见问题​

实现读写循环时,常见错误集中在缓冲区切换、消息边界和资源清理。下面按实际调用时遇到的问题说明。

9.1 flip、clear 和 compact 分别在什么时候调用?​

选择哪个方法,取决于接下来要读还是写,以及旧数据是否已处理完:

  • 写入 Buffer 后要读取刚写的数据,调用 flip()。
  • 数据全部读完,要从头覆盖写入,调用 clear()。
  • 还有半条消息等未读数据,要保留它们继续接收,调用 compact()。

9.2 Selector 为什么可以减少线程数量?​

9.3 一次 OP_READ 可以读到完整请求吗?​

9.4 SelectionKey 取消后会立即释放资源吗?​

10 · 面试题卡片​

10.1 Buffer、Channel 和 Selector 分别负责什么​

出现公司:蚂蚁金服、字节跳动

考察重点

  • 解释 Buffer 的位置属性怎样限定读写范围,以及何时切换这些位置。
  • 说明 Channel 读写时,数据在通道与 Buffer 之间怎样移动。
  • 区分 Selector 的就绪通知与异步操作的完成通知。

相关内容:Buffer、Channel、Selector。

参考回答

Buffer 保存数据与读写位置,Channel 负责传输数据,Selector 集中等待多个非阻塞通道就绪。

Buffer 用 position、limit 和 capacity 等属性确定有效范围。Channel 从文件或 Socket 读入数据时写入 Buffer,发送数据时则从 Buffer 读取。Selector 负责等待接受新连接、读取或写入等就绪事件。

收到事件后,应用仍需调用 read 或 write,并检查返回的字节数、EOF 和异常。一条消息可以跨多次读取,一次响应也可以分多次发送,所以每个连接还需保存输入、输出和协议解析状态。

10.2 NIO 为什么不等于异步 I/O​

出现公司:蚂蚁金服

考察重点

  • 区分非阻塞调用、就绪通知和完成通知各自表示什么。
  • 说明 select 返回后,由哪个线程调用实际读写。
  • 根据具体通道与配置,判断 java.nio 中的 API 采用哪种模式。

相关内容:就绪事件的含义、BIO、NIO 与异步 I/O。

参考回答

Selector 提供的是就绪通知,实际读写仍由应用线程调用;异步 I/O 则在操作完成后通知结果。

事件循环从 select() 返回后,还要调用非阻塞 read 或 write,并处理 0 或部分读写。异步通道则先接收操作请求,再通过回调或 Future 返回完成结果。

java.nio 包含多种模式。FileChannel 和阻塞模式的 SocketChannel 都使用阻塞调用,所以应根据具体通道和调用方式判断执行模型。

10.3 slice 与 duplicate 会复制数据吗,位置是否共享?​

出现公司:暂无已核验的公开来源(本站自测)

考察重点:区分共享字节与独立位置状态,解释绝对下标读写和只读视图各能保证什么。

相关内容:缓冲区视图与位置重置。

参考回答

slice() 和 duplicate() 共享底层数据,各自维护位置状态,所以创建视图无需复制字节。

slice() 对应创建时的剩余区域,duplicate() 对应整个内容区。例如,source.position(1) 后创建 view = source.slice(),view 的下标 0 就对应 source 的下标 1。执行 view.put(0, (byte) 99) 后,source.get(1) 也返回 99。绝对下标写入保持位置不变,所以 source.position() 仍为 1,view.position() 仍为 0。

独立位置并未隔离共享字节,多个线程写入仍需同步。只读视图禁止通过自身写入,但原缓冲区仍可改变这些内容。

11. 参考资料​