☰
JVM七种垃圾回收器实战:从原理到线上GC调优
2026/10/14 13:49:03 网站建设 项目流程

很多人在面试时能把七种垃圾回收器的名字背得滚瓜烂熟,Serial、ParNew、Parallel Scavenge、Serial Old、Parallel Old、CMS、G1张口就来,但一回到线上,面对内存告警、GC日志刷屏、接口超时,却不知道从哪开始查。这个反差其实很常见,因为垃圾回收器不是一道背诵题,它是一套需要在真实场景里做权衡取舍的工程决策。把这七种回收器的原理、参数和适用边界真正吃透,你才可能在遇到问题时快速定位方向——是换回收器,还是调参数,还是改代码。

这篇文章是JVM学习系列的第十五篇,我会把七种回收器放到同一个坐标系里讲,不单独罗列概念,而是把它们的演进逻辑、算法差异、组合方式、典型参数和排障思路串起来。内容偏实战,适合正在用JDK 8或更高版本做服务端开发、被GC问题困扰过、或者想把JVM基础补扎实的读者。看完之后,你至少能回答三个问题:线上该选哪种回收器、参数大概怎么设、GC日志出现什么信号时需要警惕。

1. 七种回收器全景地图:先分清三派再谈配置

1.1 为什么一定要先理解分代模型

垃圾回收器的设计起点是分代假设:大部分对象朝生夕灭,少数对象会存活很久。基于这个观察,JVM把堆划分为新生代和老年代,新生代再拆成Eden区和两个Survivor区(From和To)。新对象优先分配到Eden区,Eden满了触发新生代回收,存活对象通过复制算法挪到Survivor区,经过多次回收仍然存活的对象最终晋升到老年代。

这个模型直接决定了回收器的分派方式。新生代回收频率高、每次回收的存活对象少,所以适合复制算法;老年代对象存活率高、空间大,回收频率低,更适合标记-清除或标记-整理。七种回收器本质上就是对这两个区域的回收策略做了不同组合,理解这一点,后面所有内容都不用死记硬背。

另外要厘清三个术语:Minor GC(新生代回收)、Major GC(老年代回收,通常伴随Full GC语义)、Full GC(整堆回收,包括方法区/元空间)。很多线上问题其实并不是回收器本身慢,而是对象分配太快、存活太多,导致Full GC频繁触发,这时候换什么回收器都救不了,得先查代码。

1.2 七种回收器的派系划分

七种回收器按目标分成三个派系。

第一派是串行派,指Serial和Serial Old,它们的共同点是单线程执行回收、全程STW(Stop-The-World,停止世界)。优点是实现简单、无线程切换开销,在单核CPU或内存很小的客户端场景下反而效率高。缺点是任何一次GC都会让应用完全停顿,服务器端几乎不可用。

第二派是吞吐量派,指Parallel Scavenge和Parallel Old,它们是多线程并行回收,核心目标是最大化应用线程的运行时间占比,也就是吞吐量。吞吐量 = 用户线程运行时间 /(用户线程运行时间 + GC时间)。这一派适合对延迟不敏感、但希望单位时间处理更多请求的后台任务。

第三派是并发低延迟派,指ParNew、CMS和G1。其中ParNew是新生代的多线程回收器,常和CMS搭配;CMS是首个真正意义上并发收集老年代的回收器,目标是把STW时间压到最短;G1则更进一步,把堆拆成多个Region,实现了可预测的暂停时间。

这里有一个容易混淆的点:ParNew虽然名字里带Parallel,但它属于并发低延迟派,因为它存在的最大意义是和CMS配合使用。Parallel Scavenge才是真正的吞吐量担当。面试和实践中都有很多人栽在这上面。

1.3 一张表看清七种回收器的定位

回收器工作区域回收算法线程数目标典型场景
Serial新生代复制单线程简单可靠客户端模式、单核小内存
ParNew新生代复制多线程与CMS搭配JDK 8下低延迟Web服务
Parallel Scavenge新生代复制多线程高吞吐量后台计算、批处理任务
Serial Old老年代标记-整理单线程兜底方案CMS失败后的后备回收器
Parallel Old老年代标记-整理多线程高吞吐量搭配Parallel Scavenge
CMS老年代标记-清除并发多线程低延迟延迟敏感、堆中等偏大
G1整堆复制 + 标记-整理多线程并发可预测暂停JDK 9+ 默认回收器

