
有一种线上故障,报警很吵,现场却很安静:
MpscChunkedArrayQueue.poll,或者某个带 while 的自旋循环里。这就是 Netty 应用里一类典型的忙等死循环问题:消费者线程在一个 while (e == null) 式的循环里空转,每一轮都在烧 CPU,却没有任何状态前进——队列没数据,它等;等的方式不是挂起休眠,而是原地全速打转。更麻烦的是,在某些场景下,JIT 编译器的一次"好心优化"会让这个循环彻底出不去,从"短暂等待"变成"永久死循环"。
出问题的代码模式,通常长这样(简化示意,非生产源码逐行还原):
// 面孔一:先取,取不到就一直转
E e = queue.poll();
while (e == null) {
e = queue.poll(); // 期望"等一会儿就有了"
}
// 面孔二:直接盯着一个字段转
while (e == null) { } // 期望别的线程很快把 e 置为非空
这种写法的意图很朴素:无锁队列的消费端发现队列暂时是空的,与其挂起线程、承担唤醒开销,不如原地自旋几圈,等生产者把元素放进来。在"生产者马上就会写入"的正常场景里,自旋几次就能拿到数据,延迟极低——这正是无锁队列(如 Netty 使用的 MpscQueue,多生产者单消费者队列)高吞吐的来源之一。
消费者: poll() → null → 自旋重试 → poll() → 拿到元素 → 继续处理 生产者: ↑ 在这两拍之间完成入队 + 发布(publish)
问题的根源在于:这个"马上就有"的假设,并不总是成立。一旦生产者迟迟不写入、队列内部状态出现异常,或者 JVM 的优化动了手脚,"短暂自旋"就会退化成"无限自旋"——而自旋是烧 CPU 的,挂起是烧线程状态的,两者的故障表现完全不同。
这是这个话题里最"Java 冷知识"的一环,也是标题里 load hoist 的来源。先看一个所有 Java 开发者都值得记住的经典实验:
static boolean stop = false; // 注意:不是 volatile
// 线程 A:
while (!stop) { } // 期望别人改了 stop 就能退出
// 线程 B(很久之后):
stop = true;
直觉上线程 A 应该会退出。但在启用 JIT 编译的服务端环境里,JIT 有权把这个循环编译成"读一次 stop,然后死循环"——因为它认为 stop 是循环不变量(loop-invariant):循环体内没人修改它,那每次循环反复读内存就是浪费,把读操作提升(hoist)到循环外、循环内只用寄存器里的旧值,性能更好。
这就是 load hoisting(读提升):对非 volatile 字段的读,JVM 内存模型不保证跨线程的立即可见性,JIT 因此可以合法地缓存它。于是:
stop 被改了就能退出;把 boolean stop 换成队列里的元素引用 e,就是 Netty 场景下的同款问题:while (e == null) {} 里的 e 如果被 JIT 判定为循环不变量并被提升,即使队列数据后来"可见"了,这条线程也永远转下去。代码没变,行为因为编译深度而变——这是自旋等待类代码最隐蔽的坑。
即便没有 JIT 动手脚,无锁队列自身也有一个脆弱窗口。MPSC 队列(多生产者、单消费者)的入队通常分两步:
发布之所以做得这么"轻",就是为了性能:它不刷新写缓冲、开销接近普通写。但代价是,消费者在极短时间窗口内可能看到"槽位有索引、元素却是 null"的中间态。消费者看到 null 就自旋重试——正常情况下生产者第二步很快完成,自旋一两圈就拿到了;如果时序被打乱、或者队列内部索引状态出现不一致(例如并发场景下队列对象里出现大量永远为空的元素),消费者就会一直转下去。
Netty 社区针对这类问题有一串真实的 issue 和修复记录(netty/netty 仓库 #11956、#13137,修复 PR #11972、#12495、#10855 等),多个版本的 Mpsc 队列 / Recycler 实现都在持续修补这类"消费者 poll 死循环"的边界条件。
实际案例(如某容器化 Java 应用的排查记录)中,通过 heapdump 能直接看到实锤:无锁队列对象内部存在大量空元素,消费者线程调用 poll() 取元素时,因为取到的始终是空元素而进入死循环,线程栈长期停留在 BaseMpscLinkedArrayQueue::poll,CPU 被逐步占满,容器 hang 死。
三个成因往往不是孤立出现的:队列状态异常给了死循环"土壤",自旋写法给了它"形态",JIT 优化则可能让它"永动机化"。
Netty 内部为了极致性能,大量使用无锁结构和对象池,其中两个位置与这个问题直接相关:
1. MpscQueue(多生产者单消费者队列):Netty 的事件循环(EventLoop)把任务、定时任务串到单线程上执行,跨线程投递任务用的就是 MPSC 队列;对象池 Recycler 的跨线程回收也依赖类似结构。消费端是单线程、生产端是多线程,天然适配 MPSC。
2. Recycler(对象回收池):ByteBuf 等高频对象靠池化降低 GC 压力,回收与复用的线程间协调涉及轻量自旋。Netty 在 4.1.73 版本起提供了开关参数:
-Dio.netty.recycler.blocking=true
开启后用带锁的 BlockingMessageQueue 替代无锁队列,用一点性能换确定性——这是社区在多次死循环问题后给出的官方"安全阀"。升级到新版本后,社区还重构了相关实现,进一步收敛了这类问题。
准确边界提示:这里的"漏洞"指框架实现层面的缺陷(可用性类问题,对应 CWE-835 循环退出条件不可达 / CWE-400 资源消耗),不是能被攻击者远程注入代码的安全漏洞。但它同样能被外部流量间接触发——异常报文导致解码循环零进度空转的变体,同样能把事件循环线程烧满。搜"Netty CPU 100%",最先跳出来多半是 JDK NIO 的 epoll 空轮询 bug(JDK-6670302)。它和本文的问题是两回事,排查方向完全不同,先分清能少走一半弯路:
| 维度 | epoll 空轮询(JDK bug) | 本文的 while(e==null) 死循环 |
|---|---|---|
| 位置 | EPollSelectorImpl.doSelect,select() 空返回 |
MPSC 队列 / Recycler 的自旋循环 |
| 根源 | Linux epoll 与 JDK 封装的边界缺陷 | 自旋等待 + JIT 优化 / 队列状态异常 |
| 线程栈特征 | 卡在 sun.nio.ch.EPollSelectorImpl.doSelect |
卡在 BaseMpscLinkedArrayQueue.poll 等 |
| Netty 对策 | 空轮询计数超阈值(默认 512)后重建 Selector | 升级版本、-Dio.netty.recycler.blocking=true |
| 触发规律 | 与事件到达密度相关,偶发、"会自愈" | 一旦卡住通常持续,直到重启 |
线程栈停在哪一行,就是最可靠的分流依据。
top -H -p <pid> 看单线程级 CPU:一条或几条线程 100%,整机不高。再用 jstack 间隔 10 秒连抓几次对比:同一条线程多次抓栈都停在同一个自旋循环里、状态 RUNNABLE、CPU 时间≈墙上时间——这是忙循环;如果状态是 WAITING/BLOCKED,那是另一类问题。BaseMpscLinkedArrayQueue.poll、MpscChunkedArrayQueue.poll、Recycler 相关帧——出现即基本命中本文主题。若停在 doSelect,转到 epoll 空轮询的处理路径(升级 Netty 自带的重建机制通常已覆盖)。jmap -dump:live,format=b,file=heap.hprof <pid>,用 MAT / JProfiler 检查 Mpsc 队列对象:是否堆积大量空元素、消费索引与生产索引是否长期不一致。| 方案 | 做法 | 代价 | 适用 |
|---|---|---|---|
| 升级 Netty | 升到 4.1.73 及以上(含 #11972 / #12495 等修复),越新越好 | 回归测试 | 所有场景的第一步 |
| 切换有锁队列 | -Dio.netty.recycler.blocking=true |
轻微性能损耗 | 已复现 / 高风险环境立即止血 |
| 升级 JDK | 新版本 JIT 对循环与 safepoint 处理更完善 | 回归测试 | 配合 Netty 升级一起做 |
| 自研代码自查 | 全局搜索自旋循环,按第八节纪律整改 | 代码评审 | 所有项目 |
| 应急恢复 | 重启进程(死循环不会自愈,重启是唯一立刻见效的手段) | 中断服务 | 线上正烧着时 |
-Dio.netty.recycler.blocking=true 是止血开关,不是长期最优解——正确顺序是先开开关保命,升级版本验证后再评估是否摘掉;② 不要用"加大 CPU"来扛——死循环消耗与流量无关,加核只是延长线程池耗尽的时间。
while (e == null) {} 这类代码,在无锁编程里不是不能写,但要同时满足三条纪律,缺一就是隐患:
顺带一提,生产环境还应避免把 assert 当作协议校验——断言默认关闭,测试环境"能报错"的防线在生产 JVM 上根本不存在,这类边界错位曾让多个解析器死循环问题从测试溜进生产。
-Dio.netty.recycler.blocking=true 是否需要临时开启
-->