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 的注册与取消应跟随待发送数据变化:
- 平时只关注读取。
- 写操作未能发送完时,把剩余缓冲区保存到连接状态,并增加
OP_WRITE。 - 待发送数据全部写完后,取消对
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. 事件循环与业务线程
一个事件循环管理多个连接时,每个处理函数都占用同一个线程。长时间计算、阻塞数据库调用或远程请求,会推迟这个循环中所有其他连接的读写。因此,耗时业务应交给有界执行器,由它限制工作线程或排队任务数量。
可以按以下读写与业务分工安排执行:
- 事件循环负责接收、读取、协议拆帧和排队写出。
- 有界业务执行器处理阻塞或耗时的业务。
- 业务结果回到对应事件循环,加入连接的输出队列。
业务线程完成后,先把结果放入线程安全队列,再调用 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 为什么可以减少线程数量?
一个 Selector 可以集中等待多个通道,所以无需为每个连接分配专用的阻塞等待线程。哪个连接就绪,事件循环就处理哪个连接。业务计算仍可交给独立执行器,集中等待只改变读写调度方式。
9.3 一次 OP_READ 可以读到完整请求吗?
一次事件无法保证读到完整请求。TCP 提供连续字节流,不携带应用消息边界。一次读取可以只有部分请求,也可以包含多个请求。应用需要按长度、分隔符或固定格式拆帧,也就是从字节流中分出完整消息,未完成部分留到后续读取继续处理。
9.4 SelectionKey 取消后会立即释放资源吗?
cancel() 只取消注册关系,通道和连接状态仍需由应用清理。调用后,键先进入取消集合,Selector 在后续选择操作中完成移除。拥有通道的代码还要显式关闭通道,并清理输出缓冲区和其他连接状态。
10 · 面试题卡片
10.1 Buffer、Channel 和 Selector 分别负责什么
出现公司:蚂蚁金服、字节跳动
考察重点
- 解释 Buffer 的位置属性怎样限定读写范围,以及何时切换这些位置。
- 说明 Channel 读写时,数据在通道与 Buffer 之间怎样移动。
- 区分 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。
独立位置并未隔离共享字节,多个线程写入仍需同步。只读视图禁止通过自身写入,但原缓冲区仍可改变这些内容。