ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

JVM垃圾回收器全解析:从Serial到ZGC的设计哲学与实战选型

2026/8/15 6:27:42 拓冰建站 浏览量
JVM垃圾回收器全解析:从Serial到ZGC的设计哲学与实战选型 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时间但吞吐量有牺牲且会产生“浮动垃圾”。代表CMSConcurrent Mark-Sweep。分区与算法革新时代Partitioning Algorithmic Era引入Region分区模型和更先进的算法如SATB在吞吐和延迟间寻求更好平衡。代表G1Garbage-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:GCTimeRatioGC时间与应用时间的比率。公式为1 / (1 GCTimeRatio)。默认值99表示GC时间不超过总时间的1%即 1/(199) 。这个参数直接决定了吞吐量目标。自适应调节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停顿变得不可接受。CMSConcurrent 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后压缩一次来整理碎片。复杂度高调优困难参数众多且相互影响调优如同走钢丝。CMS的落幕尽管CMS曾风光无限但其固有的问题碎片化、并发失败在内存越来越大、应用越来越复杂的今天变得越发突出。从JDK9开始CMS被标记为废弃Deprecated并在JDK14中被彻底移除。它的历史使命已经完成但其“并发降低停顿”的思想被后续的G1、ZGC完美继承和超越。排查案例一个使用CMS的线上服务频繁发生长达数秒的Full GC。经排查发现是-XX:CMSInitiatingOccupancyFraction设置得过于激进85%且堆内存设置较小。在流量高峰时老年代占用迅速超过92%触发了“并发模式失败”进而导致Serial Old的漫长Full GC。解决方案是调低触发阈值至70%并适当增加堆内存。这个案例典型地说明了CMS调优的关键在于平衡预留空间既要足够大以避免并发失败又不能太大导致GC过于频繁。6. 面向全堆的收集器G1Garbage-FirstG1是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:G1HeapRegionSizeRegion大小。一般不用手动设置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的停顿通常仍在几十到两百毫秒可能也无法满足要求例如金融交易、实时游戏、电信系统。ZGCZ Garbage Collector和Shenandoah GC就是为了这个目标而生的“黑科技”收集器它们都将停顿时间目标设定在10ms以内且停顿时间不会随着堆内存的增大而显著增加。ZGC的核心技术染色指针与读屏障ZGC实现超低延迟的核心在于两大技术染色指针Colored PointersZGC在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服务、交易系统重延迟。可接受的停顿时间是多少100ms10ms还是亚毫秒堆内存有多大是小内存4G、大内存4G~32G还是超大内存32GJDK版本是什么这直接决定了你可用的选项。第二步根据决策树进行选择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 GCbr默认 平衡之选]; 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行为用数据说话而不是凭感觉或照搬别人的配置。垃圾回收器的选择最终是业务需求、硬件资源和运维复杂度之间的一个平衡。