ARTICLE DETAIL

资讯详情

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

Java GC:干掉 CMS,G1 才是未来——2 万字深度详解

Java GC:干掉 CMS,G1 才是未来——2 万字深度详解 一、写在前面CMS 的谢幕与 G1 的加冕如果你接触 Java 的时间足够早一定听过这样一句调侃“CMS 就像一辆没有刹车的跑车越快越危险。”在 JDK 8 及更早的时代CMSConcurrent Mark Sweep并发标记清除几乎是所有高响应服务端应用调优的首选收集器。电商、支付、交易系统里大量出现-XX:UseConcMarkSweepGC的身影似乎只要开启 CMS就能显著降低停顿时间离“零停顿”更进一步。然而技术的演进从来不会因为习惯而停下脚步。JDK 9 将 CMS 正式标记为DeprecatedJDK 14 则彻底移除了 CMS 的实现代码。取代它的正是我们今天的主角G1Garbage-First收集器。G1 从 JDK 7 中作为实验特性登场到 JDK 9 成为默认收集器再到如今几乎统治服务端 JVM 的垃圾回收场景它用 Region 化的堆布局、可预测的停顿模型和持续进化的算法完成了一场漂亮的上位。本文不是一篇泛泛而谈的科普而是一份面向真实调优与原理深挖的长文。我们将从垃圾回收的基础理论讲起逐步拆解 CMS 的实现、缺陷与退役原因再全面剖析 G1 的数据结构、回收流程、停顿模型和调优参数最后给出 CMS 到 G1 的迁移建议与实战案例。无论你是正在准备把线上旧系统从 CMS 迁移到 G1还是想系统理解 G1 的实现细节这篇文章都能给你提供一个相对完整的知识框架。本文假设读者已经了解 Java 基础语法与 JVM 的基本概念。文中的所有配置均基于 OpenJDK 17 LTS 环境验证个别参数在 JDK 8 与 JDK 17 之间可能存在差异我会在涉及处特别说明。二、垃圾回收的底层地基先把基础打牢很多人一上来就研究 G1 的 Region 和 RSet结果发现根本看不懂原因往往是底层基础没有打牢。要理解 CMS 为什么失败、G1 为什么成功必须先回到三个最根本的问题什么是垃圾怎么找到垃圾找到之后怎么清理2.1 什么是垃圾引用与对象生命周期在 Java 中所谓的“垃圾”指的就是从任何 GC Roots 出发都无法到达的对象。主流 JVM 使用的判定方式是可达性分析而不是已经被淘汰的引用计数法。引用计数法无法解决循环引用的问题比如两个对象互相持有对方的引用但外界已经无法访问它们引用计数永远不为零造成内存泄漏。可达性分析的基本单位是GC Roots。常见的 GC Roots 包括虚拟机栈栈帧中的本地变量表中引用的对象方法区中类静态属性引用的对象方法区中常量引用的对象本地方法栈中 JNI即 Native 方法引用的对象被同步锁持有的对象JVM 内部引用的对象如系统类加载器、基本数据类型对应的 Class 对象等。理解 GC Roots 非常关键因为后续 CMS 的并发标记、G1 的 STABSnapshot-At-The-Beginning原始快照算法本质上都是在围绕“如何在不暂停应用的情况下正确、完整地找到从 GC Roots 出发可达的对象”这一难题做文章。2.2 分代假设为什么堆要分层现代 JVM 垃圾回收器几乎都建立在两个经验假设之上弱分代假说Weak Generational Hypothesis绝大多数对象都是朝生夕死的存活时间极短。强分代假说Strong Generational Hypothesis熬过越多次垃圾收集的对象越难以消亡。基于这两个假设HotSpot 将堆划分为新生代Young Generation和老年代Old Generation。新生代又被细分为 Eden 区和两个 Survivor 区From 与 To也叫 S0 与 S1。新创建的对象大多先进入 Eden 区经历一次 Minor GC 后如果仍然存活就被复制到 Survivor 区之后每熬过一次 Minor GC 年龄就加一达到阈值默认 15受-XX:MaxTenuringThreshold控制后晋升到老年代。这一分层让垃圾回收器可以“区别对待”新生代回收频繁但成本低通常使用标记-复制算法老年代回收频率低但对象密度大、大对象多历史上使用过标记-清除或标记-整理算法。CMS 和老年代的矛盾正是从这组算法选择中埋下的伏笔。2.3 三种基础回收算法无论收集器再怎么复杂其底层都绕不开三种基础算法标记-清除、标记-复制、标记-整理。标记-清除Mark-Sweep先标记出所有需要回收的对象标记完成后统一回收所有被标记的对象。它的最大优点是简单不需要移动存活对象缺点是产生大量内存碎片。CMS 采用的就是这种算法碎片问题也成了 CMS 最被诟病的硬伤之一。标记-复制Mark-Copy将内存分为大小相等的两块每次只使用其中一块。回收时把存活对象复制到另一块上然后一次性清理使用过的那一块。优点是吞吐量高、没有碎片缺点是空间利用率只有一半。Edem 和 Survivor 采用的是这种思路。到了 G1 时代Region 内的回收本质上也是一种带优化的复制算法复制成了主流选择。标记-整理Mark-Compact标记完成后不直接清理而是让所有存活对象向一端移动然后清理边界以外的内存。优点是既能消除碎片又能避免复制算法中“留一半空间”的浪费缺点是移动存活对象需要更新引用、移动成本高并且必须暂停用户线程无法和用户线程并发执行。Parallel Old 采用这种算法而 G1 的 Full GC 也用整理来兜底。2.4 吞吐量与停顿永远的天平垃圾回收器的好坏通常用两个指标衡量吞吐量Throughput和停顿时间Pause Time。吞吐量是运行用户代码的时间占总运行时间的比例停顿时间是垃圾收集期间应用被暂停的总时长。此外还有内存占用、碎片率等次要指标。鱼和熊掌不可兼得。Serial、Parallel 追求高吞吐量但不顾停顿CMS 追求低停顿却牺牲吞吐量并留下碎片G1 的目标则是在可预测的停顿时间的前提下尽可能地获得较高的吞吐量。理解了这架天平你就能明白为什么 G1 的很多参数都是在“停顿时间”和“吞吐量”之间做平衡。三、走进 CMS优雅设计背后的先天缺陷CMS 的全称是 Concurrent Mark Sweep它以获取最短回收停顿时间为目标。在互联网应用崛起的年代服务器响应时间直接决定用户体验CMS 恰好迎合了这一需求因此在 JDK 5 到 JDK 8 期间被大规模采用。3.1 CMS 的适用对象与设计目标CMS 是一种老年代收集器通常与新生代的 ParNew 收集器配合使用。它的核心思想是把最耗时的标记、清除阶段与用户线程并发执行从而降低单次停顿时间。CMS 适用于对停顿时间敏感的服务如 Web 服务端、在线交易系统等。典型配置如下-XX:UseConcMarkSweepGC -XX:UseParNewGC -Xms4096m -Xmx4096m -XX:CMSInitiatingOccupancyFraction75 -XX:UseCMSInitiatingOccupancyOnly3.2 CMS 回收的四个阶段CMS 的一次老年代回收通常分为整整四个阶段。理解这四个阶段也就能看懂 CMS 的所有优劣势来源。阶段一初始标记Initial Mark这个阶段需要Stop The WorldSTW但停顿时长极短因为它只标记GC Roots 能直接关联到的对象速度非常快通常只需要几毫秒到十几毫秒。这个阶段完成之后就有一个初始的存活对象集合。阶段二并发标记Concurrent Mark从初始标记得到的对象出发与用户线程并发地遍历整个对象图找出所有可达对象。这一阶段不 STW持续时间较长可能长达数秒。并发标记期间用户线程依然在运行因此可能产生两种“错误”漏标Missing新产生的对象没有被标记导致对象被错误回收后果严重绝对不允许。浮动垃圾Floating Garbage标记期间原本可达的对象又变成垃圾但 CMS 这一轮不会回收它们等到下一轮再处理这是可以接受的。为了避免漏标CMS 在并发标记阶段使用了增量更新Incremental Update算法来记录引用变化。阶段三重新标记Remark并发标记阶段结束后由于用户线程又修改了一部分引用上一阶段的标记结果已经不完全准确。重新标记阶段需要STW用来修复并发标记阶段的遗漏和误差。这个阶段的停顿时间通常比初始标记长得多是 CMS 停顿的主要来源之一。为了尽量缩短重新标记的停顿CMS 会借助一些优化手段比如新生代对象尽量在重新标记前触发一次 Minor GC减少需要修正的对象数量。阶段四并发清除Concurrent Sweep重新标记完成后开始与用户线程并发地清理垃圾对象。这一阶段也不 STW。由于使用标记-清除算法清除后的内存会产生碎片留下大量不连续的可用空间。整个流程可以用下面这张表格总结阶段是否 STW主要工作耗时特征初始标记是标记 GC Roots 直接关联对象极短毫秒级并发标记否遍历对象图长与业务并发重新标记是修正并发期间引用变化较短但明显大于初始标记并发清除否清理垃圾对象与业务并发3.3 CMS 的三大顽疾CMS 的设计看似优雅但工程实践中暴露出几个无法绕开的致命问题。这些问题不是参数调优就能彻底解决的而是由算法本质决定的。顽疾一内存碎片与 Concurrent Mode Failure标记-清除算法最大的代价是内存碎片。随着运行时间变长老年代会出现大量小块空闲空间当需要分配一个大对象时即使总的空闲内存充足也可能因为找不到连续空间而分配失败。此时 CMS 会退化为使用 Serial Old 收集器进行一次Full GCSingle-threaded 的整理过程会导致长时间停顿往往高达几秒甚至几十秒对在线服务是灾难性的。更糟的情况是Concurrent Mode Failure并发模式失败在并发标记、并发清除阶段由于用户线程还在不断产生新对象晋升老年代如果老年代在收集完成前就被填满CMS 无法继续只能紧急退化为 Full GC 进行停顿整理。停顿时间急剧上升这正是“跑车没有刹车”说法的由来。为了降低碎片影响CMS 提供了-XX:UseCMSCompactAtFullCollection和-XX:CMSFullGCsBeforeCompaction等参数在 Full GC 时进行整理但整理操作是全停顿的治标不治本。顽疾二CPU 资源敏感CMS 的并发标记和并发清除阶段都要消耗大量 CPU 资源。虽然默认的并发线程数是(CPU 核数 3) / 4看似不多但在 CPU 核数较少的机器上GC 线程与业务线程抢占 CPU导致业务吞吐量明显下降。在容器化、资源受限的环境中这个问题尤为突出。顽疾三浮动垃圾与触发时机难控并发清除阶段用户线程仍然会产生新垃圾这些“浮动垃圾”本轮无法回收只能留给下一轮。因此 CMS 不能等到老年代快满时才回收必须预留一部分空间给并发期间的分配。这就引出了-XX:CMSInitiatingOccupancyFraction参数通常在 68% 到 75% 左右触发回收。触发比例设太高容易引发 Concurrent Mode Failure设太低又会频繁触发 CMS增加 CPU 开销。这个参数非常难调也让 CMS 的运维复杂度居高不下。3.4 CMS 的退役之路从 JDK 9 开始CMS 被标记为弃用并在 JDK 14 中彻底移除。官方给出的替代方案就是 G1。对开发者而言从 JDK 8 升级到更高版本时必须把 GC 参数从 CMS 切换到 G1、ZGC 或 Shenandoah。你可以通过java -XX:PrintCommandLineFlags -version查看当前 JDK 的默认 GC。以 JDK 17 为例默认已经使用了 G1。java -XX:PrintCommandLineFlags -version # 输出示例 # -XX:UseG1GC如果仍然使用 CMS 参数启动 JDK 17会出现类似Unrecognized VM option UseConcMarkSweepGC的报错提示参数已经不再被支持。四、G1 亮相区域化堆与可预测停顿G1Garbage-First的设计目标是在停顿时间可控的前提下尽量提升吞吐量。它不再使用连续的内存分代布局而是把堆划分成一个个大小相等的Region彻底改变了传统收集器的运作方式。4.1 Region把堆切成棋盘G1 的堆由一组大小相等的 Region 组成每个 Region 在逻辑上可以扮演不同的角色Eden、Survivor、Old以及特殊的Humongous大对象区域。Region 的大小通过-XX:G1HeapRegionSize指定必须是 2 的 N 次幂范围从 1MB 到 32MB默认会根据堆大小自动计算大约使 Region 总数在 2048 个左右。这种设计的最大好处是回收不再局限于“整个新生代”或“整个老年代”。G1 可以挑选垃圾最多的若干个 Region 进行回收这就是 “Garbage-First” 名字的由来——优先回收收益最高的垃圾。4.2 G1 的回收类型G1 的回收动作分为四类理解它们能帮你读日志、看监控时快速定位问题。Young GC只回收新生代 Region。当 Eden 区被填满时触发整个过程 STW使用并行复制算法把存活对象复制到 Survivor 区或晋升到老年代 Region。Young GC 的效率和并行能力较高停顿通常较短。Mixed GC这是 G1 最具特色的回收方式。除了回收所有新生代 Region还会加入一部分老年代 Region进行回收。G1 会根据停顿时间目标和垃圾收益选择“最值得回收”的若干个老年代 Region而不是一次性回收全部老年代。Mixed GC 同样 STW但回收范围可控因此停顿时间可控。Full GC当对象分配速度太快、Region 严重不足或者 Mixed GC 无法跟上内存增长时G1 会退化为单线程的 Full GCJDK 10 以后支持并行 Full GC对整个堆做标记-整理。Full G
返回列表