线上 Full GC 频繁,如何排查解决?

一则或许对你有用的小广告

欢迎 加入小哈的星球 ,你将获得: 专属的项目实战(已更新的所有项目都能学习) / 1v1 提问 / Java 学习路线 / 学习打卡 / 每月赠书 / 社群讨论

  • 新开坑项目: 《Spring AI 项目实战(问答机器人、RAG 增强检索、联网搜索)》 正在持续爆肝中,基于 Spring AI + Spring Boot3.x + JDK 21...点击查看;
  • 《从零手撸:仿小红书(微服务架构)》 已完结,基于 Spring Cloud Alibaba + Spring Boot3.x + JDK 17...点击查看项目介绍; 演示链接: http://116.62.199.48:7070/;
  • 《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接: http://116.62.199.48/

面试考察点

面试官抛出这个问题,想考察的远不止你知道 “Full GC是什么”,而是想全方位了解你的技术深度和实战经验:

  1. JVM 内存布局与 GC 原理:你是否清楚堆内存的分代(年轻代、老年代)、各种 GC 算法(标记-清除、标记-复制、标记-整理)以及不同垃圾收集器(如 CMS、G1)的工作机制。
  2. Full GC 触发的根本原因:不仅仅是知道 “老年代满了”,更要深入理解导致老年代无法分配对象的种种可能:比如内存泄漏导致对象无法回收、大对象直接进入老年代、元空间(Metaspace)不足、或者程序主动调用了 System.gc() 等。
  3. 问题排查的工具链与手段:面对一台线上服务器,你第一步做什么?用什么命令?看什么数据?这考察你对 jstatjmapjstackjinfo 等 JDK 自带工具的熟练度,以及是否能借助 MAT(Memory Analyzer Tool)、VisualVM 或在线分析平台(如 GCeasy)进行深度分析。
  4. 逻辑清晰的排查思路:面对一个模糊的 “频繁 Full GC” 现象,你能否有条不紊地抽丝剥茧,从现象到原因,再到解决方案,形成一个闭环。这体现了你的逻辑思维和架构能力。
  5. 解决问题的能力:找到问题根源后,如何制定解决方案?是简单的参数调优,还是需要重构代码?如何验证优化效果?这关系到你是否能将知识落地。

核心答案

当线上出现频繁 Full GC 时,我的排查思路通常遵循一个 “由外到内、由粗到细” 的步骤:

  1. 确认现象与监控:首先,通过监控系统(如 Prometheus + Grafana)或直接登录服务器,确认确实是 Full GC 频繁,并查看 CPU 负载、接口响应时间等是否受到明显影响。
  2. 开启并获取 GC 日志:这是最重要的第一步。如果没有 GC 日志,马上给 JVM 进程动态添加或重启应用带上详细的 GC 日志参数,获取关键的日志文件。
  3. 初步分析 GC 日志:使用 grep 命令或工具快速分析日志,确认 Full GC 的频率、单次耗时以及发生前后的内存变化。
  4. 获取堆内存快照(Heap Dump):这是定位内存问题最有力的手段。在 Full GC 发生前后,用 jmap 命令生成堆转储文件。
  5. 使用 MAT 等工具深度分析 Dump 文件:将 Dump 文件下载到本地,用 MAT 打开,重点看:
    • Histogram:哪些对象占据了最多的内存?
    • Dominator Tree:哪些 GC Roots 路径下的对象集合是最大的?
    • Leak Suspects:MAT 自动分析出的内存泄漏嫌疑点。
  6. 结合代码与业务场景定位问题:根据分析结果,找到对应的业务代码。看看是不是存在未关闭的流、不恰当的缓存使用、ThreadLocal 使用不当、或者 SQL 查询加载了大量数据等问题。
  7. 制定优化方案并验证:根据问题原因,进行代码修复、调整 JVM 参数(如堆大小、GC 算法)或优化 SQL。上线后,再次通过监控和 GC 日志验证 Full GC 频率是否恢复正常。

深度解析

1. Full GC 为什么会频繁发生?

要解决问题,得先知道敌人是谁。频繁 Full GC 的根本原因可以归结为以下几点:

  • 老年代空间不足:这是最常见的情况。比如,年轻代对象晋升速率过快,超过老年代回收速率;或者存在内存泄漏,大量对象被长期引用,无法被回收,最终塞满老年代。
  • 大对象直接进入老年代:如果代码中创建了“短命”的大对象(比如很大的 byte 数组),且 JVM 参数 -XX:PretenureSizeThreshold 设置不当,它们会直接进入老年代。如果频率高,老年代很快就会被占满。
  • 元空间(Metaspace)不足:当应用动态加载了大量的类(比如使用 CGLIB 频繁生成代理类),而元空间设置得比较小,就会触发 Full GC 来尝试回收卸载类。
  • 显式 GC 调用:代码中直接调用了 System.gc(),或者一些框架(如 RMI、NIO 等)在某些情况下会触发 System.gc()。如果未禁用 -XX:+DisableExplicitGC,就会导致频繁 Full GC。
  • 垃圾收集器并发模式失败:在使用 CMS 收集器时,如果老年代在 CMS 进行并发清理的同时,又有对象晋升进来,导致“老年代还没来得及清理就被塞满”的情况,就会触发 Concurrent Mode Failure,进而退化为使用 Serial Old 进行 Full GC,这通常非常耗时。