这张表建议存下来,做GC排查时先对照它明确“我当前用的是哪个回收器、它到底管哪块区域”,再往下看日志和参数,思路会清晰很多。

2. Serial与ParNew:单线程的朴素与多线程的修正

2.1 Serial回收器:最简单的复制算法实现

Serial回收器是JVM历史上最古老的回收器之一,新生代回收采用复制算法,整个过程只有一条GC线程在工作,期间所有应用线程必须停止。它的触发条件很简单:Eden区空间不足。回收时把Eden区和Survivor区中存活的对象复制到另一个空的Survivor区,然后清空Eden和原Survivor,完成一轮回收。

你可能会问:单线程、全停顿,这种回收器还有存在价值吗?有。在单核CPU环境下,多线程回收反而要承担线程创建和上下文切换的开销,Serial的简单反而成了优势。另外,Serial Old作为老年代回收器时,还是CMS发生Concurrent Mode Failure后的后备方案,这层兜底身份让它一直没有被完全淘汰。

Serial的体验和大多数服务器场景无关,但它是最适合理解GC原理的入门样本。你会直观感受到STW暂停是什么概念:JVM在用户线程的某个安全点(SafePoint)停下来,执行回收,再恢复线程。所谓安全点,就是JVM能确定线程状态一致的位置,比如循环跳转、方法调用、异常抛出这些地方。线程到了安全点才算“暂停完成”,所以GC日志里的暂停时间其实包含了一部分等待线程到达安全点的过程,这也是STW时间可能被人为拉长的一个隐蔽原因。

2.2 ParNew回收器:多线程版的Serial

ParNew回收器的算法逻辑和Serial几乎一样,都是复制算法、都只处理新生代,唯一区别是回收线程从一条变成了多条。默认并行GC线程数等于CPU核数,也可以通过 -XX:ParallelGCThreads 手动指定。在一台8核机器上,ParNew的单次Minor GC耗时可能比Serial短一大截,因为复制工作分给了多个线程并行执行。

ParNew的关键价值不在自己,而在它是CMS在新生代的老搭档。CMS会并发回收老年代,但新生代回收仍然需要STW,CMS时代的官方推荐组合就是“ParNew + CMS + Serial Old”,前两者负责正常回收,Serial Old负责兜底。JDK 8的线上服务里,这个组合非常常见。

用ParNew时有一个值得注意的点:并发回收虽然快,但它本质还是STW的,只是把“一条线程干活”变成“多条线程一起干活”。它降低的是墙钟耗时,而不是暂停是否存在。所以如果你的目标是彻底消除延迟毛刺,ParNew满足不了,它只负责在暂停期间更快地完成任务。

2.3 新生代回收的完整流程与晋升细节

无论使用Serial还是ParNew,新生代回收的流程都很固定。新对象先进入Eden区,Eden满了触发Minor GC;回收时,把Eden和Survivor From区中仍然存活的对象复制到Survivor To区,同时对象年龄加一;清空Eden和From区,然后交换From和To的角色,等待下一次GC。

对象年龄到阈值(默认15,可以通过 -XX:MaxTenuringThreshold 设置)后会晋升到老年代。但这里还有一个动态判断条件容易被忽略:如果Survivor区中相同年龄对象的大小总和已经超过Survivor空间的一半,那么年龄大于等于该值的对象会直接晋升,不需要等到15岁。这个机制是为了防止Survivor区被长期存活对象撑爆。

另外还有两个实际场景里的概念:分配缓冲区(TLAB,Thread Local Allocation Buffer)和进入老年代的阈值判定。JVM为每个线程在Eden区划分一块私有区域来分配对象,以减少线程竞争;当老年代空间不足以容纳晋升对象时,会触发一次Full GC而不是Minor GC。如果Minor GC后存活对象太多、Survivor区装不下,多余对象会直接提前晋升到老年代。这些细节决定了为什么有时候明明没到年龄阈值,老年代却出现了大量“年轻”对象。

