您的位置: 新闻资讯 > 行业动态 > 正文

Netty 死循环漏洞:while(e == null) 空转、JIT load hoist 与 CPU 100% 排查修复(图文)


来源:mozhe 2026-11-10

 

一、最"安静"的故障:CPU 100%,业务日志一片空白

有一种线上故障,报警很吵,现场却很安静:

  • 监控显示 Java 进程 CPU 一路顶到 100%,甚至吃满多个核;
  • 业务日志却一条不打——没有报错、没有慢查询、没有异常堆栈;
  • 服务"活着",端口也在,但几乎所有请求都超时;
  • 抓一把线程栈,好几条线程齐刷刷停在同一行:MpscChunkedArrayQueue.poll,或者某个带 while 的自旋循环里。

这就是 Netty 应用里一类典型的忙等死循环问题:消费者线程在一个 while (e == null) 式的循环里空转,每一轮都在烧 CPU,却没有任何状态前进——队列没数据,它等;等的方式不是挂起休眠,而是原地全速打转。更麻烦的是,在某些场景下,JIT 编译器的一次"好心优化"会让这个循环彻底出不去,从"短暂等待"变成"永久死循环"。

二、60 秒看懂问题代码:自旋等待的两副面孔

出问题的代码模式,通常长这样(简化示意,非生产源码逐行还原):

// 面孔一:先取,取不到就一直转
E e = queue.poll();
while (e == null) {
    e = queue.poll();   // 期望"等一会儿就有了"
}

// 面孔二:直接盯着一个字段转
while (e == null) { }   // 期望别的线程很快把 e 置为非空

这种写法的意图很朴素:无锁队列的消费端发现队列暂时是空的,与其挂起线程、承担唤醒开销,不如原地自旋几圈,等生产者把元素放进来。在"生产者马上就会写入"的正常场景里,自旋几次就能拿到数据,延迟极低——这正是无锁队列(如 Netty 使用的 MpscQueue,多生产者单消费者队列)高吞吐的来源之一。

消费者: poll() → null → 自旋重试 → poll() → 拿到元素 → 继续处理
生产者:                    ↑ 在这两拍之间完成入队 + 发布(publish)

问题的根源在于:这个"马上就有"的假设,并不总是成立。一旦生产者迟迟不写入、队列内部状态出现异常,或者 JVM 的优化动了手脚,"短暂自旋"就会退化成"无限自旋"——而自旋是烧 CPU 的,挂起是烧线程状态的,两者的故障表现完全不同。

三、从自旋到死循环:三个成因

3.1 JIT load hoist:编译器把"读"提出了循环

这是这个话题里最"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 被改了就能退出;
  • JIT 编译后:读被提升出循环,永远用第一次读到的旧值,循环永远退不出去。

把 boolean stop 换成队列里的元素引用 e,就是 Netty 场景下的同款问题:while (e == null) {} 里的 e 如果被 JIT 判定为循环不变量并被提升,即使队列数据后来"可见"了,这条线程也永远转下去。代码没变,行为因为编译深度而变——这是自旋等待类代码最隐蔽的坑。

3.2 无锁队列的内存可见性时序

即便没有 JIT 动手脚,无锁队列自身也有一个脆弱窗口。MPSC 队列(多生产者、单消费者)的入队通常分两步:

  1. 占位:先把元素引用放进数组槽位,或者先推进索引;
  2. 发布(publish):用有序写/懒发布(lazySet、ordered write,底层是 Unsafe 的 putOrderedObject 一类)标记"这个元素就绪了"。

发布之所以做得这么"轻",就是为了性能:它不刷新写缓冲、开销接近普通写。但代价是,消费者在极短时间窗口内可能看到"槽位有索引、元素却是 null"的中间态。消费者看到 null 就自旋重试——正常情况下生产者第二步很快完成,自旋一两圈就拿到了;如果时序被打乱、或者队列内部索引状态出现不一致(例如并发场景下队列对象里出现大量永远为空的元素),消费者就会一直转下去。

Netty 社区针对这类问题有一串真实的 issue 和修复记录(netty/netty 仓库 #11956、#13137,修复 PR #11972、#12495、#10855 等),多个版本的 Mpsc 队列 / Recycler 实现都在持续修补这类"消费者 poll 死循环"的边界条件。

3.3 队列状态异常:空元素堆积

实际案例(如某容器化 Java 应用的排查记录)中,通过 heapdump 能直接看到实锤:无锁队列对象内部存在大量空元素,消费者线程调用 poll() 取元素时,因为取到的始终是空元素而进入死循环,线程栈长期停留在 BaseMpscLinkedArrayQueue::poll,CPU 被逐步占满,容器 hang 死。

三个成因往往不是孤立出现的:队列状态异常给了死循环"土壤",自旋写法给了它"形态",JIT 优化则可能让它"永动机化"。

四、案发现场:Netty 里为什么会用到这些自旋

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 资源消耗),不是能被攻击者远程注入代码的安全漏洞。但它同样能被外部流量间接触发——异常报文导致解码循环零进度空转的变体,同样能把事件循环线程烧满。

五、别混淆:这不是那个"著名的 epoll 空轮询"

搜"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 自带的重建机制通常已覆盖)。
heapdump 看队列状态
jmap -dump:live,format=b,file=heap.hprof <pid>,用 MAT / JProfiler 检查 Mpsc 队列对象:是否堆积大量空元素、消费索引与生产索引是否长期不一致。
回溯变更与版本
确认 Netty 版本、JDK 版本、近期是否换过 JDK 供应商(不同 JIT 的编译策略差异会改变问题触发概率)与容器环境,把"版本 × 开关"的对照落到变更记录上。

