Java虚拟机之CMS垃圾收集器

 2023-01-06
原文作者:技术能量站 原文地址:https://juejin.cn/post/6988804658055610376

1. 前言

Concurrent Mark Sweep (CMS) 收集器是hotspot虚拟机中一款 低延迟并发型 垃圾收集器。CMS垃圾收集器的关注点是:尽可能缩短垃圾收集时用户线程的停顿时间,停顿时间越短(低延迟)就是越适合与用户交互的程序,良好的响应速度能提升用户体验。

CMS 垃圾收集器 以获取最短回收停顿时间为目标,采用“标记-清除”算法,分 4 大步进行垃圾收集,其中初始标记和重新标记会 STW ,多数应用于互联网站或者 B/S 系统的服务器端上,JDK9 被标记弃用,JDK14 被删除,详情可见 JEP 363

不幸的是,CMS作为老年代的垃圾收集器,却无法与Jdk 1.4中以及存在的新生代收集器 Parallel Scavenge配合工作,所以在jdk 1.5 中 使用CMS来收集老年代的时候,新生代就只能选择 ParNew 或者Serial 收集器中的一个。

202301011604506591.png 在G1 出现之前,CMS使用还是非常广泛的。

CMS 收集器通过命令行选项启用-XX:+UseConcMarkSweepGC

2. CMS工作原理

CMS提出时最大的创新在于其针对老年代并发收集的理念,下面来分析并发收集的过程。根据oracle提供的关于CMS的官方文档描述

整个并发收集通常包括以下步骤:

202301011604522532.png

  1. 初始标记
  2. 并发标记
  3. 并发预清理
  4. 重新标记
  5. 并发清除
  6. 并发重置

上述步骤只有初始化标记和重新标记会STW(Stop The World),其余三个步骤与应用程序mutator都是并发的,下面来看每个步骤具体的细节。

(1) 初始标记(Initial Mark)

初始标记 目标是 标记老年代中的GC Roots直接关联的对象 以及 年轻代指向老年代引用的对象 ,这个过程会发生STW,不过这个阶段速度还好,因为不会继续向下遍历(只标记一层)

202301011604527803.png

这里扫描年轻代,是因为CMS主要回收老年代的对象,但是也存在一些年轻代的对象会指向老年代,需要通过扫描判断是不是垃圾(无用对象)。

(2)并发标记

这个过程不会停止用户线程( 不会发生STW ),这一阶段主要是从GC Roots继续递归遍历老年代,并标记可直接或间接到达的所有老年代存活对象。

在并发标记阶段,应用线程和GC线程是并发执行的,因此可能产生新的对象或对象关系发生变化。

比如:

  • 新生代的对象晋升到老年代
  • 直接在老年代分配对象
  • 老年代对象的引用关系发生变更

对于这些对象,需要重新标记以防止被遗漏。 为了提高重新标记的效率,本阶段会把这些发生变化的对象所在的Card 标识为Dirty ,这样后续就只需要扫描这些Dirty Card的对象,从而避免扫描整个老年代。

202301011604533174.png

并发预清理

该阶段主要是希望能减少下一阶段[重新标记]消耗时间,预标记线程和应用线程并行运行,此阶段将会重新扫描前一个阶段标记的Dirty对象,并标记被Dirty对象直接或间接引用的对象,对于一些对象引用有变动的堆区域标记为Dirty 。

202301011604539485.png

预清理阶段,这些脏对象被计算在内,并且可以从它们可到达的对象也被标记

202301011604545816.png 可终止预清理

可中止的并发预清理 这个阶段是尽量为下一个步骤减轻任务 ,默认时间是5s期望在时间内做一次YGC,从而减少下一次STW的时候扫描标记新生代引用老年代的对象个数。 由于这个阶段是循环的做两件事直到发生abort的条件,如:重复的次数、多少量的工作、持续的时间等:

  • 处理 From 和 To 区的对象,标记可达的老年代对象;
  • 和上一个阶段一样,扫描处理Dirty Card中的对象。

