Pengzna's blog 👋

Aug 30, 2023

JVM GC 调研与 Apache IoTDB GC 可观测性建设

1. 背景

本文是作者在调研改进 IoTDB JVM GC 监控板块时产出的相关 wiki,主要包含 GC 基本知识、不同 JDK 下的 GC 算法细节,以及设计改进 IoTDB JVM GC 监控板块时的思路等内容。本文旨在优化 IoTDB 现有 GC 监控系统,结合实际生产需要,扩充现有 metric 指标,使其针对不同 JDK 的 GC Collector 能更精准、高效的进行监控。

  1. JVM GC 是一个浩瀚的系统领域,本文仅于皮毛处探讨,起抛砖引玉之效。如有错误,敬请指出,欢迎各位大佬通过邮件或微信与我进一步交流!

  2. 本文引用、整合了大量互联网资料(文字、图片),同时也有自己的探究、实验和思考。感谢所有互联网大佬的无私分享,结尾给出了所有的 Reference 链接。

在系统工程设计之外,为了更好地帮助理解,本文涉及以下相关概念,读者可提前按需浏览:

  • GC 基本知识,具体可参见【2. GC 基础】
  • 常见的 GC 收集器以及它们的基本特点,具体可参见【3. GC 收集器、10. 其他】
  • 如何判断 GC 异常,具体可参见【4. GC 异常的判断】

2. GC 基础

2.1. JVM 内存划分

此部分是后续监控指标设计的依据,因此详细介绍:

JVM 内存中的对象,大致可以分为两大类:一类对象,他们的生命周期很短暂,比如局部变量、临时对象等。另一类对象则会存活很久,比如用户应用程序中 DB 长连接中的 Connection 对象。为了对这两类对象分别高效回收,JVM 的内存使用分代思想进行划分。

以 Java 8 的内存结构为例:

image-20231115174532651

可以看到,JVM 的内存被分为了不同的区域。GC 主要工作在 Heap 区和 MetaSpace 区(上图蓝色部分),而在 GC 的主要工作区,内存又被分为了不同的代际。下面这张表格简单描述了我们主要需要关注的区域:

分代名 描述
Young Generation(Eden + Survivor) 新生代主要分为两个部分:Eden 区Survivor 区,其中 Survivor 区又可以分为两个部分,S0 和S1。该区域中,相对于老年代空间较小,对象的生存周期短,GC 频繁。
Old Generation(Tenured) 老年代整体空间较大,对象的生命周期长,存活率高,回收不频繁。
@DeprecatedPermanent 永久代又称为方法区,存储着类和接口的元信息以及 interned 的字符串信息。在 JDK8 中被元空间取代。
MetaSpace JDK8 以后引入,方法区也存在于元空间。

在 Java 程序中,常见的垃圾回收器,打印内存区域结果如下:

  • G1
G1 Eden Space // young
G1 Old Gen // old
G1 Survivor Space // young
  • ParNewGC + CMS
Par Eden Space // young
Par Survivor Space // young
CMS Old Gen // old
  • SerialGC
Tenured Gen // old
Eden Space // young
Survivor Space // young
  • ParallelGC + ParallelOldGC
PS Old Gen // old
PS Survivor Space // young
PS Eden Space // young
  • Shenandoah
Shenandoah // non-generational
  • ZGC
ZHeap // non-generational

2.2. GC 收集过程

基于上述分代内存,JVM 使用分代垃圾回收算法(当然,JDK 11 以后的某些新收集器使用非代际垃圾回收算法,这些会在后文中提到),其大致执行过程如下:

  • 初始态:对象分配在 Eden 区,S0、S1 区几乎为空。
  • 随着程序的运行,越来越多的对象被分配在 Eden 区。
  • 当 Eden 放不下时,就会发生 MinorGC(即 YoungGC),此时,会先标识出不可达的垃圾对象,然后将可达的对象移动到 S0 区,并将不可达的对象清理掉。这时候,Eden 区就是空的了。在这个过程中,使用了标记清理算法及复制算法。
  • 随着 Eden 放不下时,会再次触发 minorGC,和上一步一样,先标记。这个时候,Eden 和 S0 区可能都有垃圾对象了,而 S1 区是空的。这个时候,会直接将 Eden 和 S0 区的对象直接搬到S1区,然后将Eden 与 S0 区的垃圾对象清理掉。经历这一轮的 MinorGC 后,Eden 与 S0 区为空。
  • 随着程序的运行,Eden 空间会被分配殆尽,这时会重复刚才 MinorGC 的过程,不过此时,S0 区是空的,S0 和 S1 区域会互换,此时存活的对象会从 Eden 和 S1 区,向S0 区移动。然后 Eden 和 S1 区中的垃圾会被清除,这一轮完成之后,这两个区域为空。
  • 在程序运行过程中,虽然大多数对象都会很快消亡,但仍然存在一些存活时间较长的对象,对于这些对象,在 S0 和 S1 区中反复移动,会造成一定的性能开销,降低 GC 的效率。因此引入了对象晋升的行为。
  • 当对象在新生代的 Eden、S0、S1 区域之间,每次从一个区域移动到另一个区域时,年龄都会加一,在达到一定的阈值后,如果该对象仍然存活,该对象将会晋升到老年代。
  • 如果老年代也被分配完毕后,就会出现 MajorGC(即Full GC),由于老年代通常对象比较多,因此标记-整理算法的耗时较长,因此会出现 STW 现象,因此大多数应用都会尽量减少或者避免出现 Full GC 的原因。

需要特别说明的是:对于一个新分配的对象,如果 Eden 区放不下,但是老年代可以放下时,该对象会被直接分配到老年代,不会按照上述从新生代晋升到老年代。

上面提到了一些如「复制算法」、「不可达」「标记-整理」等概念,这里引用网络资料进行一一简单介绍:

2.2.1. 识别垃圾

  • 引用计数法(Reference Counting): 对每个对象的引用进行计数,每当有一个地方引用它时计数器 +1、引用失效则 -1,引用的计数放到对象头中,大于 0 的对象被认为是存活对象。虽然循环引用的问题可通过 Recycler 算法解决,但是在多线程环境下,引用计数变更也要进行昂贵的同步操作,性能较低,早期的编程语言会采用此算法。
  • 可达性分析,又称引用链法(Tracing GC): 从 GC Root 开始进行对象搜索,可以被搜索到的对象即为可达对象,此时还不足以判断对象是否存活/死亡,需要经过多次标记才能更加准确地确定,整个连通图之外的对象便可以作为垃圾被回收掉。目前 Java 中主流的虚拟机均采用此算法。

2.2.2. 收集算法

自从有自动内存管理出现之时就有的一些收集算法,不同的收集器也是在不同场景下进行组合。

  • Mark-Sweep(标记-清除): 回收过程主要分为两个阶段,第一阶段为追踪(Tracing)阶段,即从 GC Root 开始遍历对象图,并标记(Mark)所遇到的每个对象,第二阶段为清除(Sweep)阶段,即回收器检查堆中每一个对象,并将所有未被标记的对象进行回收,整个过程不会发生对象移动。整个算法在不同的实现中会使用三色抽象(Tricolour Abstraction)、位图标记(BitMap)等技术来提高算法的效率,存活对象较多时较高效。
image-20231115174549490
  • Copying(复制): 该算法是为了解决标记-清除算法效率低下、GC 之后空间不连续的问题。该算法将空间分为两个大小相同的 From 和 To 两个半区,同一时间只会使用其中一个,每次进行回收时将一个半区的存活对象通过复制的方式转移到另一个半区。复制算法可以通过碰撞指针的方式进行快速地分配内存,但是也存在着空间利用率不高(只有一半)的缺点,另外就是存活对象比较大时复制的成本比较高。
image-20231115174612759
  • Mark-Compact (标记-整理): 这个算法的主要目的就是解决在非移动式回收器中都会存在的碎片化问题,也分为两个阶段,第一阶段与 Mark-Sweep 类似,标记哪些对象死亡,哪些对象被引用,第二阶段则会对存活对象按照整理顺序(Compaction Order)进行整理。

image-20231115180028849

在 JDK 8 中,默认情况下采用分代垃圾回收策略,其中新生代使用标记-复制算法,老年代使用标记-整理算法。

这是因为老年代长生命周期对象多,如果使用复制算法会十分低效。

  • 三种算法在是否移动对象、空间和时间方面的一些对比,假设存活对象数量为 L、堆空间大小为 H,则:

image-20231115180015502

把 mark、sweep、compaction、copying 这几种动作的耗时放在一起看,大致有这样的关系:

image-20231115180005979

虽然 compaction 与 copying 都涉及移动对象,但取决于具体算法,compaction 可能要先计算一次对象的目标地址,然后修正指针,最后再移动对象。copying 则可以把这几件事情合为一体来做,所以可以快一些。另外,还需要留意 GC 带来的开销不能只看 Collector 的耗时,还得看 Allocator 。如果能保证内存没碎片,分配就可以用 pointer bumping 方式,只需要挪一个指针就完成了分配,非常快。而如果内存有碎片就得用 freelist 之类的方式管理,分配速度通常会慢一些。

  • Generational Collection(分代收集)算法

分代收集算法根据对象的生存周期,将堆分为新生代(Young)和老年代(Tenur)。在新生代中,由于对象生存期短,每次回收都会有大量对象死去,那么这时就采用复制算法。老年代里的对象存活率较高,没有额外的空间进行分配担保,所以可以使用标记-整理或者标记-清除

3. GC 收集器

这里枚举一下各 JDK 版本支持的 GC 收集器,作为快速了解。针对具体常用收集器的调研可参见【10. 其他】

根据 JDK 版本支持的策略,JDK 8、JDK 11 和 JDK 17 是目前长期支持的版本。目前这3个版本共支持7个垃圾回收器,分别是

  1. SerialGC
  2. Parallel GC
  3. CMS
  4. G1
  5. Shenandoah GC
  6. ZGC
  7. Epsilon(实验特性,仅支持分配不回收,实际场景中不会采用)

