1. 从“垃圾”到“宝藏”:理解JVM垃圾回收的本质
如果你写过Java,那你一定对OutOfMemoryError这个老朋友不陌生。它就像一个不请自来的客人,总是在你最不希望它出现的时候,比如大促压测、线上数据处理的关键时刻,突然造访,然后留下一片狼藉。很多人把这个问题简单归结为“内存不够了,加机器”,但这其实掩盖了问题的本质——垃圾回收(Garbage Collection, GC)机制没有高效地工作。
JVM的垃圾回收器,就是那个在后台默默打扫“内存房间”的清洁工。它的工作不是简单的“看见垃圾就扫”,而是一门精密的艺术。它需要决定:什么时候打扫(触发时机)?用什么工具打扫(回收算法)?是只打扫一个房间(新生代),还是全屋大扫除(老年代)?打扫时,是让家里所有人都暂停活动(Stop-The-World, STW),还是可以边活动边打扫(并发)?不同的清洁工(垃圾回收器)有不同的工作风格和擅长场景。
网上关于Serial、Parallel、CMS、G1、ZGC的文章很多,但大多是罗列概念和参数,看完之后你可能会记住“CMS是并发的,G1是分区的”,但到了真实的生产环境,面对一个持续告警的服务,你依然不知道从何下手。这篇文章的目的,就是带你穿透这些名词,从设计哲学、工作流程到实战调优,彻底搞懂这八位“清洁工”,让你不仅能通过面试,更能解决实际问题。
2. 垃圾回收器的核心矛盾与设计哲学
在深入每个回收器之前,我们必须先理解它们共同面对的核心矛盾,以及由此衍生出的不同设计哲学。这就像理解不同武术流派的前提是理解“攻防”这个基本矛盾。
核心矛盾:吞吐量(Throughput) vs. 延迟(Latency)
这是一个经典的权衡(Trade-off)。
- 吞吐量:指应用程序在单位时间内能够处理的任务量。对于垃圾回收来说,高吞吐量意味着GC线程能更高效、更快速地完成垃圾回收工作,从而将更多的CPU时间片留给业务线程。可以理解为清洁工干活特别麻利,虽然每次打扫会让家里暂停一会儿,但打扫得又快又干净,总体下来家务占用时间很少。
- 延迟:指单次垃圾回收导致的应用程序停顿时间(STW时间)。低延迟意味着每次GC停顿都非常短暂,用户几乎感知不到卡顿。这就像清洁工学会了“凌波微步”,能在家人活动的间隙,以极快的速度完成局部清理,不让任何人明显停下来等待。
几乎没有回收器能同时在这两个维度做到极致。追求高吞吐量,往往需要更集中、更“暴力”的回收方式,这容易导致单次停顿变长;追求低延迟,则需要将回收工作打散、并发进行,这又会引入额外的开销,可能降低总体吞吐量。
基于对这个矛盾的不同侧重,以及硬件(单核/多核、内存大小)和软件(服务类型)的发展,JVM的垃圾回收器演化出了清晰的代际:
- 串行时代(Serial Era):为单核CPU设计,简单粗暴,STW时间长。代表:Serial, Serial Old。
- 并行时代(Parallel Era):利用多核优势,追求高吞吐量,但STW问题依旧。代表:Parallel Scavenge, Parallel Old。
- 并发时代(Concurrent Era):引入并发标记,显著降低STW时间,但吞吐量有牺牲,且会产生“浮动垃圾”。代表:CMS(Concurrent Mark-Sweep)。
- 分区与算法革新时代(Partitioning & Algorithmic Era):引入Region分区模型和更先进的算法(如SATB),在吞吐和延迟间寻求更好平衡。代表:G1(Garbage-First)。
- 超低延迟时代(Ultra-Low-Latency Era):使用染色指针、读屏障等“黑科技”,将STW时间控制在10ms甚至亚毫秒级别,几乎做到“无感”。代表:ZGC, Shenandoah。
理解了这条主线,我们再去看每个具体的回收器,就会明白它们为什么被设计成那样,以及各自适合什么样的战场。
3. 初代目:串行回收器(Serial / Serial Old)
这是JVM垃圾回收的起点,也是最简单、最基础的实现。在JDK早期,服务器大多是单核CPU,这个设计合情合理。
Serial用于新生代回收,采用复制算法。它将新生代划分为一个Eden区和两个Survivor区(通常称为From和To)。工作流程纯粹是串行的:
- 触发:Eden区满时,触发Minor GC。
- 标记:暂停所有应用线程(STW开始),从GC Roots(如栈帧中的局部变量、静态变量等)开始,标记出所有存活对象。
- 复制与清除:将Eden区和一个Survivor区(From)中存活的对象,复制到另一个Survivor区(To)。然后一次性清空Eden区和From区。
- 年龄晋升:对象在Survivor区之间每熬过一次Minor GC,年龄就加1。当年龄超过阈值(默认15),或Survivor区空间不足时,对象会被晋升到老年代。
- 结束:STW结束,应用线程恢复。
Serial Old是Serial的老年代版本,采用标记-整理算法。当老年代空间不足或显式调用System.gc()时,会触发Full GC,其过程也是串行的:标记所有老年代存活对象,然后将它们向内存空间的一端“滑动”压缩,清理掉边界外的所有空间。
为什么今天还要了解它?
- 客户端模式的默认选择:在JDK8及之前,如果你在Windows或Mac的客户端环境运行Java程序,JVM默认使用
-XX:+UseSerialGC,即Serial + Serial Old组合。因为客户端应用对短暂停顿不敏感,且简单稳定。 - 资源极端受限的环境:例如在一些嵌入式系统或微服务场景中,容器分配的单核CPU和极小内存(如128MB),使用并行或并发回收器的多线程开销反而会成为负担,此时串行回收器是最佳选择。
- 理解基础:它的流程是所有回收器的基础模板,理解了它,再理解复杂的并发、分区模型就容易多了。
注意:千万不要在服务器环境(特别是多核)下使用串行回收器。它的长时间STW会导致服务周期性“卡死”,用户体验极差。曾经有团队误将测试环境的
-XX:+UseSerialGC配置带到生产,导致每分钟服务都有几秒不可用,排查了许久才发现是这个“古董”配置在作祟。
4. 吞吐量之王:并行回收器(Parallel Scavenge / Parallel Old)
随着多核CPU成为服务器标配,JVM引入了并行回收器,其核心目标非常明确:最大化应用程序的吞吐量。它通过多线程并行执行垃圾回收任务来达成这一目标。
Parallel Scavenge用于新生代,同样采用复制算法,但关键区别在于它是多线程并行的。在STW期间,它会启动多个GC线程,同时进行标记和复制工作,充分利用多核CPU的计算能力,从而大幅缩短了STW时间(相对于单线程的Serial)。但它依然是“Stop-The-World”的,只是停顿时干活的人多了,活干得快了。
Parallel Old是Parallel Scavenge的老年代搭档,采用标记-整理算法,也是多线程并行执行。它通常与Parallel Scavenge搭配使用,组成-XX:+UseParallelGC(或-XX:+UseParallelOldGC,两者通常一起启用)组合。
Parallel Scavenge的独特之处:自适应调节与吞吐量优先策略Parallel Scavenge有一个非常重要的特性,通过-XX:+UseAdaptiveSizePolicy(默认开启)来体现。它不像其他回收器主要关注停顿时间,而是致力于达成用户设定的吞吐量目标。
- 核心参数:
-XX:MaxGCPauseMillis:期望的最大GC停顿时间(毫秒)。注意,这是一个“软目标”,JVM会尽力达成,但不保证。设置过小会导致JVM疯狂调整堆大小,反而降低吞吐量。-XX:GCTimeRatio:GC时间与应用时间的比率。公式为1 / (1 + GCTimeRatio)。默认值99,表示GC时间不超过总时间的1%(即 1/(1+99) )。这个参数直接决定了吞吐量目标。
- 自适应调节:JVM会根据运行时的性能数据(如每次GC的耗时、吞吐量),动态调整堆的大小、新生代与老年代的比例、Eden与Survivor的比例等,以尝试满足
MaxGCPauseMillis和GCTimeRatio的目标。
适用场景与坑点Parallel系列是JDK8及之前服务器端的默认垃圾回收器。它非常适合那些后台运算任务,对延迟不敏感,但需要尽可能利用CPU资源完成大量计算的应用。例如,数据批处理、科学计算、报表生成等。
实操心得:很多团队从JDK8升级到更高版本后,发现GC情况变了,就是因为默认回收器从Parallel变成了G1。如果你有一个对吞吐量极其敏感的老应用,升级后可能需要显式指定
-XX:+UseParallelGC来保持原有特性。另外,不要将-XX:MaxGCPauseMillis设得过低(比如10ms),这会导致JVM将堆大小调整得非常小,从而引发频繁的GC,总体吞吐量不升反降。通常建议先观察实际停顿时间,再设置一个略高于平均值的保守目标。
5. 里程碑式的尝试:并发标记清除回收器(CMS)
当Java开始大规模应用于Web服务器、交易系统等对响应时间敏感的场景时,Parallel系列长达数百毫秒的Full GC停顿变得不可接受。CMS(Concurrent Mark-Sweep)应运而生,它的设计目标就是降低停顿时间,特别是老年代回收的停顿时间。它是第一款真正意义上实现了大部分标记工作与应用程序并发执行的老年代回收器。
CMS的核心工作流程(仅用于老年代回收)CMS的回收过程比较复杂,分为多个阶段,其中初始标记和重新标记需要STW,而并发标记和并发清除是与应用线程一起运行的。
- 初始标记(Initial Mark, STW):速度极快,只标记那些直接从GC Roots直接关联到的对象。
- 并发标记(Concurrent Mark):与应用线程并发执行,从初始标记的对象开始,遍历整个对象图,标记所有存活对象。因为这个阶段是并发的,所以应用线程可能修改引用关系,产生“浮动垃圾”(本次GC cycle中变成垃圾的对象)。
- 重新标记(Remark, STW):暂停应用线程,修正并发标记阶段因应用线程运行而产生的变动。这个阶段停顿比初始标记长,但远短于Parallel Old的一次性标记。
- 并发清除(Concurrent Sweep):再次与应用线程并发执行,清理那些被标记为垃圾的对象。
CMS的优缺点与经典问题
- 优点:显著降低了老年代回收的停顿时间,提升了服务的响应速度。
- 缺点与挑战:
- 对CPU资源敏感:并发阶段会占用一部分CPU资源(默认启动
(CPU核心数 + 3) / 4个线程),在CPU密集型应用中可能影响吞吐量。 - 无法处理“浮动垃圾”:并发清理阶段,用户线程还在运行,可能产生新的垃圾,这些垃圾只能留到下一次GC才能处理。因此CMS不能像其他回收器那样等到老年代快满了再回收,必须预留一部分空间(
-XX:CMSInitiatingOccupancyFraction,默认68%)供并发收集时程序使用。如果预留空间不足,会触发“并发模式失败”(Concurrent Mode Failure),此时JVM会退化为Serial Old进行Full GC,导致长时间停顿。 - 内存碎片:CMS采用标记-清除算法,不会对内存进行压缩整理。长期运行后,老年代会产生大量内存碎片。当需要分配一个大对象(如大数组)时,即使总空闲内存足够,也可能因为找不到连续空间而提前触发Full GC。这时需要开启
-XX:+UseCMSCompactAtFullCollection(默认开启)和-XX:CMSFullGCsBeforeCompaction(多少次Full GC后压缩一次)来整理碎片。 - 复杂度高,调优困难:参数众多,且相互影响,调优如同走钢丝。
- 对CPU资源敏感:并发阶段会占用一部分CPU资源(默认启动
CMS的落幕尽管CMS曾风光无限,但其固有的问题(碎片化、并发失败)在内存越来越大、应用越来越复杂的今天变得越发突出。从JDK9开始,CMS被标记为废弃(Deprecated),并在JDK14中被彻底移除。它的历史使命已经完成,但其“并发降低停顿”的思想被后续的G1、ZGC完美继承和超越。
排查案例:一个使用CMS的线上服务频繁发生长达数秒的Full GC。经排查,发现是
-XX:CMSInitiatingOccupancyFraction设置得过于激进(85%),且堆内存设置较小。在流量高峰时,老年代占用迅速超过92%,触发了“并发模式失败”,进而导致Serial Old的漫长Full GC。解决方案是调低触发阈值(至70%),并适当增加堆内存。这个案例典型地说明了CMS调优的关键在于平衡:预留空间既要足够大以避免并发失败,又不能太大导致GC过于频繁。
6. 面向全堆的收集器:G1(Garbage-First)
G1是JDK9及之后版本的默认垃圾回收器,它的设计目标是在延迟可控的情况下,尽可能获得高吞吐量。它标志着垃圾回收器从“分代”思维转向了“分区”思维。
G1的核心革新:Region分区模型G1不再坚持固定的新生代、老年代物理划分,而是将整个堆内存划分为多个大小相等(默认约2048个Region,每个Region大小1MB~32MB)的独立区域(Region)。每个Region在逻辑上被标记为Eden、Survivor、Old或Humongous(用于存放大对象)角色,但这些角色不是固定的,是可以变化的。
G1的工作流程:Mixed GC与回收优先级的核心G1虽然保留了新生代和老年代的概念,但其回收方式不再是简单的Young GC或Full GC,而是引入了Mixed GC的概念。
- Young GC:当Eden区的Region被占满时,会触发Young GC。这是一个STW的过程,会将Eden区和Survivor区的存活对象复制到新的Survivor区或晋升到Old区。
- 并发标记周期(Concurrent Marking Cycle):这不是每次Young GC都做。当堆内存总体使用率达到阈值(
-XX:InitiatingHeapOccupancyPercent,默认45%)时,G1会启动一个类似于CMS的并发标记周期,用于标记整个堆的存活对象。这个周期也包含初始标记、并发标记、最终标记、清理等阶段。 - Mixed GC:在并发标记周期完成后,G1就知道了哪些Region里垃圾最多(即回收价值最高)。Mixed GC会同时回收一部分新生代Region和一部分老年代Region。这就是“Garbage-First”名字的由来:优先回收垃圾最多的Region,从而在有限的时间内(通过
-XX:MaxGCPauseMillis设定,默认200ms)获得最大的回收效益。
G1的关键优势
- 可预测的停顿时间模型:通过设置
-XX:MaxGCPauseMillis,你可以给G1一个停顿时间目标。G1会根据这个目标,在每次回收时,智能地选择一部分回收价值最高的Region进行回收,尽量把停顿时间控制在你设定的范围内。 - 整体采用标记-整理算法,局部采用复制算法:从整体(一次回收多个Region)上看,G1通过将存活对象从一个Region复制到另一个空Region,实现了整理效果,避免了CMS的内存碎片问题。从局部(两个Region之间)看,它使用的是高效的复制算法。
- 更精细的内存管理:Region模型使得大对象(Humongous)的管理更高效,减少了因大对象分配导致的提前GC。
G1的调优要点G1的参数比CMS更友好,但仍有几个关键点:
-XX:MaxGCPauseMillis:这是最重要的目标参数。但和Parallel Scavenge一样,不要设得过于激进。通常先观察不做任何设置时的停顿时间,然后设定一个略高于平均值的合理目标。-XX:G1HeapRegionSize:Region大小。一般不用手动设置,G1会根据堆大小自动计算。但在某些特定场景(如已知对象大小分布)下,调整它可能影响效率。-XX:InitiatingHeapOccupancyPercent:触发并发标记周期的堆占用阈值。如果并发标记启动太晚,可能导致Mixed GC跟不上对象分配速度,引发Full GC。可以适当调低此值(如40%)来提前启动标记。
经验之谈:从CMS迁移到G1,对于大多数应用来说是平滑的,甚至无需调优就能获得更好的表现。G1的“自适应”能力很强。监控时,重点关注“Mixed GC”的停顿时间和频率,以及是否发生了“Evacuation Failure”(疏散失败,会导致Full GC)。如果频繁发生Full GC,通常需要增加堆内存或降低
-XX:InitiatingHeapOccupancyPercent。
7. 面向未来的超低延迟回收器:ZGC与Shenandoah
当延迟要求进入亚百毫秒、甚至十毫秒级别时,G1的停顿(通常仍在几十到两百毫秒)可能也无法满足要求,例如金融交易、实时游戏、电信系统。ZGC(Z Garbage Collector)和Shenandoah GC就是为了这个目标而生的“黑科技”收集器,它们都将停顿时间目标设定在10ms以内,且停顿时间不会随着堆内存的增大而显著增加。
ZGC的核心技术:染色指针与读屏障ZGC实现超低延迟的核心在于两大技术:
- 染色指针(Colored Pointers):ZGC在64位指针的高位借用了几个比特位来存储元数据信息(如标记、重定位状态)。这意味着对象的状态信息不是保存在对象头里,而是保存在指向它的指针里。这带来了一个巨大好处:对象在GC过程中可以被移动,而无需修改所有引用该对象的指针。GC线程只需要修改指针中的元数据位,就能完成对象的标记和重定位。
- 读屏障(Load Barrier):这是与染色指针配合的技术。当应用线程从堆中加载一个引用时,JVM会插入一小段代码(读屏障),检查指针的元数据状态。如果发现该对象正在被重定位,读屏障会“拦截”这次读操作,先完成对象的移动,然后返回新的地址。这样,对象移动的工作就被“分摊”到了应用程序线程执行的过程中,极大地减少了STW的时间。
ZGC的工作阶段ZGC的回收周期也是并发的,它主要包括:
- 并发标记:遍历对象图,标记存活对象。
- 并发预备重分配:确定本次回收要清理哪些Region。
- 并发重分配:将存活对象从待回收的Region复制到新的Region。这是ZGC最核心的阶段,对象的移动是与应用线程并发进行的,主要依靠读屏障来保证引用的正确性。
- 并发重映射:修正所有指向旧对象地址的引用,指向新地址。
在整个周期中,STW阶段只有初始标记和最终标记,而且它们只扫描GC Roots,与堆大小无关,因此停顿时间极短且可控。
ZGC vs. Shenandoah两者都是超低延迟回收器,目标相似,但实现路径不同:
- ZGC:由Oracle开发,JDK15成为生产特性。核心是染色指针,需要特定的硬件/操作系统支持(如Linux/x64),其读屏障开销相对较低。
- Shenandoah:由Red Hat开发,JDK12成为生产特性。其核心是** Brooks指针**(一种转发指针),对象头中有一个额外的引用字段指向对象本身或新位置。它不依赖染色指针,因此移植性更好(支持更多平台),但其读屏障开销在早期版本中略高于ZGC。
如何选择与启用
- 选择:对于追求极致低延迟(P99停顿 < 10ms)、堆内存巨大(TB级别)的新应用,ZGC和Shenandoah是首选。目前社区中ZGC的接受度和优化进展似乎更活跃一些。
- 启用:命令行添加
-XX:+UseZGC或-XX:+UseShenandoahGC。注意,它们通常需要较新版本的JDK(如JDK17+)才能获得最佳性能和稳定性。
重要提示:ZGC和Shenandoah通过牺牲一部分吞吐量来换取极低的延迟。如果你的应用是计算密集型、对吞吐量要求极高但对延迟不敏感,那么使用它们可能得不偿失,Parallel或G1可能是更好的选择。启用前务必进行充分的压测,评估其读屏障带来的额外CPU开销对你的应用是否可接受。
8. 其他回收器与组合模式
除了上述主流回收器,JVM中还有一些其他存在,它们通常在特定场景下使用。
Serial Old前面已介绍,它是Serial收集器的老年代版本,也作为CMS并发失败时的“备胎”使用。
Parallel OldParallel Scavenge的老年代搭档,前面已介绍。
Epsilon一个非常特殊的“无操作”回收器,从JDK11引入。它只负责分配内存,但从不回收垃圾。当堆内存耗尽时,JVM直接关闭。它的用途非常专一:
- 性能测试:用于衡量GC本身对应用性能的影响。使用Epsilon运行测试,得到的是“无GC开销”的理想性能基线。
- 超短生命周期任务:对于一些运行时间极短(几秒内)、内存需求确定的任务(如某些Lambda函数),可以精确分配足够内存,使用Epsilon避免任何GC开销。
- 启用参数:
-XX:+UseEpsilonGC。警告:切勿在生产环境中用于未知内存占用的服务!
回收器组合在JDK8及之前,新生代和老年代回收器可以按一定规则组合使用。常见的组合有:
-XX:+UseSerialGC: Serial + Serial Old-XX:+UseParallelGC/-XX:+UseParallelOldGC: Parallel Scavenge + Parallel Old-XX:+UseConcMarkSweepGC: ParNew + CMS + Serial Old (作为后备)- 这里的新生代是ParNew,它是Serial的多线程并行版本,专为与CMS配合而设计,因为CMS需要一种能与它配合的、可控制的新生代收集器。
-XX:+UseG1GC: G1 (自身涵盖新生代和老年代)
从G1开始,回收器趋向于一体化设计,不再需要用户显式组合。
9. 实战:如何为你的应用选择垃圾回收器?
了解了所有回收器,面对一个具体应用,到底该怎么选?这里提供一个决策流程图和详细说明:
第一步:明确应用类型与SLA要求这是最重要的前提。问自己几个问题:
- 吞吐量优先还是延迟优先?批处理、科学计算重吞吐;Web服务、交易系统重延迟。
- 可接受的停顿时间是多少?100ms?10ms?还是亚毫秒?
- 堆内存有多大?是小内存(<4G)、大内存(4G~32G)还是超大内存(>32G)?
- JDK版本是什么?这直接决定了你可用的选项。
第二步:根据决策树进行选择
graph TD A[开始选择GC] --> B{堆内存大小与JDK版本}; B -- 内存极小<br>或客户端模式 --> C[Serial GC]; B -- JDK8及以前 --> D{主要目标}; D -- 吞吐量优先 --> E[Parallel GC]; D -- 低延迟优先 --> F[CMS GC]; B -- JDK9+ --> G{延迟要求}; G -- 可接受>100ms停顿 --> H[G1 GC<br>(默认, 平衡之选)]; G -- 要求<10ms超低停顿 --> I{评估CPU与平台}; I -- Linux/x64, 追求更优性能 --> J[ZGC]; I -- 多平台支持需求 --> K[Shenandoah GC]; C --> L[完成选择]; E --> L; F --> L; H --> L; J --> L; K --> L;第三步:关键参数调优与验证选定回收器后,通常从默认配置开始。通过GC日志(-Xlog:gc*)和监控工具(如Prometheus + Grafana, JDK自带的jstat, jvisualvm)观察以下关键指标:
- Young GC频率与耗时:是否过于频繁?单次耗时是否正常?
- Old GC / Full GC频率与耗时:这是影响延迟的主要因素。关注是否有Full GC发生。
- 吞吐量:应用的实际业务处理能力。
- 内存使用情况:各区域使用率是否健康?是否有晋升过早等问题?
根据观察结果进行微调。例如,对于G1,如果Mixed GC停顿时间过长,可以尝试稍微调高-XX:MaxGCPauseMillis;如果频繁Full GC,可能需要增加堆内存或调整-XX:InitiatingHeapOccupancyPercent。
一个真实的选型案例一个提供实时价格推送的金融Java服务,要求99.9%的请求延迟低于50ms,堆内存配置为8G,使用JDK17。
- 分析:延迟要求高(<50ms),堆内存中等,JDK17支持所有现代回收器。
- 初选:G1是默认选项,其停顿时间通常在几十到两百毫秒,可能勉强满足要求,但不够保险。ZGC的设计停顿目标在10ms以内,更有保障。
- 测试:在预发环境,分别使用G1(默认参数)和ZGC(
-XX:+UseZGC)进行压测。 - 结果:G1的P99.9延迟在45ms左右, borderline(边缘)。ZGC的P99.9延迟稳定在5ms以下,且CPU开销在可接受范围内(增加约5%)。
- 决策:选择ZGC。虽然牺牲了一点吞吐量,但换来了极致的延迟稳定性和充足的安全边际。
最后,也是最重要的建议:没有银弹。最好的调优就是充分测试。在模拟真实流量和数据的预发环境进行长时间压测,观察GC行为,用数据说话,而不是凭感觉或照搬别人的配置。垃圾回收器的选择,最终是业务需求、硬件资源和运维复杂度之间的一个平衡。