七、修复与规避方案

方案 做法 代价 适用
升级 Netty 升到 4.1.73 及以上(含 #11972 / #12495 等修复),越新越好 回归测试 所有场景的第一步
切换有锁队列 -Dio.netty.recycler.blocking=true 轻微性能损耗 已复现 / 高风险环境立即止血
升级 JDK 新版本 JIT 对循环与 safepoint 处理更完善 回归测试 配合 Netty 升级一起做
自研代码自查 全局搜索自旋循环,按第八节纪律整改 代码评审 所有项目
应急恢复 重启进程(死循环不会自愈,重启是唯一立刻见效的手段) 中断服务 线上正烧着时
两个实操提醒:① -Dio.netty.recycler.blocking=true 是止血开关,不是长期最优解——正确顺序是先开开关保命,升级版本验证后再评估是否摘掉;② 不要用"加大 CPU"来扛——死循环消耗与流量无关,加核只是延长线程池耗尽的时间。

八、给开发者的启示:自旋等待的三条纪律

while (e == null) {} 这类代码,在无锁编程里不是不能写,但要同时满足三条纪律,缺一就是隐患:

纪律一:读必须有正确的内存语义跨线程自旋等待的字段,读要么是 volatile,要么走 Unsafe 的 acquire 读——这是"能否看到变化"的前提,也是对抗 load hoist 的唯一正规手段。
纪律二:必须有保底退出条件自旋要有上限(次数或时间),超限后降级为挂起/让出。"生产者马上就来"是性能假设,不是正确性依据。
纪律三:循环内必须有可观测的前进量每一轮要么推进状态、要么消耗大于零——"零进度循环"是所有解析器/队列类死循环的共同指纹。

顺带一提,生产环境还应避免把 assert 当作协议校验——断言默认关闭,测试环境"能报错"的防线在生产 JVM 上根本不存在,这类边界错位曾让多个解析器死循环问题从测试溜进生产。

九、自查清单

  • 已确认 Netty 版本 ≥ 4.1.73,且升级后的版本在测试环境压测过
  • 线上 JVM 参数中评估过 -Dio.netty.recycler.blocking=true 是否需要临时开启
  • 监控已覆盖"单线程 CPU 100% + QPS 下降"的组合告警(而非只看整机 CPU)
  • jstack 排查流程文档化:连续抓栈对比、认关键栈帧
  • 代码库中全局搜索 while 自旋模式,逐一核对内存语义与退出条件
  • 生产 JVM 未依赖 assert 做输入校验
  • 变更记录中有 JDK 供应商/版本变化与 Netty 版本的对应关系

十、常见问题(FAQ)

Q1:CPU 100% 但内存正常、GC 正常,一定是这个死循环吗?
不一定,但"CPU 高 + 日志安静 + 线程栈停在固定一行"是这个问题的指纹。先抓栈分流:停在队列 poll/自旋循环 → 本文主题;停在 doSelect → epoll 空轮询;到处都是业务线程在干活 → 真忙。
Q2:为什么升级了 Netty 还是偶发 CPU 高?
4.1.73 之后的修复收敛了队列死循环的主要边界,但 JDK 侧的 JIT 行为仍在演进。先确认问题线程的栈帧是否还是老位置,再看是否需要配合升级 JDK 或开启 blocking 开关。
Q3:-Dio.netty.recycler.blocking=true 要不要一直开着?
作为止血手段可以长期开,性能损耗对绝大多数业务不可感知;追求极致吞吐的场景建议升级新版 Netty 后实测对比再决定是否摘掉。
Q4:load hoist 既然合法,JVM 为什么不做"正确"的优化?
在 JVM 内存模型下,对非 volatile 字段,跨线程立即可见性本来就不保证——JIT 缓存该读值是符合规范的优化。问题不在 JVM,而在自旋代码没给编译器"必须每次重读"的信号(volatile/acquire)。把正确性建立在"JIT 别太聪明"上,才是脆弱的。
Q5:这个问题是"漏洞"吗,需要上报应急吗?
它是可用性类缺陷(DoS 风险),不是远程代码执行类漏洞,无需按安全事件上报。但如果服务暴露在公网、异常流量可能触发解析类死循环变体,建议按可用性风险管理,纳入版本升级计划。
Q6:自定义协议解码时怎么避免写出同类死循环?
记住一条铁律:循环每读一轮,必须要么消费掉至少一个字节、要么明确退出。读不到完整数据就 return 等下次事件(Netty 的 ByteBuf 累积器就是为此设计的),绝不允许"既不消费也不退出"的路径存在。
Q7:容器里 CPU 被 limit 限制时,这个问题会怎样?
更糟。死循环线程会先吃满 limit,同容器的其他线程(包括 GC 线程)被挤压,故障从"单线程空转"放大为"整个容器假死",恢复手段只剩重启。
Q8:怎么验证修复有效?
升级后在测试环境回放问题场景:对照修复前后的线程栈(不再出现长驻 poll)、heapdump(无空元素堆积)、长时间压测下的单线程 CPU 曲线。有条件的话,把问题报文做成回归用例固定下来。

关于墨者安全
墨者安全致力于安全防护、服务器高防、网络高防、ddos防护、cc防护、dns防护、防劫持、高防服务器、高防dns、网站防护等方面的服务,全网第一款指纹识别技术防火墙,自研的WAF指纹识别架构,提供任意CC和DDoS攻击防御

热门文章

X

7x24 小时

免费技术支持

15625276999


-->