Full GC 频繁:接口每隔几分钟卡顿一次的排查复盘

查询服务接口 P99 周期性尖刺,每隔三五分钟卡顿两三秒。查慢 SQL、网络都不是,打开 GC 日志才看清是频繁 Full GC。几天治理:读懂 GC 日志、jstat/jmap 定位、dump 出一个 static 无界本地缓存占堆 76%、缓存加上限、换 G1、排查其他诱因、补齐 GC 监控。

2024 年我们一个查询服务收到大量"接口偶尔很卡"的反馈。监控上看平均响应时间正常,但 P99 像心电图一样,每隔三五分钟就尖刺一次,卡顿能到两三秒。一开始以为是慢 SQL 或网络抖动,查了一圈都不是。最后打开 GC 日志才看清:服务在频繁地做 Full GC,每次 STW(Stop The World)就是两三秒,接口在那一刻整个被冻住。投了几天做 GC 专项治理,本文复盘这次"频繁 Full GC"的排查实战。

问题背景

业务:商品查询服务,Spring Boot,8C16G,JDK 8
事故现象:
- 接口 P99 周期性尖刺,每 3-5 分钟一次,卡顿 2-3 秒
- 平均 RT 正常,所以监控大盘上不明显,靠用户投诉才发现
- CPU 在卡顿瞬间冲高,过后回落

现场排查:
# 1. 加 GC 日志参数后重启(JDK 8):
#  -XX:+PrintGCDetails -XX:+PrintGCDateStamps
#  -Xloggc:/www/wwwroot/app/logs/gc.log
#  -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=5
#  -XX:GCLogFileSize=20M

# 2. 看 gc.log,触目惊心:
2024-xx-xx T10:02:13  [Full GC (Ergonomics)
   ... 2847.331 ms]        <- 一次 Full GC,STW 2.8 秒
2024-xx-xx T10:05:41  [Full GC (Ergonomics)
   ... 2613.882 ms]        <- 3 分钟后又一次
# 几分钟一次 Full GC,每次 STW 两三秒

# 3. jstat 看 GC 频率和各区情况
$ jstat -gcutil 12345 1000 5
  S0    S1    E      O      M     YGC   YGCT   FGC   FGCT
  0.00 12.3  68.4  98.7   95.2   1820  41.2   612   1583.7
# O(老年代)长期 98%+,FGC 已经 612 次,累计 1583 秒
# 老年代一直接近满,Full GC 反复回收又反复填满

根因初判:
1. 老年代被某些对象长期占满,Full GC 回收不掉多少
2. 回收不掉 -> 很快又满 -> 再次 Full GC -> 频繁 STW
3. 需要 dump 下来看,到底是什么对象堆在老年代里

修复 1:看懂 GC 日志,分清 Young GC 与 Full GC

# === 一次正常的 Young GC(频繁、很快,可接受)===
[GC (Allocation Failure)
 [PSYoungGen: 1398016K->174752K(1398272K)]
 2089012K->substantial...  , 0.0381 ms]
# 含义:年轻代从 1398M 回收到 174M,耗时 38ms
# Young GC 频繁是正常的,只要单次很快(几十 ms),不影响业务

# === 一次 Full GC(应当很少,一旦频繁就是大问题)===
[Full GC (Ergonomics)
 [PSYoungGen: 174752K->0K]
 [ParOldGen: 2796543K->2795011K(2796544K)]   <- 注意这里!
 ...  , 2847.331 ms]
# 关键看 ParOldGen:2796543K -> 2795011K
# 回收前后老年代几乎没变!回收了个寂寞,只腾出 1.5M
# 这说明:老年代里的对象都是"活的",GC 根本清不掉

# === 判断 GC 是否健康的几个信号 ===
# 健康:Young GC 频繁但单次 < 50ms;Full GC 极少(几小时一次甚至没有)
# 不健康信号 1:Full GC 频繁(分钟级)
# 不健康信号 2:Full GC 后老年代几乎不下降 -> 内存泄漏 / 缓存无界
# 不健康信号 3:单次 GC STW 时间过长(秒级)
#
# 我们的情况:信号 1 + 信号 2 同时出现 -> 老年代有东西清不掉

修复 2:用 jmap / dump 定位老年代里的内存大户

# === 1. jmap 看堆内对象直方图(快速,不用 dump 整个堆)===
$ jmap -histo:live 12345 | head -15
 num     #instances         #bytes  class name