3. Parallel Scavenge与Parallel Old:吞吐量组合的黄金搭档

3.1 与ParNew的本质差异在哪里

Parallel Scavenge和ParNew表面上很像,都是多线程新生代回收器、都用复制算法,但设计目标完全不同。ParNew是为了配合CMS而存在的,它关注的是降低延迟;Parallel Scavenge则专注吞吐量,它希望GC总时间占比尽量小,让用户代码跑得更久。这个差异直接体现在它能调节的参数上。

Parallel Scavenge有两个标志性参数:-XX:MaxGCPauseMillis 设置最大GC停顿毫秒数,-XX:GCTimeRatio 设置GC时间占总时间的比率。GCTimeRatio的默认值是99,含义是允许垃圾回收时间最多占总时间的1%,也就是吞吐量目标为99%。这两个参数不是没有代价的:你希望暂停时间越短,JVM就需要更频繁地触发回收,每次回收更少对象,结果GC总次数变多,总吞吐量反而下降。所以实际调参时,二选一作为主要目标就好,通常优先追求吞吐量,不要同时把两个参数都压得太狠。

Parallel Scavenge还有一个自动化特性:自适应调节策略,默认开启,对应参数是 -XX:+UseAdaptiveSizePolicy。它会根据程序运行表现自动调整Eden、Survivor比例、晋升阈值等,让JVM自己找当前场景的最优解。这个特性适合“我不想手工调,就让JVM自己看着办”的情况,但如果你希望行为完全可控,记得显式关掉。

3.2 Parallel Old回收器结束了一个尴尬时代

Parallel Old是Parallel Scavenge的老年代搭档,使用多线程并发的标记-整理算法。标记阶段找出所有存活对象,整理阶段把存活对象往内存一端移动,消除碎片。它的加入补上了Parallel Scavenge此前最大的短板——在JDK 6之前,Parallel Scavenge只能搭配Serial Old使用,老年代回收是单线程的,一旦老年代GC发生,停顿时间非常难看,整体吞吐量目标大打折扣。

JDK 8以及更早版本中,服务器端默认的组合正是“Parallel Scavenge + Parallel Old”,也就是 -XX:+UseParallelGC 对应的实际配对。这意味着你在JDK 8上不加任何GC参数运行的服务,用的就是吞吐量优先方案。这种默认选择很务实:大多数业务场景下,用户感知到的吞吐量比偶发的gc停顿更敏感,而且Parallel组合参数少、调起来比较省心。

在实际使用中,Parallel组合的调优路径通常是:先确认目标吞吐量,设置GCTimeRatio或MaxGCPauseMillis中的一项,然后配合堆大小设置(-Xms和-Xmx保持一致,避免扩容抖动)观察GC频率和耗时。若是批处理任务,我更倾向于压低GC总耗时,把堆调大一些,减少Full GC次数;如果是交互式服务,Parallel组合往往不是最佳答案,除非延迟指标不敏感。

Parallel Scavenge和Parallel Old这对组合的隐性问题在于缺少并发标记能力,整个回收过程都要STW。对象越多,STW时间越长,堆调得过大反而会放大问题。所以很多团队在堆空间超过8GB、且延迟要求较高的场景下,宁可放弃默认的Parallel组合,切换到G1,原因在下一章讲G1时会更加清晰。

4. Serial Old与CMS:老年代回收的两个极端方案

4.1 Serial Old:最不起眼却最关键的兜底回收器

Serial Old是Serial回收器的老年代版本,采用标记-整理算法、单线程执行、全程STW。单独拿它作为老年代回收器的场景非常稀少,一般只出现在客户端模式下或堆特别小的场景。但它的真正身份是CMS的后备方案:当CMS运行中出现Concurrent Mode Failure(并发失败)时,JVM会立刻切换到Serial Old执行一次Full GC作为兜底。