2. 排查实战演练

假设我们现在接到了一个线上告警,说某应用 Full GC 频繁。我们可以这样操作:

第一步:查看 GC 日志(如果有)

假设我们的 GC 日志参数设置得比较全:

-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/path/to/gc.log

查看日志,可能会看到类似这样的片段:

2024-05-20T10:30:15.123+0800: [Full GC (Allocation Failure) 2024-05-20T10:30:15.123+0800: [CMS: 2048000K->2047800K(2048000K), 1.2345670 secs] 3072000K->2978000K(3072000K), [Metaspace: 34567K->34567K(34567K)], 1.2500000 secs] [Times: user=1.50 sys=0.10, real=1.25 secs]

从日志里,我们可以快速提取信息:

  • GC 原因:Allocation Failure(分配失败),说明是老年代无法分配对象了。
  • 内存变化:老年代回收前 2,048,000K,回收后 2,047,800K,几乎没怎么回收!这强烈暗示了老年代里大部分对象都是存活的,即存在内存泄漏或大量长生命周期对象。
  • 耗时:Full GC 耗时 1.25 秒,这已经明显影响服务了。

第二步:获取堆内存快照

如果应用还活着,我们可以用 jmap 抓取快照:

jmap -dump:live,format=b,file=heap_dump.hprof <pid>

注意 -dump:live 这个参数,它会先触发一次 Full GC,只保留存活对象,这样生成的 Dump 文件更小,也更能反映问题。

第三步:用 MAT 分析 Dump 文件

打开 MAT,加载 heap_dump.hprof。我们直奔主题:

  • 先看 Histogram,按对象大小(Retained Heap)排序。你会一眼看到哪个类的实例占用了最多的内存。假设发现 com.example.CacheService$CacheEntry 这个类的对象占据了 80% 的堆内存。
  • 然后,右击这个类,选择 Merge Shortest Paths to GC Roots -> exclude weak/soft references。这一步会帮你画出,从 GC Roots 出发,到这些大对象的引用链。
  • 你会惊讶地发现,这些 CacheEntry 都被一个 ConcurrentHashMap 所引用,而这个 Map 本身被一个静态变量持有。

第四步:定位代码与解决问题

回到 IDE,搜索 com.example.CacheService。代码可能是这样的:

public class CacheService {
    // 问题就在这里!这是一个没有设置过期策略的本地缓存
    private static Map<String, CacheEntry> cache = new ConcurrentHashMap<>();

    public void put(String key, Object value) {
        cache.put(key, new CacheEntry(value));
    }

    // ... 其他方法,但缺少了清理逻辑
}

问题找到了:这是一个典型的本地缓存使用不当导致的内存泄漏。ConcurrentHashMapstatic 持有,生命周期和应用一样长。随着业务运行,cache 不断被放入数据,但永远不会被清理,最终塞满了老年代,导致频繁 Full GC。

解决方案

  1. 引入带有过期策略的缓存:使用 Guava Cache 或 Caffeine 等成熟的本地缓存框架。
  2. 显式控制缓存大小:设置最大条目数或最大权重,并配置合适的过期时间。
  3. 如果必须用 ConcurrentHashMap:需要结合 ScheduledExecutorService 定期清理过期数据。

常见误区

  • 只调参,不分析代码:遇到 Full GC 频繁,第一反应是 “把堆内存调大”。这可能暂时缓解症状,但如果是内存泄漏,只会推迟 OOM 的发生,问题依然存在。
  • 不了解 GC 日志:看到 Full GC 就慌了,不知道从日志里提取关键信息。
  • 生产环境不配置 GC 日志:这是非常危险的,排查问题时没有第一手资料,巧妇难为无米之炊。
  • 盲目使用 jmap:在流量高峰期直接 jmap -dump 不带 live 参数,可能会导致应用长时间停顿(STW),影响线上服务。

最佳实践

  • 给 JVM 预设 “体检参数”:上线前就配置好详细的 GC 日志、OOM 时自动 Dump(-XX:+HeapDumpOnOutOfMemoryError)、以及必要的内存大小和垃圾收集器。
  • 建立监控体系:对关键应用的 GC 情况(频率、耗时)、堆内存使用情况、CPU 等进行实时监控,设置合理告警阈值。
  • 代码审查:在开发阶段就关注代码中是否存在潜在的内存泄漏风险,比如未关闭的资源、无限增长的缓存、不正确的 ThreadLocal 使用等。

总结

面对线上频繁 Full GC,我们的目标是 “快、准、稳”。核心就是 “数据驱动”——通过 GC 日志和堆内存快照这两大数据来源,结合对 JVM 原理的理解,用科学的工具链,层层剖析,最终定位到问题代码,并用最小的代价解决它。这不仅是对技术的考验,更是对问题排查方法论的验证。