做Java开发这些年,我见过太多人被垃圾回收折磨得睡不着觉。不是骂它逻辑不对,而是搞不懂内存里到底发生了什么。其实把JVM垃圾回收算法那几个关键词吃透,大多数GC问题就有了方向。引用计数、可达性分析、标记清除、标记复制、标记压缩,这五个词看着像面试题背诵手册,但它们背后是一条完整的问题解决链路:先搞清楚对象什么时候该死,再决定怎么把尸体清掉,最后处理清完之后留下的坑。这套链路决定了JVM堆内存的整体运行策略,也决定了你在线上遇到Full GC的时候,到底是该调参还是该改代码。
这篇文章我会从内存结构开始讲,把五种算法的原理、优点、缺陷、应用场景全部拆开揉碎,最后用实战排查的经验收尾。适合所有被OOM困扰过的Java工程师,也适合准备面试但不想死记硬背的人。我尽量把每个算法讲成一段故事,而不是一段定义。
1. 垃圾回收的地基:内存结构、分代模型与GC Roots
1.1 先搞清楚:垃圾回收到底在“哪里”干活
JVM的内存区域分成程序计数器、虚拟机栈、本地方法栈、方法区和堆。前三个属于线程私有,跟着线程生、跟着线程死,基本不用垃圾回收器操心。方法区虽然也有回收需求,比如类的卸载,但那是极其低频的事,绝大多数情况下,垃圾回收的主战场只有一个:堆。
堆里装的就是你new出来的对象,包括大部分数组、集合元素、业务对象实例。你写的代码里每创建一个对象,JVM就在堆上给它分配一块内存。对象活着的周期各不相同:有的在方法里声明完就死了,有的被静态变量长期引用能活到进程结束,有的则在一个请求里来回流转。垃圾回收器要做的,就是从这一堆杂乱的对象里找出“没人要的”那些,把内存腾出来给新对象。
因此,搞清楚堆的结构是理解垃圾回收的第一步。堆通常分成新生代和老年代,新生代里又细分为Eden区、Survivor0区、Survivor1区。这个划分不是随便拍脑袋定的,它基于一个统计规律:绝大多数对象的生命周期极短,活不过几次年轻的垃圾回收。把短命对象集中放在新生代,把长命对象晋升到老年代,回收时就可以区别对待,效率高得多。
1.2 分代假设与内存划分:新生代、老年代与GC分类
先看新生代,Eden区是对象出生地,绝大多数对象刚new出来就落在Eden。Survivor区相当于临时收容所,对象在Eden熬过一次回收后,如果没死,就挪进Survivor区。Survivor分了两块,S0和S1,每次只用一块,另一块空着,目的是做复制回收。后面讲标记复制算法时你会看到,这两块区域就是为复制准备的。
老年代存放的是那些在Survivor区苟过了若干次回收、年龄达到阈值(默认是15)的对象,或者某些大对象直接晋升。老年代对象存活率高,回收频率低,但对内存空间的完整性要求更高,因为一旦老年代碎片化严重,大对象就找不到连续空间分配了。
基于这种分代结构,GC也分成几类:
- Minor GC:发生在新生代,收集Eden和Survivor区,频率高、速度快。
- Major GC:一般指老年代回收,但在不同的实现里叫法有些出入。
- Full GC:对整个堆和方法区进行回收,停顿时间长,是最让人头疼的事件。
- Mixed GC:G1收集器特有的,既回收新生代,也回收部分老年代的Region。
理解这几类GC是后续排查的基础。你在线上日志里看到“Full GC (System.gc())”或者“Allocation Failure”之后跟着老年代回收,就得有意识地意识到:新生代已经扛不住对象分配了,或者老年代空间不足了。
1.3 GC Roots是什么:可达性分析的起点
聊可达性分析算法之前,必须先说清楚“从哪里开始找”。所有存活对象的起点,我们叫GC Roots。这套起点集合包括虚拟机栈中局部变量表里引用的对象、方法区中静态属性引用的对象、常量引用的对象、JNI(本地方法)引用的对象,以及活跃线程相关的内部引用。
GC Roots的选取原则是要保证从这些根出发,能够触及所有“仍然被使用者持有的对象”。用大白话说,程序还在用的东西,必须从某个根出发能找得到。比如你在方法里定义了一个局部变量User user = new User(),这个User对象就被虚拟机栈里的局部变量引用了,它就是可达的,不会被回收。而一个new出来后就没人存的匿名对象,无法从任何根出发找到,那它就会被判定为垃圾。
有一次我在排查内存泄漏时,用工具dump堆快照,发现一个集合莫名其妙地膨胀到几百万条数据。最后定位原因,竟然是某个类里的静态Map不断往里塞数据,从来没清理过。这个Map就是一个根,它引用的所有对象都从根出发可达,于是越积越多,老年代塞得满满当当。这就是从GC Roots角度理解内存问题的最典型场景。
2. 判定对象死亡的两种思路:引用计数法与可达性分析
2.1 引用计数法:思路很直,但坑很深
引用计数法是最直观、最朴素的设计。它的核心逻辑是:给每个对象配一个计数器,每当有一个地方引用它,计数器加一;引用失效时,计数器减一;计数器的值变成0,就说明这个对象没人要了,立刻回收。
好处是简单,实现成本低,判定效率也高,而且没有“暂停所有业务线程”的阶段,因为对象死没死是实时计算出来的。Python的早期版本和很多脚本语言就采用过类似思路。
但引用计数有一个绕不过去的死结:循环引用。假设对象A里有一个字段指向B,对象B里同时也有一个字段指向A。除了这俩互相指之外,外面没有任何变量引用它们。那么A的计数为1,因为B引用了它;B的计数也为1,因为A引用了它。它们的值永远到不了0,哪怕这俩对象已经彻底和外界失联,垃圾回收器也拿它们没办法。
我在实际开发里遇到过类似的内存泄漏:一对互相保存对方引用的业务实体,从业务角度已经删除了,但因为循环引用,内存始终释放不掉。虽然JVM不用引用计数法,但在某些非JVM环境里写代码时,依然要小心这种环结构的清理问题。
这就是为什么JVM的主流派系没有选择引用计数,而选择了可达性分析。引用计数只能处理“没有引用”的对象,处理不了“引用成环”的对象,这个结构性缺陷让它不够格当JVM的默认判定算法。
2.2 可达性分析:从GC Roots出发找活路
可达性分析算法的思路是:从一系列叫GC Roots的节点出发,沿着引用关系往下走,整个引用关系就构成一张图。凡是能从GC Roots出发走到的对象,都是“活”的;走不到的,都是可回收的候选者。
这个算法对循环引用的处理能力是质的飞跃。回到A和B互相引用的例子:从GC Roots出发,你找不到通向A的路径,也找不到通向B的路径,那它们俩就是不可达的。即便它们互相指得再紧,在可达性分析看来依然是死路。这就是它相比引用计数的核心优势。
可达性分析还有一个需要理解的细节:不可达不等于立即死亡。JVM会给对象一个“缓刑期”,也就是finalize()机制。对象第一次被标记为不可达时,会放进一个队列,后续如果有机会执行finalize(),对象可以把自己引用给某个GC Roots,实现自我拯救。
但是我要掏心窝子劝一句:永远不要依赖finalize()来做资源释放或自救。它的执行时机不确定,甚至可能不执行,而且执行顺序无法保证。你在finalize()里写什么逻辑,都等于给垃圾回收的确定性埋雷。Java官方早就把它标记为废弃思想,现代代码里就应该彻底遗忘它。
现代JVM的可达性分析还涉及一些优化手段,比如用OopMap记录栈上哪些位置是引用,用安全点(Safepoint)让所有线程停下来时状态一致。这些都是为了在“暂停所有业务线程”这个前提下,尽量缩短垃圾回收对业务的影响时间。
2.3 对象“真正死亡”的两次标记与finalize自救
严格来说,一个对象被判定为不可达,要经过两次标记。第一次标记后,如果对象没有覆盖finalize(),或者finalize()已经被调用过,那它就直接进入回收队列。如果对象覆盖了finalize()且从未执行过,它会被放到一个低优先级的队列里,等待JVM派发执行。
finalize()执行的时机是随机的,执行过程中对象可以重新引用自己,把自己重新挂到引用链上。但如果第二次标记时对象还是没救回来,那就彻底完蛋了。这个设计在历史上是为了给某些需要兜底释放的外部资源一个机会,但代价是引入了不确定性。
在实际运维中,我从来不看代码里有没有finalize(),因为一旦业务代码里出现这玩意儿,就意味着GC行为变得不可预测。如果你在维护老代码时碰到finalize()做了资源释放的,最好的做法是把相关代码挪到try-with-resources或显式close()里,把确定性还给程序。
3. 回收阶段的三板斧:标记清除、标记复制、标记压缩
3.1 标记清除算法:简单有效,但会留一地碎片
标记清除算法要干两件事:先标记,再清除。标记阶段顺着可达性分析的结果,把所有不可达对象标个记号;清除阶段把带记号的直接内存释放掉。整个过程不移动对象,实现上最直白。
它的优点就是简单,不需要额外空间,也不用搬动存活对象。但它有两个非常要命的缺点。
第一,效率问题。标记和清除两个阶段都需要遍历所有对象,GC停顿时间随堆大小线性增长。堆越大,停顿越猛。你想想一个几十GB的堆,光标记就得扫多久。
第二,内存碎片化。清除之后,存活对象之间的空隙是一段一段的,不连续。下一次想分配一个大数组或大对象时,如果找不到一块连续空间,即使总的空闲内存足够,分配也会失败,这会直接触发新一轮垃圾回收,甚至强制Full GC。我排查过一些线上应用,堆内存明明还剩30%,但大对象就是分配失败,日志里全是Allocation Failure,原因就是老年代碎片太多。
碎片化问题本质上和清运垃圾一样——你只把垃圾捡走,但房子里家具东倒西歪,后面的东西就没法整齐摆放了。
3.2 标记复制算法:用空间换时间
标记复制算法的核心思想:把内存按容量分成两块,每次只使用其中一块。回收时把存活对象逐个复制到另一块,然后把整块旧空间一次性清空。乍一听很浪费,但它的收益很可观:复制后的内存是连续排列的,没有碎片;分配新对象时只需要一个指针往前进,效率极高;而且回收只针对整块区域,不用频繁遍历。
它有个著名缺点:空间浪费。如果按1:1划分,那一半内存永远空着。所以JVM在新生代没有采用1:1的平均划分,而是把Eden和Survivor比例设计成大约8:1:1。这样总共浪费的新生代空间只有10%,换来了还算不错的空间利用率和高速回收。
但复制算法还有一个更务实的代价:当存活率高时,一切就不那么美了。试想一个区域里90%都是活对象,复制90%的对象到另一块空间,工作量很大,而清掉的垃圾只占10%,性价比极低。所以复制算法只适合“朝生夕死”的新生代,不适合老年代,老年代的对象普遍寿命长,要是每次GC都复制一大批活对象,系统早就不堪重负了。
新生代使用复制算法时还会配合“分配担保机制”。正常情况下Survivor区只有一块,万一Minor GC后存活对象比Survivor区容量还多,多出来的对象就会直接通过一种担保逻辑进入老年代。这就像你临时借宿的朋友太多,小客房住不下,只能往大客厅里挤。
3.3 标记压缩算法:把活着的东西码齐
标记压缩算法可以理解为“标记清除的加强版”。它的标记阶段和标记清除一样,但清除过程变成了整理:把存活的对象向内存空间的一端移动,按顺序排列,然后把边界之外的部分一次性清空。
这个设计的直接好处是没有碎片。所有活对象被码得整整齐齐,空闲内存变成一大块连续空间,大对象分配不再犯难。同时,它不需要像复制算法那样预留一整块空区域,内存空间利用率高。
代价是什么?对象移动本身就是大工程。移动存活对象意味着要修改栈上、寄存器里、本地方法里所有指向这些对象的引用,这些引用的更新必须在GC停顿期间完成。对象越多,移动越久,STW越长。所以在老年代执行标记压缩,通常停顿要比新生代复制长一个数量级。
关于标记压缩的实现,经典的Lisp2算法用三遍扫描来记录转发地址并更新引用,内存开销是对象头里加一个指针。还有一种两遍扫描的碰撞指针法,不额外占用内存,但需要扫描全部指针。这些实现细节说明一个道理:压缩不是白用的,它是用“停多久”换“未来分配多顺畅”。
3.4 三种回收算法横向对照
| 算法 | 是否移动对象 | 是否额外空间 | 是否有碎片 | 适用区域 | 核心代价 | | 标记清除 | 否 | 否 | 有 | 老年代(早期CMS) | 碎片导致分配困难 | | 标记复制 | 是 | 预留一半空间 | 无 | 新生代 | 空间浪费、存活率高时成本高 | | 标记压缩 | 是 | 否(可能需辅助指针) | 无 | 老年代 | 移动对象更新引用,STW长 |
这张表是我面试新人时最常拿出来问的。能把三种算法的代价和适用场景讲清楚的人,说明真的理解分区与算法之间的匹配逻辑。
现在的JVM收集器都没有只用一种算法。新生代普遍用复制,老年代要么用标记清理要么用标记压缩,甚至同一台机器上不同代的回收是不同算法组合起来的。理解单个算法是基础,理解怎么组合才是实战能力。
4. 从理论到实践:主流垃圾收集器如何组合这些算法
4.1 串行与并行回收器:起步与吞吐量优先
串行回收器(Serial)是最古老的收集器。它回收时只有一个GC线程做标记、复制、清理,线程数就一个,回收期间所有业务线程都停着。单核环境或者堆特别小的时候,它的表现其实可以接受,因为线程切换开销少,单线程反而稳定。它内部使用的就是新生代标记复制加老年代标记压缩的组合。
并行回收器(Parallel Scavenge)是串行的多线程版本,主打吞吐量优先。所谓吞吐量,就是运行用户代码的时间占总时间的比例。它特别关注两个参数:-XX:MaxGCPauseMillis用来限制最大停顿时间,-XX:GCTimeRatio用来设定吞吐量目标。默认以吞吐量优先时,JVM会在停顿时间和吞吐量之间做折中。
并行回收在实际应用中适合那些后台批量任务、离线计算之类的场景,它们不要求毫秒级响应,但希望在单位时间尽量多干活。换句话说,这种场景宁愿GC经常停一下,也不希望每次GC停太久之后,总的运行时间被拖长。
4.2 CMS收集器:标记清除的“并发改良”
CMS(Concurrent Mark Sweep)收集器是标志性的老年代收集器,第一次把“并发”概念带上GC舞台。它把老年代回收拆成四个阶段:初始标记、并发标记、重新标记、并发清除。其中初始标记和重新标记需要STW,但耗时极短;真正耗时的并发标记和并发清除阶段,GC线程和应用线程一起跑,业务不至于被整个卡住。
CMS用的是标记清除算法,不移动对象。这带来一个让人头疼的结果:碎片化。运行时间一长,老年代碎片遍地都是。CMS的应对办法是开启压缩整理,比如设置参数让Full GC期间对老年代做一次碎片整理,但这样停顿时间会明显变长。
CMS还有浮动垃圾问题。并发标记阶段应用程序同时在跑,一边标记一边可能有新垃圾落地,这些垃圾这一轮标记看不到,只能留到下一轮。所以CMS必须预留空间给这些浮动垃圾,而且如果预留空间不够,触发一次恐怖的Concurrent Mode Failure,直接退化到Serial Old做Full GC,停顿时间飙上十几秒。这也是CMS被淘汰的主要原因。
4.3 G1:混合回收与复制算法的大规模实践
G1收集器是更现代的选择,它的设计目标非常激进:在多核大内存机器上实现可预测的停顿时间,同时维持高吞吐量。G1放弃了物理上严格分代的做法,把整个堆划分成许多大小一致的Region,每个Region在逻辑上可以扮演Eden、Survivor或者老年代。
G1的年轻回收还是用复制算法,把Eden和Survivor区的存活对象复制到新的Survivor或老年代Region里。老年代回收则采用了Mixed GC的方式,在一次回收中同时处理部分老年代Region和新生代Region。它维护全局的存活对象信息和标记数据,回收时优先清理垃圾占比高的Region,这就是“Garbage First”名字的来历。
G1在并发标记阶段使用了Snapshot-at-the-Beginning(SATB)技术,记录并发标记开始时点的对象图快照。它能保证在并发标记期间新分配的对象不会被错误地判定为垃圾,从而避免回收过程中丢失活对象。这个机制解决了CMS浮动垃圾的根本性缺陷,也使得G1能在混合回收时更从容地处理老年代垃圾。
我用G1遇到过最典型的调优场景是:业务接口的TP99延迟突然抖动。查看GC日志发现Mixed GC的停顿时间超过预期,后来通过调低-XX:MaxGCPauseMillis让GC更频繁但每次更短,反而让接口延迟稳定下来。这里要注意,目标停顿时间设得越低,G1的回收频率越高,整体CPU开销也越大,必须结合业务指标做权衡。
4.4 收集器选型与参数调优的实用建议
大多数人面对GC选型的第一反应是找“最好”的收集器,实际上没有最好的,只有最合适的。我自己的判断标准是:先看业务指标,再看硬件配置。如果服务对延迟极其敏感,比如交易链路、实时推荐,ZGC或Shenandoah这类超低延迟收集器更合适;如果服务是批处理任务,Parallel是稳妥选择;如果只是普通在线服务,G1是当前JDK版本的默认选项,基本不会错。
JDK版本的默认配置是一个很重要的基础事实:不同版本的JVM自带不同的垃圾回收器。比如JDK 8的默认是Parallel,JDK 11之后G1成了默认,而JDK 17之后ZGC在很多平台也变得越来越常用。所以你在排查GC问题时,第一步应该是确认当前应用到底用的是哪个收集器,而不是上来就设计一堆参数。
调优的顺序也很关键:先使用监控平台或者jstat观察到GC频率和耗时,再打开GC日志(比如-Xlog:gc*:file=gc.log的方式)记录每次GC前后的堆占用和停顿,最后根据数据调整参数。不要一上来就加堆内存,堆内存加得过大,会造成单次GC停顿时间暴涨,反而得不偿失。
还有一个小建议:尽量把堆里新生代的大小控制在一个合理范围。如果新生代太小,Minor GC太频繁;如果太大,老年代就会被压缩,Full GC间隔变短。通常好的做法是让新生代占总堆的三分之一到一半左右,然后观察Minor GC频率是不是在几秒到十几秒一次,再慢慢微调。
5. 常见问题与排查技巧实录
5.1 循环引用真的会被回收吗
这是我能想到最经典的面试题,也是很多人绕不过去的坎。在JVM里,答案是:会。因为JVM默认采用可达性分析,不是引用计数,环状引用关系没有根可达就一起回收。我在分析一些老项目的堆快照时,经常看到成环的缓存对象,它们占用的内存其实是可以回收的,问题反倒是那些“有根可达但业务上认为没用了”的对象,它们才是内存泄漏的真凶。
排查循环引用和内存泄漏的最佳工具是堆快照分析,比如用对象引用链展示功能从某个GC Root出发,看哪些对象被意外地保住了。如果你在快照里看到一个Map结构持有大量对象,而这些对象的引用者是一条意想不到的路径,那你基本就找到泄漏点了。
5.2 频繁Full GC或者内存碎片导致分配失败怎么办
Full GC频繁的根因五花八门,但排查思路是可复用的。首先看业务线程创建对象的速度是否过高,比如在热点接口里循环new大对象、大数组,这可能就是元凶。其次看堆分配是否合理,老年代空间不够但又没有明显泄漏,往往是晋升阈值设置不当导致短命对象提前进入老年代。最后看是否有外部缓存或静态集合在持续增长。
排查的时候我习惯这样走:
- 用jstat
-gcutil观察各区的使用率和GC次数。 - 打开GC详情日志,确认每次Full GC前后的内存变化。
- 再不行就dump堆快照,用内存直方图统计类实例数量和占用大小。
- 如果看到某个自定义类实例数量异常,沿着引用链往上追,找到谁在引用它。
遇到碎片导致的分配失败,就观察GC日志里是否有“promotion failed”或者类似信息。这类表现说明老年代碎片化到无法分配晋升对象,通常需要考虑切换到压缩型的收集器,或者通过参数启用老年代整理。
5.3 CMS碎片化怎么破
老项目还在用CMS的场景不少。碎片化是CMS最头疼的问题,它在默认策略下不会像标记压缩那样整理老年代。我常用的止损手段是开启-XX:+UseCMSCompactAtFullCollection(旧版本JDK中),让Full GC时顺便做一次压缩。但要注意,压缩阶段会加倍停顿时间,所以它只能作为兜底手段。
更好的建议是,如果你的服务还在用CMS,而且经常因为碎片化触发并发模式失败,我建议趁早迁移到G1或者ZGC。这类迁移对业务透明,只需要修改启动参数,再配合一段时间的灰度观察即可。我一个同事迁移后,Full GC次数从每小时几次降到几乎没有,效果立竿见影。
5.4 STW时间过长的止损技巧
STW时间过长的直接感受是接口超时、服务卡顿。止损的第一步永远是“让GC快点结束”,不是“优化代码”。如果你当前堆是几十GB的老年代,一次压缩整理可能要好几秒,那最直接的办法是缩小单次GC的堆范围,或者切换到增量回收的收集器。
另外有一个经常被人忽略的招数:检查是否有显式的System.gc()调用。某些框架会在RPC调用前后或者定时任务里偷偷调用它,导致Full GC频繁发生。排查时先加-XX:+DisableExplicitGC看症状是否消失。如果确实是因为显式GC调用,禁用之后观察是否还有其他依赖这种调用的逻辑,再做使用策略调整。
5.5 一套排查工作流
把前面的零碎经验串成一条工作流,我的习惯是这样的:
第一步,确认指标。通过监控平台或者jstat看GC频率、GC耗时、堆使用率曲线,判断是内存泄漏还是短暂的分配高峰。
第二步,抓现场。打开GC详情日志,记录Full GC发生前后的内存数据。如果OOM,就利用-XX:+HeapDumpOnOutOfMemoryError在OOM时自动dump堆快照。
第三步,分析根因。把堆快照装进分析工具,看哪些对象占了最多的内存,引用链是从哪里出发的。这一步要有耐心,不要急着下结论,因为很多时候对象数量最大的是byte[],你得继续分析谁创建了这么多byte[]。
第四步,验证策略。改完代码或者改完参数后,跑一轮压力测试,对比GC指标和业务RT变化,确认优化有效再发布。
这套工作流我在多个项目里复用,每次都稳。真正踩过的坑反而都在第一步:如果基础监控数据不准,后面分析全都白搭。所以我建议先把GC日志接入日志平台,每天定期观察趋势,等出了问题再回头翻,往往线索都在历史日志里。
最后再分享一个小技巧:做GC调优时,不要贪心一次把所有参数都改掉。每次只改一个变量,比如堆大小、或者新生代比例、或者目标停顿时间,然后观察一两天,用数据说话。垃圾回收算法本身是成熟的,你的应用才是变量,把应用的行为吃透,比背一堆参数更有用。我自己早期喜欢一股脑儿堆参数,结果经常导致性能不升反降,后来学会“单变量控制”,再也没为调参的事抓狂过。