GC 实现机制,这个词在求职季的关注度通常会突然飙高,但真正在线上环境被 Full GC 和内存抖动折磨过的开发者,才会理解这东西绝不只是面试题。我最早被这套逻辑“毒打”是某次活动压测,服务刚启动二十分钟,老年代回收时间从几百毫秒直线上涨到两秒多,当时主管盯着监控只说了一句,你把 GC 日志调出来看完再找我。从那之后,我再也不敢把垃圾回收当作黑盒,也是从那次开始逐渐把 JVM 的分代模型、可达性分析、回收器选型这些概念串成了一条线。这篇文章不打算给你画什么宏大的技术蓝图,就按我做性能调优时的实际路径,把 GC 实现机制掰开揉碎讲清楚,你会看到判定对象死亡的根搜索算法、三种基础垃圾回收算法的演变逻辑、分代模型为什么要存在、以及 G1 和 ZGC 这类回收器到底在你的服务器上做了什么。讲完机制,我会用一段带真实参数的调优案例,演示怎么从 GC 日志反推堆配置是否合理。如果你是准备面试的开发者,这篇可以作为系统性梳理的参考,如果你正在排查线上问题,直接跳到后面的排查部分会更高效。
1. GC 到底在回收什么:先搞懂“对象已死”的判定逻辑
1.1 引用计数法的致命短板
当垃圾回收器盯上一个对象,首要问题是确定它是否还有存在的必要。最早的 JVM 实现普遍采用引用计数法,思路非常符合直觉:每个对象头上放一个计数器,被引用则加一,引用失效则减一,归零就回收。这套机制在 Python 这类语言中至今仍是内存管理的基础,但在 JVM 里被彻底抛弃了,原因是一组循环引用对象就能把它难住。看一个最简单的场景:对象 A 持有 B 的引用,对象 B 持有 A 的引用,除此之外没有任何外部引用指向它们。按引用计数的规则,A 和 B 的计数器都是 1,永远不可能归零,可它们在程序逻辑上已经是死对象了。这就会造成内存泄漏,而且极难定位,因为从代码上根本看不到谁还持有引用,最终只能靠复盘推演。
我当年用 C++ 写小工具时也踩过类似手动管理内存的坑,两个对象互相 sp 引用,析构函数怎么调试都不调用,后来换成弱引用才解开死锁。JVM 不选择引用计数还有一个工程上的原因:计数器增减必须原子操作,在高并发环境下这会成为瓶颈,为了准确而牺牲吞吐量,显然不划算。概括来说,引用计数法的两个硬伤分别是空间上的计数器存储开销和时间上的原子操作损耗,这两个问题在 JVM 这种追求极致性能的平台上都不可以接受。
1.2 可达性分析:从根出发的“活人判定”
现在的 HotSpot 虚拟机统一采用可达性分析算法来判断对象存活。思路可以类比一个庄园的巡查逻辑:划定一批根对象,就是庄园的入口,从每个入口出发,沿着引用链能访问到的对象都标记为存活,没有路径可达的对象统一判定为可回收。这套逻辑不存在循环引用问题,因为存活与否取决于根的可达性,而不是依赖计数器。那哪些对象可以被当成 GC Roots?最核心的几类包括:虚拟机栈中栈帧里的局部变量表所引用的对象、方法区中的静态变量引用、JNI 中被全局引用的对象,以及活跃线程本身。其中栈上的局部变量引用是最常见的根来源,你在一个方法里 new 出来的对象,只要还没出方法,它就被栈帧局部变量引用,活得好好的;方法一执行完,这个引用就消失了,对象失去了根,下轮回收就有资格被清除。
这段逻辑里还有一个值得记住的点:可达性分析必须在一个一致性的快照状态下执行,也就是用户线程必须停顿下来。这个停顿在业内被称为 Stop The World,简称 STW。哪怕世界上最快的分析算法,也需要先冻结引用关系的变动,否则一边标记一边有人修改引用链,结果必然失真。这也是 GC 停顿的根源,后续所有变态级的垃圾回收器优化——从 G1 到 ZGC——大部分精力都花在缩短 STW 时间上,底层原理都逃不开可达性分析这条基准线。
1.3 四种引用类型对回收的干预效果
除了判定可达性,JVM 还提供了几种引用类型来给对象“缓刑”。强引用就是正常代码中Object obj = new Object()这种,只要强引用关系还在,垃圾回收器绝不会动它。软引用描述一些有用但非必需的对象,在即将发生内存溢出异常之前,JVM 会先把软引用关联的对象回收掉,因此在实现缓存时很讨巧。弱引用比软引用更短命,对象只能存活到下一次垃圾回收发生前。虚引用则是最弱的,完全不影响对象的生命周期,唯一作用是收到一个系统通知,告诉你这个对象即将被回收。
这些引用类型在实际生产中非常有价值。我用过弱引用做图片缓存的 key,用软引用来做需要常驻内存的大对象缓存池。设计时要特别留意一点:被软引用或弱引用关联的对象只有在 GC 发生时才有机会被回收,如果 GC 一直不触发,它们依旧占着空间。所以,引用类型是帮你管理“回收优先级”的工具,不是实时清理的开关。
2. 三种基础回收算法与分代模型的由来
2.1 标记-清除、标记-复制、标记-整理的基本思路
分清了对象死活只是第一步,真正的难点在于用什么策略腾出空间。最早的标记-清除算法分两个阶段,先标记所有可回收对象,然后统一清除。逻辑最简单,但存在两个致命问题:首先是执行效率不稳定,堆越大标记和清除就越慢;其次是空间碎片化,清除后留下大量不连续的内存碎片,后面分配一个大对象时,明明总空闲空间足够,却因为找不到连续区域而提前触发新一轮 Full GC。这种情况我用一个生活概念来类比:把书架上的旧书全抽走后,各个隔层留下了零散空隙,你再想放一本厚词典时,每一格都不够放。
标记-复制算法解决了碎片问题,思路是内存对半分,只用一半,回收时把存活对象搬到另一半,然后一次性清空当前半区。分配效率很高,因为只需要移动一个指针;回收也不会产生碎片。可代价同样明显:可用内存直接打了五折,而且存活对象如果很多,复制开销就居高不下。这种以空间换时间的策略只适合存活率低的场景,所以才设计了下面的分代模型。
标记-整理算法兼顾了空间利用率和碎片问题。标记完存活对象后,不是直接清空,而是把存活对象往内存空间的一端移动,然后清理掉边界以外的区域。这个过程会移动对象引用,导致所有引用地址都要更新,成本并不低,但它避免了 50% 的空间损失。到这里可以明确一点:没有完美的算法,只有适配具体场景的组合策略,JVM 的聪明之处在于把不同算法用在了不同区域里。
2.2 对象分代假说与新生代/老年代分工
现代 JVM 的分代设计基于一个统计假说:绝大多数对象的生命周期非常短。我跑过很多次压测和线上数据分析,结论基本一致。大批对象创建后被用一下就再无引用,只有极少数对象能从方法开始活到方法结束再活到 GC 之后。根据这个统计特性,JVM 把堆分成新生代和老年代。新生代进一步划分为一个 Eden 区和两个 Survivor 区,默认比例是 8:1:1。新对象一律先进 Eden 区,Eden 满了触发 Minor GC。
Minor GC 用的就是标记-复制算法。回收时,把 Eden 区和 Survivor 中存活的对象,一次性复制到另一个空着的 Survivor 区,然后整体清空 Eden 和上一个 Survivor。因为新生代对象存活率很低,复制成本很小,而且内存也只有 10% 的浪费。当一个对象经过若干次 Minor GC 仍存活,它的年龄会逐步增长,达到阈值(默认 15)就被晋升到老年代。还有几种特殊情况也会导致对象直接进入老年代:大对象直接分配在老年代,避免在新生代复制来复制去;或者当 Survivor 区里相同年龄对象的总大小超过 Survivor 区空间的一半时,年龄大于等于这些对象的对象全部晋升。这里建议每个做调优的人关注年龄阈值和 Survivor 区是否经常溢出,因为这是观察晋升是否异常的最直观指标。
2.3 老年代回收策略:保守与激进的两条路
老年代的对象存活率高,用标记-复制显然不合适,所以根据不同的回收器选择标记-清除或标记-整理。CMS 回收器是典型的标记-清除代表,它的设计目标是低停顿,但在并发清除阶段会产生浮动垃圾,而且必须预留空间给并发清除与用户线程同时运行,所以 CMS 老年代的使用率到 92% 左右就要触发回收,否则会退化成 Serial Old 进行 Full GC。G1 回收器则把堆划分成一个个 Region,在逻辑上维护分代,但回收时不再要求物理连续,Young GC 仍然用复制算法,Mixed GC 则同时回收新生代和部分高收益的老年代 Region。这些复杂机制不再适合用一句话概括,我下面会单独拆开说明序列化场景下如何做选择和调整。
3. 从 Serial 到 ZGC:七款垃圾回收器的实现与适用场景
3.1 串行与并行回收器的定位差异
Serial 回收器是单线程回收,垃圾回收时必须暂停所有用户线程,适合客户端模式或堆内存极小的嵌入式环境。Parallel 回收器也叫吞吐量优先回收器,它把标记和复制过程改为多线程并行,在 GC 期间利用多个 CPU 核同时干活,但用户线程依然要暂停。这里有个非常重要的概念:并行指的是多个 GC 线程同时工作,并发指的是 GC 线程与用户线程同时运行,两者不要混淆。Parallel 回收器适合对吞吐量要求高、对停顿时间不那么敏感的后台计算任务,很多服务端默认配置就用它。如果你的核心指标是每秒处理请求数而不考虑偶尔的卡顿,Parallel 是比较稳妥的选择。
3.2 CMS:第一款面向低延迟的并发回收器
CMS 回收器全称 Concurrent Mark Sweep,它的主要工作围绕老年代开展,目标是尽量减少停顿。整个回收过程分为初始标记、并发标记、重新标记和并发清除四个阶段。其中初始标记和重新标记需要 STW,但耗时非常短;并发标记和并发清除阶段可以和用户线程同时运行,所以整体停顿远低于 Serial 和 Parallel 老年代回收。听起来很完美,但它有三个我实际踩过的坑:内存碎片化严重,回收后空间利用率下降;并发模式下 CPU 资源紧张时吞吐量明显下滑;最重要的,CMS 并发清除阶段无法处理用户线程运行中新产生的垃圾,也就是浮动垃圾。这导致 CMS 无法等到内存耗尽再回收,必须预留空间,而且一旦预留不够就会直接降级成 Serial Old 回收,这是最糟糕的兜底路径。从 JDK 9 开始官方已经标记 CMS 为废弃,但很多存量系统还在跑 CMS,排查问题时仍要懂得它的特性。
3.3 G1:分区化设计与可预测停顿模型
G1 回收器从 JDK 9 起成为默认垃圾回收器,核心思路是把堆划分成固定大小的 Region,通常默认 2048 个。G1 在逻辑上依旧分代,但各个 Region 可以动态扮演 Eden、Survivor 或 Old 的角色。G1 之所以能实现可预测停顿,是因为它不断跟踪每个 Region 里垃圾的价值量,也就是回收它能释放多少空间、耗时多久,然后在后台维护一个优先级列表。每次回收时根据用户设定的-XX:MaxGCPauseMillis目标,选取收益最高的若干个 Region 优先回收。这就是“Garbage First”这个名称的由来:回收时先挑最肥的肉下手。
G1 的难点在于大对象处理。超过 Region 大小一半的对象会被直接放进连续的 Humongous 区域,这些对象在回收时往往代价高收益低。我曾遇到过一个大对象疯狂增长的场景,G1 的 Young GC 频繁触发,但老年代 Region 回收乏力。原因是某开发同学把一份大的配置对象通过弱引用缓存了多个版本,每次迭代都会新增一个大对象。排查出来的思路是确认 Humongous 分配是否导向了老年代,以及是否可以考虑对超大对象做拆分而不是持续膨胀。
3.4 ZGC 低延迟目标实现的读屏障技术
ZGC 是 JDK 11 引入的实验性回收器,JDK 15 后逐渐转正,它的核心目标是把 STW 控制在 10 毫秒以内,而且无论堆多大,停顿时间几乎不随堆大小增长。实现这个目标的底层技术是着色指针和读屏障。ZGC 把对象引用的若干位用来记录状态,当 GC 线程标记或者移动对象时,不用先停下来通知所有线程,用户线程在读取引用时通过读屏障检查引用状态并修正。这样一来,大部分并发阶段用户可以照常运行,才能做到超低停顿。
需要提醒的是,ZGC 的吞吐量通常略低于 Parallel 或 G1,因为读屏障存在额外开销。在我的测试中,ZGC 更适合追求极致响应时间的场景,比如实时交易或游戏服务端的某些模块,但不适合批量离线计算。选择 ZGC 前最好先压测观察吞吐量是否符合预期,不要只看停顿时间一好就无脑切过去。
3.5 针对不同场景的回收器选型建议
- 内存低于 4GB 的单机服务,优先 Serial 或 Parallel,省去复杂回收器带来的调度开销。
- 内存 4GB 到 32GB 的常规后端服务,若对吞吐量要求高,选择 Parallel 或调整 G1 的暂停目标。
- 响应时间敏感且内存较大,建议采用 G1 并设置合理的
MaxGCPauseMillis。 - 堆内存极大且要求毫秒级停顿的,可以评估 ZGC,但要先做性能对比。
- 存量系统如果在跑 CMS,至少要知道什么场景会触发它的降级,这是线上事故排查的关键。
4. 核心参数与实测调优:从 GC 日志逆推堆配置
4.1 必知的 JVM 参数清单
调优的前提是能读懂 GC 日志,而理解 GC 日志的前提是正确输出 GC 日志。我常用的参数组合如下:
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintHeapAtGC -Xloggc:/path/to/gc.log -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/to/dump.hprof -XX:+PrintTenuringDistribution上面配置里,PrintGCDetails是基础,能看到新生代和老年代的回收前后大小;PrintGCDateStamps保留精确的时间点,方便对照业务日志找因果;PrintTenuringDistribution可以输出年龄分布,是排查晋升异常的重要依据。JDK 9 之后日志参数改成统一的-Xlog:gc*语法,但核心思路一致,就是把详细日志落盘,方便离线分析。
4.2 从日志数据判断新生代和老年代规模
读一段真实日志比较直接:
[GC (Allocation Failure) [PSYoungGen: 61440K->7168K(61440K)] 61440K->10352K(199680K), 0.0379916 secs] [Times: user=0.07 sys=0.01, real=0.04 secs]这段日志说明新生代回收前占用 61440K,回收后剩余 7168K,新生代总容量 61440K。整堆回收前也是 61440K,回收后是 10352K,总堆容量 199680K。关键点在于:回收后新生代只剩 7168K 存活对象,这些对象全部升入某个 Survivor 区。如果 Survivor 区容量放不下,多余部分就会晋升到老年代,这就是老年代不断膨胀的一个隐蔽来源。
我遇到过一个典型案例:新生代设置得过大,Minor GC 频率很低,但每次回收后 Survivor 区都放不下存活对象,导致大量对象提前晋升到老年代,最终 Full GC 变得非常频繁。调整思路是把新生代缩到合理范围,把整体对半改为动态增长,观察 Survivor 的容纳情况。要记住,新生代越大,Minor GC 间隔越长,但单次回收存活对象也越多,必须综合两个方向看,不能只盯频率。
4.3 一个实际案例的完整调优路径
有一次在模拟项目 X 中,我负责的那个服务每轮压测都会在二十分钟左右出现老年代回收时间陡增。第一反应是打开 GC 日志,看到的规律是大量对象在 Minor GC 后进入老年代。通过PrintTenuringDistribution输出确认,对象存活年龄很低,说明是 Survivor 容量不足导致过早晋升。当时的堆参数是-Xms2048m -Xmx2048m -XX:NewRatio=2,新生代大概 682MB,而 Survivor 默认只有新生代的十分之一左右。压测时一个请求会在短时间内创建好几批生命周期稍长的对象,它们没能等到年龄达到阈值就被塞进了老年代。
调整方式是缩小新生代到 512MB,同时显式指定-XX:SurvivorRatio=6,把 Survivor 区扩大一些。改完后再看日志,老年代的增长速率明显放缓。这里有个反直觉的结论:不是堆越大越好,也不是新生代越大越不容易触发老年代回收。分配的目标是让一批对象在新生代完成生死循环,尽量在它们进入老年代之前就被回收掉。如果 Survivor 装不下,再大的新生代也是给老年代输送压力。
4.4 不要乱调的参数:MaxGCPauseMillis 与吞吐量的对抗关系
G1 的-XX:MaxGCPauseMillis最能体现调优中的权衡。有些人把目标停顿时间设成 1 毫秒,以为越小越好,结果反而导致 GC 线程过于频繁地进入回收状态,每轮只挑很少的 Region 清理,整体回收效率严重下降,吞吐量暴跌。我实测过一个案例,从默认 200 毫秒改到 20 毫秒之后,停顿确实降低了,但吞吐量下降超过 15%,请求平均延迟反而升高。
因为停顿目标和吞吐量是天然矛盾,GC 需要在“尽量少打断用户线程”和“尽量一次性多干活”之间做取舍。如果你没有明确的 SLA 要求,我建议先保持默认值,用压测压出真实的停顿数据,再逐步收窄目标,以观察业务指标的变化为准,而不是拍脑袋调参。
5. GC 调优的常见问题与排查锦囊
5.1 Full GC 频繁的快速定位路径
Full GC 频繁是一个宽泛的报警词,原因可能来自老年代空间不足、元数据区膨胀、大对象持续堆积、或者代码里的隐性内存泄漏。我的排查顺序是固定的:
- 先看监控图确认 Full GC 的时间点和频率,与发版、流量高峰期、定时任务窗口对齐。
- 打开 GC 日志看 Full GC 前后堆内存的变化,判断是老年代占满还是元空间触发。
- 抓一把堆转储文件,用内存分析工具(比如 MAT 或 VisualVM)看大对象和支配树。
- 根据分析结果定位到可疑类,再回到代码查引用来源。
这个顺序看似简单,但很多人一上来就猜是缓存没设上限,结果偏离很远。现场数据永远是第一位的,先看图,再看日志,再分析堆,最后才做假设。
5.2 元空间扩容引发的 Full GC 陷阱
JDK 8 用元空间替代永久代后,默认元空间是没有上限的,可以一直占物理内存。但如果在启动参数里设置了-XX:MetaspaceSize和-XX:MaxMetaspaceSize,且初始值设得过小,就会频繁触发元空间扩容。元空间扩容同步伴随着一次 Full GC,原因是需要清理无用类加载器。这个类型的问题有点隐蔽,因为看起来堆内存空闲很多,但 Full GC 就是不断发生。
我遇到过某公司一个网关服务,堆内存常年只有 30% 使用率,但每过 20 分钟就有一次 Full GC。排查发现是动态生成类非常频繁,元空间扩容阈值被频繁触发。解决思路是调大MetaspaceSize初始值,给元空间预留足够空间,同时顺手检查了类加载器的复用情况。这个案例很能说明一个问题:GC 调优不只是看堆,方法区相关参数同样会影响整体行为。
5.3 大对象分配与 Humongous 对象处理的实践心得
在 G1 下,大对象会被特殊对待,超过 Region 一半大小的对象直接进入 Humongous 区域,这些区域不会被新生代回收逻辑处理,只有老年代回收时才能清理。如果一个大对象是某个缓存里存的一张大表数据,且这个缓存频繁更新,就会造成 Humongous 区域的反复分配和回收。这种情况下建议的优化方向有三个:将大对象拆分为小对象;改用多级缓存降低单对象体积;或者考虑堆外存储,避开堆内 GC。
我原来的一个图片处理服务就遇到过类似问题,每一张原图对象超过了 Region 的一半大小,并发上传时 Humongous 分配量急剧上升,G1 的回收收益下降,整体延迟飙升。后来我把图片数据拆成多个小缓冲切片,配合对象数组管理,GC 表现立刻好转。如果你在生产环境用 G1,可以打印-XX:+PrintAdaptiveSizePolicy配合日志观察 Humongous 分配情况,这一步在排障时非常有用。
5.4 一些容易被忽略但很实用的排查经验
排查 GC 问题不要只依赖人眼盯日志,那样效率太低。我日常会用脚本对 GC 日志做统计,比如把 YGC 频率、FGC 频率、平均停顿时间都算出来,再对比版本迭代前后的变化趋势。用命令行可以快速分析:
grep "Full GC" gc.log | wc -l grep "GC (Allocation Failure)" gc.log | awk '{print $NF}' | sort | uniq -c另外一个重要习惯是保留基线:当你做任何调优操作前,先保存一份改动前的 GC 日志和线程快照,再跟改动后的数据做对比。这能帮你判断优化到底有没有效,而不是凭感觉觉得“好像流畅了一点”。没有基线,后续任何变更都是盲人摸象。
6. 从机制到实践:我的一点经验总结
很多人在网上搜索 GC 机制,背熟了可达性分析、标记-复制、分代回收这些名词,但一到线上环境仍然手足无措。我特别理解这种状态,因为 GC 机制是一个需要“同时看源码和看现象”的知识领域。建议的学习路径是这样:先把 HotSpot 的分代模型、三种基础算法和回收器的并发机制搞清楚,这是理解一切日志的底座;然后拿一个测试服务攒一份 GC 日志,亲自计算新生代和老年代的吞吐量;最后再尝试调参,观察参数变化对停顿和吞吐量的影响。
我给团队做培训时经常强调的一个观点是:GC 调优不是把参数调到某个理论最优值,而是给你的业务找到一个它可以接受的停顿与吞吐量的平衡点。举个例子,如果某个接口 SLA 允许 200 毫秒延迟,那你完全没必要把 GC 停顿压到 10 毫秒以下,把省下来的 CPU 资源交给业务线程才是更划算的选择。我后来做性能调优时,不再一上来调参数,而是先问业务对延迟和吞吐量的真实需求,再倒推 GC 策略。这也是这篇内容想传递的核心经验。
另外想分享一个很实用的小技巧,拿去就能用:如果你定位到某段代码频繁产生临时大对象,与其反复调 GC 参数,不如直接改代码减少对象分配。毕竟 GC 机制再高效,也敌不过业务代码本身不产生垃圾。把String拼接改成StringBuilder、避免在循环里反复new对象、用池化缓冲,这些改动比折腾任何参数都快准狠。我处理过的绝大多数 GC 异常,根因都是代码层的问题,参数只是背锅侠。
如果你手头正好有 GC 日志但不知道怎么下嘴,我可以给你一个最简单的话:先找到每次回收后存活对象的大小,再对比 Survivor 区容量。只要这个比例长期偏高,就意味着提升和晋升压力偏大,你的堆配置一定可以继续调整。GC 机制看似复杂,但大部分问题其实都能从这几个基础指标里找到答案。