上一篇文章我们把JVM运行时数据区(堆、栈、方法区、程序计数器、本地方法栈)过了一遍,评论区有不少人在问:内存区域划分清楚了,那对象到底什么时候被回收?CMS和G1天天听人讲,真正落实到线上该怎么选?还有人说面试被问到三色标记,当场就懵了。
这篇我就把JVM垃圾回收这条主线完整串起来:从根可达性算法这个判定对象生死的“第一原理”开始,到标记-清除、标记-复制、标记-整理三个经典算法的取舍逻辑,再到支撑CMS和G1并发标记的三色标记算法,最后落到这两个收集器的实现差异和调优实战上。如果你正在准备JVM面试,或者想让自己的GC日志不再“看了等于没看”,这篇应该能帮你把零散的知识点焊成一个整体。
1. 从线上Full GC案例到垃圾对象的判定
1.1 一次让我重新理解GC的线上故障
先讲个真实经历。前段时间排查一个线上服务变慢的问题,接口响应时间从几十毫秒涨到几秒,日志里不断出现Full GC。我第一反应是堆内存设置太小,结果一看监控,老年代使用率像爬楼梯一样一节一节往上顶,每次Full GC能回收一点点,但很快又涨回去。
后来用jmap导出一份堆dump,在MAT(Memory Analyzer Tool)里分析时发现,大量重复对象被一个静态Map里的临时对象链“持有”。这个Map是历史代码遗留下来的缓存,早就不再写入新数据了,但一直没人清理。于是整个对象链就像一块挂在墙上的旧海报,明明已经没人看,却因为胶带还在,就一直贴在墙上。
这个案例让我彻底意识到一个问题:判断一个对象能不能被回收,从来不取决于“它有没有被引用”,而是取决于“它能不能从GC Roots被访问到”。这就是根可达性算法的核心思想,也是理解JVM GC一切行为的前提。
1.2 为什么“没人引用的对象”这个说法是错的
很多人在面试时会说:没有任何引用指向的对象就可以被回收。这个说法放在JVM里其实是不准确的。
准确的说法是:从一组称为GC Roots的根节点出发,无法通过引用链到达的对象,才会被判定为可回收。一个对象可以被其他对象引用,但只要它的整个引用链到不了任何GC Roots,在GC眼里它就是垃圾。
打个比方,一个仓库里有很多货箱,判定哪些箱子是废品,不是看箱子里有没有标签,而是看能不能从仓库门口(根)沿着传送带走到这个箱子。哪怕箱子外面缠满了线,但这些线的另一端没有接在门口,这个箱子一样是要被清走的。
这个思想直接决定了后面所有垃圾回收器的实现方向,也决定了为什么CMS和G1能做并发标记,而早期的Serial、Parallel回收器只能STW(Stop The World)。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 根可达性算法:判定对象生死的第一原理
2.1 引用计数法的遗憾
在根可达性之前,历史上有一种更直观的方案,叫引用计数法:每个对象维护一个计数器,每被一个地方引用计数器加1,引用失效减1,计数为0就回收。
这套方案听起来简单,Python早期版本就靠它来管理内存,但它有两个致命短板。第一,每次引用赋值都要做加减法,本身就有额外开销;第二,循环引用问题完全无解。举个例子,A引用B、B引用A,外部对它们的引用断开后,两个对象的计数器都还是1,永远降不到0,内存泄漏就成了必然。
JVM最终选择了根可达性算法,从源头绕开了这个问题。因为只要回答“从根出发能不能到这个对象”,循环引用自然就断了——那两个互相抱团的对象,如果没有任何一条链指向它们,照样会被回收。
2.2 GC Roots到底有哪些
根可达性的关键在于根节点的定义。JVM里的GC Roots不是一个笼统的概念,具体包括这几类,面试时最好结合项目经验按这个分类说:
- 虚拟机栈中局部变量表引用的对象:正在执行的方法里的参数、局部变量、临时变量。
- 静态属性引用的对象:方法区中类的static字段指向的对象。
- 常量池引用的对象:字符串常量池里的引用、类常量等。
- 本地方法栈中JNI引用的对象:Java调用native方法时传过去的强引用。
- 被synchronized锁持有的对象:正在被加锁的Monitor对应的对象。
- 存活的Java线程对象本身,以及各类JVM内部的系统对象。
这里有个容易忽略的点:GC Roots里包含了“当前存活线程对象”,所以你在dump堆的时候经常会看到Thread对象,它们通常是存活状态的,除非线程已经终止。这也是为什么排查线程泄漏时要重点看线程堆栈,线程本身有时候就是根。
2.3 一次自救机会:finalize()的复活机制
根可达性算法还给对象留了一个“缓刑”阶段。当对象被判定为不可达后,如果它的finalize()方法还没有被JVM调用过,它会先被放入一个F-Queue队列,由一个低优先级的Finalizer线程去执行finalize()。
如果在finalize()里把this引用重新赋给某个静态变量或者某个GC Roots可达的对象,这个对象就能在下一次GC时重新“活过来”。但要注意,这种自救只能成功一次,第二次判定不可达时,JVM不会再调用finalize(),对象会被直接回收。
我在实际项目中从来没有依赖过这个机制,JDK 9之后finalize()也被标记为废弃了。但它是面试中一个非常好的追问点,理解了“可达性判定之后还有二次机会”这个流程,你对整个GC框架的完整度会提升一个台阶。
2.4 可达性与引用类型的关系
根可达性算法和引用类型(强引用、软引用、弱引用、虚引用)是配合工作的。GC在遍历引用链时,会根据引用类型做不同处理:
- 强引用:只要强引用还在,对象绝不会被回收。
- 软引用:内存足够时不回收,在快要OOM时(第一次OOM前)会回收软引用对象,适合做缓存。
- 弱引用:只要发生GC,弱引用关联的对象就会被回收,适合做缓存或观察者模式的弱引用集合。
- 虚引用:最弱的引用,无法通过它获取对象实例,主要用来跟踪对象被回收的时机,比如
sun.misc.Cleaner配合DirectByteBuffer做堆外内存清理。
这也解释了为什么WeakHashMap、ThreadLocal的ThreadLocalMap里的Entry key用弱引用——一旦外部强引用断开,GC就能立刻把key回收掉,避免内存泄漏。
3. 三个经典GC算法:标记清除、标记复制、标记整理
3.1 标记-清除:简单直接的内存碎片制造机
标记-清除算法分两步:先按根可达性把可回收对象标记出来,然后统一回收这些对象占用的内存。
优点是实现简单,缺点有两个。第一,标记和清除两个阶段的效率都不稳定,如果堆里有大量对象需要标记或清除,耗时直接跟对象数量成正比;第二,清除之后会产生大量不连续的内存碎片。碎片多了之后,一个2MB的数组明明堆里总空闲空间足够,但找不到一块连续区域,只能提前触发新一轮GC,严重时甚至OOM。
用书架来类比:把不要的书抽走很简单,但抽完之后书架上到处都是半空的“洞”,想放一本厚书进去时才发现中间放不下,要重新折腾。
另外,很多人以为“清除”是把内存清零,其实不是。JVM只需要把空闲区域记录到空闲列表里,下次分配对象时从列表里找合适的块就行,并不会做实际的清写动作。记住这一点,面试时你会比其他候选人显得更懂底层。
3.2 标记-复制:用空间换时间的新生代策略
为了解决碎片和效率问题,标记-复制算法把可用内存按容量划分为大小相等的两块,每次只使用其中一块。GC时把存活对象复制到另一块空闲区域,再把原来那块整块清空。
复制算法的优点是简单高效,没有碎片,而且如果存活对象比例很低,只需要复制少量对象。但代价是内存要折半,非常奢侈。
JVM在新生代里对这个思路做了关键优化:不把内存均分,而是分成一块较大的Eden区和两块较小的Survivor区,默认比例是8:1:1。每次使用Eden加一块Survivor,GC时把存活对象复制到另一块Survivor。这样内存浪费只有10%。
如果Survivor区装不下了,就要靠分配担保机制(Handle Promotion),把放不下的对象提前晋升到老年代。这个机制是理解新生代GC日志里“晋升”比例的关键,后面调优时经常会看到。
3.3 标记-整理:老年代的瘦身术
老年代对象存活率高,如果还用复制算法,每次GC都要复制大量对象,显然不划算。标记-整理算法的做法是:在标记之后,让所有存活对象向内存一端移动,然后清理掉端边界以外的内存空间,相当于把碎片“顶”到一起,变成一整块连续区域。
代价是移动对象需要更新所有引用,而且移动过程必须暂停用户线程(STW),所以标记-整理通常比标记-清除更慢,但换来了空间连续性和几乎零碎片。
JVM的老年代GC通常在“标记-清除”和“标记-整理”之间做选择。CMS并发清理阶段用标记-清除,Serial Old和Parallel Old用标记-整理。不同的选择换来的是不同的停顿特征和碎片程度。
3.4 算法选择背后的代际假设
三种算法能并存,是因为JVM对对象生命周期有一个基本假设:绝大多数对象都是朝生夕死的,新生代里活过几轮GC的对象很少;而能活到老年代的对象,大概率会存活很久。
所以新生代适合复制算法,因为对象存活率低,复制代价小;老年代适合标记-清除或标记-整理,因为对象多、存活率高,复制不划算。
分代收集GC并不是“发明了什么新算法”,而是根据对象年龄分层,让每种算法在它最适合的场景里发挥作用。理解了这一点,你就不会再去纠结“为什么新生代不用标记-整理”“为什么老年代不用复制算法”这类问题了。
4. 三色标记算法:并发GC的理论地基
4.1 从全停到并发:为什么需要三色标记
传统的GC(比如Serial、Parallel)在标记和收集阶段会“整个世界停下来”,等GC结束之后用户线程再恢复。对于追求低延迟响应的系统来说,STW时间完全不可接受。
CMS是第一款真正意义上的并发收集器,它的核心想法是:在标记阶段,能不能让用户线程继续跑,GC线程和业务线程同时工作?但问题来了,一边标记一边改引用,怎么保证标记结果仍然准确?这就需要一个能在“并发变化”环境下维护正确性的标记算法——三色标记算法应运而生。
4.2 黑白灰:三色标记的推进过程
三色标记把所有对象抽象成三种颜色:
- 白色:初始状态,未被标记算法访问过的对象,也就是潜在的垃圾。
- 灰色:该对象已经被访问过(自己被标记了),但它引用的其他对象还没有全部被扫描完。
- 黑色:该对象及其所有引用对象都已扫描完,不需要再被访问,它引用的对象也不会是垃圾。
整个标记过程可以想象成一个BFS式遍历:从GC Roots出发,初始时所有对象都是白色,根对象先被标记为灰色,然后每处理一个灰色对象,就把它的所有引用对象标记为灰色,处理完成后把当前对象标记为黑色。遍历结束后,所有白色对象就是没有路径可达的垃圾。
三色标记本身不算新算法,它是可达性分析的一种抽象表达方式。真正困难的是在并发场景下,用户线程不断改变引用关系,颜色状态和真实可达性会出现偏差,这时候就产生了两个经典问题:漏标和浮动垃圾。
4.3 并发标记的致命问题:漏标与浮动垃圾
并发标记期间,用户线程可能做两类操作。第一类是让一个原本要被回收的对象变成可达,比如new出新对象并让一个黑色对象引用它;第二类是让一个对象原本唯一的引用被断开,比如用户线程把某个白色对象从灰色对象上摘除。
第一件事如果处理不好,就会把存活对象当垃圾回收,这叫漏标;第二件事如果处理不好,就会把本该回收的对象当成存活,导致对象多存活一轮,这叫浮动垃圾。浮动垃圾的危害比漏标小,因为最多少回收一点内存,下一轮GC还能处理;但漏标一旦发生,就会把用户正在使用的对象回收掉,程序直接出问题甚至崩溃。
漏标的严格定义需要同时满足两个条件:
- 某黑色对象新增了对白色对象的引用;
- 该白色对象原本唯一的引用(来自某个灰色对象)被断开了。
只有这两个条件同时发生,才会漏标。这也是所有并发标记算法的修复方向:要么阻止黑色对象新增引用白色对象,要么在灰色对象断开引用时做一些记录。
4.4 CMS的增量更新与G1的SATB
针对漏标的两个条件,业界给出了两种主流解法。
CMS采用增量更新(Incremental Update):它破坏的是条件1。当检测到黑色对象重新引用了白色对象时,把这个黑色对象重新标记为灰色,放进一个队列里等待重新扫描。这样黑色对象就不再是“最终状态”,重新变回灰色后,它引用的白色对象也会被扫描到。
G1采用SATB(Snapshot At The Beginning,起始快照):它破坏的是条件2。在并发标记开始时,把当前对象图拍一张快照。标记期间如果某个灰色对象断开了对白色对象的引用,SATB会把这个“断开事件”记录下来,重新标记阶段再把这些白色对象视为存活。也就是说,凡是标记开始时存在的对象,都会被当作存活对象来处理。
两种方案各有取舍。增量更新理论上更精确,但需要追踪大量新引用的产生,扫描负担更重;SATB思路更保守,把标记期间的并发修改都当成“对象仍存活”处理,实现相对简单,但会人为放大存活集合,产生更多浮动垃圾。
4.5 写屏障:并发GC不得不付出的代价
增量更新和SATB都依赖一个关键基础设施——写屏障。写屏障就是在程序每次更新对象引用时插入的一段额外逻辑,负责把“引用了新对象”或“断开了旧引用”这些事件记录下来。
听起来像动态代理或AOP拦截,其实JIT编译在JVM内部就完成了这项工作。每次有reference字段写入,JIT都会在写入指令前插入一段屏障逻辑,一旦发现引用关系变化,就把对应的对象或卡片记录到一个标记队列里,供GC线程后续处理。
这也是并发GC的吞吐量会比串行GC低一些的根本原因:每次引用赋值都要多执行一段额外逻辑。虽然屏障本身很短,但在高频赋值的场景里,积累起来也是一笔不小的开销。选择GC时一定要想清楚这个权衡——用一点吞吐量换更短的STW停顿,到底值不值,取决于你的业务场景。
5. CMS和G1的前世今生
5.1 CMS的工作流程与四宗罪
CMS(Concurrent Mark Sweep)的历史意义在于它第一次让老年代GC实现了并发标记和并发清理。它完整分五个阶段:
- 初始标记(STW):只标记GC Roots直接引用的对象,速度极快;
- 并发标记:从GC Roots递归遍历整个引用链,用三色标记法完成核心标记,与用户线程并发执行;
- 重新标记(STW):修正并发标记期间因用户程序运行导致的引用变化,主要依靠增量更新;
- 并发清理:与用户线程并发执行,清除判定为垃圾的对象;
- 并发重置:重置CMS相关的数据和状态,等待下次触发。
CMS优点确实是低停顿,但它有四个明显痛点。
一是对CPU资源非常敏感。并发标记和并发清理会抢CPU,默认并发线程数是(CPU核数+3)/4,如果服务器只有4核,CMS线程最多占2个,用户线程的处理能力会被明显压缩。
二是浮动垃圾。并发清理阶段用户线程还在运行,新产生的垃圾对象无法在本轮被回收,只能留给下一轮。所以CMS不能等老年代满了才触发GC,必须预留空间。
三是并发模式失败(Concurrent Mode Failure)。如果并发标记期间老年代空间被占满,CMS会退化到Serial Old做Full GC,停顿时间瞬间爆炸。
四是内存碎片。CMS默认使用标记-清除,老年代会产生大量碎片,最终导致分配大对象时频繁触发Full GC。
这四宗罪也是CMS在JDK 14之后被正式移除的根本原因。很多团队还守着CMS,是因为它在小堆、低延迟场景下表现确实优秀,但已经没有了未来。
5.2 G1的Region化设计
G1(Garbage First)放弃了传统的物理分代内存布局,把堆分割成一个个大小相等的Region。默认情况下堆会被分成大约2048个Region,每个Region的大小从1MB到32MB不等,JVM会根据堆大小自动计算。
Region在物理上不再固定属于哪个代,而是由G1根据动态策略决定:同一个Region可以充当Eden、Survivor,也可以充当Old。另外还有一个特殊的Humongous区域,专门存放大对象——当一个对象大小超过Region容量的50%时,会直接分配进Humongous区,由连续多个Region存放。
Region化的好处是让GC范围不再是整个堆,而是可以根据Region的回收价值有选择地回收。这也是G1这个名字的由来:优先回收垃圾最多的Region,把回收收益最大化。
5.3 RSet:G1怎么知道谁引用了谁
G1引入了一个重要数据结构叫RSet(Remembered Set),作用是在回收某个Region时,快速找到所有“指向本Region对象”的外部引用来源。
RSet的原理可以理解为把分代GC里的Card Table进一步细化。每个Region都维护一个集合,记录其他Region中有哪些Card(卡片)里的对象引用过自己。每次对引用关系做写入操作时,写屏障会记录这个变化并更新对应的RSet。等到需要回收某个Region时,G1不需要扫描整个堆来找引用,只需要遍历该Region的RSet,就能精确定位“谁在外部引用我”。
这比CMS每次GC都要从GC Roots扫描全堆高效得多,也是G1能真正做到“部分回收”而不是动不动全堆GC的技术基础。代价是RSet本身有内存开销,对象引用越分散,RSet越庞大,在引用密集场景下可能拖累整体性能。
5.4 G1的回收流程与停顿预测模型
G1的完整回收过程分几个环节。首先是Young GC:新对象分配在Eden Region,Eden满了就触发Young GC,把Eden和Survivor的存活对象复制到新的Survivor Region,达到年龄阈值的晋升到Old Region。这一阶段会STW,同时会利用RSet完成跨Region引用的扫描。
其次是并发标记周期:当堆整体使用率超过InitiatingHeapOccupancyPercent(默认45%)时启动,经过初始标记、并发标记、最终标记几个阶段后,获得每个Region的存活数据和回收成本。
最后是Mixed GC:这是G1特有的阶段,在保证满足MaxGCPauseMillis(默认200ms)目标的前提下,选择一批收益最高的Region(包含Eden、Survivor和部分Old)进行回收。如果Mixed GC之后老年代仍然持续膨胀,触发了Humongous分配失败或晋升失败,就会退化为Full GC,G1会做标记-整理式的全堆回收。
停顿预测模型是G1最精妙的设计。它会记录过去回收每个Region的耗时和回收量,用衰减平均值做统计,建立一个小型预测模型,然后根据MaxGCPauseMillis的目标反推本轮应该回收多少个Region。这也是为什么G1能对停顿时间做出“软性承诺”——它不保证绝对不超时,但能大概率控制在目标范围内。
5.5 CMS和G1选型对比
很多团队在JDK 8时代纠结要不要上线G1,其实可以按下面这张表来对照:
| 对比维度 | CMS | G1 |
|---|---|---|
| 堆布局 | 物理分代,整堆管理 | 逻辑分代,Region化,物理不分代 |
| 回收单位 | 新生代/老年代整块处理 | 按Region为单位筛选回收 |
| 引用追踪 | 并发标记时需要扫描全堆 | 通过RSet精确跨Region引用 |
| 碎片问题 | 标记-清除,碎片多 | 可选用复制/整理,几乎无碎片 |
| 停顿可控性 | 停顿不可预测,可能退化为Serial Old | 有停顿预测模型,软性可控 |
| 默认状态 | JDK9起废弃,JDK14移除 | JDK9起默认收集器 |
| 适用场景 | 小堆、极致低延迟的历史选型 | 大堆、追求可预测停顿的主流选择 |
从JDK 9开始G1已是默认收集器,JDK 14之后CMS被正式移除。如果你的线上服务还在JDK 8上用CMS,建议尽早测试迁移到G1;如果堆比较小(4G以内)、延迟要求极其苛刻,CMS在小堆上可能仍然比G1更平滑。但也要注意,G1在超大堆(几十GB以上)上的表现,需要配合RegionSize和并发线程数一起调,并不能无脑套参数。
6. 面试考点与调优实战方向
6.1 高频面试题清单与答题思路
围绕根可达性、三色标记、CMS和G1,我整理了面试官最常追问的几个点,以及我的回答切入方式:
- JVM如何判断一个对象可以回收?答根可达性算法+GC Roots分类+引用类型补充,如果能把finalize()二次复活机制带上,会很加分。
- 三色标记中漏标的两个条件是什么?CMS和G1分别怎么解决?答增量更新vs SATB,顺便把写屏障提出来,体现深度。
- CMS为什么比Parallel停顿低?CMS有哪些缺陷?答并发标记/并发清理只停顿两个极短阶段,再结合浮动垃圾、CPU敏感、碎片、并发模式失败四点说透。
- G1和CMS的最大区别是什么?答Region布局、RSet、可预测停顿、Mixed GC四个角度。
- G1什么时候会触发Full GC?答并发标记后存活对象过多、晋升失败、Humongous分配失败、堆内存不足等几种情况。
- 为什么新生代用复制算法、老年代用标记整理?从“对象存活率”这个核心指标来回答,逻辑链就完整了。
答题时一定要把“为什么”讲出来,面试官追问的深度往往就落在算法取舍背后的理由上。
6.2 生产环境GC参数配置实战
G1场景下,我经常见到的生产配置长这样(JDK 11+):
code复制-Xms4g -Xmx4g
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:InitiatingHeapOccupancyPercent=45
-XX:G1NewSizePercent=5
-XX:G1MaxNewSizePercent=60
-XX:ConcGCThreads=4
-XX:ParallelGCThreads=8
核心原则是Xms和Xmx保持一致,避免GC时动态扩展堆带来额外抖动;MaxGCPauseMillis不要拍脑袋设置成50ms,除非你能接受频繁GC带来的CPU开销;InitiatingHeapOccupancyPercent调低会提前开始并发标记,调高则相反,需要结合实际观测来定。
CMS场景(仅限JDK 8/11迁移前)可以这样配:
code复制-XX:+UseConcMarkSweepGC
-XX:+UseCMSInitiatingOccupancyOnly
-XX:CMSInitiatingOccupancyFraction=68
-XX:ParallelCMSThreads=4
-XX:CMSScavengeBeforeRemark
CMSInitiatingOccupancyFraction设置成68%左右,是很多团队的经验值,目的是给并发标记和浮动垃圾留足空间,尽量避免Concurrent Mode Failure。UseCMSInitiatingOccupancyOnly会关闭JVM自动判断,强制按这个比例触发。调优的第一步永远是先明确目标是低延迟还是高吞吐,不要盲目抄参数。
6.3 我总结的GC排查与调优路径
写下我踩过几次GC坑之后形成的排查路径,可以复现使用:
第一步,先看监控图。重点关注Full GC频率、老年代使用率、分配速率(Allocation Rate)和晋升速率(Promotion Rate)四个指标,它们之间的组合基本能定位方向。
第二步,抓GC日志。JVM要提前打开GC日志参数,JDK 9以上建议用:
code复制-Xlog:gc*:file=/opt/logs/gc.log:time,uptime,level,tags
JDK 8则用-XX:+PrintGCDetails -XX:+PrintGCDateStamps。
第三步,把GC日志喂给gceasy.io或GCeasy之类的工具,能自动生成暂停时间分布、吞吐量趋势、晋升分析,省去自己数日志的功夫。
第四步,根据结果反向排查代码:如果晋升速率很高,通常是创建了大量大对象或者Survivor设置过小;如果老年代使用率一直降不下来,重点检查静态缓存、ThreadLocal、Kafka/ES客户端等长生命周期对象。
第五步,小步调整参数,一次只改一个变量,改完用压测观察一整天。调GC参数从来没有银弹,只有数据说真话。
我在实际操作中的体会是,三色标记和根可达性这些概念看上去是纯理论,但当你真正盯着GC日志看的时候,满脑子都是这些机制在背后运作。比如看到G1的Mixed GC回收了哪些Region,你会想起RSet和SATB在背后做了多少工作;看到CMS的并发模式失败,你会立刻理解增量更新和浮动垃圾到底在讲什么。所以别把这些知识点当成面试八股,它们是理解GC日志、定位线上问题的底层语言。下一篇如果有机会,可以聊聊JVM调优实战里常用到的监控指标和工具链,先把这篇消化透,遇到GC问题至少知道该往哪个方向看。
