有个印象挺深的排查经历:某业务线的一台接入机 CPU 常年 300%,线程栈里全是一个while(true)里的select()调用在打转,连接数不多,流量也不高,就是空转。最后定位下来,问题不在于用了 NIO,而在于对SocketChannel的语义理解只停留在“非阻塞”三个字上——读返回 0 和 -1 没分清,写返回 0 当成了写完,OP_WRITE该挂的时候没挂、不该挂的时候一直挂着。这套东西写对了很稳,写错了就是 CPU 和连接一起耗。
SocketChannel是 Java NIO 里真正干活的角色,Selector负责告诉你谁准备好了,ByteBuffer负责装数据,而SocketChannel是你和内核之间那根管子。这篇就围绕它的最佳实践展开:怎么创建和配置、读写循环怎么写、事件循环怎么不翻车、缓冲区怎么管、零拷贝什么时候能用、线程模型怎么选。顺带把秋招面试里围绕这一块的常见问法和答法拆一遍,把“看起来在问 API、实际在问模型”的题挑出来讲。刚接触 NIO 的能照着代码跑通,写过多路复用但在生产上被坑过的,也能在里面找到对应的处置办法。
1. 先搞清楚 SocketChannel 在 NIO 里到底站在什么位置
1.1 从阻塞到非阻塞,变化的不只是“加了个 flag”
很多人入门 NIO 的第一印象是“把configureBlocking(false)加上就是非阻塞了”,这个理解容易埋雷。真正的变化在于调用模型:阻塞模式下,read没数据就停在那儿等,线程被挂起;非阻塞模式下,read没数据立刻返回 0,线程继续往下走。前者用线程数量换吞吐,一个连接一个线程;后者用一个线程管一堆连接,用事件通知替代等待。
源头在于内核提供的就绪通知机制。线程调用Selector.select()时会被挂起,内核在某个 fd 上有数据可读、可写、有新连接可接收的时候把它唤醒,返回给你一批SelectionKey,你只处理这批准备好的连接。没准备好的连接,一个字节都不用管。这套模型里,线程数量和连接数解耦了,几万个长连接用几个线程就能撑住,代价是你要自己管状态机。
关键点在于线程不再为单个连接等待。这也是为什么 NIO 代码里到处是“返回 0 怎么办”“返回 -1 怎么办”这种分支,因为线程没有停下来等你的机会,它必须立刻决定下一步做什么。
1.2 SocketChannel、Selector、Buffer 三者的协作关系与职责边界
三者职责分得很清楚,混在一起写就会出乱子。SocketChannel只管两件事:把字节从内核缓冲区搬到你的ByteBuffer里,或者从你的ByteBuffer搬进内核。它不负责缓存,不负责协议解析,不负责重试。Selector只负责通知“谁准备好了”,它不知道你准备好了以后要干嘛。ByteBuffer是数据的中转容器,position、limit、capacity三个指针管着“写到哪了”“读到哪了”“总共多大”。
实际编码里最容易搞混的是事件状态和业务状态分离。SelectionKey上的interestOps描述的是“我关心什么事件”,业务上“这条连接已经读了 3 个半包了,还差后面半截”这件事,得你自己用attachment挂一个会话对象上去存。Selector不会替你记这些,它每次只回答“能读”或“能写”。
// 会话对象:业务状态放这里,别塞进 Selector 的脑子里 static class Session { final SocketChannel channel; ByteBuffer readBuf = ByteBuffer.allocateDirect(8 * 1024); Deque<ByteBuffer> writeQueue = new ArrayDeque<>(); StringBuilder frame = new StringBuilder(); // 拼帧用 Session(SocketChannel channel) { this.channel = channel; } }1.3 一个常见的架构误判:以为非阻塞就等于高性能
有次评审代码,看到有人把所有连接都设成非阻塞,然后用for循环挨个遍历调用read,读到 0 就跳过。这是典型的“用了非阻塞 API,但模型还是轮询”。结果就是几百个连接下 CPU 直接拉满,因为线程在不停地空转问内核“有数据吗”。
SocketChannel的非阻塞能力必须配合Selector才成立,二者是一套的。单独把 channel 设成非阻塞而不用 Selector,你得到的是一个需要自己写退避策略的忙轮询,比阻塞模式还糟。这个判断在面试里也常被问到:为什么 NIO 不 Selector 就不行?答案就在这儿——非阻塞解决的是“线程不被单个连接卡住”,Selector 解决的是“线程不用挨个问”。缺了后者,前者带来的收益会被空转吃掉。
2. 通道创建与连接:把服务端和客户端的骨架搭对
2.1 服务端 accept 与客户端 connect 的标准写法
服务端这边,ServerSocketChannel.open()之后要bind再configureBlocking(false)再注册OP_ACCEPT,顺序不能乱。注册的时候如果通道还是阻塞模式,会直接抛IllegalBlockingModeException,这个异常不少人是第一次见,原因就是configureBlocking写在了register后面。
ServerSocketChannel server = ServerSocketChannel.open(); server.bind(new InetSocketAddress(9000), 1024); // backlog 显式给 server.configureBlocking(false); Selector selector = Selector.open(); server.register(selector, SelectionKey.OP_ACCEPT);客户端这边,非阻塞连接要处理connect返回 false 的情况,这时候连接还没建好,得注册OP_CONNECT,等finishConnect()返回 true 才算连上。
SocketChannel client = SocketChannel.open(); client.configureBlocking(false); boolean ok = client.connect(new InetSocketAddress("127.0.0.1", 9000)); if (!ok) { client.register(selector, SelectionKey.OP_CONNECT); } // 事件循环中: if (key.isConnectable()) { SocketChannel ch = (SocketChannel) key.channel(); if (ch.finishConnect()) { key.interestOps(SelectionKey.OP_READ); } else { key.cancel(); ch.close(); } }注意:
finishConnect()在连接建立后会返回 true,但如果连接失败会抛ConnectException,别只判断返回值不接异常,否则失败连接会在循环里反复触发。
2.2 阻塞和非阻塞模式的选择依据与实操建议
一个实用的判断规则:如果你需要同时管超过几十个连接,或者连接的生命周期长、空闲时间多(推送、IM、网关),用非阻塞加 Selector;如果是一问一答的短连接、内部 RPC 调用、批处理任务,阻塞模式反而更省心。阻塞模式下代码线性、异常处理简单,调试也容易,堆栈一看就知道停在哪。
我自己的项目里有个文件同步的小工具,走的就是阻塞模式,一个连接一个线程,十几路并发跑得好好的。后来有人提议改成 NIO,改完代码量翻了三倍,性能没提升,因为这个场景的限制在磁盘 IO 不在网络并发。NIO 的收益来自连接数和线程数的解耦,连接数不到一定量级,纯属自找麻烦。
还有一点,同一个通道在生命周期内模式不能随便切。configureBlocking只在通道没注册到 Selector、也没在阻塞 IO 中间的时候安全,中途改会抛IllegalBlockingModeException。
2.3 四个必调的 socket 参数:NODELAY、REUSEADDR、backlog、keepalive
这四个参数的影响面比多数人想的大,尤其是第一个。
| 参数 | 默认值 | 调优建议 | 影响面 |
|---|---|---|---|
TCP_NODELAY | false(Nagle 开启) | 低延迟场景设 true | 延迟、小包数量 |
SO_REUSEADDR | 平台相关 | 服务端重启频繁时设 true | 重启时的端口占用 |
backlog | 由系统决定 | 按 QPS 峰值估算,1024 起步 | 突发连接被拒 |
SO_KEEPALIVE | false | 不建议依赖,改用应用层心跳 | 死连接检测时效 |
TCP_NODELAY是最容易被忽略的一个。Nagle 算法会把小包攒起来等 ACK,为的是减少网络上的小包数量,但这在 RPC、即时通讯场景里会带来几十毫秒的额外延迟。写一个高频小消息的服务,不关 Nagle,延迟就是上不去。代码就一行:
channel.setOption(StandardSocketOptions.TCP_NODELAY, true);backlog的含义是内核已完成三次握手、但应用还没accept的队列长度。默认值在不同系统上不一样,压测时如果看到客户端侧大量连接超时、服务端日志里却一条 accept 都没有,多半是这个队列满了导致内核直接丢 SYN。给个 1024 以上的值,再配合accept循环一次把队列抽空,能缓解不少。
server.bind(new InetSocketAddress(9000), 1024); // accept 时用循环,而不是一次只接一个 while ((SocketChannel ch = server.accept()) != null) { ch.configureBlocking(false); ch.setOption(StandardSocketOptions.TCP_NODELAY, true); ch.register(selector, SelectionKey.OP_READ, new Session(ch)); }SO_KEEPALIVE的探测间隔由系统参数决定,通常是小时后级别,指望它发现死连接基本没戏。真要检测,应用层发心跳包,还能顺带验证对端业务是否正常,比 TCP 层的空探测有价值得多。
3. 读写循环:把“读一半、写半包”这两类问题处理清楚
3.1 读操作:read 返回 0 和 -1 是完全不同的两件事
这是 NIO 里最基础也最容易错的一处。read返回0的意思是“现在没数据”,连接好好的,下次再来;返回-1的意思是“对端已经关闭了写端,这是最后一条消息”,你必须处理收尾——把缓冲里的数据处理完、关通道、从 Selector 里注销。把 -1 当成 0 处理,连接会一直挂着,直到超时;把 0 当成 -1,好端端的连接会被你踢掉。
ByteBuffer buf = session.readBuf; int n = channel.read(buf); if (n > 0) { buf.flip(); // 交给解码器,可能只解出半包,剩下的留着 decoder.decode(buf); buf.compact(); // 关键:保留未消费的残余数据 } else if (n == 0) { // 正常,什么都不做 } else { // n == -1,对端关闭 key.cancel(); channel.close(); }compact()这一行值得单独说。读完数据后如果你调clear(),会把还没解析完的半个包一起清掉;调flip()之后不compact,下次读会把位置搞乱。compact()的语义是“把 position 到 limit 之间没读的部分挪到开头,position 设为剩余长度,limit 设为 capacity”,正好符合“保留残余、准备续读”的需求。
注意:直接调用
SocketChannel.read()的阻塞读,不受socket().setSoTimeout()约束,那个超时只对Socket的InputStream生效。想给读加超时,要么用Selector.select(timeout),要么自己在应用层记时间戳。这一点实测过,写了setSoTimeout(3000)却发现阻塞读能挂几分钟的人不在少数。
3.2 写操作:write 返回值不等于写完了,OP_WRITE 才是正解
非阻塞通道上,write(ByteBuffer)返回的是这次真正写进内核缓冲区的字节数,可能小于你给的字节数,也可能返回 0(内核发送缓冲区满了)。很多人看到返回 0 就慌了,其实这是完全合法的情况,正确做法是把没写完的部分存起来,注册OP_WRITE,等可写了继续写。
void doWrite(SelectionKey key, Session session) throws IOException { SocketChannel ch = session.channel; while (!session.writeQueue.isEmpty()) { ByteBuffer head = session.writeQueue.peek(); ch.write(head); if (head.hasRemaining()) { // 内核缓冲满了,等 OP_WRITE key.interestOps(SelectionKey.OP_READ | SelectionKey.OP_WRITE); return; } session.writeQueue.poll(); } // 队列清空,把 OP_WRITE 摘掉,务必! key.interestOps(SelectionKey.OP_READ); }OP_WRITE有一个反直觉的特性:内核发送缓冲区有空位时它就是就绪的,也就是说绝大多数时候它都是就绪的。如果你在队列空的情况下还一直注册着OP_WRITE,select()会立刻返回,你的循环就变成了纯空转,CPU 直接起飞。所以“队列空了必须摘掉OP_WRITE”不是可选项,是硬性要求。
我在生产上见过一次 CPU 100% 的事故,根因就是某个分支里忘了摘OP_WRITE,连接数只有二十几个,CPU 却打满了。这类问题的特征是连接数很少但 CPU 很高,排查的时候先看interestOps位的状态,一抓一个准。
3.3 粘包与半包:协议层要解决的问题,别指望通道
TCP 是字节流,read读到的可能是半个包,也可能是三个包连在一起。SocketChannel对此不负任何责任,它只保证字节的顺序和完整性,不保证边界。处理方式就三条路:定长、分隔符、长度字段。
| 方案 | 适用场景 | 缺点 |
|---|---|---|
| 定长 | 协议固定、字段少 | 浪费带宽,扩展性差 |
| 分隔符 | 文本协议、日志 | 需要转义,解析开销 |
| 长度字段 | 二进制协议、RPC | 需要处理长度上限 |
生产上基本都用长度字段:先读 4 个字节的长度头,再按长度读 body,不够就等下次。实现时用ByteBuffer的remaining()判断,别用available(),那是InputStream的思维。
// 长度前缀解码的骨架 void decode(ByteBuffer buf, Consumer<ByteBuffer> onFrame) { while (true) { buf.mark(); if (buf.remaining() < 4) { buf.reset(); return; } int len = buf.getInt(); if (len <= 0 || len > 16 * 1024 * 1024) throw new IllegalStateException("非法长度"); if (buf.remaining() < len) { buf.reset(); return; } ByteBuffer frame = buf.slice().limit(len); onFrame.accept(frame); buf.position(buf.position() + len); } }注意:长度字段一定要做上限校验,否则一个恶意或者错乱的包会让你的
ByteBuffer疯狂扩容或者直接 OOM。上限设多少看业务,一般 1MB 到 16MB 之间。
4. Selector 事件循环:事件驱动最容易出错的地方
4.1 标准事件循环的骨架与各分支的正确处理
一个可靠的事件循环长这样,看着简单,但每一行都有讲究:
while (!Thread.currentThread().isInterrupted()) { int ready = selector.select(500); // 带超时,给退出和监控留出口 if (ready == 0) continue; Iterator<SelectionKey> it = selector.selectedKeys().iterator(); while (it.hasNext()) { SelectionKey key = it.next(); it.remove(); // 必须移除,否则下次还在 if (!key.isValid()) continue; try { if (key.isAcceptable()) handleAccept(key); else if (key.isReadable()) handleRead(key); else if (key.isWritable()) handleWrite(key); } catch (IOException e) { key.cancel(); try { key.channel().close(); } catch (IOException ignore) {} } } }it.remove()这一句,selectedKeys()返回的集合不会自动清理,你不移除,下一次循环同一个 key 还在里面,同一个事件被处理两遍。这个问题在单连接测试时看不出来,压测的时候会表现为“消息重复处理”。
另外select()一定要带超时。不带超时的select()会一直阻塞到有事件,程序没有机会检查退出标志、刷新监控指标、做定时任务。带着 500ms 到 1s 的超时,既不影响响应速度,又给了主循环呼吸的空间。
4.2 空轮询的成因与两种规避做法
空轮询的表现是select()频繁返回但selectedKeys()是空的,或者返回了 key 但一个都不可处理。底层原因是内核的就绪通知和用户态的状态之间存在窗口期,某些平台的实现会在特定条件下反复唤醒而没有任何实际事件。这个现象在旧版本 JDK 上更明显,也是 Netty 里那段著名的“空轮询检测 + 重建 Selector”逻辑的由来。
规避手段有两种,实际项目里可以都上。第一种是计数 + 重建:连续 N 次select返回 0(或返回但没处理到任何 key),就把旧 Selector 上的所有 key 迁移到新 Selector 上,旧的关掉。
int emptyCount = 0; while (running) { int ready = selector.select(500); if (ready == 0) { if (++emptyCount >= 512) { rebuildSelector(); // 迁移 key,替换 selector emptyCount = 0; } continue; } emptyCount = 0; // ... 正常处理 }第二种是每次循环至少阻塞一小会儿,靠超时参数兜底。这两种做法的思路都是“不让空转吃掉 CPU”,重建 Selector 是治本,超时是保底。
注意:重建 Selector 时,
SelectionKey不能直接搬到新 Selector 上,得用channel.register(newSelector, key.interestOps(), key.attachment())重新注册,再把旧 key 取消。迁移期间如果有新事件到达,要保证不丢。
4.3 注册、注销与失效 key 的边界处理
SelectionKey.cancel()之后,key 并不会立刻从 Selector 里消失,它会在下一次select()时被清理。也就是说,如果你 cancel 完立刻select(),可能还会拿到一些“已取消”的 key。所以循环里那句if (!key.isValid()) continue;不能省。
写数据的时候如果对端已经关了,write会抛IOException(常见的是Broken pipe或Connection reset by peer)。这时候的处理顺序是:先key.cancel(),再channel.close(),最后清理会话对象里的缓冲。顺序反了的话,关闭通道会触发CancelledKeyException,因为 key 还挂在 Selector 上而通道已经没了。
还有一个细节:selector.wakeup()用于从其他线程唤醒阻塞在select()上的线程。典型场景是业务线程要往某个连接写数据,把数据放进队列后调一次wakeup(),让 IO 线程立刻出来处理。但wakeup()调用太频繁会导致 IO 线程被打断得太勤,反而降低效率,一般配合“一次唤醒处理一批”的策略用。
5. 缓冲区、零拷贝与线程模型:决定上限的三个因素
5.1 堆内和堆外缓冲区的取舍,以及复用策略
ByteBuffer.allocate()分配在堆上,GC 管得着,创建成本低;allocateDirect()分配在堆外,读写时少一次从堆到内核的拷贝,但分配和释放成本高,且不受 GC 直接管理(靠Cleaner兜底),大量短生命周期的直接缓冲区会让内存曲线变得很难看。
判断标准很实际:缓冲区生命周期长、反复读写、单块比较大(几 KB 以上),用 Direct;一次性的小缓冲,用 Heap。NIO 的读写路径上,SocketChannel读入 Direct 缓冲区时,JVM 可以直接把数据从内核拷过去,省掉一次中转;读入 Heap 缓冲区时,如果 JVM 没有做特殊优化,中间会多一次拷贝。
更重要的其实是复用。每个连接每次都allocate一个新缓冲区,在高并发下就是给 GC 制造压力。常见做法是每个会话持有一个固定大小的读缓冲,配合compact()滚动使用;写方向用队列,队列里的 buffer 从池子里拿,写完归还。
// 简易池化:生产环境建议用成熟的对象池实现 static final class BufferPool { private final ArrayDeque<ByteBuffer> pool = new ArrayDeque<>(); ByteBuffer acquire() { ByteBuffer b = pool.poll(); if (b == null) return ByteBuffer.allocateDirect(4 * 1024); b.clear(); return b; } void release(ByteBuffer b) { if (pool.size() < 1024) pool.offer(b); } }注意:直接缓冲区的大小要控制。默认的
MaxDirectMemorySize大约等于堆最大值,堆外内存泄漏的表现是堆很空闲但进程 RSS 一直涨,最后被系统 OOM Killer 干掉,排查起来比堆内泄漏难得多。
5.2 transferTo 零拷贝:能用和不能用的边界
文件传输场景下,FileChannel.transferTo()可以让数据在内核里直接从文件页缓存走到 socket 缓冲区,不经过用户态,省掉两次拷贝和两次上下文切换。代码很短:
try (FileChannel src = FileChannel.open(path); SocketChannel dst = SocketChannel.open(addr)) { long pos = 0, size = src.size(); while (pos < size) { long n = src.transferTo(pos, size - pos, dst); if (n <= 0) break; // 传不动了,别死循环 pos += n; } }但有两条边界必须清楚。第一,transferTo一次不一定传完,返回值可能小于请求长度,必须循环。第二,也是最容易踩的:目标SocketChannel如果是非阻塞模式,transferTo可能一个字节都不传就返回 0。JDK 的文档对这一点有说明,非阻塞通道上的传输行为是不保证的。
这意味着零拷贝在“非阻塞 + Selector”的架构里并不是随手就能用。真要用,常见做法是把文件传输交给独立的阻塞线程池处理,或者在文件通道之间用transferTo(这个场景没有上述限制),再配合应用层的分块调度。面试里如果被问到“零拷贝能不能用在 NIO 非阻塞通道上”,答“能,但要注意返回值可能为 0、需要循环处理,并且非阻塞模式下传输不保证进度”,比直接答“能”要有分量得多。
5.3 Reactor 线程模型的演进与选型依据
从单线程 Reactor 到主从 Reactor,本质是在解决“accept 和读写抢同一个线程”以及“IO 和业务抢同一个线程”这两个问题。
| 模型 | 结构 | 适用场景 | 瓶颈 |
|---|---|---|---|
| 单线程 Reactor | 一个线程管 accept + 读写 + 业务 | 连接少、业务轻 | 业务阻塞会拖垮全部连接 |
| 多线程 Reactor | 一个 IO 线程 + 业务线程池 | 中等并发、业务有耗时 | IO 线程仍是单点 |
| 主从 Reactor | 主线程 accept,多个 IO 线程管读写 | 高并发网关 | 实现复杂度高 |
实际项目里,最少也要做到“IO 线程只做编解码和分发,业务逻辑扔线程池”,因为一个耗时的数据库查询放在 IO 线程里,会把这根线程上挂着的所有连接一起拖住。这是 NIO 服务端最常见的性能事故来源,比缓冲区管理不当更常见。
再往上,多个 IO 线程分担连接时,要注意同一个连接必须固定绑在同一个 IO 线程上,否则会出现两个线程同时读写同一个通道,数据错乱。绑定的方式是按连接 ID 取模,或者用一致性哈希。
6. 秋招面试:围绕 SocketChannel 的高频问法与答题框架
6.1 看起来在问 API,其实在问模型的那些题
面试官问“read返回 0 和 -1 有什么区别”,考的不是记忆,是你有没有真的写过能跑的 NIO 代码。答题的时候不要只答语义,要带上处置动作:返回 0 说明当前无数据,连接正常,保持注册继续监听;返回 -1 说明对端关闭写端,应当取消 key、关闭通道、清理该连接的缓冲和会话状态。再把异常情况补一句:read抛IOException通常意味着连接已经不可用,处理方式和 -1 类似但要记日志。
问“OP_WRITE什么时候注册”,标准答法是:只有在write返回 0 或者返回值小于待写长度、内核发送缓冲区满的时候才注册,写完之后立刻摘掉。补充一句“因为OP_WRITE在发送缓冲区有空位时总是就绪的,常驻注册会导致空转”,这一句就能拉开差距。
问“为什么不直接用InputStream而要用Channel”,可以从三个角度答:通道是双向的,一个通道同时支持读写;通道可以配成非阻塞,配合 Selector 做多路复用;通道可以直接对接直接缓冲区和零拷贝接口。
6.2 场景设计题的拆解框架
典型的场景题是“设计一个支持十万长连接的推送服务”,或者“线上出现 CPU 打满怎么排查”。这类题别急着说技术栈,先给框架。
对设计题,按四步走:连接管理(怎么 accept、怎么分配 IO 线程、连接状态存哪)、事件处理(读事件怎么解帧、写事件怎么排队、空闲检测怎么做)、资源控制(缓冲区怎么池化、慢连接怎么限流、内存水位怎么监控)、可观测性(哪些指标要看:连接数、读写 QPS、事件循环耗时、队列积压)。每一步都能展开讲,比一上来就说“用 Netty”要有内容。
对排查题,先说现象定位:CPU 高但连接数少,先怀疑空转(OP_WRITE常驻、空轮询、select超时为 0);CPU 高且连接数多,先怀疑编解码开销和锁竞争。然后说工具:线程栈看循环位置,jstack多打几次对比;网络层看连接状态分布,ss看Recv-Q和Send-Q是不是堆积;堆外内存看进程 RSS 和堆内存的差值。
6.3 我见过的几类答错方式
第一种是只背概念不给动作。说“非阻塞就是不等”,问“那没数据的时候线程干嘛”,答不上来。面试官顺着往下问一句就露底。
第二种是把 Netty 当成终极答案。问 NIO 的事件循环怎么写,直接答“用 Netty”。Netty 确实好用,但面试问的是原理,你需要能说出 Netty 解决了哪些原生 NIO 的问题:空轮询检测、半包解码器的抽象、写操作的排队和 flush 时机、内存池。能讲清楚这些,才说明你用过而不是只用过。
第三种是忽略异常路径。只讲正常流程,讲到一半面试官问“对端突然断开你怎么办”,答“捕获异常”。继续问“捕获之后呢,key 怎么处理,缓冲里的数据怎么办”,就答不上来了。NIO 代码里异常路径的代码量经常比正常路径还多,这块答得好很加分。
7. 常见问题排查速查与线上实战经验
7.1 问题现象与处置速查表
| 现象 | 高概率原因 | 处置 |
|---|---|---|
| CPU 打满,连接数不多 | OP_WRITE常驻、空轮询 | 检查interestOps,空转计数重建 Selector |
| 消息重复处理 | selectedKeys未 remove | 循环里加it.remove() |
| 连接莫名卡死、无日志 | read返回 -1 未处理 | 补 EOF 分支,close 并清理 |
| 大消息丢失、帧解析错乱 | clear()替代了compact() | 改用compact()保留残余 |
| 客户端连不上,服务端无 accept 日志 | backlog 队列溢出 | 调大 backlog,accept 循环抽空 |
| 重启服务报端口占用 | 未开SO_REUSEADDR,TIME_WAIT 堆积 | 开启该选项,检查连接回收 |
| 堆空闲但进程 RSS 涨 | 直接缓冲区未释放 | 检查ByteBuffer引用,限制直接内存 |
偶发CancelledKeyException | 先 close 通道后 cancel key | 调整顺序为 cancel → close |
7.2 排查时的两条实用路径
第一条是从线程栈切进去。CPU 高的时候连续打三次jstack,间隔一两秒,看哪几帧重复出现。如果重复的是sun.nio.ch.EPollArrayWrapper.epollWait附近,说明是事件循环在空转;如果是业务代码里的循环,那就是业务逻辑的问题。这个方法比看监控快,因为监控只能告诉你 CPU 高,栈能告诉你在哪高。
第二条是从连接状态切进去。ss -tanp看每条连接的Recv-Q和Send-Q。Recv-Q持续不为 0,说明应用读得慢,可能是 IO 线程被业务堵住了;Send-Q持续堆积,说明对端读得慢或者网络有问题,这时候要考虑给写队列设水位线,超过就断开或者降级,否则内存会被慢连接吃掉。
7.3 几条踩出来的经验
第一条,写队列一定要有上限。慢连接加上无上限队列,就是内存泄漏的另一种写法。给每条连接设一个待发字节数的阈值,超过就断开或者丢弃低优先级消息,比等着 OOM 强。
第二条,空闲检测放在应用层。给每条连接记一个最后活跃时间,事件循环每隔几秒扫一遍,超过阈值就发心跳或者直接关闭。别指望 TCP 的 keepalive,那个时间尺度太长了。
第三条,日志里带上连接标识和当前关注的interestOps。排查的时候,能一眼看出“这个连接现在在等读还是在等写”,比看一百行日志都有用。这个字段的成本几乎为零,但救场的次数不少。
第四条,别在 IO 线程里做任何可能阻塞的事。日志同步写磁盘、数据库查询、加锁等待,这些都算。真要做,扔进业务线程池。我见过一个服务因为 IO 线程里同步打日志,高峰期所有连接一起卡住,把日志改成异步之后立刻恢复。
7.4 关于要不要自己写 NIO 的判断
最后说个务实的:绝大多数业务场景,直接上成熟框架比手写 NIO 划算。手写这套东西的收益在于你理解每个分支为什么存在,出了问题能定位到哪一层;成本在于这些细节需要反复踩坑才能写对,而生产环境不给你试错的机会。
我的做法是,框架用现成的,但原理必须自己过一遍。自己动手写一个几百行的 echo 服务,把 accept、读、写、空轮询、半包这几条路走通,比读十篇教程都管用。等你能对着自己写的代码解释清楚“为什么这里必须compact”“为什么这里必须摘掉OP_WRITE”,面试那一关也就顺带过了。