这个兜底机制很重要。CMS是并发回收,回收线程和用户线程同时跑,但如果老年代空间在并发清除阶段被大量“浮动垃圾”填满,JVM来不及等CMS完成,就只能退化成Full GC。这时候Serial Old单线程全量整理老年代,停顿会非常久,对线上服务来说几乎是灾难。所以凡是用了CMS的生产环境,日志里最怕看到的就是Concurrent Mode Failure。

Serial Old和CMS的组合之所以能成立,是因为它足够简单、足够稳定。真正做过CMS调优的人都会理解,并发回收器最怕的不是慢,而是不可控的“现象级停顿”。有一个简单可靠的兜底方案在背后,起码能保证最坏情况不会无限恶化。

4.2 CMS:并发回收老年代的开创者

CMS(Concurrent Mark Sweep)是第一个让老年代回收线程和用户线程同时运行的主流回收器,核心追求是最短STW时间。它把老年代回收拆成四个阶段:

初始标记(Initial Mark):只标记GC Roots直接引用的对象,需要STW,但停顿很短。

并发标记(Concurrent Mark):从GC Roots往下遍历整个对象图,与用户线程并发执行,耗时最长但不暂停用户线程。

重新标记(Remark):修正并发标记期间因用户线程继续运行而产生变化的那部分对象引用,需要STW,停顿比初始标记长,但远小于全程回收。

并发清除(Concurrent Sweep):清理死亡对象,与用户线程并发执行,回收线程和用户线程同时抢占CPU,所以CMS对CPU资源比较敏感,默认并发线程数通常是CPU核数的四分之一。

CMS基于标记-清除算法,这一点让它背上了三个包袱:一是会产生大量内存碎片,长时间运行后老年代碎片化严重,大对象分配困难,容易提前触发Full GC;二是无法处理并发阶段新产生的浮动垃圾,必须预留一部分老年代空间;三是对CPU敏感,并发阶段占用CPU会轻微影响应用吞吐。

这四个阶段里,最容易被忽略的是重新标记。并发标记期间用户线程一直在改对象引用,必须停下来确保最终标记结果一致。为了让重新标记尽量快,JDK引入了增量更新和多线程并行标记,但核心思路还是“标记-修正-确认”。很多人只在面试里背CMS四阶段名称,真到了线上,看到一次短暂的STW,并不知道这其实已经是CMS在重新标记环节拼命压缩停顿时间的结果。

CMS常用参数包括:-XX:CMSInitiatingOccupancyFraction 设置老年代使用率达到多少时开始CMS回收(默认值是-1配合UseCMSInitiatingOccupancyOnly使用),-XX:+UseCMSCompactAtFullCollection 和 -XX:CMSFullGCsBeforeCompaction 控制Full GC后是否压缩碎片。需要说明的是,JDK 9之后CMS被标记为废弃,JDK 14彻底移除,所以它还活跃在大量JDK 8线上服务中,但已经不适用于新项目。

5. G1:从物理分代到Region逻辑分代的暂停可控之路

5.1 G1为什么要打破连续的内存分区

G1(Garbage First)的设计纲领和前面所有回收器完全不同:它不再把堆严格划分为连续的新生代和老年代,而是将整个堆划分为若干个大小相等的Region,每个Region依然扮演Eden、Survivor或Old的角色,但这种逻辑角色的分配是动态的,不需要物理连续。

这种Region化带来了两个核心能力。第一个是可预测的暂停时间模型:G1在后台维护一个可回收对象的优先级列表,每次GC只选择回收收益最大的若干个Region,也就是包含最多可回收对象的Region优先被清理,所以它的停顿时间是可控的,可以通过 -XX:MaxGCPauseMillis 设置目标(默认200毫秒)。第二个是整堆统一管理:年轻代、老年代甚至大对象都落在Region里,G1可以在不需要切换回收器的情况下同时处理新生代和老年代的对象。

G1里还有一个特殊的大对象区(Humongous)。超过Region容量一半的对象会被直接分配到连续的多个Humongous Region,而且这些Region不会在Minor GC被回收,只能等Full GC或并发回收周期处理。线上如果频繁创建大对象,G1的行为会和常规对象分配有明显差异,排查时要留意这个方向。