(3)最终标记

该阶段发生第二次STW, 目标是完成标记老年代中所有活动的对象,由于之前的预清理阶段是并发的,它们可能无法跟上应用程序的变化速度,所以需要STW 二次验证。

  • 遍历新生代对象,重新标记;(新生代会被分块,多线程扫描)
  • 根据GC Roots,重新标记;
  • 遍历老年代的Dirty Card,重新标记。这里的Dirty Card,大部分已经在Preclean阶段被处理过了。

在三个标记阶段之后,老年代的所有存活对象都被标记,现在垃圾收集器将通过清除老年代来回收所有未使用的对象:

(4)并发清除 与应用程序同时执行,无需STW。该阶段的目的是移除未使用的对象并回收它们占用的空间以备将来使用。

202301011604551487.png

(5)并发重置 将清理并恢复在CMS GC过程中的各种状态,重新初始化CMS相关数据结构,为下一个垃圾收集周期做好准备。 CSM执行日志:

    2020-10-21T23:42:56.214-0800: [CMS-concurrent-mark-start]
    2020-10-21T23:42:56.245-0800: [CMS-concurrent-mark: 0.031/0.031 secs] [Times: user=0.03 sys=0.00, real=0.03 secs]
    2020-10-21T23:42:56.245-0800: [CMS-concurrent-preclean-start]
    2020-10-21T23:42:56.246-0800: [CMS-concurrent-preclean: 0.001/0.001 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
    2020-10-21T23:42:56.246-0800: [CMS-concurrent-abortable-preclean-start]
     CMS: abort preclean due to time 2020-10-21T23:43:01.313-0800: [CMS-concurrent-abortable-preclean: 0.110/5.066 secs] [Times: user=0.15 sys=0.01, real=5.07 secs]
    2020-10-21T23:43:01.313-0800: [GC (CMS Final Remark) [YG occupancy: 20889 K (59008 K)]2020-10-21T23:43:01.313-0800: [Rescan (parallel) , 0.0032949 secs]2020-10-21T23:43:01.316-0800: [weak refs processing, 0.0000792 secs]2020-10-21T23:43:01.317-0800: [class unloading, 0.0305113 secs]2020-10-21T23:43:01.347-0800: [scrub symbol table, 0.0108352 secs]2020-10-21T23:43:01.358-0800: [scrub string table, 0.0009403 secs][1 CMS-remark: 13558K(65536K)] 34447K(124544K), 0.0464377 secs] [Times: user=0.03 sys=0.02, real=0.04 secs]
    2020-10-21T23:43:01.360-0800: [CMS-concurrent-sweep-start]
    2020-10-21T23:43:01.367-0800: [CMS-concurrent-sweep: 0.007/0.007 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
    2020-10-21T23:43:01.367-0800: [CMS-concurrent-reset-start]
    2020-10-21T23:43:01.367-0800: [CMS-concurrent-reset: 0.000/0.000 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]

3. CMS 优劣势

3.1 优点

  • 并发收集
  • 低停顿 所以CMS收集器适合与用户交互较多的场景,注重服务的响应速度,能给用户带来较好的体验!所以我们在做WEB开发的时候,经常会使用CMS收集器作为老年代的收集器!

3.2 缺点

  1. 内存碎片问题 :CMS本质是实现了标记清除算法的收集器,这意味着会产生内存碎片。由于碎片太多,又可能导致内存空间不足触发Full GC,CMS一般会在触发full GC这个过程时对碎片进行整理,整理涉及代 移动标记 ,那这个过程会有STW,如果内存足够大(意味着装载的对象足够多),那这个过程卡顿也需要一定的时间。

导致并发清除后,用户线程可用的空间不足。在年轻代无法分配大对象的情况下,不得不提前触发Full GC。 2. CMS 空间预留问题 :因为CMS的并发特性,它可以一边回收垃圾,一边处理用户线程,那需要保证在这个过程中有充足的的内存空间供用户使用。如果CMS运行过程中预留的空间不够用时,会出现“Concurrent Mode Failure”的 失败而可能导致另一次FUll GC 产生。

万一这时候用户线程创建了大量的对象导致预留内存不足时,可以通过 -XX:CMSInitiatingOccupancyFraction参数来设置预留内存大小。

当然如果超出了预留内存,JVM也会启用 后备方案 ,就是前面介绍过的Serial Old收集器,这样也会导致另一次的Full GC的产生,这样的代价是很大的,所以CMSInitiatingOccupancyFraction这个参数设置需要根据程序合理设置!

总结一下

  • 内存碎片过多,导致空间利用率降低
  • 空间本身就是留给用户线程使用,现在碎片内存占用部分空间,导致有可能垃圾收集器降级为Serial Old 同时可能触发Full GC 从而卡顿时间更长。
  • full GC的时候要处理内存碎片的问题(整理),同样导致卡顿。

4. GC 分类

堆内存划分为 Eden、Survivor 和 Tenured/Old 空间,在整个过程中,经常对 Minor、Major、和 Full GC 事件的使用感到困惑。下面分别说明一下。

4.1 Minor GC

从年轻代空间(包括 Eden 和 Survivor 区域)回收内存被称为 Minor GC

这一定义既清晰又易于理解。但是,当发生Minor GC事件的时候,有一些有趣的地方需要注意到:

  1. JVM 无法为一个新的对象分配空间时会触发 Minor GC ,比如当 Eden 区满了。所以分配率越高,越频繁执行 Minor GC。
  2. 内存池被填满的时候,其中的内容全部会被复制,指针会从0开始跟踪空闲内存。Eden 和 Survivor 区进行了标记和复制操作,取代了经典的标记、扫描、压缩、清理操作。所以 Eden 和 Survivor 区不存在内存碎片。写指针总是停留在所使用内存池的顶部。
  3. 执行 Minor GC 操作时,不会影响到永久代。 从永久代到年轻代的引用被当成 GC roots ,从年轻代到永久代的引用在标记阶段被直接忽略掉。
  4. 质疑常规的认知,所有的 Minor GC 都会触发“全世界的暂停(stop-the-world)”,停止应用程序的线程。对于大部分应用程序,停顿导致的延迟都是可以忽略不计的。其中的真相就 是,大部分 Eden 区中的对象都能被认为是垃圾,永远也不会被复制到 Survivor 区或者老年代空间。如果正好相反,Eden 区大部分新生对象不符合 GC 条件,Minor GC 执行时暂停的时间将会长很多。

所以 Minor GC 的情况就相当清楚了——每次 Minor GC 会清理年轻代的内存。

YGC是什么时候触发的?

大多数情况下,对象直接在年轻代中的Eden区进行分配,如果Eden区域没有足够的空间,那么就会触发 YGC(Minor GC) ,YGC处理的区域只有新生代。因为大部分对象在短时间内都是可收回掉的,因此YGC后只有极少数的对象能存活下来,而被移动到S0区(采用的是复制算法)。

当触发下一次YGC时,会将Eden区和S0区的存活对象移动到S1区,同时清空Eden区和S0区。当再次触发YGC时,这时候处理的区域就变成了Eden区和S1区(即S0和S1进行角色交换)。每经过一次YGC,存活对象的年龄就会加1。

4.2 Major GC vs Full GC

大家应该注意到,目前,这些术语无论是在 JVM 规范还是在垃圾收集研究论文中都没有正式的定义。但是我们一看就知道这些在我们已经知道的基础之上做出的定义是正确的,Minor GC 清理年轻带内存应该被设计得简单:

  • Major GC 是清理永久代。
  • Full GC 是清理整个堆空间—包括年轻代和永久代。

Full GC又是什么时候触发的

下面4种情况,对象会进入到老年代中:

  • YGC时,To Survivor区不足以存放存活的对象,对象会直接进入到老年代。
  • 经过多次YGC后,如果存活对象的年龄达到了设定阈值,则会晋升到老年代中。
  • 动态年龄判定规则,To Survivor区中相同年龄的对象,如果其大小之和占到了 To Survivor区一半以上的空间,那么大于此年龄的对象会直接进入老年代,而不需要达到默认的分代年龄。
  • 大对象:由-XX:PretenureSizeThreshold启动参数控制,若对象大小大于此值,就会绕过新生代, 直接在老年代中分配。

当晋升到老年代的对象大于了老年代的剩余空间时,就会触发FGC(Major GC),FGC处理的区域同时包括新生代和老年代。除此之外,还有以下4种情况也会触发FGC:

  1. 老年代的内存使用率达到了一定阈值(可通过参数调整),直接触发FGC。
  2. 空间分配担保:在YGC之前,会先检查老年代最大可用的连续空间是否大于新生代所有对象的总空间。如果小于,说明YGC是不安全的,则会查看参数 HandlePromotionFailure 是否被设置成了允许担保失败,如果不允许则直接触发Full GC;如果允许,那么会进一步检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果小于也会触发 Full GC。
  3. Metaspace(元空间)在空间不足时会进行扩容,当扩容到了-XX:MetaspaceSize 参数的指定值时,也会触发FGC。
  4. System.gc() 或者Runtime.gc() 被显式调用时,触发FGC。

5. CMS GC 参数配置

CMS(Concurrent Mark Sweep,并发-标记-清除)是目前最常用的 JVM 垃圾回收器,这里不解释 CMS 的工作过程,只记录一些基础要点以帮助理解后面的内容:

  • CMS 是一种基于并发、使用标记清除算法的垃圾回收器。CMS 会尽可能让 GC 线程与用户线程并发执行,可以消除长时间的 GC 停顿(STW)。
  • CMS 不会对新生代做垃圾回收,默认只针对老年代进行垃圾回收。此外,CMS 还可以开启对永久代的垃圾回收(或元空间),避免由于 PermGen 空间耗尽带来 Full GC,JDK6以上受参数 -XX:+CMSClassUnloadingEnabled 控制,这个参数在 JDK8 之前默认关闭,JDK8 默认开启了。
  • CMS 要与一个新生代垃圾回收器搭配使用,所谓"分代收集"。能与 CMS 配合工作的新生代回收器有 Serial 收集器和 ParNew 收集器,我们一般使用支持多线程执行的 ParNew 收集器。
  • 使用 CMS GC 策略时,GC 类别可以分为: Young GC(又称 Minor GC)Old GC(又称 Major GC、CMS GC) ,以及Full GC。其中 Full GC 是对整个堆的垃圾回收,STW 时间较长,对业务影响较大,应该尽量避免 Full GC

5.1 JVM 参数配置

经过理解各个参数的含义及取值影响,总结了以下的 JVM 参数配置,可以几乎不用调整使用:

    -Xmx32g -Xms32g -Xmn1g Xss256k
    -XX:SurvivorRatio=2 
    -XX:MaxPermSize=256m
    -XX:+UseParNewGC
    -XX:+UseConcMarkSweepGC
    -XX:ParallelGCThreads=10
    -XX:ParallelCMSThreads=16
    -XX:+CMSParallelRemarkEnabled
    -XX:MaxTenuringThreshold=15
    -XX:+UseCMSCompactAtFullCollection
    -XX:+UseCMSInitiatingOccupancyOnly
    -XX:CMSInitiatingOccupancyFraction=70
    -XX:+CMSClassUnloadingEnabled
    -XX:-DisableExplicitGC
    -XX:+HeapDumpOnOutOfMemoryError
    -verbose:gc-XX:+PrintGCDetails
    -XX:+PrintGCTimeStamps
    -XX:+PrintGCDateStamps
    -Xloggc:/app/log/hbase/gc-hbase-REGIONSERVER-`hostname`-`date +%Y%m%d`.log

如果是 64G 及以上的大堆,-Xmn 可以调整到2g,其他参数不变或微调。下面对一些重要的 JVM 参数介绍说明。

重点参数解析

以下参数解析都建立在使用 CMS GC 策略基础上,这里使用 CMS GC 表示老年代垃圾回收,Young GC 表示新生代垃圾回收。 ① -Xmx, -Xms, -Xmn

  • -Xmx、-Xms 分别表示 JVM 堆的最大值,初始化大小。

    • -Xmx 等价于-XX:MaxHeapSize
    • -Xms 等价于-XX:InitialHeapSize。
  • -Xmn表示新生代大小,等价于-XX:MaxNewSize、-XX:NewSize,这个参数的设置对 GC 性能影响较大,设置小了会影响 CMS GC 性能,设置大了会影响 Young GC 性能,建议取值范围在1~3g,比如32g堆大小时可以设为1g,64g堆大小时可以设为2g,通常性能会比较高。

-Xss

  • 表示线程栈的大小,等价于-XX:ThreadStackSize,默认1M,一般使用不了这么多,建议值256k。 ③ -XX:SurvivorRatio
  • 新生代中 Eden 区与 Survivor 区的比值,默认8,这个参数设置过大会导致 CMS GC 耗时过长,建议调小,使得短寿对象在Young区可以被充分回收,减少晋升到Old区的对象数量,以此提升 CMS GC 性能。

-XX:+UseParNewGC, -XX:+UseConcMarkSweepGC

  • 分别表示使用并行收集器 ParNew 对新生代进行垃圾回收,使用并发标记清除收集器 CMS 对老年代进行垃圾回收。

-XX:ParallelGCThreads, -XX:ParallelCMSThreads

  • 分别表示 Young GC 与 CMS GC 工作时的并行线程数,建议根据处理器数量进行合理设置。

-XX:MaxTenuringThreshold

  • 对象从新生代晋升到老年代的年龄阈值(每次 Young GC 留下来的对象年龄加一),默认值15,表示对象要经过15次 GC 才能从新生代晋升到老年代。设置太小会严重影响 CMS GC 性能,建议默认值即可。

-XX:+UseCMSCompactAtFullCollection

  • 由于 CMS GC 会产生内存碎片,且只在 Full GC 时才会进行内存碎片压缩(因此 使用 CMS 垃圾回收器避免不了 Full GC)。这个参数表示开启 Full GC 时的压缩功能,减少内存碎片。

-XX:+UseCMSInitiatingOccupancyOnly -XX:CMSInitiatingOccupancyFraction

  • -XX:CMSInitiatingOccupancyFraction 表示触发 CMS GC 的老年代使用阈值,一般设置为 70~80(百分比),设置太小会增加 CMS GC 发现的频率,设置太大可能会导致并发模式失败或晋升失败。默认为 -1,表示 CMS GC 会由 JVM 自动触发。
  • -XX:+UseCMSInitiatingOccupancyOnly 表示 CMS GC 只基于 CMSInitiatingOccupancyFraction 触发,如果未设置该参数则 JVM 只会根据 CMSInitiatingOccupancyFraction 触发第一次 CMS GC ,后续还是会自动触发。建议同时设置这两个参数。

-XX:+CMSClassUnloadingEnabled

  • 表示开启 CMS 对永久代的垃圾回收(或元空间),避免由于永久代空间耗尽带来 Full GC。

6. 写在最后

hotspot 有这么多垃圾收集器,那么Serial GC、Prallel GC、CMS GC 有什么不同呢?

  • 如果想要最小化的使用内存和并行开销 就用 Serial GC
  • 如果想要最大化的使用吞吐量 就用 Prallel GC
  • 如果想要最小化的使用 GC中断和停顿时间(低延迟) 就用 CMS GC

同时注意,在Jdk9中默认使用 G1 垃圾收集器,将CMS标记为废弃。在Jdk14中移除了CMS垃圾收集器。

相关文章

  1. Java8官网 并发标记扫描 (CMS) 收集器
  2. Java中9种常见的CMS GC问题分析与解决
  3. CMS 垃圾收集算法实现