----------------------------------------------
   1:       8,234,011    1,053,953,408  java.lang.String
   2:       8,233,900      263,484,800  java.util.HashMap$Node
   3:       4,116,950      131,742,400  com.x.ProductDetail   <- 可疑
   4:       2,058,000       49,392,000  [Ljava.lang.Object;
# 八百多万个 String、四百多万个 ProductDetail
# 一个查询服务,内存里怎么会常驻这么多商品详情对象?

# === 2. 完整 dump 下来用 MAT 深挖 ===
$ jmap -dump:live,format=b,file=/tmp/heap.hprof 12345
# 拉到本地,MAT 打开,看 Dominator Tree:

# MAT 分析结果:
# 一个 static 的 ConcurrentHashMap 占了堆的 76%
#   com.x.LocalProductCache.CACHE
#   -> 持有 410 万个 ProductDetail
#   -> 这个 Map 从服务启动后,只增不减
// === 找到元凶:一个没有任何上限的"本地缓存" ===
public class LocalProductCache {
    // static Map,GC Root 可达,里面的东西永远不会被回收
    private static final Map<Long, ProductDetail> CACHE =
        new ConcurrentHashMap<>();

    public static ProductDetail get(Long id) {
        // 缓存没有就查库,查到就塞进 CACHE —— 然后【永远不删】
        return CACHE.computeIfAbsent(id, LocalProductCache::loadFromDb);
    }
}
// 问题:
// 1. 商品 ID 是海量的,每查一个新商品就往 CACHE 里加一个
// 2. CACHE 没有容量上限、没有过期、没有淘汰策略
// 3. 跑得越久,CACHE 越大,最后把老年代占满
// 4. 这些对象全是"活的"(被 static Map 强引用),Full GC 清不掉
// 这是一个典型的【内存泄漏式本地缓存】。

修复 3:给本地缓存装上"上限"

// === 错误的本地缓存:裸 HashMap,无界 ===
// private static final Map<Long, ProductDetail> CACHE = new ConcurrentHashMap<>();

// === 正确:用带淘汰策略的缓存(Caffeine)===
public class LocalProductCache {
    private static final Cache<Long, ProductDetail> CACHE =
        Caffeine.newBuilder()
            .maximumSize(50_000)                       // 最多 5 万条,超了按 LRU 淘汰
            .expireAfterWrite(10, TimeUnit.MINUTES)    // 写入 10 分钟后过期
            .recordStats()                             // 开启命中率统计
            .build();

    public static ProductDetail get(Long id) {
        return CACHE.get(id, LocalProductCache::loadFromDb);
    }
}
// maximumSize 是关键:本地缓存的内存占用从此有了硬上限,
// 不会再随运行时间无限膨胀。expireAfterWrite 让数据不会太陈旧。

// === 如果只是想要一个简易的有界 LRU,LinkedHashMap 也能做 ===
public class LruCache<K, V> extends LinkedHashMap<K, V> {
    private final int maxSize;
    public LruCache(int maxSize) {
        super(16, 0.75f, true);   // accessOrder=true,按访问顺序排
        this.maxSize = maxSize;
    }
    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        return size() > maxSize;   // 超过上限,自动移除最久未访问的
    }
}
// === 核心原则 ===
// 任何进程内的缓存 / 集合,只要它会随业务运行不断变大,
// 就【必须】有一个明确的上限 —— 容量上限或时间上限。
// "只往里加,从不删"的 static 集合,本质就是内存泄漏。

修复 4:合理设置堆与选择垃圾收集器

# === 堆参数:Xms 和 Xmx 一定要设成相等 ===
# 不相等的话,堆会动态扩缩容,扩容时本身可能触发 Full GC
-Xms8g -Xmx8g
# 16G 物理内存,堆给 8G,留一半给元空间、线程栈、堆外内存、系统

# === JDK 8 默认是 Parallel GC,STW 时间不可控,换成 G1 ===
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200       # 给 G1 一个停顿时间目标:期望单次 STW ≤ 200ms
-XX:InitiatingHeapOccupancyPercent=45   # 老年代占 45% 就启动并发标记,别等满

# === G1 为什么适合我们这种场景 ===
# Parallel GC:吞吐优先,但 Full GC 是单次整堆回收,STW 长且不可控
# G1:把堆分成很多 Region,大部分回收工作【与业务线程并发】进行,
#     STW 被切成多个小段,可以朝着 MaxGCPauseMillis 的目标努力
# 对"怕卡顿、要低延迟"的在线服务,G1(及更新的 ZGC)远优于 Parallel