5.2 三色标记法与SATB:并发安全的关键机制

要理解G1的并发标记,先理解三色标记法。在并发标记过程中,对象被分为白色(未被访问)、灰色(自身已访问但引用的对象未访问完)、黑色(自身和引用都访问完毕)。回收结束后仍为白色的对象可以被回收。

并发标记最大的风险是“对象丢失”:一个黑色对象在标记完成后被修改,引用了某个白色对象,同时原本指向白色对象的灰色引用被删除,这个白色对象就会在GC结束后漏掉。G1采用了SATB(Snapshot At The Beginning,起始快照)策略来对抗这个问题:在并发标记开始时为整个对象图拍一张逻辑快照,标记过程中所有引用变化都基于这张快照判断,就算对象在快照中存在、并发期间变成了垃圾,G1仍然会保留它到下一轮回收。这本质上是一种“宁可多留也不误杀”的保守策略,代价是可能产生少量浮动垃圾。

这套策略单独看可能有点绕,但结合实践就清晰了:我刚开始用G1时,总以为并发标记后就能大量回收老年代,后来发现GC日志里的回收集合(Collection Set)只包含部分Region,很多在并发标记期间存活下来的垃圾要等下一轮。理解了SATB,再看Mixed GC(混合回收)的日志就不会觉得奇怪了。

5.3 G1的GC流程:从Young GC到Mixed GC再到退化Full GC

G1的常规回收路径比CMS复杂,但主线分明。第一步是Young GC:Eden区满了就触发,把Eden和Survivor的存活对象复制到新的Survivor或Old Region,全程STW但只暂停一小段时间。第二步是并发标记周期:当堆整体使用率达到阈值(默认45%,可用-XX:InitiatingHeapOccupancyPercent调整)时,G1启动并发标记,标记完老年代存活对象。第三步是Mixed GC:并发标记结束后,G1选择一批回收收益最高的Region进行混合回收,这里既包含年轻代Region也包含老年代Region,目标是逐步清理老年代。

G1也有自己的Full GC:如果对象分配速度过快、Region回收来不及腾出空间,或者巨型对象放不下,G1会退化成单线程的Full GC,停顿时间很长。G1的Full GC大多不是回收器本身有问题,而是对象分配压力超出了GC的消化能力。我在处理这类问题时,第一反应不是调G1参数,而是先查内存快照找对象来源,往往是代码里缓存了不该缓存的东西。

G1常用参数包括-XX:MaxGCPauseMillis(目标暂停时间)、-XX:G1HeapRegionSize(Region大小,默认由堆大小自动推算,范围1MB到32MB)、-XX:InitiatingHeapOccupancyPercent(并发标记阈值,默认45)。还有一个经常被忽略的细节:G1的Region划分对堆大小很敏感,Region数量建议在2048左右,太多或太少都可能影响回收效率。

6. 组合选型与调参实战:从启动参数到GC日志排查

6.1 不同业务场景下怎么选回收器组合

选回收器不是追求最新,而是匹配延迟和吞吐量之间的取舍。以下是我在各类型项目里相对统一的选择逻辑。

场景特征推荐组合原因
单核小内存、客户端工具Serial + Serial Old无并发竞争,STW短且可控
JDK 8后台批处理、离线计算Parallel Scavenge + Parallel Old吞吐量最大化,GC总耗时占比低
JDK 8延迟敏感Web服务ParNew + CMS + Serial Old老年代并发回收,暂停时间短,但需兜底
JDK 9以上、堆较大、延迟敏感G1可预测暂停时间,RSet + Mixed GC动态清理
堆超过32GB、超低延迟考虑ZGC等新一代回收器G1在大堆下停顿控制仍有压力

以我个人的经验,如果你的服务还停留在JDK 8且不想改代码,延迟指标要求严苛,那CMS组合确实是个可选方案。但新项目我建议直接用G1,并且把堆、暂停目标、线程数都显式配置,而不是全走默认。原因很简单:JDK本身已经逐步淘汰CMS,与其在历史包袱上调参,不如在G1的模型上重新建立调优基线。

