
1. 这不是“内存不够”的简单问题一次真实OOMCPU 100%的连环故障复盘上周五下午三点监控告警突然炸开核心订单服务的JVM堆内存使用率在90秒内从45%飙升至99.8%紧接着GC频率暴涨Full GC耗时从平均200ms跳到3.2秒最后服务直接卡死所有HTTP请求超时。更诡异的是同一台机器的CPU使用率同步冲上100%top里却看不到明显高CPU的Java线程——%CPU列全是零点几但%us用户态和%sy内核态加起来死死钉在100%。运维同事第一反应是“堆内存配小了”立刻把-Xmx从4G调到8G重启。结果——5分钟后同样的曲线再次上演服务再度瘫痪。这根本不是“内存配小了”能解释的。真正的线索藏在三个地方一是jstat -gc输出里GCTGC总耗时和FGCTFull GC总耗时的比值异常高说明GC本身成了性能瓶颈二是jstack快照里大量线程卡在java.lang.ref.Reference$ReferenceHandler和java.lang.ref.Finalizer这两个守护线程上三是jmap -histo:live显示byte[]、char[]对象数量暴增但jmap -dump:formatb,fileheap.hprof生成的堆转储文件里这些对象的引用链却异常“干净”找不到明确的业务代码持有者。这三个现象拼在一起指向一个被很多开发者忽略的真相堆外内存泄漏 Finalizer队列阻塞 内核态CPU空转。这不是Java基础面试题里“String常量池溢出”那种教科书案例而是生产环境里最棘手的组合拳——它不报OutOfMemoryError: Java heap space却让服务彻底失能它不靠Thread.sleep()拖垮CPU却让top里的CPU使用率纹丝不动地锁死在100%。接下来我会带你完整走一遍从现象定位、根因深挖到最终修复的全过程每一步都附带命令、参数含义和我踩过的坑。你不需要记住所有命令但必须理解为什么是这条命令、为什么是这个参数、为什么这个结果意味着什么。2. 拆解“CPU 100%但无高CPU线程”的假象Finalizer与ReferenceHandler的暗战当top显示CPU 100%而jstack里找不到高CPU线程时绝大多数人会怀疑是jstack采样时机不对或者线程在做系统调用。但这次我们先验证一个更基础的假设CPU真的被Java进程占用了执行ps -eo pid,ppid,cmd,%cpu --sort-%cpu | head -20确认PID对应的Java进程确实占用了接近100%的CPU。接着用pidstat -t -p PID 1 5每秒采样一次共5次观察线程级CPU使用率。你会发现除了main线程和几个业务线程有零点几的%CPU其余线程几乎为零——包括那些本该活跃的Netty EventLoop线程。这说明CPU时间没有花在Java字节码执行上而是在内核态或JVM内部调度中被消耗掉了。这时候jstack的价值就凸显出来了。不要只扫一眼线程状态要重点看两个守护线程Reference Handler #2 daemon prio10 os_prio0 tid0x00007f8b4c00a800 nid0x1a waiting on condition [0x00007f8b4b7fe000] java.lang.Thread.State: RUNNABLE at java.lang.ref.Reference.waitForReferencePendingList(Native Method) at java.lang.ref.Reference.processPendingReferences(Reference.java:241) at java.lang.ref.Reference.access$200(Reference.java:46) at java.lang.ref.Reference$ReferenceHandler.run(Reference.java:192) Finalizer #3 daemon prio8 os_prio0 tid0x00007f8b4c00c000 nid0x1b in Object.wait() [0x00007f8b4b6fd000] java.lang.Thread.State: WAITING (on object monitor) at java.lang.Object.wait(Native Method) at java.lang.ref.ReferenceQueue.remove(ReferenceQueue.java:143) at java.lang.ref.ReferenceQueue.remove(ReferenceQueue.java:164) at java.lang.ref.Finalizer$FinalizerThread.run(Finalizer.java:209)Reference Handler线程的状态是RUNNABLE但它卡在waitForReferencePendingList这个Native方法里这意味着它在等待JVM内部的一个C层的pending list待处理引用列表非空。而Finalizer线程的状态是WAITING它在ReferenceQueue.remove上等待这是它的标准工作模式。问题来了如果Finalizer线程在等待那谁来把需要终结的对象放进ReferenceQueue答案是Reference Handler。但Reference Handler又在等pending list这就形成了一个经典的“鸡生蛋还是蛋生鸡”式死锁前兆——pending list一直不为空Reference Handler就一直忙等不释放CPU而Finalizer线程因为队列没东西可取就一直WAITING不干活。可pending list为什么一直不为空因为有大量对象被标记为finalizable但Finalizer线程处理不过来导致pending list持续堆积。Reference Handler线程在这种状态下会以极高的频率轮询pending list这种纯CPU轮询busy-waiting就是top里看到100% CPU的真正来源——它不执行业务逻辑只在内核态疯狂检查一个内存地址是否变化。提示Reference Handler的busy-waiting行为在JDK 8u20及以后版本已被优化改用park/unpark机制但如果你用的是较老的JDK如8u191之前这个问题会非常典型。我们当时用的就是8u181strace -p PID能看到大量futex系统调用证实了这一点。那么为什么会有这么多对象需要finalizefinalize方法是Java中一个被废弃Deprecated但未被移除的机制任何重写了finalize()方法的类其对象在被GC判定为可回收时不会立即释放而是先被放入Finalizer队列由Finalizer线程异步调用finalize()方法之后才真正回收。如果finalize()方法执行很慢或者有死锁、IO阻塞Finalizer线程就会卡住队列越积越多。我们检查了代码果然发现一个被遗忘的旧模块里面有一个FileChannel的包装类重写了finalize()并在其中调用了channel.close()——而channel.close()在某些文件系统下可能触发阻塞式系统调用。更糟的是这个类被大量用于临时文件操作每次创建一个新实例就向Finalizer队列里扔一个任务。这就是压垮骆驼的最后一根稻草。3. 堆外内存泄漏的隐秘路径DirectByteBuffer与Cleaner的无声吞噬解决了CPU 100%的表象下一个问题是为什么堆内存也爆了jstat -gc显示OU老年代使用量持续上涨jmap -histo:live里java.nio.DirectByteBuffer对象数量稳定在几千个看起来并不夸张。但jmap -dump:formatb,fileheap.hprof后用Eclipse MAT分析发现这些DirectByteBuffer对象的cleaner字段指向的sun.misc.Cleaner对象其thunk字段一个Runnable引用了一个巨大的byte[]数组——这个数组不在堆里而在堆外off-heap。DirectByteBuffer本身很小约24字节它只是堆内的一个“句柄”真正的内存块capacity大小是通过Unsafe.allocateMemory在堆外分配的。Cleaner的作用就是在DirectByteBuffer被GC回收时自动调用Unsafe.freeMemory释放这块堆外内存。但如果Cleaner本身没被及时清理或者DirectByteBuffer的引用链太长导致GC延迟这块堆外内存就会一直挂着成为“幽灵内存”。我们用jcmd PID VM.native_memory summary scaleMB命令查看JVM的本地内存使用情况需启动时加-XX:NativeMemoryTrackingsummary参数。输出显示Total: reserved12345MB, committed8765MB - Java Heap (reserved4096MB, committed3980MB) - Class (reserved1024MB, committed950MB) - Thread (reserved512MB, committed480MB) - Code (reserved256MB, committed240MB) - Internal (reserved128MB, committed110MB) - Other (reserved128MB, committed105MB) - Symbol (reserved64MB, committed58MB) - Native Memory Tracking (reserved4MB, committed4MB) - Arena Chunk (reserved16MB, committed12MB) - Unknown (reserved1024MB, committed980MB) -- 关键这里的Unknown区域就是JVM无法精确归类的本地内存其中绝大部分就是DirectByteBuffer分配的堆外内存。980MB的Unknown已远超我们预设的堆大小且随时间推移持续增长。为了确认我们执行jcmd PID VM.native_memory detail scaleMB | grep -A 10 DirectByteBuffer找到了关键日志- Direct Buffer (reserved980MB, committed980MB) (mmap: reserved980MB, committed980MB)这980MB就是被DirectByteBuffer吃掉的堆外内存。问题根源在于我们的服务大量使用Netty进行网络通信而Netty默认使用PooledByteBufAllocator它会从内存池中分配DirectByteBuffer。但内存池的maxOrder和chunkSize配置不当导致大量小块内存无法被有效复用频繁申请/释放Cleaner的注册和触发跟不上节奏。更致命的是某个下游服务返回了异常大的响应体超过10MBNetty的ByteBuf在解析时由于maxCapacity限制未生效触发了UnpooledByteBufAllocator的兜底分配直接创建了DirectByteBuffer而这个DirectByteBuffer的生命周期管理完全依赖于GC一旦GC压力大Cleaner就来不及清理。注意DirectByteBuffer的Cleaner是PhantomReference的一种实现它不阻止对象被回收只在对象被GC后通知Cleaner线程。如果Cleaner线程本身被Finalizer阻塞如上一节所述那么DirectByteBuffer的堆外内存就永远得不到释放直到JVM进程退出。这就是OOM和CPU 100%的终极耦合点。4. 三步定位法从现象到根因的完整排查链路面对OOMCPU 100%的复合故障不能靠猜必须建立一套可复现、可验证的排查链路。我总结了一套“三步定位法”每一步都对应一个确定性的证据缺一不可。4.1 第一步确认是堆内还是堆外问题证据jstat与jcmd交叉验证很多人一看到OOM就直奔-Xmx这是最大的误区。首先用jstat -gc PID连续采样如jstat -gc PID 1000 10每秒一次共10次观察OU老年代使用量和OC老年代容量的比值。如果OU/OC持续接近100%且FGC次数激增说明是堆内问题。但我们的数据显示OU在3.8G左右OC4GFGC每分钟发生3-4次但OU并未达到OC上限这说明GC在努力回收但总有新对象快速进入老年代。此时立刻执行jcmd PID VM.native_memory summary scaleMB对比Total committed与Java Heap committed的差值。如果差值巨大如我们案例中的980MB且Other或Unknown区域异常高则锁定为堆外问题。这一步的关键是拒绝假设用数据说话。jstat告诉你堆里发生了什么jcmd告诉你堆外发生了什么两者结合才能看清全貌。4.2 第二步定位高CPU的元凶线程证据pidstat与jstack深度关联top只能告诉你CPU被谁占了但不能告诉你为什么。pidstat -t -p PID 1 5能精准定位到线程IDTID然后将TID转换为16进制printf %x\n TID再在jstack PID的输出中搜索这个16进制字符串。我们正是这样锁定了Reference Handler线程。但仅仅找到线程还不够要深入其栈帧。jstack输出中nid0x1a就是Reference Handler的线程ID16进制at java.lang.ref.Reference.waitForReferencePendingList(Native Method)这一行是关键——它暴露了这是一个Native层的忙等。此时jstack的另一个价值是看其他线程是否在等待Reference Handler处理完。我们发现大量业务线程的栈顶是java.lang.ref.ReferenceQueue.poll()或remove()它们都在等ReferenceHandler把对象放进队列。这证明了整个引用处理链路已经堵塞。这一步的核心是将操作系统级的线程视图pidstat与JVM级的线程视图jstack打通形成一条完整的证据链。4.3 第三步追溯堆外内存的源头证据jmap堆转储与MAT的支配树分析jmap -dump:formatb,fileheap.hprof PID是必选项但dump文件本身不直接显示堆外内存。我们需要用MATEclipse Memory Analyzer打开它执行Histogram直方图按Retained Heap排序找到java.nio.DirectByteBuffer。右键点击它选择Merge Shortest Paths to GC Roots合并到GC根的最短路径排除Weak Reference、Soft Reference等只保留Strong Reference。我们会发现这些DirectByteBuffer大多被io.netty.buffer.PoolThreadCache或io.netty.buffer.PooledByteBufAllocator持有。再对PoolThreadCache做Dominator Tree支配树分析就能看到具体的Chunk和Page对象以及它们的memoryMap数组。这个数组的每个元素代表一个内存块的分配状态。如果发现大量memoryMap元素的bitmap为0表示已分配但未释放且其size字段很大就说明内存池出现了严重的碎片化或泄漏。这一步的难点在于MAT的操作门槛很多人导出dump后就放弃了。我的经验是先用Histogram找大头再用Dominator Tree看谁在“养”着这些大头最后用OQLObject Query Language写查询语句如SELECT * FROM java.nio.DirectByteBuffer WHERE retainedHeap 1000000直接筛选出占用超过1MB的DirectByteBuffer实例效率极高。5. 根治方案从JVM参数、代码改造到架构优化的立体防御定位清楚后修复就水到渠成但绝不能只打补丁。我们实施了三层防御体系确保问题永不复发。5.1 JVM层参数调优与机制禁用立竿见影第一刀砍掉Finalizer这个历史包袱。在JVM启动参数中加入-XX:DisableExplicitGC -XX:UseG1GC -XX:MaxGCPauseMillis200 -XX:UnlockDiagnosticVMOptions -XX:WhiteBoxAPI -XX:DisableFinalizers。-XX:DisableFinalizers是JDK 9引入的诊断参数需配合-XX:UnlockDiagnosticVMOptions它会直接禁用Finalizer机制任何重写的finalize()方法都将被忽略。这从根本上杜绝了Finalizer线程阻塞的风险。同时-XX:UseG1GC替代了老旧的CMSG1的并发标记和混合回收能更好地应对大堆场景-XX:MaxGCPauseMillis200设定了GC停顿目标让GC更积极。-XX:DisableExplicitGC则禁止了System.gc()的显式调用避免人为触发Full GC。第二刀严控堆外内存。-XX:MaxDirectMemorySize512m强制设定了堆外内存上限一旦DirectByteBuffer申请的总内存超过512MB就会抛出OutOfMemoryError: Direct buffer memory而不是让服务慢慢窒息。这个值不是拍脑袋定的我们根据jcmd的历史数据取了峰值的1.2倍。此外-XX:NativeMemoryTrackingsummary必须开启它是后续所有jcmd诊断的基础。5.2 代码层重构与清理釜底抽薪禁用Finalizer只是止血代码里的“病灶”必须切除。我们做了两件事一是全局搜索finalize()方法将所有重写finalize()的类全部重构。对于FileChannel包装类我们改用try-with-resources语法确保channel.close()在作用域结束时被确定性调用不再依赖GC。二是审查所有DirectByteBuffer的创建点。Netty的PooledByteBufAllocator配置被大幅调整// 旧配置问题所在 PooledByteBufAllocator.DEFAULT; // 新配置精细化控制 new PooledByteBufAllocator( true, // useCacheForAllThreads 1, // defaultNumHeapArena 1, // defaultNumDirectArena 8192, // pageSize (8KB) 11, // maxOrder (2^11 2048 pages 16MB per chunk) 0, // tinyCacheSize 0, // smallCacheSize 0, // normalCacheSize DEFAULT_MAX_CAPACITY, // maxCapacity PlatformDependent.directBufferPreferred() );关键改动是pageSize从默认的81928KB提升到1638416KBmaxOrder从11降到10即最大chunk为8MB并关闭了所有线程本地缓存tinyCacheSize等设为0。这牺牲了一点分配速度但极大减少了内存碎片让DirectByteBuffer的生命周期更可控。所有手动创建DirectByteBuffer的地方都加上了try-finally块确保buffer.clear()和buffer null被显式调用。5.3 架构层熔断与降级最后一道保险即使代码和JVM都完美外部依赖的异常仍可能击穿防线。我们在网关层增加了针对大响应体的熔断规则当单个HTTP响应的Content-Length超过5MB或响应体流式读取时累计字节数超过5MB立即中断连接并返回413 Payload Too Large。这从源头上杜绝了超大响应体触发DirectByteBuffer失控分配的可能性。同时在服务内部所有对外部服务的调用都强制添加了HystrixCommand或Resilience4j的TimeLimiter和CircuitBreaker超时时间严格设定为下游SLA的1.5倍避免一个慢接口拖垮整个线程池。这套架构级防护让我们在后续一次Kafka集群网络抖动事件中成功避免了类似故障——虽然Kafka消费者拉取到了大量乱序消息但熔断器在消息反序列化失败时就已触发服务保持了99.99%的可用性。6. 经验总结那些文档里不会写的实战心得做完这一切服务恢复了稳定但真正的收获是那些写在文档角落、只有踩过坑的人才懂的经验。我想把这些“血泪教训”分享给你它们比任何参数配置都重要。第一个心得永远不要相信“默认配置”。Netty的PooledByteBufAllocator.DEFAULT、JDK的-XX:UseParallelGC、甚至Linux的vm.swappiness60这些默认值都是为通用场景设计的。在你的具体业务里它们很可能就是定时炸弹。我们花了整整两天时间才搞明白为什么maxOrder11会导致内存池碎片化——因为2^11 * 8KB 16MB而我们大部分请求的ByteBuf大小在128KB到1MB之间16MB的chunk被切成很多小块后很难被高效复用。后来我们画了一张pageSize、maxOrder和实际分配大小的对照表才真正理解了内存池的数学逻辑。所以我的建议是上线前用jcmd PID VM.native_memory detail跑一轮压测把所有内存区域的committed值记下来作为基线。任何配置变更后都必须重新采集对比。第二个心得jstack不是用来“看线程状态”的而是用来“看线程关系”的。新手看jstack只关注RUNNABLE或BLOCKED但高手看的是线程之间的引用和等待关系。比如当你看到Finalizer线程在WAITING就要立刻去查哪些线程在调用ReferenceQueue.remove()当你看到Reference Handler在RUNNABLE但卡在Native方法就要想到是不是Finalizer线程卡住了。jstack的精髓在于把一堆孤立的线程快照拼成一张动态的“线程协作图”。我养成了一个习惯每次jstack后用文本编辑器把所有线程按nid排序然后手动画一个简单的流程图标出谁在等谁谁在通知谁。这张图往往比任何监控图表都更能揭示问题本质。第三个心得堆外内存泄漏的修复永远比堆内泄漏更难验证。堆内泄漏jmap一dumpMAT一分析谁持有了大对象一目了然。但堆外泄漏jcmd只能告诉你“有多少”不能告诉你“是谁”。我们曾以为关闭了Finalizer就万事大吉结果一周后Unknown内存又开始缓慢上涨。最后发现是某个第三方SDK内部使用了sun.misc.Unsafe直接分配内存且没有提供任何清理接口。这种情况下唯一的办法是升级SDK版本或用javaagent技术在Unsafe.allocateMemory调用处埋点记录调用栈。所以我的忠告是对所有引入的第三方库都要用jcmd PID VM.native_memory detail做一次“体检”重点关注Internal和Unknown区域的增长趋势。如果某个库上线后这两个区域突增它就是头号嫌疑犯。最后一点也是最重要的一点不要追求“一次性根治”要建立“故障自愈”的能力。我们最终上线的方案里包含了一个轻量级的健康检查脚本它每5分钟执行一次#!/bin/bash PID$(pgrep -f java.*OrderService) if [ -z $PID ]; then exit; fi # 检查Finalizer队列长度需JDK 8u20 FINALIZER_QLEN$(jcmd $PID VM.native_memory summary | grep Finalizer | awk {print $3}) if [ $FINALIZER_QLEN -gt 1000 ]; then echo Finalizer queue too long: $FINALIZER_QLEN | logger -t oom-monitor # 触发紧急GC jcmd $PID VM.run_finalization fi # 检查DirectByteBuffer数量 DBB_COUNT$(jcmd $PID VM.native_memory detail | grep Direct Buffer | awk {print $3}) if [ $DBB_COUNT -gt 5000 ]; then echo Too many DirectByteBuffer: $DBB_COUNT | logger -t oom-monitor # 发送告警并记录堆转储 curl -X POST http://alert-api/oom?serviceorder jmap -dump:formatb,file/tmp/oom-dump-$(date %s).hprof $PID fi这个脚本不会解决根本问题但它能在问题恶化前发出预警为我们争取宝贵的10分钟处置时间。在分布式系统里完美的预防是奢望可靠的自愈才是王道。