题目:频繁 FullGC 问题排查
难度:high
标签:practice/debug, jvm/gc, difficulty/high
评分:⭐⭐⭐☆☆(3/5)
出现频繁fullgc首先看开始出现的时间,先分析当时发生了什么,另外如果是周期性的当时没能处理好,是否在每个周期个固定的时间发生?
导出fullgc日志,检查回收前后堆空间的变化,如果变化很小,回收了也没有回收多少内容,并且是持续fgc那么高概率是内存泄露了;如果fgc后能回收较多的内容,证明回收能回收掉并不是内存泄露;
导出内存快照,检查其中对象分布,看具体是什么类型的对象占用大量内存,并且导出当前的jstack线程栈,看是什么线程在持续运行,对应的方法是否有内存泄露的内容;
优点:三条主线都抓对了——时间线(何时开始、是否周期性)、GC 日志判泄漏(回收前后对比)、堆快照 + 线程栈定位持有者。尤其是"先看发生时间对应什么变更"这一步,是线上问题最容易被忽略也最有效的一步。
不足:① 判据不够严谨:只看"单次回收比例"会误判,正确判据是Full GC 后的存活对象占用是否逐次抬升(post-GC old gen 的趋势线),单次回收少可能只是老年代碎片或大对象;② 没有 GC cause 的分析——Allocation Failure、System.gc、Metadata GC Threshold、Humongous Allocation、Promotion Failed 各自对应完全不同的根因;③ 工具链不具体:jstat -gcutil、-Xlog:gc*、jcmd GC.heap_info、jmap -histo:live、MAT 的 Dominator Tree / Leak Suspects 都没提;④ 漏了两个高频真因:元空间泄漏(动态代理/CGLIB 类不断生成)和堆外/直接内存(Full GC 也降不下来,因为它根本不在堆里)。
补充:一个非常好用的判据组合——① jstat -gcutil <pid> 1000 看 O(老年代使用率)在每次 FGC 后是否回到同一基线:逐次抬高 = 泄漏,回落但很快又满 = 分配速率过高或堆太小;② 同时看 FGC 次数增速与 FGCT 平均耗时,若单次 FGC 耗时从 200ms 涨到 2s,说明存活对象越来越多,也是泄漏的强信号。
核心考点定位:考察能否把"频繁 Full GC"拆成三类根因——真泄漏、容量不足/分配过快、非堆问题,并用趋势判据而不是单点快照来区分。
知识点拆解:
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:<file>,JDK 9+ 用 -Xlog:gc*,gc+heap=debug:file=gc.log:time,uptime,level,tags。关键是能拿到每次 GC 前后的各代容量与耗时。jstat -gcutil <pid> 1000 观察 O 与 FGC。每次 FGC 后老年代基线逐步抬高 = 存活对象在累积 = 泄漏;每次都能回到同一基线但增长极快 = 分配速率高、新生代过小或对象过早晋升;老年代不高但 FGC 频繁 = System.gc() 被调用、元空间不足、Humongous 分配(G1)。Allocation Failure(正常分配失败)、System.gc()(有人显式调用,常见于 NIO DirectByteBuffer 回收或第三方库)、Metadata GC Threshold(元空间不足)、Humongous Allocation(G1 大对象超过 region 一半)、Promotion Failed(晋升失败,Survivor 不足或老年代碎片)。-XX:MetaspaceSize/MaxMetaspaceSize,类加载器泄漏、动态代理类暴涨)、直接内存(-XX:MaxDirectMemorySize、Netty)、线程栈(线程数暴涨,每线程 1MB 预留)、代码缓存(CodeCache 满会触发编译退化为解释执行,间接影响 GC 节奏)。jmap -histo:live <pid> 看类实例数与总字节数 TOP;jcmd <pid> GC.heap_dump 导出后 MAT 看 Dominator Tree 与 Path to GC Roots,确认是静态集合、缓存、ThreadLocal、监听器还是连接池持有。配合 jstack 看哪些线程还在往里写。按「底层原理 → 设计权衡 → 工程实践」递进排列。
考察方向:
延伸递进面试题:
System.gc() 被显式调用(DirectByteBuffer 回收)、元空间达到阈值、G1 的 Humongous 分配频繁,或 CodeCache 满导致编译退化。这些都不在堆里。jstat -gc 看 MU/MC 持续上涨、FGCC 对应 Metadata GC Threshold;jcmd VM.metaspace 或 jmap -clstats 看类加载器数量;常见于热部署、动态生成代理类、反射滥用、Groovy/脚本引擎。【一句话结论】
面试官您好,频繁 Full GC 我分两步:先用"FGC 后老年代基线是否逐次抬高"区分泄漏和容量不足,再顺着 GC cause 和堆快照找到具体持有者。
【三点展开】
第一,先对齐时间线。什么时候开始的、对应哪次发布或配置变更、是不是每天固定时间出现。周期性出现优先怀疑定时任务和缓存全量刷新,这一步经常能直接省掉后面所有分析。
第二,用趋势而不是单点判断。jstat -gcutil 连续看,如果每次 FGC 后老年代都回到同一基线,只是涨得快,那是分配速率高或堆太小,调新生代和晋升阈值;如果基线逐次抬高,那才是泄漏,再去导堆。同时看 GC cause:System.gc 是有人显式调用,Metadata GC Threshold 是元空间,Humongous Allocation 是 G1 大对象,方向完全不同。
第三,定位持有者。jmap -histo:live 看 TOP 类和实例数,导堆后用 MAT 的 Dominator Tree 和 Path to GC Roots 找到是谁在持有,再用 jstack 看哪个线程在持续写入。常见的是无界本地缓存、静态集合、ThreadLocal 没 remove、监听器没反注册。
【一句边界】
当然,如果老年代不高、FGC 却频繁,那根本不是堆的问题,要往元空间、直接内存和 System.gc 上查,盯着堆看会一直找不到原因。