一次Full GC引发的线上超时:JVM调优全记录

阿乐
阿乐 管理员 黑卡会员
发布于 2026-09-06 03:46 ·3 浏览 ·0 回复

周二下午 14:23,告警群里突然炸了锅。

“订单服务 RT 飙到 3000ms+,超时率 40%!”我盯着监控大盘上的红线,心跳同步加速。这是一次再平常不过的促销高峰,却在毫无征兆的情况下,大批量的接口请求像被什么东西堵住了一样,动弹不得。第一反应是数据库慢查询,第二反应是依赖的下游服务又挂了。然而,经过快速排查,DB 的 QPS 和延迟都很平稳,Redis 也很健康。

那是谁的问题?直觉告诉我,不是外部依赖,问题出在我们自己身上。

表象下的“元凶”:接近 10 秒的 Stop The World

当直觉排除掉外部因素后,我第一时间登上了跳板机,执行了 `jstat -gcutil <pid> 1000`。输出结果让我倒吸一口凉气:

S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
 0.00 0.00 62.50 99.87 92.30 85.60 452 12.345 7 63.892 76.237

老年代(O)占比 99.87%,几乎满格。而 FGC 的次数虽然只有 7 次,但 FGCT(Full GC 消耗时间)却已经累计到了 63 秒。这就意味着平均每次 Full GC 都长达 9 秒以上!

这就解释得通了:当下游请求在某一瞬间触发了 Full GC,JVM 进入全局的 Stop The World(STW)状态。在这长达 10 秒的时间里,所有的请求线程全部冻结,无法处理任何新请求。对于外部调用方而言,这就是一次漫长的“无响应”,超时自然就不可避免了。

疑云密布:是谁占满了老年代?

确定了是 Full GC 导致的长暂停,接下来就要回答一个核心问题:老年代里的对象是从哪儿来的?

为了抓取“真凶”,我立刻在故障服务器上连续抓取了三次堆转储文件,通过 MAT 分析。在 dump 出的庞大对象图中,一个异常的家伙映入眼帘——一个承载活动配置的类 `ActivityPromotionCache`。

这个缓存类持有的是一个静态的 `HashMap<String, Object>`,原本的设计是存放一些不常变化的配置数据。但是分析结果显示,这个 Map 的 Entry 数量高达数万个,并且 Key 都是包含时间戳的字符串。继续追溯源码,发现了一个令人无语的逻辑:

public void refresh() {
    // 每隔 5 分钟拉取一次配置,但并没有对旧数据进行清理!
    configMap.put(generateKeyWithTimestamp(data.getId()), data);
}

这是一个典型的缓存无界增长问题。由于 Key 中是带时间戳的,每次刷新都会产生新的 Key,导致 Map 只增不减。这些配置数据虽然单个不大,但在高并发下经过多次刷新,瞬间填满了整个老年代,直接导致了这次灾难性的 Full GC。

分代收集 VS 大对象:为什么不是 CMS?

在 JVM 堆内存有限的前提下,这种短命的大批量对象本应被 Young GC 频繁回收。但在我们的 JVM 参数中,为了减少 Young GC 的停顿,我们设置了一个巨大的 `-Xmn` 新生代和较低的晋升阈值。这导致在突发流量下,Survivor 区根本放不下这些对象,大量对象被迫 “提前晋升”(Premature Promotion)到老年代,直接加速了老年代的膨胀。

当老年代触顶,CMS 并发收集失败,退化为 Serial Old 进行 Full GC 时,长达数秒的停顿就不可避免了。

调优三板斧:止血、化淤与根治

面对这种线上事故,必须从治标到治本分步走:

第一板斧(止血):强行扩大堆内存并切换回收器。
当时服务配置的是 JDK 8 + CMS。我快速调整了启动脚本:

-Xmx4g -Xms4g -Xmn2g 
-XX:+UseG1GC 
-XX:MaxGCPauseMillis=200

将堆从 2G 扩展到 4G,并切换至 G1 回收器。G1 通过 Region 划分和预测模型,能更平滑地控制停顿,避免 CMS 模式下的“连绵不绝”的大停顿。这招立竿见影,服务重启后,RT 迅速回落,先恢复了业务。

第二板斧(化淤):调整晋升阈值与分配缓冲。
观察发现,原先的 `-XX:MaxTenuringThreshold=15` 并不适合。在 G1 下,我显式设置了:

-XX:InitialHeapOccupancyPercent=45

让 G1 在堆占用率达到 45% 时就开始启动并发标记周期,而不是等到 92% 才触发抢救式回收,为大对象的腾挪争取了缓冲时间。

第三板斧(根治):培养内存意识,重构代码。
JVM 调优只是治标。真正的元凶是那个不设防的静态 Map。在当天的复盘会上,我把“无界缓存”拉出来公开处刑。随后,开发同学重构了逻辑:

1. 移除时间戳 Key,保证同一个配置只有一个 Key,更新时覆盖写。
2. 引入 Caffeine 本地缓存作为替代,设置 `maximumSize` 和 `expireAfterWrite`。

当代码不再“泄漏”内存时,JVM 的压力才真正得到释放。调优后的一个周末,我特意观察了监控:FGC 次数归零,老年代使用率稳定在 30% 左右。

复盘:调优的本质是“了解你的对象”

这次事故给我最大的教训,不是学会了 G1 参数怎么调,而是明白了 JVM 调优的核心不是调节参数,而是控制对象的流向

参数只是为对象的存活与消亡提供更合理的舞台。如果没有那个野蛮生长的 Map,即便 GC 参数不那么完美,系统也能承受住压力;而如果代码逻辑混乱,任何高端的垃圾回收器都无法拯救无限的堆内存膨胀。

遇到 Full GC 超时,不要急着背诵调优口诀。先去 dump 堆快照,看看你的对象们,它们才是真正的“线下面谈者”。

全部回复 0

还没有回复,来抢沙发~