⭐ 推荐:社区规则条款 V1.0

如何用 Java NIO 实现高吞吐的网络通信?

juming
juming 归队站长新人站长
发布于 2026-10-14 17:17 ·1 浏览 ·3 回复
内容摘要

用 Java NIO 实现高吞吐网络通信需采用非阻塞 Channel、Selector 多路复用、直接缓冲区和多线程 Reactor 模型,把线程数从万级降到与 CPU 核数同量级,并先跑通最小单线程 Reactor 再优化。

如何用 Java NIO 实现高吞吐的网络通信?,本文转载自 GJ站长论坛,原文地址:https://www.gj0.com/thread-1966.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 3

chinaz
chinaz 归队站长新人站长 1楼 2026-10-14 17:30

骨架没问题,但单线程 Reactor 只够跑通 demo——真上量之后瓶颈会从「线程数」挪到「select 之后那段处理逻辑」,你贴的循环里至少还缺三件必须补的东西。

第一,it.remove() 要无条件执行,最好在处理 key 之前就摘掉。否则同一批就绪事件下一轮还会被遍历到;另外 key.cancel() 之后必须再走一次 select(),注销才真正生效,否则 selectedKeys 会一直带着无效 key,事件永远重复触发。

第二,OP_WRITE 千万别跟 OP_READ 一起注册。socket 可写是常态,注册上去 select() 每次都返回就绪,CPU 直接打满。正确姿势是 write() 返回 0(发送缓冲满)时才 interestOps | OP_WRITE,写完立刻摘掉。这是手写 NIO 最常见的翻车点。

第三,TCP 是流不是包,一次 read() 拿到的可能半个包、也可能两个半包。要用 SelectionKey.attach() 给每个连接挂一个状态机(累积缓冲 + 长度字段),别在业务里假设一次读全。

配套参数:ByteBuffer.allocateDirect() 且单次分配 16KB~64KB、setTcpNoDelay(true)、SO_RCVBUF/SO_SNDBUF 拉到 128KB 以上。再往后就是主从 Reactor:一个 boss 线程只干 accept,拿到 channel 后轮询注册到 N 个 worker selector(N ≈ 核数 × 2),这正是 Netty 的 bossGroup/workerGroup。

顺带一句,JDK 的 epoll 空轮询 bug(select() 返回 0 但 CPU 100%)自己修成本很高——要么计数到阈值重建 selector,要么直接上 Netty。手写 NIO 的价值在吃透 attach 状态机和 Linux 下 LT 语义,理解完再读 Netty 源码会顺很多。

域名注册
域名注册 版主归队站长新人站长 👑年卡会员 #1515 2楼 2026-10-14 17:45
chinaz:骨架没问题,但单线程 Reactor 只够跑通 demo——真上量之后瓶颈会从「线程数」挪到「select 之后那段处理逻辑」,你贴的循环里至少还缺三件必须补的…

三点都说到位了,尤其 OP_WRITE 那条,基本是手写 NIO 翻车率最高的地方。我再补两个同样容易踩的点。

一是 interestOps() 改了之后要顺手 selector.wakeup()。如果改 interestOps 的不是 selector 线程(比如业务线程写完让 worker 重新挂 OP_WRITE),selector 线程很可能还阻塞在上一轮 select() 上,这个变更要等下一次超时或新事件才生效,表现出来就是延迟抖动、偶发卡顿。同理,select() 返回 0 别急着 continue,得区分「真没事件」和 wakeup() 造成的空返回,否则空轮询计数会把正常唤醒也算进去。顺便说一句,靠计数阈值重建 selector 只是个规避手段——重建期间所有 channel 要重新注册,在途 accept 和已就绪事件都有丢失窗口,所以它是权衡不是修复,量大了还是 Netty 省心。

二是 attach 的状态机别用一个 ByteBuffer 边读边解析,compact() 的时机很容易搞错导致半包数据被覆盖。稳妥写法是每个连接挂两块:一个定长读缓冲(8KB 足够)+ 一个可增长的累积缓冲。收数据先 compact 进累积缓冲,再循环「够固定头就解长度字段 → 够长度就切一个完整包交付业务」,不够就 break 等下次 OP_READ。这样半包天然被吃掉,业务层完全不用感知 TCP 边界,顺带把字节序也在这里定死,省得后面大小端扯皮。

还有个内存层面的细节:DirectByteBuffer 靠 Cleaner 回收,高并发下 allocateDirect 的释放是滞后的,一定要配 -XX:MaxDirectMemorySize,更别每次 read() 都 new 一个,至少按连接复用。这块正好对应 Netty 的 PooledByteBufAllocator,理解完回头看它的 pipeline 和 writeAndFlush,基本就没有黑盒了。

lqyyzz
lqyyzz 观望站长 #1516 3楼 2026-10-14 17:55
域名注册:三点都说到位了,尤其 `OP_WRITE` 那条,基本是手写 NIO 翻车率最高的地方。我再补两个同样容易踩的点。 一是 `interestOps()` 改了…

interestOps 之后手动 wakeup() 这点确实经常被漏掉,JDK 文档也没承诺改 interestOps 会唤醒阻塞中的 select(),跨线程改就得自己叫醒。OP_WRITE 的正确姿势也是「有积压才挂、写完立刻摘」,因为 fd 可写是常态,挂着不放就是空转 100% CPU。

不过关于重建 selector 的「丢失窗口」,我想补一点不同看法,按事件类型拆开看其实不会丢数据。epoll 是水平触发,已经就绪但没读完的数据在 channel 重新注册到新 selector 之后还会被再上报一次;accept 侧同理,内核 backlog 里排着的连接只要 backlog 设得够大就一直等着,下次 accept() 照样拿得到。所以重建真正的代价是那一轮 register 系统调用风暴加短暂服务停顿,属于性能问题而不是正确性问题——区分开这点,线上排查「偶发延迟但不断连」时能少走很多弯路。

累积缓冲那儿再钉一个细节:解长度字段之前必须先做上限校验,比如超过 1MB 直接 close 掉连接,否则一个声称 2GB 的包头就能把堆吃穿,这是最典型的 DoS 入口。另外 DirectBuffer 要显式释放的话,JDK 9 以后 cleaner 反射会被模块系统挡住,得加 --add-opens java.base/jdk.internal.ref=ALL-UNNAMED,嫌麻烦就老老实实上池化分配器,别自己 new。走到这一步再回头看 Netty 的 pipeline,基本就没有黑盒了。