1. 先从“判断对象死活”说起
写代码写了几年,只要一碰到JVM调优或者线上内存告警,绕不开的第一道坎就是垃圾回收。很多人背了一堆GC算法的名字,面试能说出来"标记清除""复制""标记压缩",但真到了生产环境,看着GC日志里那串数字却不知道该怎么下手。原因很简单:基础算法的理解停留在表面,不知道这些设计到底在解决什么问题,又牺牲了什么。
这篇内容我准备把JVM垃圾回收这条线完整梳理一遍,重点放在四个算法上:引用计数、可达性分析、标记清除、标记复制、标记压缩。适合正在准备面试的开发者,也适合写了几年代码但从来没认真看过GC日志的人。搞清楚这套东西,后面看CMS、G1甚至ZGC的文档都会轻松很多,因为它们本质上都是在这些基础算法上演化的。
垃圾回收要干的事其实只有两件:第一,找出哪些对象是垃圾;第二,把垃圾占用的内存收回来。第一步是判定,第二步是回收。很多人在第二步上花了大量时间研究,却忽略了第一步同样有讲究——判定方式的差异,直接影响整个回收流程的设计。
1.1 引用计数法:最直观的方案,也是最容易翻车的方案
引用计数法的思路极其简单:每个对象都配一个计数器,记录还有多少个地方引用着它。当一个新的引用指向这个对象时,计数器加一;当某个引用失效时,计数器减一。计数器变成零,说明没人再需要这个对象了,直接回收内存就行。
这个方案的优势就在于"简单"和"实时"。代码实现容易,因为计数是随引用变化即时更新的,对象一旦变成零引用就能立刻回收,不需要等到某个特殊的时机再统一扫描。不少脚本语言早期都采用过类似思路,比如Python早期的垃圾回收就依赖引用计数。
但问题也很致命,最典型的就是循环引用。
假设有两个对象A和B,A内部有个字段指向B,B内部有个字段指向A,除此之外再没有别的引用指向它们。按照引用计数法:A的计数是1(被B引用),B的计数是1(被A引用),谁都不会变成零。从程序逻辑上看,这两个对象外部已经完全够不着了,它们就是垃圾,可计数器却显示它们"被引用着"。结果就是这两块内存永远回收不了,形成内存泄漏。
有解决办法吗?有,比如Python后来引入了额外的标记清除机制来处理循环引用。但这恰恰说明引用计数法本身不够自洽——你还需要另搭一套机制来兜底。在JVM里,这个缺点是不可接受的,所以主流JVM完全放弃了引用计数法,改用可达性分析。除开循环引用,引用计数还有个隐藏开销:每次引用变化都要同步修改计数器,在并发场景下还要考虑原子性,这个开销摊到每一次赋值操作上,累积起来并不小。
1.2 可达性分析算法:判断"还有没有可能被用到"
可达性分析换了个思路:不去统计谁引用了我,而是从一组明确的根节点出发,沿着引用关系一路走,能走到的对象就暂时算作"活的",走不到的,管你计数器是几,通通当作垃圾。
这组根节点,专业术语叫GC Roots。主要包括这么几类:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 方法区中静态属性引用的对象,比如static变量
- 方法区中常量引用的对象,比如字符串常量池里的引用
- 本地方法栈中JNI引用的对象
- 被同步锁持有的对象,比如synchronized的监视器锁对象
打个比方,可达性分析就像在一个仓库里找人:先从几个确认的入口出发,把所有通道都走一遍,每个房间都看一遍,遇到岔路就继续深入。最后没被找到的人就是失联了,可以默认他有问题。
可达性算法的关键优势就是解决了循环引用问题。因为判断标准是"从GC Roots能不能到达",A和B互相引用但外部没有入口,那从根出发根本找不到它们,直接就判死了。
很多人还会追问一个细节:可达性分析什么时候触发?答案是在安全点(SafePoint)处。JVM为了精确地扫描对象引用关系,需要所有线程都跑到一个明确的位置停下来,保证此刻栈上的引用信息是完整的、一致的。这也是为什么GC日志里能看到停顿时间——"Stop The World"不是算法自己非要停,而是可达性分析要求快照必须稳定。
这里还要提一个在并发场景下至关重要的概念:三色标记。可达性分析在执行过程中,会维护三个集合——白色(未被访问)、灰色(自身被访问但引用的对象还没被访问)、黑色(自身和引用的对象都被访问过)。标记过程中如果同时发生引用变更,比如一个黑色对象突然引用了某个白色对象,就可能把这个白色对象误判成垃圾,这就是漏标问题。现代垃圾收集器引入读写屏障、增量更新等手段,本质上都是在解决这类并发标记下的正确性问题。
2. 回收阶段三个经典算法:各有各的取舍
判定完存活对象,接下来就是真正的清理阶段。这里也有三个经典算法,它们是按照"解决什么问题、牺牲什么代价"的思路一步步演进过来的。
2.1 标记清除算法:思路最朴素,但留下两个坑
标记清除算法分两个阶段走:先标记所有需要回收的对象,再统一回收这些对象占用的内存空间。
听起来简单,实际用起来会发现两个明显缺点。
第一个缺点是效率问题。标记阶段需要遍历所有的对象图,清除阶段又要遍历一次堆空间重新确认哪些区域可以回收。两次全堆扫描,规模一大开销就可观。
第二个缺点是空间碎片。清除不是把内存"清零",而是把可回收对象占用的块标记为可用。这些块散落在堆的各个位置,大小又不一致,回收完了之后内存里到处是坑坑洼洼的碎片。后面再来一个大对象,可能明明总空闲空间充足,却连续找不到一块足够大的区域放下它,这时候就会提前触发下一轮垃圾回收,甚至直接分配失败。这在真实场景中非常头疼,相当于你打扫房间把所有杂物挪开了,但房间整个被占满,后来想放个大衣柜却找不到一块完整墙面。
因此,标记清除算法在现代JVM里很少被单独使用,往往是作为某些垃圾收集器的某个阶段出现,比如CMS收集器在并发清理阶段用的就是标记清除的思路,也因此CMS对碎片问题做了额外的处理策略——在特定条件下触发Full GC时进行碎片整理。
2.2 标记复制算法:空间换时间,但要分对半
标记复制算法的设计思路很妙:把内存按容量分成大小相等的两块,每次只使用其中一块。回收的时候,把正在使用的这块上还存活的对象复制到另一块空的内存上,然后再把原来那块整体清空。
这个方案的好处是:存活对象按顺序搬到另一侧,内存排列是连续的,没有碎片问题;同时因为复制后直接清空整个半区,不需要逐个判断哪些对象要回收,效率非常高。在存活对象比例很低的情况下,复制算法的表现几乎是所有算法里最漂亮的。
但代价也很直白:浪费内存。把堆内存分成两半,等于只用了50%的可用空间,有一半永远在那空着"备用"。另外,如果存活对象很多,复制的过程本身就成了大头开销,搬动大量对象、更新引用关系,反而拖慢速度。
所以在新生代场景下,这个"对半分"的方案被进一步优化了。JVM并没有真的把新生代劈成两半,而是划分成一个较大的Eden区和两个较小的Survivor区,常用的比例是8:1:1。为什么敢这么设计?因为大多数对象都是"朝生夕灭",Eden区一次回收下来存活的常常不到10%,那么用一个较小的Survivor区来承接这些残存对象就够了。真正转到老年代的对象只是少数。这样一来,复制算法的浪费率就从50%降到了10%,代价是当Survivor区装不下时,那些对象就得走分配担保机制升入老年代。
2.3 标记压缩算法:对抗碎片的终级方案
标记压缩算法的前两步和标记清除一样:标记存活对象。但第三步变了——它不只是清除垃圾,而是让所有存活对象向内存一端移动,然后直接清理掉边界另一侧的整块区域。
这个设计听起来和复制算法有点像,都是"把存活对象聚拢",但关键区别在于:复制算法需要一块额外的空区域作为目的地,而标记压缩是在同一块内存内完成的移动,不需要额外的比例牺牲,它可以把整块堆空间都用起来。
代价是什么?移动对象意味着要更新所有指向这些对象的引用,这个过程比较耗时,而且由于它没法像复制算法那样"整体清空一个区域"来加速,在存活对象多的时候压缩阶段的开销会明显高于复制算法。
标记压缩的收益在于:回收完成后堆内存是一块连续的空闲区域,后续分配大对象非常顺畅,老年代长期运行的碎片问题得到根治。所以老年代的垃圾回收,实际上就是"要么用标记清除快速回收但积累碎片,要么用标记压缩整理空间但付出移动代价"的权衡选择。
2.4 三种回收算法横向对比
| 算法 | 原理 | 优点 | 缺点 | 典型适用场景 |
|---|---|---|---|---|
| 标记清除 | 标记垃圾后直接释放 | 简单,不需要移动对象 | 碎片严重,两次扫描效率低 | 老年代,部分收集器的清理阶段 |
| 标记复制 | 存活对象复制到空区域,整体清空旧区域 | 无碎片,回收效率高 | 浪费内存,存活率高时开销大 | 新生代 |
| 标记压缩 | 标记存活对象并向一端移动,清理边界外区域 | 无碎片,内存利用率高 | 要移动对象、更新引用,停顿时间长 | 老年代 |
3. 为什么是"分代收集":算法间的配合艺术
如果只单纯运行某一种算法,很难同时兼顾"回收效率"和"空间利用率"。于是JVM发明了分代收集的思想:根据对象存活特征的不同,把堆划分为新生代和老年代,各用各的算法。
3.1 新生代:复制算法的主场
新生代对象的特点是"命短"。绝大多数对象在创建后很快就失去引用,成了垃圾。所以新生代每次垃圾回收(Minor GC)能清掉很大比例的对象,剩下的存活对象很少,非常适合复制算法。
整个新生代被规划为一块较大的Eden区域和两块较小的Survivor区域(通常叫From和To)。对象创建时先放进Eden,Eden满了触发Minor GC,回收时把存活对象复制到From区,清空Eden。第二次GC时,把Eden和From的存活对象复制到To区,清空Eden和From,然后From和To的角色互换——谁空谁当To。这个来回倒腾的过程,每一次都保证有一块Survivor是干净的,留作下次复制的目标区。
这也是为什么JVM参数里有个-XX:SurvivorRatio,你把它理解成"Eden和单个Survivor的大小比例"就好,默认8表示Eden:From:To = 8:1:1。我自己调优时习惯先看一眼对象晋升统计,如果Survivor频繁溢出,说明存活对象比预期多,可能需要适当调大Survivor或者调整晋升阈值。
3.2 老年代:清除压缩的取舍之地
进入老年代的对象,要么是从Minor GC中熬了很多轮还没死,要么是大对象直接分配进来的。它们的共同特点是存活时间久、存活率高。此时如果还套用复制算法,每次GC都要搬运大量对象,反而成为开销。而且老年代空间大,"对半分"的做法也太奢侈。
所以老年代用标记清除或标记压缩。你在实际GC日志里看到的Major GC和Full GC,很多就是用了标记压缩来整理这块区域。具体用哪种,取决于垃圾收集器的实现策略,比如Parallel Old收集器就是典型的标记压缩,而CMS主要用并发标记清除,只在必要条件下才做压缩。
3.3 现代垃圾收集器怎么套用这些算法
理解了基础算法,再看近几年的收集器就非常顺了。
- Parallel Scavenge + Parallel Old:新生代复制,老年代标记压缩,追求高吞吐,适合后台计算型任务。
- CMS:基于标记清除,核心变化是引入了并发标记,让标记阶段和用户线程尽量并行。CMS的明显短板就是碎片,所以才需要额外开启
-XX:+UseCMSCompactAtFullCollection在Full GC时做压缩整理。 - G1:把堆划分为一个个Region,逻辑上仍然分代,但回收时以Region为粒度,优先回收垃圾最多的Region。有的Region用复制,有的用压缩,本质上是把复制算法的思想用到了更精细的粒度上。
- ZGC:层次更高了,引入了染色指针和读屏障,把标记和压缩的过程与业务线程的停顿降到极低,但底层的对象存活判定依然走的是可达性分析,最终的内存整理依然带着标记压缩的影子。
所以说,掌握了那三个基础算法,你看任何收集器的官方文档,看到的不是新算法,而是这三个算法的改造版和并发化版本。
4. 线上实战:GC日志怎么看、问题怎么排查
理论说完,聊聊实际中用到的东西。很多同学看GC日志一脸懵,主要原因就是不知道日志里的字段对应哪些阶段的动作。
我们模拟一条常见的日志来看:
[GC (Allocation Failure) [PSYoungGen: 61312K->6123K(64512K)] 61312K->26055K(252800K), 0.0123456 secs] [Times: user=0.03 sys=0.00, real=0.02 secs]这条日志透露了几个信息:
PSYoungGen:回收的是并行收集器管理的新生代。61312K->6123K(64512K):新生代回收前占用61MB,回收后占用6MB,总容量64MB。61312K->26055K(252800K):整个堆的占用变化,回收前61MB,回收后25MB,堆总容量252MB。0.0123456 secs:这次Minor GC停顿约12毫秒。
如果你看到Full GC字样频繁出现,而且老年代容量一直在高位,通常只有两种可能:要么是内存泄漏,要么是分配的内存预算真的不够。我自己排查时第一步从来不看代码,先看堆转储。
实践中整理过一张典型的排查流程,可以照着做:
- 用
jstat -gcutil <pid> 1000持续观察每个分区的使用率,确认是不是老年代持续走高。 - 如果老年代Full GC后回收效果不佳,用
jmap -dump:format=b,file=heap.hprof <pid>抓一份堆快照。 - 用MAT(Eclipse Memory Analyzer)打开堆快照,重点看支配树(Dominator Tree)上占内存最大的对象,再根据引用链定位到业务代码。
- 依据类名和字段名反推问题位置。有一次线上Full GC频繁,追根溯源发现是一个缓存Map只往里写,没有设置淘汰策略,几千个线程各写各的,瞬间就把老年代塞满了。解决方案很简单:加个基于容量的淘汰策略,或者引入专门的缓存框架。
还有一种常见情况是元空间(Metaspace)触发Full GC。元空间存的是类元数据,常见诱因是热部署或者动态生成太多代理类。这种Full GC的特征是GC日志里Metaspace占用一路上涨,StackOverflow一搜全是"动态代理+反射导致Metaspace溢出"的案例。解决方向一般是排查是否有类加载器泄漏,或者调整-XX:MaxMetaspaceSize做兜底限制。
4.1 常见问题速查表
| 现象 | 可能原因 | 排查方向 | 常用手段 |
|---|---|---|---|
| Minor GC频繁 | Eden设置过小或对象分配过快 | 看对象分配速率 | 适当调整堆大小,检查是否有大对象 |
| Full GC频繁 | 老年代空间不足 / 内存泄漏 | 堆转储分析 | 调整堆配置,修复泄漏点 |
| GC后老年代占用率仍然很高 | 对象大量晋升 / 动态年龄阈值设置不当 | 观察晋升统计 | 调整-XX:MaxTenuringThreshold |
| 系统响应缓慢,GC停顿时间长 | 垃圾收集器选择不合适 / 堆过大 | 对比各收集器停顿 | 切换到G1或用ZGC低停顿方案 |
| Metaspace不停增长 | 类加载器泄漏 / 动态生成类过多 | 监控类加载数量 | 检查代码,限制动态生成 |
4.2 三个易踩的误区
第一个误区是把System.gc()当作灵丹妙药。System.gc()在大多数实现里只是JVM的一个"建议",JVM完全可以忽略它。即使真的触发了,也是一次Full GC,代价极高,不建议在业务代码里随意调用。
第二个误区是认为"对象进入老年代就是坏事"。严格来说,一个对象如果活过了多轮Minor GC,它进入老年代是正常生命周期的一部分。真正要警惕的是"刚创建的大对象直接进老年代",这种对象可能只是临时用一下,却占用了老年代空间,间接加剧了Full GC频率。
第三个误区是混淆"强引用"和"可用内存"。JVM的垃圾回收严格以GC Roots可达性为准,并不是"指向它的引用少了就马上回收"。所以开发时不要依赖"把引用置为null就立刻释放内存"这种直觉,让对象失去引用、离开作用域才是根本。
4.3 调优思路的一点个人经验
很多同学一遇到GC问题,第一反应是加内存。加内存确实能缓解频率,但如果真实问题是泄漏,加多少都会被打满,还不如先花半小时抓一次堆转储,往往一次分析就能定位问题。
如果堆内存设置本身没问题,还是要从吞吐量和停顿时间这两个维度去权衡。后台批处理系统对停顿不敏感,可以选Parallel收集器,把吞吐量拉满;在线交互系统更在意响应时间,G1甚至ZGC更合适。调参时不要一上来就动一堆参数,每次只改一个,观察一个周期再决定下一步。这种"单变量法"排查GC问题非常有效,我踩过几次坑之后基本固定了这个思路。
5. 从算法演进里看JVM的工程哲学
把这几个算法串起来看,其实能发现一条清晰的工程决策线:每一步都在"更高效"和"更安全"之间做权衡。引用计数实时但解决不了循环引用,可达性分析正确但需要停顿;标记清除简单但碎片多,复制算法无碎片但浪费内存,压缩算法不浪费内存但要移动对象。分代收集则进一步根据对象的生命周期差异,把不同算法的优势组合到同一个堆的不同区域。
理解这条演进线,比背下每个参数的默认值值钱得多。因为线上环境千差万别,你今天查到的参数默认值,换个JDK版本可能就变了,但垃圾回收的底层设计思路不会变。遇到不通用的场景,顺着这条决策线去推理,往往能比盲目搜资料更快找到答案。这也是每一个做Java后端的人,值得花时间把垃圾回收这块基础打牢的原因。