CMS 仅在 JDK 8 和 JDK 11 中支持,ZGC 在 JDK11 中为实验特性,在 JDK 17 中为正式产品,Shenandoah 在JDK 17 中为正式产品,Epsilon 在 JDK 11 和 JDK 17 中为实验特性。

image-20231115175821783

3.1. 分代收集器

  • ParNew: 一款多线程的收集器,采用复制算法,主要工作在 Young 区,可以通过 -XX:ParallelGCThreads 参数来控制收集的线程数,整个过程都是 STW 的,常与 CMS 组合使用。
  • CMS: 以获取最短回收停顿时间为目标,采用「标记-清除」算法,分 4 大步进行垃圾收集,其中初始标记和重新标记会 STW ,多数应用于互联网站或者 B/S 系统的服务器端上。虽然在 JDK9 被标记弃用,JDK14 被删除,但仍经常使用。

3.2. 分区收集器

  • G1: 一种服务器端的垃圾收集器,应用在多处理器和大容量内存环境中,在实现高吞吐量的同时,尽可能地满足垃圾收集暂停时间的要求。经常使用。
  • ZGC: JDK11 中推出的一款低延迟垃圾回收器,适用于大内存低延迟服务的内存管理和回收,SPECjbb 2015 基准测试,在 128G 的大堆下,最大停顿时间才 1.68 ms,停顿时间远胜于 G1 和 CMS。近期也经常见到应用。
  • Shenandoah: 由 Red Hat 的一个团队负责开发,与 G1 类似,基于 Region 设计的垃圾收集器,但不需要 Remember Set 或者 Card Table (都是 G1 中的数据结构)来记录跨 Region 引用,停顿时间和堆的大小没有任何关系。停顿时间与 ZGC 接近。

4. GC 异常的判断

4.1. 评价标准

这里引用美团技术团队的文章,美团在生产实践中发现如下指标比较重要:

  • 延迟(Latency): 也可以理解为最大停顿时间,即垃圾收集过程中一次 STW 的最长时间,越短越好,一定程度上可以接受频次的增大,GC 技术的主要发展方向。
  • 吞吐量(Throughput): 应用系统的生命周期内,由于 GC 线程会占用 Mutator 当前可用的 CPU 时钟周期,吞吐量即为 Mutator 有效花费的时间占系统总运行时间的百分比,例如系统运行了 100 min,GC 耗时 1 min,则系统吞吐量为 99%,吞吐量优先的收集器可以接受较长的停顿。

目前各大互联网公司的系统基本都更追求低延时,避免一次 GC 停顿的时间过长对用户体验造成损失,衡量指标需要结合一下应用服务的 SLA,主要如下两点来判断:

image-20231115175939344

简而言之,即为**一次停顿的时间不超过应用服务的 TP9999,GC的吞吐量不小于 99.99%**。

TP9999: 即 Top Percentile 99.99%,保证 99.99% 的网络服务可用的最低耗时

举个例子,假设某个服务 A 的 TP9999 为 80 ms,平均 GC 停顿为 30 ms,那么该服务的最大停顿时间最好不要超过 80 ms,GC 频次控制在 5 min 以上一次。如果满足不了,那就需要调优或者通过更多资源来进行并联冗余。

备注:除了这两个指标之外还有 Footprint(资源量大小测量)、反应速度等指标,互联网这种实时系统追求低延迟,而很多嵌入式系统则追求 Footprint。

个人认为:作为时序数据库,IoTDB 追求低延时、异常可告警的需求和互联网公司系统这方面的需求大致吻合,因此可以将上述指标映射到 IoTDB 的 JVM GC 监控系统中作为参考。

4.2. GC Cause

个人了解到,IoTDB 目前在某些实际环境有一些异常 GC,而要分析异常 GC,先要读懂 GC Cause,即 JVM 什么样的条件下选择进行 GC 操作,具体 Cause 的分类可以参考 Hotspot 源码:src/share/vm/gc/shared/gcCause.hpp 和 src/share/vm/gc/shared/gcCause.cpp

const char* GCCause::to_string(GCCause::Cause cause) {
switch (cause) {
case _java_lang_system_gc:
return "System.gc()";

case _full_gc_alot:
return "FullGCAlot";

case _scavenge_alot:
return "ScavengeAlot";

case _allocation_profiler:
return "Allocation Profiler";

case _jvmti_force_gc:
return "JvmtiEnv ForceGarbageCollection";

case _gc_locker:
return "GCLocker Initiated GC";

case _heap_inspection:
return "Heap Inspection Initiated GC";

case _heap_dump:
return "Heap Dump Initiated GC";

case _wb_young_gc:
return "WhiteBox Initiated Young GC";

case _update_allocation_context_stats_inc:
case _update_allocation_context_stats_full:
return "Update Allocation Context Stats";

case _no_gc:
return "No GC";

case _allocation_failure:
return "Allocation Failure";

case _tenured_generation_full:
return "Tenured Generation Full";

case _metadata_GC_threshold:
return "Metadata GC Threshold";

case _cms_generation_full:
return "CMS Generation Full";

case _cms_initial_mark:
return "CMS Initial Mark";

case _cms_final_remark:
return "CMS Final Remark";

case _cms_concurrent_mark:
return "CMS Concurrent Mark";

case _old_generation_expanded_on_last_scavenge:
return "Old Generation Expanded On Last Scavenge";

case _old_generation_too_full_to_scavenge:
return "Old Generation Too Full To Scavenge";

case _adaptive_size_policy:
return "Ergonomics";

case _g1_inc_collection_pause:
return "G1 Evacuation Pause";

case _g1_humongous_allocation:
return "G1 Humongous Allocation";

case _last_ditch_collection:
return "Last ditch collection";

case _last_gc_cause:
return "ILLEGAL VALUE - last gc cause - ILLEGAL VALUE";

default:
return "unknown GCCause";
}
ShouldNotReachHere();
}

对于以上 Cause,这里还是引用美团技术团队在业界实践的经验:

重点需要关注的几个GC Cause:

  • System.gc(): 手动触发GC操作。
  • CMS(如果使用的是 CMS 收集器的话): CMS GC 在执行过程中的一些动作,重点关注 CMS Initial Mark 和 CMS Final Remark 两个 STW 阶段。
  • Promotion Failure: Old 区没有足够的空间分配给 Young 区晋升的对象(即使总可用内存足够大,此处特指 CMS)。
  • Concurrent Mode Failure(如果使用的是 CMS 收集器的话): CMS GC 运行期间,Old 区预留的空间不足以分配给新的对象,此时收集器会发生退化,严重影响 GC 性能。
  • GCLocker Initiated GC: 如果线程执行在 JNI 临界区时,刚好需要进行 GC,此时 GC Locker 将会阻止 GC 的发生,同时阻止其他线程进入 JNI 临界区,直到最后一个线程退出临界区时触发一次 GC。

5. 启示

通过以上的基础调研,可以对 IoTDB JVM GC 监控系统的设计得出如下启示:

  1. 收纳 延迟(Latency)和吞吐量(Throughput)作为 metric(可通过直接收集指标或间接计算得出),可以考虑使用 Timer 对每一个 GC Cause 记录 Duration,将 Cause 和 Duration 绑定起来一起记录,从而对 GC 异常分析有据可循。
  • 期望大致效果如下:可以看到一段时间内应用 GC Cause 的集合以及 GC Duration 相关信息。

image-20231115175800875

  1. 由于不同的 GC 收集器对内存的划分不同(对不同区域内存的行为也不同),因此需要针对不同 GC 收集器定制化 Memory usage 监控指标。
  2. 某些 GC 收集器包含 concurrent phase(具体可以查看【10.其他】)。由于它们的 concurrent phase 并不 stw,导致 JMX 的 GcInfo.getDuration()实际值比应用程序的停止时间更长(因为它们把并发阶段也算进去了)。因此对于某些特殊收集器(如涉及到并行 GC),需要定制化指标计算方式,防止指标计算失准。

6. 现有监控工具及框架

功能大同小异,主要是使用体验上的差别,此处不赘述,仅罗列以扫盲。

6.1. 框架

  • 集成框架(内置 JVM GC 监控模块)Micormeter、Dropwizard

6.2. 命令行终端

  • jps、jinfo、jstat、jstack、jmap、jcmd、arthas 等

6.3. 可视化界面

  • JConsole、GCHisto、GCViewer、JProfiler 等

本文参考了以上项目关注的指标和设计方法。部分项目在 Github 有源码,感兴趣的读者可自行查阅。

7. JMX - 运行时指标收集工具

在实际的 Java 应用中,常常见到开发者通过添加 VM 参数-XX:+PrintGC-XX:+PrintHeapAtGC等来获取 GC 相关信息。那么如何用代码的形式在运行时显式获取这些信息呢?答案是使用 JVM 提供的相应 api(JMX)。

我们可以利用 JMX 提供的一些钩子函数,获得我们想要的信息,理论上来说,通过 JMX,我们可以自己手搓-XX:+PrintGC等的相关功能。接下来以 GC Info 为例,详细介绍一下 JMX 的相关概念和快速使用。

7.1. 什么是 JMX?

Java Management Extensions(JMX)技术是 Java SE 平台的标准功能,提供了一种简单的、标准的监控和管理资源的方式,对于如何定义一个资源给出了明确的结构和设计模式,主要用于监控和管理 Java 应用程序运行状态、设备和资源信息、Java 虚拟机运行情况等信息。 JMX 是可以动态的,所以也可以在资源创建、安装、实现时进行动态监控和管理,JDK 自带的 jconsole 就是使用 JMX 技术实现的监控工具。

使用 JMX 技术时,通过定义一个被称为 MBeanMXBean 的 Java 对象来表示要管理指定的资源,然后可以把资源信息注册到 MBean Server 对外提供服务。MBean Server 充当了对外提供服务和对内管理 MBean 资源的代理功能,如此优雅的设计让 MBean 资源管理和 MBean Server 代理完全独立开,使之可以自由的控制 MBean 资源信息。

7.2. 资源管理 MBean