# === 保留出事现场:OOM 时自动 dump ===
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/www/wwwroot/app/dump/
# 真到 OOM 那一刻,自动留一份堆快照,事后才有得分析

# 注意:换 GC、调堆只是"缓解",治本仍是修复 3 那个无界缓存。
# 内存泄漏不修,再大的堆也只是把 Full GC 推迟一会儿。

修复 5:其他常被忽略的 Full GC 诱因

// === 诱因 1:代码里手动调 System.gc() ===
// 某些老代码 / 三方库里藏着 System.gc(),它会直接触发 Full GC
System.gc();   // 删掉它!
// 实在拦不住(三方库),用 -XX:+DisableExplicitGC 让它变成空操作

// === 诱因 2:大对象直接进老年代 ===
// 超过 -XX:PretenureSizeThreshold 的大对象会跳过年轻代直接进老年代
byte[] huge = new byte[20 * 1024 * 1024];   // 一次性 20M 的大数组
// 频繁创建大对象,老年代很快被填满 -> Full GC
// 排查:别一次性把大结果集 / 大文件全读进内存,改成流式 / 分页处理

// === 诱因 3(JDK 8):元空间(Metaspace)打满 ===
// Metaspace 存类的元数据,默认无上限(用本地内存)。
// 动态生成大量类(频繁用 CGLIB 代理、反复加载脚本、热部署)
// 会让 Metaspace 膨胀,打满时同样触发 Full GC。
// -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m  显式设上限并监控

// === 诱因 4:年轻代太小,对象过早晋升 ===
// 年轻代太小 -> Young GC 太频繁 -> 对象还没"死"就被迫晋升到老年代
// -> 老年代被一堆本该在年轻代就死掉的"短命对象"填满 -> Full GC
// 适当调大年轻代(-Xmn),让短命对象在年轻代里就被回收掉。

// === 排查 Full GC 诱因的顺序 ===
// 1. 先看 Full GC 后老年代降不降:不降 -> 内存泄漏(最常见,先 dump)
// 2. 降得很多但很快又满 -> 晋升太快 / 大对象 / 年轻代偏小
// 3. GC 日志里写 "Full GC (System.gc())" -> 有人手动调了 gc
// 4. GC 日志里写 "Full GC (Metadata GC Threshold)" -> 元空间问题

修复 6:GC 监控告警

# GC 是慢性病,要靠监控早发现,别等用户投诉
groups:
- name: jvm-gc
  rules:
  # 1. Full GC 频率(健康的服务几乎不该有 Full GC)
  - alert: FullGcFrequent
    expr: increase(jvm_gc_pause_seconds_count{action="end of major GC"}[10m]) > 3
    for: 5m
    annotations:
      summary: "{{ $labels.instance }} 10 分钟内 Full GC > 3 次"

  # 2. 单次 GC 停顿过长
  - alert: GcPauseTooLong
    expr: jvm_gc_pause_seconds_max > 1
    for: 3m
    annotations:
      summary: "{{ $labels.instance }} 单次 GC 停顿 > 1 秒,接口将明显卡顿"

  # 3. 老年代使用率持续高位(Full GC 后降不下来 = 泄漏前兆)
  - alert: OldGenHigh
    expr: |
      jvm_memory_used_bytes{area="heap",id=~".*Old.*|.*Tenured.*"}
      / jvm_memory_max_bytes{area="heap",id=~".*Old.*|.*Tenured.*"} > 0.9
    for: 10m
    annotations:
      summary: "{{ $labels.instance }} 老年代 > 90%,排查内存泄漏"

  # 4. GC 耗时占比(GC 时间 / 总运行时间,反映 GC 开销)
  - alert: GcOverhead
    expr: rate(jvm_gc_pause_seconds_sum[5m]) > 0.1
    for: 5m
    annotations:
      summary: "{{ $labels.instance }} GC 耗时占比 > 10%,GC 压力过大"

优化效果

指标                      治理前              治理后
=============================================================
Full GC 频率              每 3-5 分钟一次       基本为 0(数小时偶发)
单次 Full GC STW          2-3 秒               —
接口 P99                  周期尖刺 2-3 秒      稳定 180ms
老年代使用率              长期 98%+            稳定 40-55%
本地缓存                  无界(只增不减)      Caffeine 上限 5 万 + 过期
垃圾收集器                Parallel GC          G1(停顿目标 200ms)
堆参数                    Xms≠Xmx              Xms=Xmx=8g
GC 可观测性               无                   4 条告警 + GC 日志归档