6.2 一套可复用的JVM启动参数模板

在JDK 8环境下,如果你决定用CMS组合,可以参考以下参数模板:

java -Xms4g -Xmx4g -Xmn2g \ -XX:+UseConcMarkSweepGC \ -XX:+UseCMSInitiatingOccupancyOnly \ -XX:CMSInitiatingOccupancyFraction=70 \ -XX:ConcGCThreads=4 \ -XX:ParallelGCThreads=8 \ -XX:+ExplicitGCInvokesConcurrent \ -jar app.jar

-Xms和-Xmx保持一致,避免堆扩容导致性能抖动;-XX:+UseCMSInitiatingOccupancyOnly告诉JVM只按设定的阈值触发CMS,而不是依赖JVM自行预测;CMSInitiatingOccupancyFraction设为70算是比较常见的保守值,相当于老年代用了七成就开始并发回收,留出足够空间给浮动垃圾;ConcGCThreads和ParallelGCThreads根据CPU核数折中设置。

如果换到JDK 11或更高版本,直接用G1并显式声明参数:

java -Xms8g -Xmx8g \ -XX:+UseG1GC \ -XX:MaxGCPauseMillis=100 \ -XX:ParallelGCThreads=8 \ -XX:ConcGCThreads=4 \ -XX:G1HeapRegionSize=8m \ -Xlog:gc*:file=gc.log:time,uptime,level,tags \ -jar app.jar

MaxGCPauseMillis设定100毫秒是相对常见的低延迟目标,但不要设得太小,否则G1为了满足暂停目标会过于频繁地触发Young GC,吞吐量受损;G1HeapRegionSize设为8m适合8GB左右堆,能保证Region数量够多,回收选择更灵活;-Xlog:gc*是JDK 9之后的GC日志开启方式,JDK 8则用-XX:+PrintGCDetails -XX:+PrintGCDateStamps。

这里有个经验之谈:MaxGCPauseMillis不是越小越好。我把一个服务的暂停目标从50毫秒调到200毫秒之后,吞吐量反而上升了接近15%,因为GC次数明显减少。调参之前一定要想清楚,你的核心指标是P99延迟还是每秒请求数,两者经常无法兼得。

6.3 线上GC日志的排查链路

遇到GC问题,我的标准排查流程基本是四步。第一步用jps确认进程号,再用jmap -heap或jcmd GC.heap_info查看当前堆使用情况和回收器类型,确认实际生效参数。第二步用jstat -gcutil 1000持续观察每秒GC数据变化,重点看YGC、FGC次数以及各自耗时占比。第三步如果Full GC次数偏高,抓一次堆转储(jmap -dump或jcmd GC.heap_dump)分析对象构成,定位大对象和异常缓存。第四步看GC详细日志,确认每次GC发生在什么阶段、停顿时间多长、是否出现Concurrent Mode Failure或Humongous分配预警。

举一个实际见过的例子(完全虚构的某订单数据处理服务):Full GC每十分钟一次,每次停顿接近三秒。jstat显示老年代在Full GC之后下降不明显,堆转储发现一个静态Map缓存了大量历史订单对象,且未设置淘汰策略。这类问题换任何回收器都没用,根源是代码层的内存泄漏。反过来,如果是G1频繁发生Mixed GC但仍然显得吃力,且日志里大量年轻代晋升对象,那就要调晋升阈值或检查Eden和Survivor比例是否合理。

还有一个容易踩的坑:GC日志打印级别。JDK 8用-XX:+PrintGCDetails时,日志输出到控制台会吞掉大量系统输出,影响性能观测。建议显式写到文件,配合-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/k.hprof,确保OOM时能留下快照。排查问题最重要的是保留第一现场,日志和堆转储就是现场,不要等出问题才想起来没留。

我在多次GC排障后的体会是:垃圾回收器的参数调整永远只是手段,对象分配源头才是根本。先把工具链跑熟,把日志、堆转储、监控三件套配好,再把七种回收器的模型装进脑子里,线上再出现GC问题,你至少不会慌——能快速判断该看哪里、该调什么,这就是这篇文章最想带给你的能力。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询