在 JMX 中, 使用 MBeanMXBean 来表示一个资源(下面简称 MBean),访问和管理资源也都是通过 MBean,所以 MBean 往往包含着资源的属性和操作方法

JMX 已经对 JVM 进行了多维度资源检测,所以可以轻松启动 JMX 代理来访问内置的 JVM 资源检测,从而通过 JMX 技术远程监控和管理 JVM。下表列出了一些 JMX 中的资源接口:

资源接口 管理的资源 Object Name VM 中的实例个数
ClassLoadingMXBean 类加载 java.lang:type= ClassLoading 1个
CompilationMXBean 汇编系统 java.lang:type= Compilation 0 个或1个
GarbageCollectorMXBean(本文主要关注的) 垃圾收集 java.lang:type= GarbageCollector, name=collectorName 1个或更多
LoggingMXBean 日志系统 java.util.logging:type =Logging 1个
MemoryManagerMXBean 内存池 java.lang: typeMemoryManager, name=managerName 1个或更多
MemoryPoolMXBean 内存 java.lang: type= MemoryPool, name=poolName 1个或更多
MemoryMXBean 内存系统 java.lang:type= Memory 1个
OperatingSystemMXBean 操作系统 java.lang:type= OperatingSystem 1个
RuntimeMXBean 运行时系统 java.lang:type= Runtime 1个
hreadMXBean 线程系统 java.lang:type= Threading 1个

7.2. JMX 的具体使用

  1. 根据上面的信息,我们可以根据 JMX 内置的 MBean 获取系统信息,本文的 JVM GC 监控使用了此方法。

实例:获得堆内存和非堆内存的大小。

public String monitorMemory() {
StringBuilder sb = new StringBuilder("Memory:");

MemoryMXBean mmbean = ManagementFactory.getMemoryMXBean();
MemoryUsage hmu = mmbean.getHeapMemoryUsage();
sb.append("[HeapMemoryUsage:");
sb.append(" Used=" + formatBytes(hmu.getUsed()));
sb.append(" Committed=" + formatBytes(hmu.getCommitted()));
sb.append("]");

MemoryUsage nhmu = mmbean.getNonHeapMemoryUsage();
sb.append("[NonHeapMemoryUsage:");
sb.append(" Used=" + formatBytes(nhmu.getUsed()));
sb.append(" Committed=" + formatBytes(nhmu.getCommitted()));
sb.append("]");

return sb.toString();
}
  • 我们甚至可以自己按需写一个 MBean 注册到 JMX 中,以供外部使用。(实际上在 IoTDB 中经常能够见到这样的应用)

MBean 的编写必须遵守 JMX 的设计规范,MBean 很像一个特殊的 Java Bean,它需要一个接口和一个实现类。MBean 资源接口总是以 MBean 或者 MXBean 结尾,实现类则要以接口去掉 MBean 或 MXBean 之后的名字来命名。

MBean 资源需要注册到 MBean Server 进行代理才可以暴露给外部进行调用,所以我们想要通过远程管理我们自定义的 MyMemory 资源,需要先进行资源代理。

// 获取 MBean Server
MBeanServer platformMBeanServer = ManagementFactory.getPlatformMBeanServer();
// 注册
ObjectName objectName = new ObjectName(MBeanName);
platformMBeanServer.registerMBean(your_mbean, objectName);

7.3.1. 监听器模式——Notification

JMX API 定义了一种机制,使得 MBeans 能够生成通知 Notification,比如通知一个状态改变、一个检测到的事件或者问题,系统状态,GC 事件。通知的作用是主动通知远程客户端。例如程序出现异常,CPU使用率过高,出现了死锁等。这时程序能事件触发主动发送给远程客户端,将这些问题记录下来,或者执行一些其他的报警操作。本文的 JVM GC 监控使用了此方法。

javax.management.Notification
public class NotificationBroadcasterSupport implements NotificationEmitter {
...
public MBeanNotificationInfo[] getNotificationInfo(){...}
public void sendNotification(Notification notification){...}
...
}
  • 一个 MBean 生成通知必须实现接口 NotificationEmitter 或者扩展 NotificationBroadcasterSupport
  • Notification 监听器必须实现 NotificationListener 接口

其中,本文重点关注 GC 相关的 Info,通过阅读源码得知 **GarbageCollectionNotificationInfo**( notification)的数据结构如下:

属性名 类型 注释
gcName java.lang.String 如: G1 Old Generation,G1 Young Generation等
gcAction java.lang.String 标识是哪个 gc 动作,一般为:end of major GC,end of minor GC 等,分别表示老年代和新生代的 gc 结束。
gcCause java.lang.String 引起 gc 的原因,如:System.gc(),Allocation Failure,G1 Humongous Allocation等
gcInfo javax.management.openmbean.CompositeData gc 的详细信息,见下表

通过阅读 openjdk 官网手册和源码,我们可以得知 GCInfo 详细信息如下:

属性名 类型 注释
index java.lang.Long 标识这个收集器进行了几次 gc(垃圾回收事件的 ID)
startTime java.lang.Long gc 的开始时间
endTime java.lang.Long gc 的结束时间
memoryUsageBeforeGc javax.management.openmbean.TabularData gc 前内存情况
memoryUsageAfterGc javax.management.openmbean.TabularData gc 后内存情况

以 GC 为例,最简单的 NotificationListener 使用如下:

  • 该程序实现了:当发生 GC 时,自动读取 GC 时间、GC 原因、相关内存池前后使用情况等相关信息(见 map 变量)
static class GarbageNotificationListener implements NotificationListener {
@Override
public void handleNotification(Notification notification, Object handback) {
String notifType = notification.getType();
if (notifType.equals(GarbageCollectionNotificationInfo.GARBAGE_COLLECTION_NOTIFICATION)) {
CompositeData cd = (CompositeData) notification.getUserData();
GarbageCollectionNotificationInfo info = GarbageCollectionNotificationInfo.from(cd);
GcInfo gcInfo = info.getGcInfo();
HashMap<String, Object> map = new HashMap<>();
map.put("duration", gcInfo.getDuration());
map.put("id", gcInfo.getId());
map.put("UsageAfterGc", gcInfo.getMemoryUsageAfterGc());
map.put("UsageBeforeGc", gcInfo.getMemoryUsageBeforeGc());
map.put("GcAction", info.getGcAction());
map.put("GcCause", info.getGcCause());
map.put("GcName", info.getGcName());
System.out.println(map);
}
}
}

8. 监控方案设计

8.1. push base

参考:org.apache.cassandra.service.GCInspector、io.micrometer.core.instrument.binder.jvm.JvmGcMetrics

IoTDB 实现类:

  • org.apache.iotdb.metrics.metricsets.jvm.JvmGcMetrics

思路:利用 JMX 的 notification 机制,实现一个 Listener 对 GC 进行监听。每次 JVM 发生 GC 时,接受 JVM 主动发送的 GcInfo,从中解析出相应的信息(具体查看【9. 监控指标设计】)

核心方法:可查看源码,此处不再赘述

注意点:

  • 包含并发过程 GC 的 duration 需要采用特别的计算方式,因为 GcInfo.getDuration() 得到的 duration 比实际 stw 的时间长(把并发标记的时间也算上去了)
  • 一些 GC 如 G1 会在 minor GC 时减小 old gen memory 的 size,需要对其进行追踪
  • 需要对非代际 GC 和 分代 GC 分别监控

与 IoTDB 整合的实现效果:

image-20231115175714192

8.2. pull base

参考:org.apache.hadoop.util.GcTimeMonitor

IoTDB 实现类:

  • org.apache.iotdb.commons.service.metric.JvmGcMetrics
  • org.apache.iotdb.commons.service.metric.GcTimeAlerter

思路:设定好的频率下,不断 pull JMX 中的 GC 信息。该 metric 类将监视 JVM 在指定观察窗口(例如1分钟)内的 GC 暂停时间的百分比。用户可以提供一个钩子(hook),当这个百分比超过指定的阈值(例如 70%)时,将会调用该钩子进行告警、处理等操作。

核心方法:calculateGCTimePercentageWithinObservedInterval() 在设定好的频率下不停运行。假设调用该方法的时刻是 startTs,那么该方法将计算 [startTs - observerWindowMsstartTs] 时间窗口下的 GcTimeGcCount

  • 方法内部:统计所有 collector 的 GcTimeGcCount,并将该次统计信息保存到一个循环缓冲区里,并更新该缓冲区的开始和结束索引以跟踪最早和最新的 GC 暂停时间戳。
    • 注意从最早的时间戳开始累加 GcTime 时,需要考虑 startTs - observerWindowMs 之前发生并延续到[startTs - observerWindowMsstartTs] 时间窗口下的 GC。这时我们只需要统计该时间窗口下的 Gc,舍弃startTs - observerWindowMs 之前的 GC 时间。

告警处理:当在指定观察窗口内的 GC 暂停时间百分比超过指定的阈值(例如 70%)时,将会调用org.apache.iotdb.commons.service.metric.GcTimeAlerter进行告警、处理等操作。用户可以在此部分自定义代码,比如打日志、清理内存等。(目前只实现了简单的 logger.warn() 告警功能,如下图)

目前实现的 Alerter 效果:

  • 打印当前异常时间
  • 打印当前时间窗口内,GC 时间所占比例(类似吞吐量)
  • 打印当前时间窗口内,累计 GC 时间
  • 打印当前时间窗口的起始时间
  • 打印当前时间窗口的时长(默认为 1 分钟,如果 IoTDB 启动还没满 1 分钟,那么该值为 IoTDB 的累计运行时间)

与 IoTDB 整合的实现效果:

image-20231115175656708

8.3. 测试方法

IDEA 3c3d(3个 ConfigNode、3个 DataNode) 环境下,在 IoTDB ConfigNode 和 DataNode 启动时开启一个添加内存的线程,并可以按需 System.gc()。通过观察日志和 curl localhost:9091/metrics 观察指标。以 IoTDB ConfigNode 为例:

