GC,别闹!— 从算法内幕到生产实战的深度解剖

那是一个平平无奇的周二,凌晨三点,我被报警短信炸醒。线上服务突然没有响应,日志里铺天盖地的 Full GC。关键业务指标跌成一条直线。重启,又迅速挂了。那感觉——像被扔进冰窖。事后复盘,起因不过是一个小小的缓存配置错误,但真正致命的是我们对 GC 的漠视。没错,GC,这个 JVM 的扫地僧,平时默默无闻,一旦发威,够你喝一壶的。

Java虚拟机GC导致线上故障告警截图
Java虚拟机GC导致线上故障告警截图

说实话,大多数开发者对 GC 的理解,还停留在“自动释放内存,好棒棒”的层面。但如果你不想半夜被它背刺,就得扒开它的底裤看一看。

GC 的核心算法:别拿除草当借口

GC 的目标很简单——找到死对象并回收它们的内存。怎么找?主流就两招:引用计数可达性分析。引用计数嘛,天真烂漫,遇到循环引用就傻眼。所以 JVM 用可达性分析,从一组叫 GC Roots 的根对象出发,顺着引用链往下摸,能摸到的就是活的,摸不到的……对不起,死透透。

JVM堆内存GC Roots可达性分析图解
JVM堆内存GC Roots可达性分析图解

但是,听起来很顺,对吧?别急,标记完了怎么清理?这才是噩梦开始的地方。

标记-清除(Mark-Sweep):最原始的做法。标记出垃圾,直接清掉。然后内存就变成了蜂窝煤——全是碎片。你申请一个大对象,明明总空闲内存足够,却因为找不到连续空间而抛出 OOM。坑不坑?

于是有了标记-整理(Mark-Compact):清除之后,把所有存活对象往一端推,像整理货架。解决了碎片,但搬动对象是个重体力活,停顿时间长到让你怀疑人生。

还有复制算法(Copying):把内存一分为二,只用一半。回收时,把活对象复制到另一半,然后整片清空当前半区。快是快,但空间利用率只有 50%,奢侈得肉疼。

现代 GC 都是这些算法的缝合怪。比如分代假说——大部分对象朝生夕死,熬过几次回收的才可能是常青树。于是堆内存被分成新生代、老年代。新生代用复制算法(因为死得多,复制成本低),老年代用标记-整理或标记-清除。这就是我们熟悉的新生代 Minor GC老年代 Full GC 的由来。并且,为了降低停顿,又搞出了并发标记、增量回收,把步骤拆散,和业务线程交错跑。这种设计中的妥协与权衡,简直就是工程美学的典范。

数据不会说谎:一次惨无人道的压测

我永远忘不了那次压测。场景很简单:模拟高并发下订单系统,堆内存设为 4G,对象分配速率大约 200M/s。我们先用了经典的 ParNew + CMS 组合。初始还好,Minor GC 每秒一次,每次堪堪 20ms,能接受。但随着时间推移,老年代慢慢填满,CMS 并发收集开始跟不上分配速率,触发 Concurrent Mode Failure,然后就坠入 Serial Old 的深渊——一次 Full GC 直接暂停了 5.8 秒!整个系统的吞吐量瞬间归零,画出的曲线像被刀切的悬崖。

后来我们换成了 G1,照样 4G 堆,同样压力。G1 把堆划分成多个 Region,优先回收垃圾最多的 Region。结果显示,它的混合回收(Mixed GC)将停顿严格控制在 200ms 以内,而且基本没有出现恶性退化。再激进一点,换上 ZGC——对,就是那双十一神器——在 4G 堆下,停顿时间 10ms 以下,甚至用 16G 堆,停顿也能维持在亚毫秒级。吞吐量几乎无损。这种对比是碾压级别的:CMS 像一辆老破车,动不动就抛锚;G1 是混合动力,平滑但偶尔抖一抖;ZGC 就是磁悬浮,几乎感不到颠簸。别跟我扯什么“稳定压倒一切”,在绝对的数据面前,保守就是愚蠢。

当然,别以为 ZGC 是银弹。它的代价是 CPU 消耗更高,且需要额外创建染色指针的成本。这就是取舍——没有完美的 GC,只有最适合业务的 GC。

三个血坑,你别踩

坑一:System.gc() —— 请神容易送神难。
有个同事觉得“手动调用 GC 有助于清理内存”,在业务代码里埋了 System.gc()。结果生产上,每当这个方法被触发,JVM 默认会执行一次 Full GC,导致秒级停顿。尤其在 RPC 调用频繁的路径上,简直就是自杀。诊断时发现,GC 日志里频繁出现“Full GC (System)”,气到吐血。
方案:要么直接删除代码中的显式调用;要么通过 JVM 参数 -XX:+DisableExplicitGC 禁止 System.gc() 生效。如果用了 NIO 导致 Direct ByteBuffer 依赖它清理,那就改用 -XX:+ExplicitGCInvokesConcurrent 让调用时触发 CMS 或 G1 并发回收,而不是 stop-the-world 的 Full GC。

坑二:元空间溢出 —— 隐藏在暗处的刺客。
一次上线后,系统莫名频繁 Full GC,但堆内存明明很宽裕。查了半天,发现 Metaspace 被动态生成的代理类填满了。因为使用了大量 CGLIB 创建增强类,默认元空间大小无上限,它不断增长,直到撑爆物理内存,诱发 Full GC 试图回收,但类卸载条件苛刻,收效甚微。
方案:务必将元空间设定一个合理的上限:-XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=256m,让问题尽早暴露。并且监控元空间使用率,发现持续增长就要检查是否有类加载泄漏。代码层面,避免大量动态生成类的场景无限膨胀,考虑缓存或复用。

坑三:大对象直接进老年代 —— 扼杀新生代的元凶。
我们有个接口需要缓存大量 2MB 的图像数据。测试环境 OK,上线后却频繁 Full GC。原因?这些大对象超过了 -XX:PretenureSizeThreshold 设定的阈值(默认 0 表示所有对象先在新生代分配,但一些收集器如 Serial、ParNew 支持该参数),或者因为新生代区域不够大,直接被塞进老年代。老年代迅速堆满,被迫 Full GC。
方案:如果不希望大对象绕过新生代,可以调大新生代大小,确保能够容纳常用大对象。或者故意让它们直接进老年代,但前提是老年代有足够容量且 Full GC 代价可控。更推荐从应用架构入手——避免创建这么大的短命对象,用对象池或直接内存(DirectByteBuffer)替代堆内存。DirectByteBuffer 的回收虽然依赖 GC,但可通过合理重用降低开销。

踩过的坑数不胜数。GC 调优这条路,没有一蹴而就,只有反复试探与妥协。最重要的原则:不要过早优化,但要持续监控。开启 GC 日志(-Xlog:gc* 或 -XX:+PrintGCDetails),配合 grafana、prometheus 做可视化,永远比凭感觉调参靠谱。

说到底,GC 就是一个小世界,里面有哲学,有取舍,有意外。它绝非“自动”两字那么云淡风轻。当你真正理解它的时候,它不再是半夜捅刀子的疯子,而是一个可以对话的伙伴。而伙伴,值得花时间去了解。

免责声明:市场有风险,选择需谨慎!此文仅供参考,不作买卖依据。如有侵权请联系删除。
文章名称:GC,别闹!— 从算法内幕到生产实战的深度解剖
文章链接:https://m.lfdjt.com/info_23_7844.html