ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

干掉 CMS,ZGC 才是未来:2 万字深度详解

干掉 CMS,ZGC 才是未来:2 万字深度详解 1. 为什么 CMS 必须被干掉在 Java 垃圾回收器的演进史上CMSConcurrent Mark Sweep曾经是低延迟应用的事实标准。从 JDK 1.4 引入至今大量电商、金融、中间件系统都曾在 JVM 参数里写下那一串熟悉的-XX:UseConcMarkSweepGC。然而CMS 在 JDK 9 被标记为废弃JDK 14 被正式移除。很多人以为这只是社区例行清理但实际上CMS 的没落是被技术趋势宣判的必然结果。CMS 的核心设计目标是减少停顿时间。它采用“标记-清除”算法并把大部分标记工作放到和应用线程并发执行试图通过牺牲吞吐量和 CPU 来换取低停顿。这个思路在早期硬件和业务规模下是可行的但随着堆内存越来越大、对象生命周期越来越复杂、CPU 核心越来越多CMS 的设计缺陷被逐个放大最终到了无法维护的地步。而 ZGCZ Garbage Collector的出现则是垃圾回收技术的一次范式转移。它把停顿目标从 CMS 的几百毫秒、G1 的几十毫秒直接拉低到亚毫秒级别并且不随堆大小和对象数量而恶化。更重要的是ZGC 在 JDK 15 正式转正JDK 21 引入分代 ZGC已经具备面向生产环境的完整能力。这篇文章将从 CMS 的工作原理讲起逐条拆解它的致命缺陷然后深入 ZGC 的技术内核给出完整的迁移路径、性能对比、调优实践和避坑指南帮助你理解为什么“干掉 CMSZGC 才是未来”不是一句口号而是一条经过充分验证的技术路线。2. CMS 的前世今生经典布局与工作原理2.1 CMS 的历史定位在 JDK 1.4 之前HotSpot 主要的垃圾回收器是 Serial GC 和 Parallel GC。它们都采用完整的 Stop The WorldSTW方式回收堆越大停顿越长。随着互联网应用对响应时间的要求提高一个能在应用运行期间并发完成大部分回收工作的收集器成为刚需。CMS 应运而生它是 HotSpot 第一款真正意义上的并发收集器专门服务以响应时间为优先的应用。CMS 只负责老年代回收通常与 ParNew 配合组成“ParNew CMS”的经典组合。年轻代由 ParNew 并行复制回收老年代由 CMS 并发标记清除。这个组合统治了 Java 服务端长达十余年。2.2 分代假设与内存布局CMS 建立在分代收集理论上。JVM 将堆内存划分为年轻代和老年代。年轻代存放生命周期短的对象使用复制算法回收老年代存放生命周期长的对象使用标记-清除算法回收。年轻代又细分为 Eden 区和两个 Survivor 区。对象先在 Eden 分配经过若干次 Minor GC 存活后晋升到老年代。这套布局的假设是绝大多数对象朝生夕灭。年轻代只复制存活对象回收成本低老年代因为存活率高采用标记-清除避免反复复制。在堆内存只有几个 GB、对象规模有限的年代这个假设大体成立。CMS 还会维护一个叫做“卡表Card Table”的结构用来记录老年代中哪些区域可能存在指向年轻代的引用从而在 Minor GC 时避免扫描整个老年代。同时通过写屏障在引用赋值时维护卡表状态。2.3 CMS 的七个回收阶段CMS 的一次老年代回收分为七个阶段其中两个阶段需要 Stop The World其余阶段与应用线程并发执行。完整周期如下初始标记Initial MarkSTW只标记 GC Roots 能直接关联到的对象以及年轻代指向老年代的对象。这一步停顿时间很短但依赖一次完整的 Minor GC因为需要年轻代对象作为根。并发标记Concurrent Mark从 GC Roots 出发遍历整个对象图标记所有存活对象。这个阶段与应用并发耗时最长期间应用仍然对外服务。并发预清理Concurrent Preclean处理并发标记期间引用关系发生变化的对象把脏卡重新标记尽量降低重新标记阶段的负担。可中止的并发预清理Concurrent Abortable Preclean在等待 Minor GC 的过程中继续预清理把重新标记的等待时间分摊掉。这个阶段可以被中止因此带“Abortable”。重新标记Final RemarkSTW暂停所有应用线程修正并发标记期间因程序运行导致的标记偏差。这个阶段停顿时间比初始标记长因为要处理大量并发期间产生的变更。并发清除Concurrent Sweep清理所有未被标记的对象回收其占用的内存。这个阶段也与应用并发。并发重置Concurrent Reset重置 CMS 内部数据结构为下一次回收做准备。从流程上看CMS 已经尽力把 STW 压缩到初始标记和重新标记两个点。单看设计意图CMS 是优雅的把最耗时的标记和清除过程并发化。但真实的工程世界远比纸面复杂CMS 的问题恰恰源于它对“并发”和“标记-清除”的过度依赖。3. CMS 的七宗罪致命缺陷逐个拆解3.1 并发模式失败最让人头疼的兜底CMS 最大的隐患是 CMS Full GC 的兜底逻辑。CMS 在并发回收期间如果老年代剩余空间不足以容纳新晋升的对象或者无法满足分配需求就会触发并发模式失败Concurrent Mode Failure。触发条件通常是老年代使用率达到某个阈值默认 92%可由-XX:CMSInitiatingOccupancyFraction控制后开始回收但回收期间应用持续分配一旦新对象把剩余空间耗尽CMS 就会放弃并发回收退化为一次 Serial Old 单线程 Full GC。这次兜底 Full GC 是灾难性的单线程、全堆扫描、完整 STW。在线上的大堆应用里一次 Serial Old Full GC 停顿几十秒甚至几分钟都很常见。CMS 的全部努力就在一次并发模式失败后被彻底清零。而且并发模式失败往往发生在流量高峰频繁失败会导致系统陷入反复 Full GC 的死亡螺旋。很多人给 CMS 调大CMSInitiatingOccupancyFraction看似推迟了回收触发点实际上让并发回收的可用缓冲空间更小反而更容易并发模式失败。这是一种进退两难的结构性矛盾。3.2 内存碎片标记-清除的原罪CMS 使用标记-清除算法它不像复制算法那样能整理内存也不像标记-整理算法那样能消除碎片。经过多次并发回收后老年代会出现大量不连续的小空闲区。当应用需要分配一个较大的连续对象时即使老年代总体空闲空间足够也可能因为没有足够大的连续空间而分配失败触发 Full GC。碎片问题还有两个衍生问题一是大对象如大数组、大字符串的分配对连续性要求极高很容易被碎片卡死二是碎片会导致提前晋升或者分配路径变慢。CMS 提供了一个-XX:UseCMSCompactAtFullCollection参数在不可避免的 Full GC 时进行压缩整理但这只是亡羊补牢无法根治碎片而且碎片整理仍然需要 STW。3.3 浮动垃圾与两次 STW 的不可控CMS 的初始标记依赖 Minor GC实际线上环境中CMS 的初始标记与 Minor GC 耦合在一起这导致年轻代停顿和老年代停顿相互叠加停顿时间难以精确预估。重新标记阶段在多核大堆下也可能非常漫长。浮动垃圾问题同样棘手并发清除结束后新产生的垃圾要等下一轮才能被回收进一步压缩了可用空间。3.4 CPU 与吞吐量的巨大损耗CMS 的并发标记和并发清除要占用大量 CPU 资源。默认情况下CMS 会启用大约四分之一的 CPU 核心来执行并发回收线程公式为(CPU核心数 3) / 4。在 4 核机器上CMS 最多用 1 个核心在 32 核机器上CMS 会占用约 8 个核心。这些被占用的 CPU 不会用于业务计算直接导致吞吐量下降。对于 CPU 密集型应用CMS 的并发回收会与业务线程激烈争夺 CPU业务性能可能因此下降 20% 以上。CMS 用吞吐量换停顿的设计在很多场景下得不偿失。3.5 无法处理超大堆随着服务器内存从 32GB 迈向 256GB、512GB 甚至 TB 级CMS 的表现急剧恶化。标记阶段需要扫描全堆堆越大并发标记时间越长浮动垃圾越多触发回收越频繁。在 100GB 以上的堆里CMS 经常出现“回收速度赶不上分配速度”的窘境最终沦为不停 Full GC 的机器。更重要的是CMS 没有分区概念全堆式扫描让它的可扩展性被锁死。堆一旦变大CMS 的建模和调优就会变成一场赌博参数需要反复试探而且不同业务负载下表现完全不同。3.6 调优困难黑盒化严重CMS 的参数数量惊人CMSInitiatingOccupancyFraction、UseCMSInitiatingOccupancyOnly、CMSParallelRemarkEnabled、CMSClassUnloadingEnabled、CMSScavengeBeforeRemark、CMSMaxAbortablePrecleanTime等等。每次调优都像是在解一个多变量方程任何一个参数调整都可能引发连锁反应。再加上 GC 日志信息相对杂乱新手几乎无法独立完成 CMS 调优。3.7 官方放弃维护CMS 的代码在 HotSpot 中维护成本极高。随着 G1 的成熟和 ZGC、Shenandoah 的崛起OpenJDK 社区决定不再维护 CMS。JDK 9 标记废弃JDK 14 正式移除。继续使用 CMS意味着你将失去官方支持、安全更新和性能优化在新技术面前彻底掉队。4. 从 CMS 到 G1Java 垃圾回收的过渡时代4.1 G1 的设计突破在讨论 ZGC 之前有必要先回顾 G1。G1Garbage First从 JDK 7 开始商业化可用JDK 9 成为默认收集器。G1 抛弃了 CMS 的物理分代细粒度布局改用 Region 分区管理。整个堆被划分为大量大小相等的 Region逻辑上仍然区分年轻代和老年代但物理上不再连续。G1 通过分析每个 Region 的垃圾占比优先回收“性价比最高”的 Region这也是它名字的由来。G1 提供了-XX:MaxGCPauseMillis目标停顿参数把调优从“调节各种阈值”简化为“告诉它你想要多短”。对于多数应用G1 已经比 CMS 表现更好停顿更可控。4.2 G1 的局限但 G1 仍然不完美。它的并发标记与 CMS 类似依赖 SATBSnapshot At The Beginning算法混合回收阶段仍然可能产生较长停顿。G1 的目标停顿值并非硬保证在堆非常大、对象图非常复杂时G1 的实际停顿可能严重偏离目标值。默认 200 毫秒的目标在大型应用中经常被击穿。G1 从根本上仍然属于“基于 STW 的回收”只是把 STW 切得更碎。它没有能力把停顿降到亚毫秒级别。真正的低延迟需要一种全新的回收范式而这就是 ZGC 登场的理由。5. ZGC 横空出世重新定义低延迟 GC5.1 ZGC 的定位与承诺ZGC 从 2018 年作为实验特性出现在 JDK 11 中设计目标是停顿时间不超过 10 毫秒且在堆大小和存活对象数量增长时仍然保持。支持从几百 MB 到 16TB 的堆内存。相比 G1吞吐量损失不超过 15%。未来能够支持分代收集和压缩指针等特性。这里的 10 毫秒并不是平均停顿而是目标上界。在 JDK 21 的分代 ZGC 中孤儿停顿进一步降低典型停顿可以稳定在 1 毫秒以下甚至接近 0.5 毫秒。这个数量级已经超出了传统 GC 的想象空间。ZGC 在 JDK 15 转正为生产可用JDK 16 支持并发线程栈扫描JDK 17 支持动态扩展堆和未提交内存JDK 21 引入分代 ZGC默认启用。5.2 ZGC 的三大核心机制ZGC 之所以能把停顿压到亚毫秒级靠的是三个相互配合的机制着色指针Colored PointersZGC 在 64 位指针中借用若干位来存储对象的标记信息和转发信息。也就是说GC 状态直接编码在指针里而不是维护额外的标记位图。这样 GC 可以避免为了读取元数据而访问对象头大幅减少内存访问开销。读屏障Load Barrier当应用线程从堆中读取对象引用时会经过一个轻量级读屏障。如果指针的标记状态表明这个引用指向的对象已经被移动读屏障会立即修正指针指向新的位置。这样即使对象被 GC 移动应用线程也无需停顿等待。多重映射Multi-Mapping与地址视图ZGC 在虚拟内存中为同一块物理内存维护多个地址视图例如 Marked0、Marked1 和 Remapped。当 GC 需要改变指针的颜色语义时只需要切换地址视图而不需要物理移动内存。这结合着色指针使用可以在并发移动对象时保持指针一致性。这三者共同实现了一个了不起的目标ZGC 的 STW 阶段只做根扫描等极小的工作对象移动、标记、重映射全部与应用并发完成。因此停顿不再与堆大小和对象数量成正比。6. ZGC 核心技术深度解析6.1 着色指针把 GC 信息写进指针在 64 位系统上指针通常只使用低 48 位地址空间高位有大量空闲位。ZGC 利用这些高位存储 GC 元数据。具体来说ZGC 使用了其中 4 位来标记对象状态包括 Marked0、Marked1、Remapped 和 Finalizable 等信息。当对象被标记或被移动时ZGC 并不修改对象头而是翻转指针上的颜色位。例如一个对象在本次回收中被标记它的引用指针会从 Remapped 状态变为 Marked 状态后续读屏障遇到 Marked 指针时就触发重映射操作将对象引用修正到新的地址。着色指针的优势在于零附加内存GC 状态信息跟随指针流动不需要额外的对象头字段或外部位图。它的代价是增加了读屏障的检查逻辑但现代 CPU 的分支预测和缓存让这一开销被控制在极低水平通常在 4% 以内。6.2 读屏障并发移动的正确性保证传统 GC 在移动对象时必须停止所有应用线程否则应用线程可能通过旧引用访问已经被移动的对象。ZGC 通过读屏障解决这个问题。每次应用线程从堆读取引用时读屏障会检查引用指针的颜色如果发现对象已经移动到新地址就自动修正引用如果发现对象尚未完成标记就先完成必要的标记工作。读屏障的检查逻辑可以概括为当从堆中加载引用时若指针处于“坏颜色”状态就以慢路径处理若处于“好颜色”状态则直接返回。几乎所有的正常读取都走快路径只有触及正在被回收的少量对象时才进入慢路径。值得注意的是ZGC 的读屏障是自愈的Self-Healing一旦引用被修正它就直接指向新地址后续读取不会再触发慢路径。这与 G1 的 SATB 屏障有本质区别G1 的记录型屏障会持续产生写前日志而 ZGC 的读屏障在被修正后不再产生额外开销。6.3 并发重定位无 STW 的对象移动传统标记-整理收集器在移动对象时会产生两个痛点一是移动本身需要 STW二是指针更新需要遍历整个对象图。ZGC 的并发重定位分为两个阶段第一阶段并发标记阶段识别出需要移动的存活对象并把它们复制到新的 Region第二阶段通过读屏障在应用线程访问对象时“顺路”修正引用。这意味着 ZGC 不急于在回收周期内一次性更新所有引用而是把重映射的成本推迟到应用访问对象的那一刻并通过读屏障完成“按需修正”。在 STW 阶段ZGC 只处理根集合等极小的工作量对象移动、标记、重映射全部与应用并发完成。因此无论堆是 8GB 还是 8TB对象移动本身都不会显著增加停顿时间。7. 从 CMS 到 ZGC完整迁移路径7.1 迁移前的评估并不是所有应用都一定要立刻切换到 ZGC。迁移前需要确认三个前提第一JDK 版本足够新生产环境建议直接使用 JDK 17 或 JDK 21 两个 LTS 版本其中 JDK 21 的 ZGC 默认启用分代模式更贴近传统分代 GC 的使用习惯第二应用确实存在低延迟诉求例如 P99 延迟敏感的支付链路、网关、实时计算或核心中间件第三团队具备基本的 GC 日志分析和监控能力。如果当前仍跑在 JDK 8 且短期无法升级则 ZGC 不在可选范围之内。7.2 参数切换与启动配置从“ParNew CMS”切换到 ZGC不是简单替换一个收集器名称而是要清理整套 CMS 专属参数。以下是一组 JDK 21 分代 ZGC 的推荐启动参数java -Xms16g -Xmx16g \ -XX:UseZGC \ -XX:ZGenerational \ -Xlog:gc:gc.log:time,uptime,level,tags \ -jar app.jar配置要点如下-Xms与-Xmx建议保持一致减少堆动态扩缩带来的抖动不再需要-XX:UseConcMarkSweepGC、-XX:UseParNewGC、-XX:CMSInitiatingOccupancyFraction等历史参数如果仍使用 JDK 17 的非分代 ZGC则不要添加-XX:ZGenerational。7.3 灰度与回滚策略迁移不要一蹴而就。建议先在预发或影子集群跑通完整回归再按 10%、30%、50%、100% 的节奏灰度切流。灰度期间重点观察三类指标GC 停顿的 P99 和最大停顿、吞吐量变化、内存实际占用量。回滚路径也很简单如果出现不可接受的性能回退只需移除-XX:UseZGC并恢复原 GC 参数后重启即可业务代码不需要任何改动。8. ZGC 性能对比实测8.1 停顿时间对比停顿时间是 ZGC 的主场。不同收集器在典型生产负载下的表现可以归纳如下收集器正常停顿量级最差表现超大堆支持CMS几十毫秒Serial Old Full GC 可达秒级甚至分钟级差G1几十到几百毫秒默认 200ms 目标常被击穿一般ZGC亚毫秒到毫秒级10 毫秒目标上界稳定优秀支持 16TBCMS 在并发回收正常时停顿约几十毫秒但一旦触发并发模式失败停顿可能彻底失控G1 虽然把停顿切碎却仍然无法摆脱 STW 的上限ZGC 的分代版本在多数场景下可以把停顿稳定在 1 毫秒以内。8.2 吞吐量与 CPU 开销ZGC 的吞吐量相比 G1 通常损失不超过 15%这是为了保证亚毫秒停顿而付出的代价。与 CMS 相比ZGC 的停顿表现碾压式领先而吞吐量未必更差因为 CMS 并发失败后的 Full GC 会让平均吞吐量大幅波动。对 CPU 敏感的业务可以适当限制 ZGC 的并发线程数以换取更平稳的业务延迟。8.3 内存占用与特殊开销ZGC 因为着色指针和多重映射早期版本在内存占用上略显激进虚拟地址空间会被放大但实际物理内存仍主要由堆大小决定。JDK 21 分代 ZGC 引入后内存回收更及时、碎片更少整体占用已经明显优化。迁移时建议预留比传统 GC 略高的内存水位并通过 GC 日志观察堆的真实使用曲线。9. ZGC 调优实践9.1 堆大小与内存配置ZGC 的调优原则是“能不改就不改”。首选固定堆大小把-Xms和-Xmx设为相同值避免动态扩缩带来的停顿波动。堆大小应根据对象常驻量和回收速率综合设置而不是沿用 CMS 时代的默认值。内存过小会让系统频繁进入分配停顿内存过大虽然不会拖垮停顿却会提高单次标记扫描的成本。9.2 并发线程数ZGC 的并发线程数由-XX:ConcGCThreads控制默认按 CPU 核心数自动计算通常不需要调整。只有当应用线程和 GC 线程频繁争抢 CPU、业务吞吐明显下降时才考虑下调该值。注意不要把值调得过低否则回收速度不足可能触发分配停顿。9.3 GC 日志与监控ZGC 的日志比 CMS 更模块化。推荐启用-Xlog:gc并按需追加gcheapinfo、gcphasesdebug等标签。分代 ZGC 会分别输出年轻代和老年代的回收信息团队应重点监控停顿时间分布、回收频率和分配停顿建立与业务指标联动的告警而不是只看 GC 次数。10. ZGC 避坑指南10.1 分配速率过高当应用对象的分配速率超过回收能力时ZGC 会触发分配停顿Allocation Stall表现为短暂的分配回退等待。解决思路不是无脑调大堆而是从业务侧减少临时对象、复用对象池或者优化高分配热点代码。监控时如果看到分配停顿频繁出现说明应用的内存分配模式需要优化而不是 ZGC 本身出了问题。10.2 大页与透明大页ZGC 依赖多重映射对页表比较敏感。部分内核的透明大页Transparent Huge PagesTHP配置不当会导致延迟毛刺和内存碎片。建议在部署 ZGC 应用的机器上关闭 THP或者显式配置大页并预留足够空间避免运行时页表频繁分配带来的停顿。10.3 压缩指针与老版本限制早期 ZGC 不支持压缩指针很多从 JDK 8 升级的项目会残留-XX:UseCompressedOops等历史参数。当前 JDK 17 和 JDK 21 的 ZGC 已支持压缩指针但仍建议清理历史遗留的 GC 参数避免不同参数之间的兼容性问题。升级前务必在测试环境用实际启动参数完整验证一遍。10.4 监控指标误读ZGC 的并发工作方式与传统 STW 收集器不同单纯看“GC 暂停时间”容易误判。例如ZGC 的 CPU 使用率会包含并发标记和并发重定位的开销而传统监控可能把这段归到业务负载中。正确做法是同时观察 GC 停顿、吞吐量和业务延迟三者建立反映真实体验的指标看板。11. 总结CMS 已死ZGC 才是未来CMS 的没落不是偶然并发模式失败、内存碎片、浮动垃圾、CPU 损耗、超大堆无力、调优黑盒和官方放弃维护七宗罪无一不是生产事故的温床。G1 是过渡ZGC 才是答案。它用着色指针、读屏障和并发重定位把停顿压到亚毫秒并在 JDK 21 分代化之后补齐了最后一块短板。从 CMS 到 ZGC 的迁移技术门槛其实比想象中低升级到 JDK 17 或 JDK 21、清理历史 GC 参数、做好灰度和监控即可。真正的挑战在于改变团队对“GC 必须停顿”的旧认知。当你亲眼看到 16TB 堆上的停顿不到 1 毫秒时就会明白旧时代的收集器是时候退场了。
返回列表