public static void main(String[] args) {
LOGGER.info(
"{} environment variables: {}",
ConfigNodeConstant.GLOBAL_NAME,
ConfigNodeConfig.getEnvironmentVariables());
LOGGER.info(
"{} default charset is: {}",
ConfigNodeConstant.GLOBAL_NAME,
Charset.defaultCharset().displayName());
new ConfigNodeCommandLine().doMain(args);

new Thread(
() -> {
List<byte[]> byteList = new ArrayList<>();
for (int i = 0; i < 100000; i++) {
byte[] bytes = new byte[1024];
byteList.add(bytes);
// 可以手动 GC,也可以让系统自动 GC 直至 heap overflow
// if (i % 10 == 0) {
// System.gc();
// }
}
})
.start();
}

9. 监控指标设计

9.1. push base

metric Type Monitor Approach Tips
jvm_gc_max_data_size_bytes AutoGauge JMX 长生命周期内存池 (old gen & perm gen(Metaspace) 最大内存大小
jvm_gc_live_data_size_bytes AutoGauge notification update 长生命周期内存池已使用大小
jvm_gc_young_memory_allocated_bytes Counter notification update 年轻代内存池已分配对象大小
jvm_gc_old_memory_allocated_bytes Counter notification update 年老代内存池直接分配的对象大小(不包括晋升的对象)
jvm_gc_non_gen_memory_allocated_bytes Counter notification update 非代际内存池已分配的对象大小
jvm_gc_memory_promoted_bytes Counter notification update 移动到 old gen 的对象大小
jvm_gc_pause Timer notification update 具体 GC cause 下的 GC 暂停时间(将不同 GC 的 cause 跟每次 GC 的 duration 绑定,Timer 还可以统计各 cause 的频率)
jvm_zgc_pauses_count Counter notification update ZGC pauses 的次数
jvm_zgc_cycles_count Counter notification update ZGC circles 的次数

9.2. pull base

metric Type Monitor Approach Tips
jvm_gc_accumulated_time Alerter 日志打印 JMX + 累加计算 在指定时间窗口内,GC 总时间,单位毫秒,支持 ZGC
observation_window_start_time Alerter 日志打印 计算 指定时间窗口的起始时间
observation_window_end_time Alerter 日志打印 计算 指定时间窗口的结束时间
observation_window_time Alerter 日志打印 计算 指定时间窗口的总时长,默认为 1 min。如果 IoTDB 启动还没满 1 分钟,那么该值为 IoTDB 的累计运行时间
jvm_gc_accumulated_time_percentage AutoGauge jvm_gc_accumulated_time / total_time 在指定时间窗口内,GC 时间所占的比例(类似吞吐量的概念)。若超过 threshold,进行告警(可选)

10. 其他

在实际场景中,G1 和 CMS 使用的较多,而 ZGC 作为内存不分代的新型垃圾回收器代表,也有必要被研究。尽管这些算法的具体执行细节难以监控追踪,但我们仍可以了解它们。这里做一个简单整理,更深入可以阅读资料或者查看 hotspot 源码。

10.1. CMS - 经久不衰的常用回收器

CMS(Concurrent Mark Sweep)垃圾回收器是第一个关注 GC 停顿时间的垃圾收集器。 在这之前的垃圾回收器,要么就是串行垃圾回收方式,要么就是关注系统吞吐量。这样的垃圾回收器对于强交互的程序很不友好,而 CMS 垃圾回收器的出现,则打破了这个尴尬的局面。因此,CMS 垃圾回收器诞生之后就受到了大家的欢迎,导致现在还有非常多的应用还在继续使用它。

CMS 垃圾回收器之所以能够实现对 GC 停顿时间的控制,其本质来源于对「根可达算法」的改进,即三色标记算法。在 CMS 垃圾回收器出现之前,无论是 Serious 垃圾回收器,还是 ParNew 垃圾回收器,亦或是 Parallel Scavenge 垃圾回收器,他们在进行垃圾回收的时候都需要 Stop the World,即无法实现垃圾回收线程与用户线程并发执行。而 CMS 垃圾回收器通过三色标记算法,实现了垃圾回收线程与用户线程并发执行,从而极大地降低了系统响应时间,提高了强交互应用程序的体验。

10.1.1. 三色标记算法

三色标记法将对象的颜色分为了黑、灰、白,三种颜色。

「白色」:该对象没有被标记过。(对象垃圾)

「灰色」:该对象已经被标记过了,但该对象下的属性没有全被标记完。(GC 需要从此对象中去寻找垃圾)

「黑色」:该对象已经被标记过了,且该对象下的属性也全部都被标记过了。(程序所需要的对象)

image-20231115175630425

10.1.2. 算法流程

从 GC Root 开始沿着他们的对象向下查找,用黑灰白的规则,标记出所有跟 GC Root 相连接的对象,扫描一遍结束后,一般需要进行一次短暂的 STW,再次进行扫描,此时因为黑色对象的属性都也已经被标记过了,所以只需找出灰色对象并顺着继续往下标记(且因为大部分的标记工作已经在第一次并发的时候发生了,所以灰色对象数量会很少,标记时间也会短很多), 此时程序继续执行,GC 线程扫描所有的内存,找出扫描之后依旧被标记为白色的对象(垃圾)并清除。具体流程如下

  1. 首先创建三个集合:白、灰、黑。
  2. 将所有对象放入白色集合中。
  3. 然后从根节点开始遍历所有对象,把遍历到的对象从白色集合放入灰色集合。
  4. 之后遍历灰色集合,将灰色对象引用的对象从白色集合放入灰色集合,之后将此灰色对象放入黑色集合
  5. 重复 4 直到灰色中无任何对象
  6. 通过 write-barrier 检测对象有变化,重复以上操作
  7. 收集所有白色对象(垃圾)
10.1.3. 存在问题
  1. 浮动垃圾:并发标记的过程中,若一个已经被标记成黑色或者灰色的对象,突然变成了垃圾,由于不会再对黑色标记过的对象重新扫描,所以不会被发现,那么这个对象不是白色的但是不会被清除,重新标记也不能从 GC Root 中去找到,所以成为了浮动垃圾,「浮动垃圾对系统的影响不大,留给下一次 GC 进行处理即可」。
  2. 对象漏标问题(需要的对象被回收):并发标记的过程中,一个业务线程将一个未被扫描过的白色对象断开引用成为垃圾(删除引用),同时黑色对象引用了该对象(增加引用)(这两部可以不分先后顺序);因为黑色对象的含义为其属性都已经被标记过了,重新标记也不会从黑色对象中去找,导致该对象被程序所需要,却又要被 GC 回收,此问题会导致系统出现问题,对此「CMS 对增加引用环节进行处理(Increment Update),G1 则对删除引用环节进行处理 (SATB)(这里不做详细介绍,可以自行查阅)」

10.1.2. 与用户线程并发执行 GC

主要分为4个阶段(注意,该部分在 G1 回收器中也能窥见思想和相似应用)

  • 初始标记(CMS initial mark),指的是寻找所有被 GCRoots 引用的对象,该阶段需要「Stop the World」。 这个步骤仅仅只是标记一下 GC Roots 能直接关联到的对象,并不需要做整个引用的扫描,因此速度很快。

image-20231115175606653

  • 并发标记(CMS concurrent mark),指的是对「初始标记阶段」标记的对象进行整个引用链的扫描,该阶段不需要「Stop the World」。 对整个引用链做扫描需要花费非常多的时间,因此通过垃圾回收线程与用户线程并发执行,可以降低垃圾回收的时间,从而降低系统响应时间。但这也带来了一些问题,即:并发标记的时候,引用可能发生变化,因此可能发生漏标(本应该被标记的对象,没有被正确地标记颜色,导致不应该回收的对象被回收)和多标(本不该被标记的对象,被错误地标记颜色,导致应该被回收的对象没有被回收)了。

image-20231115175554390

  • 重新标记(CMS remark),指的是对「并发标记」阶段出现的问题进行校正,该阶段需要「Stop the World」。 正如并发标记阶段说到的,由于垃圾回收算法和用户线程并发执行,虽然能降低响应时间,但是会发生漏标和多标的问题。所以对于 CMS 回收器来说,它需要这个阶段来做一些校验,解决并发标记阶段发生的问题。
  • 并发清除(CMS concurrent sweep),指的是将标记为垃圾的对象进行清除,该阶段不需要「Stop the World」。 在这个阶段,垃圾回收线程与用户线程可以并发执行,因此并不影响用户的响应时间。

image-20231115175542197

从上面的描述步骤中我们可以看出:CMS 之所以能极大地降低 GC 停顿时间,本质上是将原本冗长的引用链扫描进行切分。通过 GC 线程与用户线程并发执行,加上重新标记校正的方式,减少了垃圾回收的时间。

10.2. G1 - CMS 的替代者 | Java 9 默认回收器

10.2.1. 内存模型

传统的 GC 收集器将连续的内存空间划分为新生代、老年代和永久代(JDK8 去除了永久代,引入了元空间 Metaspace),这种划分的特点是各代的存储地址(逻辑地址,下同)是连续的。如下图所示:

image-20231115175528545

而G1的各代存储地址是不连续的,每一代都使用了 n 个不连续的大小相同的 Region,每个 Region 占有一块连续的虚拟内存地址(但内存仍是分代际的,只是分散在不连续的 Region 中)。如下图所示:

image-20231115175518344

这里为了帮助理解,再贴一张 CMS(传统收集器) 和 G1 的内存分布图:

image-20231115175504522

在上面的图中,注意到还有一些 Region 标明了 H,它代表 Humongous,这表示这些 Region 存储的是巨大对象(humongous object,H-obj),即大小 ≥ region 一半的对象。H-obj 有如下几个特征:

  • H-obj 直接分配到了 old gen,防止了反复拷贝移动。
  • H-obj 在 global concurrent marking 阶段的 cleanup 和 full GC 阶段回收。
  • 在分配 H-obj 之前先检查是否超过 initiating heap occupancy percent 和 the marking threshold(G1 的相关参数,可通过 VM 参数设定), 如果超过的话,就启动 global concurrent marking,为的是提早回收,防止 evacuation failures 和 full GC。

为了减少连续 H-objs 分配对 GC 的影响,需要把大对象变为普通的对象,建议增大 Region size。

一个 Region 的大小可以通过参数 -XX:G1HeapRegionSize 设定,取值范围从 1M 到 32M,且是2的指数。如果不设定,那么 G1 会根据 Heap 大小自动决定

每一个分配的 Region,都可以分成两个部分,已分配的和未被分配的。它们之间的界限被称为 top。总体上来说,把一个对象分配到 Region 内,只需要简单增加 top 的值。这个做法实际上就是 bump-the-pointer。过程如下:

image-20231115175448522

Region 可以说是 G1 回收器一次回收的最小单元。即每一次回收都是回收 N 个 Region。这个 N 是多少,主要受到 G1 回收的效率和用户设置的软实时目标(用户可以指定垃圾回收时间的限时,G1会努力在这个时限内完成垃圾回收,但是G1并不担保每次都能在这个时限内完成垃圾回收。通过设定一个合理的目标,可以让达到90%以上的垃圾回收时间都在这个时限内。)有关。每一次的回收,G1 会选择可能回收最多垃圾的 Region 进行回收。与此同时,G1 回收器会维护一个空间 Region 的链表。每次回收之后的 Region 都会被加入到这个链表中。

10.2.2. GC 模式

G1提供了两种GC模式,Young GCMixed GC,两种都是完全 Stop The World 的,下面依次简单介绍:

补充论据:为什么 Young GC 和 Mixed GC 都是 stw 的?

结论:几乎所有收集器的所有回收策略(Young、Mixed、Full)都会 stw,只是停顿时间不同。

  1. 什么情况/操作会 STW
    1. 以 G1 为例。引用《深入理解 Java 虚拟机》,G1 的运行过程有4个步骤:
      • 初始标记:标记 GC Roots 的可达对象以及其他一些指针修改操作。GC Roots 的对象扫描操作是 stw 的,尽管停顿时间很短。因为如果与用户线程并行,会存在漏扫、错扫等问题
      • 并发标记,不 stw
      • 最终标记:很显然是 stw 的,因为是为了修正并发标记时可能发生的漏标错标问题,所以必须暂停用户线程以保证正确性。
      • 筛选回收:负责更新 Region 的统计数据,制定回收计划。可以自由的选择任意多个 Region 构成回收集,然后把决定回收的那部分 Region 复制到空的 Region 中,再清理掉整个旧 Region 的空间(即标记-复制算法)。由于操作涉及存活对象的移动,必须 stw。
    2. 让我们把粒度再变细一点,在 GC 操作中,以下情况会 stw:
      • 根节点枚举。因为必须 Root scan 必须在一个能保障一致性的快照中才能得以进行,否则无法保证正确性,尽管现在可达性分析算法中的查找引用链过程已经能够做到和用户线程并发。
      • 存活对象的移动。常发生在标记-复制、标记-整理算法的对象清理过程中
  2. Young GC、Mixed GC、Full GC 过程中发生了什么?
    1. GC 无论是发生在哪个区域(哪个 region),基本过程都类似,都需要有扫描和标记的环节(如枚举根节点)来找出需要被回收/复制的对象,如果涉及标记-整理算法,如 Full GC ,还需要整理内存。这些环节都需要 stw 来保证正确性。所以我们说所有种类的 GC 都 stw只是 stw 的时间不同。
  3. 验证论据
    1. 提出 G1 的论文:Garbage-first garbage collection | Proceedings of the 4th international symposium on Memory manageme

      • 文中 section 3.4 提到:In fully-young generational mode, we maintain a dynamic estimate of the number of young-generation regions that leads to an evacuation pause that meets the pause time bound, and initiate a pause whenever this number of young regions is allocated. For steady-state applications, this leads to a natural period between evacuation pauses. Note that we can meet the soft real-time goal only if this period exceeds its time slice. In partially-young mode, on the other hand, we do evacuation pauses as often as the soft real-time goal allows. Doing pauses at the maximum allowed frequency minimizes the number of young regions collected in those pauses, and therefore maximizes the number of non-young regions that may be added to the collection set.
      • 具体的论述与 GC 的停顿时间预测有关,此处不展开赘述。但我们可以从作者的论述看出:Young GC 和 Mixed GC 都会有 evacuation pauses。
      • 那么什么是 evacuation pause 呢?论文的 section 2.4 给出了定义:At appropriate points (described in section 3.4), we stop the mutator threads and perform an evacuation pause.
      • 由此可以感性的认知:G1 的 young GC 和 mixed GC 是会 stw 的。
    2. 源码:G1 触发 Young GC 后,会执行一个叫 do_collection_pause 的方法,方法签名如下:

    3. HeapWord* G1CollectedHeap::do_collection_pause(size_t word_size,
                                                     unsigned int gc_count_before,
                                                     bool* succeeded,
                                                     GCCause::Cause gc_cause) {
        // 记录 gc 停顿
        g1_policy()->record_stop_world_start();
        // gc 操作任务类,第三个参数表示本次 gc 是不是老年代并发 gc
        VM_G1IncCollectionPause op(gc_count_before,
                                   word_size,
                                   false, /* should_initiate_conc_mark */
                                   g1_policy()->max_pause_time_ms(),
                                   gc_cause);
        // 真正的停顿方法在 VMThread::execute(&op) 中
        VMThread::execute(&op);
                                                      
        HeapWord* result = op.result();
        bool ret_succeeded = op.prologue_succeeded() && op.pause_succeeded();
                                                      
        *succeeded = ret_succeeded;
                                                      
        return result;
      }
      

      4. do_collection_pause 会在 VMThread::execute(&op) 里会产生停顿,再来看这个方法:
      5. ```C++
      void VMThread::execute(VM_Operation* op) {
      Thread* t = Thread::current();
      // 判断当前线程是否是 vm 线程
      if (!t->is_VM_thread()) {
      // 跳过这里,其实这里的逻辑是当前不是 vm 线程是 java 线程或者 watcher 线程
      // 会先将任务放到一个 queue 中,之后再执行
      ......
      } else {
      // 如果是 vm 线程则会进入这里
      ......
      HandleMark hm(t);
      _cur_vm_operation = op;
      // 判断任务是否需要在安全点执行且当前是否在安全点
      if (op->evaluate_at_safepoint() && !SafepointSynchronize::is_at_safepoint()) {
      // 如果不是安全点,则等待所有线程进入安全点,然后把线程暂时挂起
      // 这个类中有个状态 _state,所有 java 线程转换线程状态时会去判断这个状态然后
      // 决定是否 block
      SafepointSynchronize::begin();
      // 开始任务, op 是刚刚传入的 VM_G1IncCollectionPause 操作任务类
      // evaluate() 方法最后会调用 gc 操作任务类的 doit() 方法
      op->evaluate();
      // 安全点结束
      SafepointSynchronize::end();
      } else {
      // 是安全点则直接执行
      op->evaluate();
      }

      if (op->is_cheap_allocated()) delete op;

      _cur_vm_operation = prev_vm_operation;
      }
      }
    4. 该方法会判断线程是否都进入安全区(安全区概念参考:https://www.zhihu.com/question/371699670,该问题高赞回答比较全面的阐述了安全区的概念,挺受用的),如是,执行 op->evaluate() 以 stw。实际上,SafepointSynchronize::begin() 方法内包括了准备进入安全点到所有 java 线程 Block 的过程,此时 Young GC 的全局停顿开始了。因此,Young GC 是 stw 的。

    5. Mixed GC 和 Full GC 由于涉及到老年代内存对象的回收,需要根节点枚举、对象复制整理等操作,显然是 stw 的,此处不再从源码角度赘述。

  • Young GC:选定所有年轻代里的 Region。通过控制年轻代的 region 个数,即年轻代内存大小,来控制 young GC的时间开销。
  • Mixed GC:选定所有年轻代里的 Region,外加根据 global concurrent marking 统计得出收集收益高的若干老年代 Region。在用户指定的开销目标范围内尽可能选择收益高的老年代 Region。

由上面的描述可知,Mixed GC 不是 full GC,它只能回收部分老年代的 Region,如果 mixed GC 实在无法跟上程序分配内存的速度,导致老年代填满无法继续进行 Mixed GC,就会使用 serial old GC(full GC)来收集整个 GC heap。所以我们可以知道,G1 是不提供 full GC 的

此外,Young GC 和 Mixed GC 都是基于「标记-复制算法」的

上文中,多次提到了「global concurrent marking」,它的执行过程类似 CMS,但是不同的是,在 G1 GC 中,它主要是为 Mixed GC 提供标记服务的,并不是一次 GC 过程的一个必须环节。global concurrent marking 的执行过程分为四个步骤:

  1. 初始标记(initial mark,STW)。它标记了从 GC Root 开始直接可达的对象。
  2. 并发标记(Concurrent Marking)。这个阶段从 GC Root 开始对 heap 中的对象标记,标记线程与应用程序线程并行执行,并且收集各个 Region 的存活对象信息。
  3. 重新标记(Remark,STW)。标记那些在并发标记阶段发生变化的对象,将被回收。
  4. 清除垃圾(Cleanup)。清除空 Region(没有存活对象的),加入到 free list。

第一阶段 initial mark 是共用了 Young GC 的暂停,这是因为他们可以复用 root scan 操作,所以可以说 global concurrent marking 是伴随 Young GC 而发生的。第四阶段 Cleanup 只是回收了没有存活对象的 Region,所以它并不需要 STW。

那什么时候发生 Mixed GC 呢?其实是由一些参数控制着的,另外也控制着哪些老年代 Region 会被选入 Collection Set(Collection Set(CSet),记录了 GC 要收集的 Region 集合,集合里的 Region 可以是任意年代的)。

  • G1HeapWastePercent:在 global concurrent marking 结束之后,我们可以知道 old gen regions 中有多少空间要被回收,在每次 YGC 之后和再次发生 Mixed GC 之前,会检查垃圾占比是否达到此参数,只有达到了,下次才会发生Mixed GC。
  • G1MixedGCLiveThresholdPercent:old generation region 中的存活对象的占比,只有在此参数之下,才会被选入CSet。
  • G1MixedGCCountTarget:一次 global concurrent marking 之后,最多执行 Mixed GC 的次数。
  • G1OldCSetRegionThresholdPercent:一次 Mixed GC 中能被选入 CSet 的最多 old generation region 数量。

由于篇幅限制,这里就只整理了对理解 G1 比较重要的 region 概念以及 GC 模式。其实 G1 还有其它的专有算法以及数据结构,比如停顿预测、Remember Set、SATB 等,感兴趣的读者可以自行搜索。

10.3. ZGC - 基于非代际内存的最新回收器

ZGC(The Z Garbage Collector)是 JDK 11中推出的一款低延迟垃圾回收器,它的设计目标包括:

  • 停顿时间不超过10ms;
  • 停顿时间不会随着堆的大小,或者活跃对象的大小而增加;
  • 支持 8MB~4TB 级别的堆(未来支持 16TB)

10.3.1. 内存模型

ZGC 与传统的 CMS、G1 不同、它没有分代的概念,只有类似 G1 的 Region 概念,ZGC 的 Region 可以具有如下图所示的大中下三类容量:

  • 小型 Region(Small Region):容量固定为2MB,用于放置小于256KB的小对象。
  • 中型 Region(Medium Region):容量固定为32MB,用于放置大于256KB但是小于4MB的对象。
  • 大型 Region(Large Region):容量不固定,可以动态变化,但必须为2MB的整数倍,用于放置4MB或以上的大对象。每个大型 Region 中会存放一个大对象,这也预示着虽然名字叫“大型 Region”,但它的实际容量完全有可能小于中型 Region,最小容量可低至4MB。大型 Region 在 ZGC 的实现中是不会被重分配的(重分配是ZGC的一种处理动作,用于复制对象的收集器阶段)因为复制大对象的代价非常高。

image-20231115175430186

与 CMS 中的 ParNew 和 G1 类似,ZGC 也采用「标记-复制」算法,不过 ZGC 对该算法做了重大改进:ZGC 在标记、转移和重定位阶段几乎都是并发的,这是 ZGC 实现停顿时间小于10ms目标的最关键原因。

ZGC 垃圾回收周期如下图所示:

image-20231115175414710

ZGC只有三个STW阶段:初始标记再标记初始转移。其中,初始标记和初始转移分别都只需要扫描所有 GC Roots,其处理时间和 GC Roots 的数量成正比,一般情况耗时非常短;再标记阶段 STW 时间很短,最多1ms,超过1ms则再次进入并发标记阶段。即,ZGC 几乎所有暂停都只依赖于 GC Roots 集合大小,停顿时间不会随着堆的大小或者活跃对象的大小而增加。与 ZGC 对比,G1 的转移阶段完全 STW 的,且停顿时间随存活对象的大小增加而增加。

Java 实测打印 ZGC 的内存池结果如下:

CodeHeap 'non-nmethods'
Metaspace
ZHeap
CodeHeap 'profiled nmethods'
Compressed Class Space
CodeHeap 'non-profiled nmethods'

10.3.2. 关键技术

ZGC 通过着色指针读屏障技术,解决了转移过程中准确访问对象的问题,实现了并发转移。大致原理描述如下:并发转移中“并发”意味着 GC 线程在转移对象的过程中,应用线程也在不停地访问对象。假设对象发生转移,但对象地址未及时更新,那么应用线程可能访问到旧地址,从而造成错误。而在 ZGC 中,应用线程访问对象将触发“读屏障”,如果发现对象被移动了,那么“读屏障”会把读出来的指针更新到对象的新地址上,这样应用线程始终访问的都是对象的新地址。那么,JVM 是如何判断对象被移动过呢?就是利用对象引用的地址,即着色指针。下面介绍着色指针和读屏障技术细节。

着色指针

着色指针是一种将信息存储在指针中的技术。

ZGC仅支持64位系统,它把64位虚拟地址空间划分为多个子空间,如下图所示:

image-20231115175350372

其中,[0~4TB) 对应Java堆,[4TB ~ 8TB) 称为 M0 地址空间,[8TB ~ 12TB) 称为 M1 地址空间,[12TB ~ 16TB) 预留未使用,[16TB ~ 20TB) 称为 Remapped 空间。

当应用程序创建对象时,首先在堆空间申请一个虚拟地址,但该虚拟地址并不会映射到真正的物理地址。ZGC 同时会为该对象在 M0、M1 和 Remapped 地址空间分别申请一个虚拟地址,且这三个虚拟地址对应同一个物理地址,但这三个空间在同一时间有且只有一个空间有效。ZGC之所以设置三个虚拟地址空间,是因为它使用“空间换时间”思想,去降低 GC 停顿时间。“空间换时间”中的空间是虚拟空间,而不是真正的物理空间。后面将详细介绍这三个空间的切换过程。

与上述地址空间划分相对应,ZGC 实际仅使用64位地址空间的第041位,而第4245位存储元数据,第47~63位固定为0。

image-20231115175338597

ZGC将对象存活信息存储在42~45位中,这与传统的垃圾回收并将对象存活信息放在对象头中完全不同。

读屏障

读屏障是JVM向应用代码插入一小段代码的技术。当应用线程从堆中读取对象引用时,就会执行这段代码。需要注意的是,仅“从堆中读取对象引用”才会触发这段代码。

读屏障示例:

Object o = obj.FieldA   // 从堆中读取引用,需要加入屏障
<Load barrier>
Object p = o // 无需加入屏障,因为不是从堆中读取引用
o.dosomething() // 无需加入屏障,因为不是从堆中读取引用

ZGC 中读屏障的代码作用:在对象标记和转移过程中,用于确定对象的引用地址是否满足条件,并作出相应动作。

接下来详细介绍ZGC一次垃圾回收周期中地址视图的切换过程:

  • 初始化:ZGC初始化之后,整个内存空间的地址视图被设置为 Remapped。程序正常运行,在内存中分配对象,满足一定条件后垃圾回收启动,此时进入标记阶段。
  • 并发标记阶段:第一次进入标记阶段时视图为 M0,如果对象被 GC 标记线程或者应用线程访问过,那么就将对象的地址视图从 Remapped 调整为 M0。所以,在标记阶段结束之后,对象的地址要么是 M0视图,要么是 Remapped。如果对象的地址是 M0 视图,那么说明对象是活跃的;如果对象的地址是Remapped视图,说明对象是不活跃的。
  • 并发转移阶段:标记结束后就进入转移阶段,此时地址视图再次被设置为 Remapped。如果对象被 GC转移线程或者应用线程访问过,那么就将对象的地址视图从 M0 调整为 Remapped。

其实,在标记阶段存在两个地址视图 M0 和 M1,上面的过程显示只用了一个地址视图。之所以设计成两个,是为了区别前一次标记和当前标记。也即,第二次进入并发标记阶段后,地址视图调整为 M1,而非M0。

着色指针和读屏障技术不仅应用在并发转移阶段,还应用在并发标记阶段:将对象设置为已标记,传统的垃圾回收器需要进行一次内存访问,并将对象存活信息放在对象头中;而在 ZGC 中,只需要设置指针地址的第42~45位即可,并且因为是寄存器访问,所以速度比访问内存更快。

image-20231115175323045

10.4. 其他分享

10.4.1. 如何调试 GC 的 notification 线程?

JDK 8、11 的 notification 是 Service Thread 在做,这个 Thread 不能被 idea 捕获,无法正常调试;

image-20231115175301099

解决:JDK 17 里 notification 的 handle 由 Notification Thread 负责,可以被 idea 捕获,把 JDK 换成 17 之后就可以切换到 Notification Thread 调试 handleNotification

image-20231115175243105

10.4.2. GC 使用参数

Collector Option
指定年轻代为Serial收集器 -XX:+UseSerialGC
指定老年代为Serial收集器 ‐XX:+UseSerialOldGC
指定年轻代为ParNew收集器 ‐XX:+UseParNewGC
指定年轻代为 Parallel 收集器 ‐XX:+UseParallelGC
指定老年代为 Parallel 收集器 ‐XX:+UseParallelOldGC
指定老年代为 CMS 收集器 ‐XX:+UseConcMarkSweepGC
G1 ‐XX:+UseG1GC
ZGC -XX:+UseZGC

IDEA 使用:在 VM options 添加即可

image-20231115175206555

10.4.3. JMX 可以监控 G1 的 mixed GC 吗?

不能。实验过程如下:

可以看到,对于同一次 GC,JVM自带的 GC log 显示是 mixed GC,但 JMX 显示为 minor GC。

  • 猜测:由于 mixed GC 在 hotspot 源码中是由 YGC 触发,所以 JMX 为了简化将 YGC 和 mixed GC 统一视为 minor GC。
[GC pause (G1 Evacuation Pause) (mixed), 0.0237647 secs]
[Parallel Time: 22.2 ms, GC Workers: 9]
[GC Worker Start (ms): Min: 10897.2, Avg: 10899.3, Max: 10900.4, Diff: 3.3]
[Ext Root Scanning (ms): Min: 0.0, Avg: 0.1, Max: 0.4, Diff: 0.3, Sum: 0.7]
[Update RS (ms): Min: 0.0, Avg: 1.1, Max: 4.3, Diff: 4.3, Sum: 9.8]
[Processed Buffers: Min: 0, Avg: 1.6, Max: 6, Diff: 6, Sum: 14]
[Scan RS (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.1]
[Code Root Scanning (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
[Object Copy (ms): Min: 7.4, Avg: 8.6, Max: 8.8, Diff: 1.4, Sum: 77.2]
[Termination (ms): Min: 0.0, Avg: 1.3, Max: 10.3, Diff: 10.3, Sum: 11.8]
[Termination Attempts: Min: 1, Avg: 397.3, Max: 588, Diff: 587, Sum: 3576]
[GC Worker Other (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.1]
[GC Worker Total (ms): Min: 8.9, Avg: 11.1, Max: 19.0, Diff: 10.2, Sum: 99.7]
[GC Worker End (ms): Min: 10909.3, Avg: 10910.4, Max: 10919.3, Diff: 10.0]
[Code Root Fixup: 0.0 ms]
[Code Root Purge: 0.0 ms]
[Clear CT: 0.1 ms]
[Other: 1.5 ms]
[Choose CSet: 0.0 ms]
[Ref Proc: 1.2 ms]
[Ref Enq: 0.0 ms]
[Redirty Cards: 0.1 ms]
[Humongous Register: 0.1 ms]
[Humongous Reclaim: 0.0 ms]
[Free CSet: 0.0 ms]
[Eden: 46080.0K(46080.0K)->0.0B(48128.0K) Survivors: 7168.0K->7168.0K Heap: 1043.1M(1049.0M)->1044.6M(1098.0M)]
[Times: user=0.05 sys=0.01, real=0.02 secs]
17:58:54.446 [Service Thread] INFO org.apache.iotdb.metrics.jvm.GCListenerTest - end of minor GC
17:58:54.447 [Service Thread] INFO org.apache.iotdb.metrics.jvm.GCListenerTest - minor GC: - 45 (G1_Evacuation_Pause) start: 2023-07-25 17:58:54.422, end: 2023-07-25 17:58:54.445
17:58:54.447 [Service Thread] INFO org.apache.iotdb.metrics.jvm.GCListenerTest - [G1 Old Gen] init:235520K; used:24.1%(1014924K) -> 25.3%(1062540K); committed: 24.2%(1017856K) -> 25.4%(1065984K)
17:58:54.447 [Service Thread] INFO org.apache.iotdb.metrics.jvm.GCListenerTest - [Code Cache] init:2496K; used:1.9%(2586K) -> 1.9%(2586K); committed: 2.0%(2624K) -> 2.0%(2624K)
17:58:54.447 [Service Thread] INFO org.apache.iotdb.metrics.jvm.GCListenerTest - [G1 Survivor Space] init:0K; used:7168K -> 7168K); committed: 7168K -> 7168K)
17:58:54.447 [Service Thread] INFO org.apache.iotdb.metrics.jvm.GCListenerTest - [Compressed Class Space] init:0K; used:0.0%(777K) -> 0.0%(777K); committed: 0.0%(896K) -> 0.0%(896K)
17:58:54.447 [Service Thread] INFO org.apache.iotdb.metrics.jvm.GCListenerTest - [Metaspace] init:0K; used:6965K -> 6965K); committed: 7296K -> 7296K)
17:58:54.447 [Service Thread] INFO org.apache.iotdb.metrics.jvm.GCListenerTest - [G1 Eden Space] init:26624K; used:46080K -> 0K); committed: 49152K -> 51200K)
17:58:54.447 [Service Thread] INFO org.apache.iotdb.metrics.jvm.GCListenerTest - duration:23ms, throughput:96.4%

测试参数:

这里有一个 trick:由于 G1 的特点,我们希望能平缓的增加内存,使得 G1 「来得及」进行垃圾回收,触发 mixed GC,而不是触发 full GC。因此需要对 JVM 进行调参,同时测试代码中需要延缓内存消耗速度。

-XX:+PrintGCDetails -XX:+UseG1GC -XX:InitiatingHeapOccupancyPercent=20 // 默认值为 40,是触发 mixed GC 的堆内存占用 threshold,这里调低一点,防止 full GC -XX:G1HeapWastePercent=5 // 当整个堆可回收的百分比小于该阈值时,Java HotSpot VM不会启动Mixed GC。默认值为10%。这里同理,调低一点防止 full GC

// 测试代码
new Thread(
() -> {
// manually add heap usage
List<byte[]> byteList = new ArrayList<>();
int i = 0;
while (true) {
if (i % 10000 == 0) {
try {
// 为了尽量少的触发 Full GC 而触发 mixed GC,需要减缓内存消耗速度
Thread.sleep(TimeUnit.MILLISECONDS.toMillis(100));
} catch (InterruptedException ie) {
return;
}
}
byte[] bytes = new byte[1024];
byteList.add(bytes);
i++;
}
})
.start();

10.4.4. Duration 的计算准确性

这里贴一个详细的例子来说明:涉及并发 GC 时,JMX notification 提供的 duration 比实际 duration 大。

image-20231115175146661

[GC pause (G1 Evacuation Pause) (mixed), 0.0162640 secs]
[Parallel Time: 15.6 ms, GC Workers: 9]
[GC Worker Start (ms): Min: 9212.1, Avg: 9214.5, Max: 9215.2, Diff: 3.1]
[Ext Root Scanning (ms): Min: 0.0, Avg: 0.1, Max: 0.6, Diff: 0.6, Sum: 0.6]
[Update RS (ms): Min: 0.9, Avg: 2.0, Max: 3.2, Diff: 2.2, Sum: 18.0]
[Processed Buffers: Min: 1, Avg: 2.1, Max: 4, Diff: 3, Sum: 19]
[Scan RS (ms): Min: 0.0, Avg: 0.1, Max: 0.3, Diff: 0.3, Sum: 1.1]
[Code Root Scanning (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
[Object Copy (ms): Min: 9.3, Avg: 10.2, Max: 10.7, Diff: 1.4, Sum: 92.0]
[Termination (ms): Min: 0.0, Avg: 0.4, Max: 0.5, Diff: 0.5, Sum: 3.3]
[Termination Attempts: Min: 1, Avg: 449.8, Max: 606, Diff: 605, Sum: 4048]
[GC Worker Other (ms): Min: 0.0, Avg: 0.0, Max: 0.1, Diff: 0.1, Sum: 0.4]
[GC Worker Total (ms): Min: 12.2, Avg: 12.8, Max: 15.2, Diff: 3.1, Sum: 115.4]
[GC Worker End (ms): Min: 9227.3, Avg: 9227.3, Max: 9227.4, Diff: 0.1]
[Code Root Fixup: 0.0 ms]
[Code Root Purge: 0.0 ms]
[Clear CT: 0.1 ms]
[Other: 0.5 ms]
[Choose CSet: 0.0 ms]
[Ref Proc: 0.2 ms]
[Ref Enq: 0.0 ms]
[Redirty Cards: 0.1 ms]
[Humongous Register: 0.1 ms]
[Humongous Reclaim: 0.0 ms]
[Free CSet: 0.0 ms]
[Eden: 38912.0K(38912.0K)->0.0B(40960.0K) Survivors: 6144.0K->6144.0K Heap: 880.2M(886.0M)->882.2M(928.0M)]
[Times: user=0.08 sys=0.02, real=0.02 secs]
10:48:58.092 [Service Thread] INFO org.apache.iotdb.metrics.jvm.GCListenerTest - end of minor GC
10:48:58.092 [Service Thread] INFO org.apache.iotdb.metrics.jvm.GCListenerTest - minor GC: - 41 (G1_Evacuation_Pause) start: 2023-07-26 10:48:58.074, end: 2023-07-26 10:48:58.091
10:48:58.092 [Service Thread] INFO org.apache.iotdb.metrics.jvm.GCListenerTest - duration:17ms, throughput:96.4%

可以看到:这里发生了 mixed GC,有部分 old genertaion 内存被回收。由于 old generation 的回收有一部分 phase 和用户线程并发,这里 JMX notification 提供的 duration 17ms 大于实际的 duration(GC log 打印)16.2ms。

实测这里的误差比较小,在 Millisecond 的粒度,很多时候看不出来误差。然而误差仍然是存在的。

当涉及并发 GC(某些阶段可以和用户线程并发执行,如 G1 old generation)的 duration 时,JMX notification 提供的 duration 实际上比准确的 duration 要大。因此此时需要换一种计算方式:主动向 gcBean 获取总回收时间,通过作差算出现在所需的运算时间。

10.4.4.1. 源码说明

结论

  • JMX 提供了两种得到 GC duration 的 api,一种是 notification 的 gcInfo,一种是向 GarbageCollectorMXBean 拉取累计 GC 时间,作差算出 duration。前者在涉及并发 GC 的时候 duration 有点不准确。
  • GarbageCollectorMXBean 的 getCollectionTime() 方法计算的是 GC pause 的时间,如果 GC 包含与用户线程并发的部分,不会被计算入 getCollectionTime() 中。因此这种方法算出来的 GC duration 是准确的 stw 时间。

接下来看看为什么 GarbageCollectorMXBean 的 getCollectionTime() 方法获取的是准确的 stw 时间。

首先来看看 java 上该方法的签名:

image-20231115175127154

深入 getCollectionTime(),发现是 native 方法

image-20231115175112530

查看 openjdk 源码,native 方法签名如下:

JNIEXPORT jlong JNICALL Java_sun_management_GarbageCollectorImpl_getCollectionTime
(JNIEnv *env, jobject mgr) {
return jmm_interface->GetLongAttribute(env, mgr, JMM_GC_TIME_MS);
}

可以看到是返回 JMM_GC_TIME_MS 变量,继续追踪,

static jlong get_gc_attribute(GCMemoryManager* mgr, jmmLongAttribute att) {
switch (att) {
case JMM_GC_TIME_MS:
return mgr->gc_time_ms();

case JMM_GC_COUNT:
return mgr->gc_count();

case JMM_GC_EXT_ATTRIBUTE_INFO_SIZE:
// current implementation only has 1 ext attribute
return 1;

default:
assert(0, "Unrecognized GC attribute");
return -1;
}
}

该变量通过 mgr->gc_time_ms() 获取,mgr 是 GCMemoryManager,这个类的签名如下:

class GCMemoryManager : public MemoryManager {
private:
// TODO: We should unify the GCCounter and GCMemoryManager statistic
size_t _num_collections;
elapsedTimer _accumulated_timer;
GCStatInfo* _last_gc_stat;
Mutex* _last_gc_lock;
GCStatInfo* _current_gc_stat;
int _num_gc_threads;
volatile bool _notification_enabled;
bool _pool_always_affected_by_gc[MemoryManager::max_num_pools];

public:
GCMemoryManager(const char* name);
~GCMemoryManager();

void add_pool(MemoryPool* pool);
void add_pool(MemoryPool* pool, bool always_affected_by_gc);

bool pool_always_affected_by_gc(int index) {
assert(index >= 0 && index < num_memory_pools(), "Invalid index");
return _pool_always_affected_by_gc[index];
}

void initialize_gc_stat_info();

bool is_gc_memory_manager() { return true; }
jlong gc_time_ms() { return _accumulated_timer.milliseconds(); }
size_t gc_count() { return _num_collections; }
int num_gc_threads() { return _num_gc_threads; }
void set_num_gc_threads(int count) { _num_gc_threads = count; }

void gc_begin(bool recordGCBeginTime, bool recordPreGCUsage,
bool recordAccumulatedGCTime);
void gc_end(bool recordPostGCUsage, bool recordAccumulatedGCTime,
bool recordGCEndTime, bool countCollection, GCCause::Cause cause,
bool allMemoryPoolsAffected, const char* message);

void reset_gc_stat() { _num_collections = 0; _accumulated_timer.reset(); }

// Copy out _last_gc_stat to the given destination, returning
// the collection count. Zero signifies no gc has taken place.
size_t get_last_gc_stat(GCStatInfo* dest);

void set_notification_enabled(bool enabled) { _notification_enabled = enabled; }
bool is_notification_enabled() { return _notification_enabled; }
};

可以发现,最终 gc_time 是 _accumulated_timer 来记录的。这个 timer 是通过记录 start 和 end 时间,将 end 和 start 时间作差进行计时的。

那 start 和 end 时间是在哪里记录的呢?继续跟进发现在 gc_begin() 和 gc_end() 方法里。

void GCMemoryManager::gc_begin(bool recordGCBeginTime, bool recordPreGCUsage,
bool recordAccumulatedGCTime) {
assert(_last_gc_stat != nullptr && _current_gc_stat != nullptr, "Just checking");
if (recordAccumulatedGCTime) {
_accumulated_timer.start();
}
// _num_collections now increases in gc_end, to count completed collections
if (recordGCBeginTime) {
_current_gc_stat->set_index(_num_collections+1);
_current_gc_stat->set_start_time(Management::timestamp());
}

...
}

void GCMemoryManager::gc_end(bool recordPostGCUsage,
bool recordAccumulatedGCTime,
bool recordGCEndTime,
bool countCollection,
GCCause::Cause cause,
bool allMemoryPoolsAffected,
const char* message) {
if (recordAccumulatedGCTime) {
_accumulated_timer.stop();
}
if (recordGCEndTime) {
_current_gc_stat->set_end_time(Management::timestamp());
}

...
}

那么 gc_begin() 和 gc_end() 方法是何时调用呢?继续跟进,省去一些中间 wrapper,最终可以发现是在 TraceMemoryManagerStats 这个类里调用。该类签名如下:

TraceMemoryManagerStats::TraceMemoryManagerStats(GCMemoryManager* gc_memory_manager,
GCCause::Cause cause,
const char* end_message,
bool allMemoryPoolsAffected,
bool recordGCBeginTime,
bool recordPreGCUsage,
bool recordPeakUsage,
bool recordPostGCUsage,
bool recordAccumulatedGCTime,
bool recordGCEndTime,
bool countCollection) {
initialize(gc_memory_manager, cause, end_message,
allMemoryPoolsAffected, recordGCBeginTime, recordPreGCUsage,
recordPeakUsage, recordPostGCUsage, recordAccumulatedGCTime,
recordGCEndTime, countCollection);
}

// for a subclass to create then initialize an instance before invoking
// the MemoryService
void TraceMemoryManagerStats::initialize(GCMemoryManager* gc_memory_manager,
GCCause::Cause cause,
const char* end_message,
bool allMemoryPoolsAffected,
bool recordGCBeginTime,
bool recordPreGCUsage,
bool recordPeakUsage,
bool recordPostGCUsage,
bool recordAccumulatedGCTime,
bool recordGCEndTime,
bool countCollection) {
_gc_memory_manager = gc_memory_manager;
_cause = cause;
_end_message = end_message;
_allMemoryPoolsAffected = allMemoryPoolsAffected;
_recordGCBeginTime = recordGCBeginTime;
_recordPreGCUsage = recordPreGCUsage;
_recordPeakUsage = recordPeakUsage;
_recordPostGCUsage = recordPostGCUsage;
_recordAccumulatedGCTime = recordAccumulatedGCTime;
_recordGCEndTime = recordGCEndTime;
_countCollection = countCollection;

MemoryService::gc_begin(_gc_memory_manager, _recordGCBeginTime, _recordAccumulatedGCTime,
_recordPreGCUsage, _recordPeakUsage);
}

TraceMemoryManagerStats::~TraceMemoryManagerStats() {
MemoryService::gc_end(_gc_memory_manager, _recordPostGCUsage, _recordAccumulatedGCTime,
_recordGCEndTime, _countCollection, _cause, _allMemoryPoolsAffected,
_end_message);
}

重点来了:TraceMemoryManagerStats 在初始化时调用 gc_begin() 来记录开始时间,在析构时调用 gc_end() 记录结束时间。也就是说,TraceMemoryManagerStats 的生命周期长度等于记录的 GC 时间。在 JVM 中,在需要记录 GC duration 的地方声明一个 TraceMemoryManagerStats 对象就可以达到记录时间的目的,如果某些 GC phases,如 G1 的并发标记阶段,不需要记录 GC duration,那么就不会声明 TraceMemoryManagerStats 对象。

接下来追踪该对象在哪里被声明,哪里被析构。继续跟进,发现对于 G1,G1MonitoringScape 类 wrap 了 TraceMemoryManagerStats 对象:

// Scope object for java.lang.management support.
class G1MonitoringScope : public StackObj {
G1MonitoringSupport* _monitoring_support;
TraceCollectorStats _tcs;
TraceMemoryManagerStats _tms;
protected:
G1MonitoringScope(G1MonitoringSupport* monitoring_support,
CollectorCounters* collection_counters,
GCMemoryManager* gc_memory_manager,
const char* end_message,
bool all_memory_pools_affected = true);
~G1MonitoringScope();
};

继续跟进,发现 G1 有三个类记录了 G1MonitoringScope 对象。换句话来说,只要声明了这三个类,就等于声明了 TraceMemoryManagerStats 对象,而后者会随着初始化和析构记录 gc 时间。

G1YoungGCMonitoringScope::G1YoungGCMonitoringScope(G1MonitoringSupport* monitoring_support,
bool all_memory_pools_affected) :
G1MonitoringScope(monitoring_support,
monitoring_support->_young_collection_counters,
&monitoring_support->_young_gc_memory_manager,
"end of minor GC",
all_memory_pools_affected) {
}

G1FullGCMonitoringScope::G1FullGCMonitoringScope(G1MonitoringSupport* monitoring_support) :
G1MonitoringScope(monitoring_support,
monitoring_support->_full_collection_counters,
&monitoring_support->_full_gc_memory_manager,
"end of major GC") {
}

G1ConcGCMonitoringScope::G1ConcGCMonitoringScope(G1MonitoringSupport* monitoring_support) :
G1MonitoringScope(monitoring_support,
monitoring_support->_conc_collection_counters,
&monitoring_support->_conc_gc_memory_manager,
"end of concurrent GC pause") {
}

这里我们重点关注一下 G1ConcGCMonitoringScope,顾名思义,该类主要用于 concurrent G1。继续跟进,发现只有在 VM_G1PauseConcurrent::doit() 该类被声明。也就是说,对于 concurrent G1,JVM 只统计了 VM_G1PauseConcurrent::doit() 的耗时。

void VM_G1PauseConcurrent::doit() {
GCIdMark gc_id_mark(_gc_id);
G1CollectedHeap* g1h = G1CollectedHeap::heap();
GCTraceCPUTime tcpu(g1h->concurrent_mark()->gc_tracer_cm());

// GCTraceTime(...) only supports sub-phases, so a more verbose version
// is needed when we report the top-level pause phase.
GCTraceTimeLogger(Info, gc) logger(_message, GCCause::_no_gc, true);
GCTraceTimePauseTimer timer(_message, g1h->concurrent_mark()->gc_timer_cm());
GCTraceTimeDriver t(&logger, &timer);

G1ConcGCMonitoringScope monitoring_scope(g1h->monitoring_support());
SvcGCMarker sgcm(SvcGCMarker::CONCURRENT);
IsGCActiveMark x;

work();
}

我们来看一下 VM_G1PauseConcurrent::doit() 干了什么。doit() 是 VM_G1PauseConcurrent 的入口方法,而 VM_G1PauseConcurrent 是 concurrent G1 stw 的 operations。这点源码也通过注释解释的很清楚了。

综上,可以看出:JMX 对于 getCollectionTime() 方法只记录了 concurrent G1 的 stw 部分的耗时,因此 getCollectionTime() 的统计耗时是 accumulated stw GC time,更为精确。

Reference

《深入理解Java虚拟机(第3版)》 周志明

https://openjdk.java.net/projects/jdk/15/

https://aijishu.com/a/1060000000083459

https://www.jianshu.com/p/aef0f4765098

https://blog.csdn.net/lbh_paopao/article/details/120269135

https://zhuanlan.zhihu.com/p/546651403

https://zhuanlan.zhihu.com/p/431406707

https://juejin.cn/post/6844903970142421005

https://stackoverflow.com/questions/19154607/how-actually-card-table-and-writer-barrier-work

https://tech.meituan.com/2020/11/12/java-9-cms-gc.html

https://tech.meituan.com/2016/09/23/g1.html

https://tech.meituan.com/2020/11/12/java-9-cms-gc.html

https://tech.meituan.com/2020/08/06/new-zgc-practice-in-meituan.html

https://blog.csdn.net/wisgood/article/details/79850093

https://juejin.cn/post/7095643412082196511#heading-7

https://blog.csdn.net/weixin_42596455/article/details/81228611

OLDER > < NEWER