治理过程:
- 加 GC 日志 + jstat 确认频繁 Full GC:0.5 天
- jmap dump + MAT 定位无界缓存:1 天
- 本地缓存改 Caffeine 有上限:1 天
- 切换 G1 + 堆参数调整 + 压测验证:1.5 天
- GC 监控告警接入:1 天

避坑清单

  1. 接口周期性卡顿、P99 尖刺,优先怀疑 Full GC,先把 GC 日志打开
  2. Young GC 频繁正常,只要单次快;Full GC 频繁(分钟级)一定是大问题
  3. 看 GC 日志重点看 Full GC 后老年代降不降,降不下来就是内存泄漏
  4. jstat -gcutil 快速看 GC 频率与各区占用,jmap -histo 快速看对象大户
  5. static 的集合 / 缓存只增不删,就是内存泄漏,Full GC 也清不掉
  6. 任何进程内缓存必须有上限:容量上限或过期时间,推荐 Caffeine
  7. Xms 和 Xmx 设成相等,避免堆动态扩缩容时触发 Full GC
  8. 怕卡顿的在线服务用 G1/ZGC,别用 STW 不可控的 Parallel GC
  9. 排查 System.gc()、大对象直接进老年代、元空间打满等其他诱因
  10. 换 GC、调大堆只是缓解,内存泄漏不修复,Full GC 迟早卷土重来

总结

这次"接口周期性卡顿"的排查,最有价值的一课是关于"如何透过表象看本质"。一开始用户反馈接口偶尔很卡,我们第一反应是去查慢 SQL、查网络抖动、查下游依赖,折腾了一圈一无所获,因为监控大盘上的平均响应时间一直是正常的——这正是它最具迷惑性的地方:Full GC 的卡顿是周期性的、瞬时的,它把 P99 拉成了一根根尖刺,但平均值会把这些尖刺稀释得几乎看不见,所以靠看平均 RT 的监控大盘根本发现不了。真正让真相浮出水面的,是把 GC 日志打开的那一刻——服务每隔三五分钟就做一次 Full GC,每次 Stop The World 两三秒,在那两三秒里整个 JVM 的业务线程全部冻结,接口当然就卡死了。所以第一条经验是:遇到周期性、瞬时性的卡顿,GC 一定要排在嫌疑名单的最前面,而排查 GC 问题没有任何捷径,就是老老实实把 GC 日志打开去读。读 GC 日志也有门道,最关键的一个动作是看每次 Full GC 之后老年代到底降没降——如果回收前后老年代纹丝不动,几个 G 只腾出来一两兆,那就说明老年代里堆的全是"活对象",它们被某个 GC Root 牢牢抓着,GC 想清也清不掉,这就是内存泄漏的铁证。顺着这个线索 jmap dump 下来用 MAT 一看,元凶是一个 static 的 ConcurrentHashMap 当本地缓存用,却没有任何容量上限、没有过期、没有淘汰策略,服务跑得越久它就越大,最后把老年代彻底占满。这引出了第二条、也是我认为最该记住的一条经验:任何一个会随着业务运行而不断变大的进程内集合或缓存,都必须有一个明确的上限——要么限容量,要么限时间——一个"只进不出"的 static 集合,无论你给它起名叫缓存还是叫别的,它的本质就是一个内存泄漏。最后还想强调一点:换垃圾收集器、调大堆内存这些手段,在排查过程中我们也做了,G1 确实把停顿切得更碎、更可控,但必须清醒地认识到这些都只是"缓解"而不是"治本"——只要那个无界缓存还在,再大的堆也只是让 Full GC 晚来一会儿而已。真正的修复永远是找到并堵上内存泄漏的源头,而要让这类慢性病在拖垮服务之前就被发现,就得把 Full GC 频率、单次停顿时长、老年代使用率这些指标都接进监控告警,别再让用户的投诉成为你唯一的 GC 监控。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

double 算钱算出误差:一笔分账对不上账的复盘

2026-5-20 12:59:36

技术教程

CompletableFuture 踩坑:异步任务静默失败,异常被吞了的复盘

2026-5-20 13:05:02

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索