ZGC 的探索与实践

ZGC

ZGC(The Z Garbage Collector)是 JDK 11 中推出的一款低延迟垃圾回收器。基于 Region 内存分布,使用读屏障、染色指针,内存多重映射等技术实现,以低延时为首要目标的垃圾收集器。{设计思路借鉴 Azul 的 PGC & C4}

设计目标

  • 停顿时间不超过 10ms

  • 停顿时间不会随着堆大小或者活跃对象的大小而增加

  • 支持 8MB~4TB 级别的堆(未来会支持到 16TB)

    SPECjbb 2015 基准测试,在 128G 大堆下,最大停顿时间 1.68ms

内存模型

与 G1 类似的,采取基于页面(Page)的堆内存分布,不同的是 ZGC 的 Page 是动态的,动态的创建、销毁和动态的区域大小

ZGC 的 Page 主要分成三类

  • 小型 Page Small:容量固定 2MB,用于存放小于 256KB 的小对象
  • 中型 Page Medium:容量固定 32MB,用于存放 256KB-4MB 的对象
  • 大型 Page Large:容量为 N*2MB(收操作系统控制),大小不固定。用于存放大于等于 4MB 的对象,且一个 Page 只存放一个对象

在垃圾回收时,小页面优先回收,中页面和大页面尽量不回收。

内存多重映射

为了清晰地了解 ZGC 的内存管理,先要了解操作系统的虚拟内存和物理内存。

虚拟内存是操作系统根据 CPU 的寻址能力,支持访问的虚拟空间。例如早年的 32 位的操作系统,对应的虚拟空间为 0232,即 04GB,而物理内存只有 512MB。

因虚拟内存与物理内存大小不一致的情况,需要额外的映射机制关联起来。当程序试图访问一个虚拟内存页面时,这个请求会通过操作系统来访问真正的内存。首先到页面表中查询该页是否已映射到物理页框中,并记录在页表中。如果已记录,则会通过内存管理单元(Memory Management Unit,MMU)把页码转换成页框码(frame),并加上虚拟地址提供的页内偏移量形成物理地址后去访问物理内存;如果未记录,则意味着该虚拟内存页面还没有被载入内存,这时 MMU 就会通知操作系统发生了一个页面访问错误(也称为缺页故障(page fault)),接下来系统会启动所谓的“请页”机制,即调用相应的系统操作函数,判断该虚拟地址是否为有效地址。如果是有效的地址,就从虚拟内存中将该地址指向的页面读入内存中的一个空闲页框中,并在页表中添加相对应的表项,最后处理器将从发生页面错误的地方重新开始运行;如果是无效的地址,则表明进程在试图访问一个不存在的虚拟地址,此时操作系统将终止此次访问。当然,也存在这样的情况:在请页成功之后,内存中已经没有空闲物理页框了,这时,系统必须启动所谓的“交换”机制,即调用相应的内核操作函数,在物理页框中寻找一个当前不再使用或者近期可能不会用到的页面所占据的页框。找到后,就把其中的页移出,以装载新的页面。对移出页面根据两种情况来处理:如果该页未被修改过,则删除它;如果该页曾经被修改过,则系统必须将该页写回辅存。

ZGC 使用内存多重映射将多个不同的虚拟内存地址映射到同一个物理内存地址。

当应用程序创建对象时,先在堆空间申请一个虚拟地址,同时会在 M0,M1 和 Remapped 地址申请一个虚拟地址,这三个地址对应同一个物理地址,同一时间,只有一个地址生效。(虚拟地址与物理地址由映射表维护)

ZGC 在 Linux64 位系统,在 jvm 启动时创建一个文件描述符,在内存分配时,新分配的虚拟地址转化成 3 个映射视图中的虚拟地址(对低 42~44 位进行位或运算),再通过 mmap 映射到这个文件描述符上。

染色指针 Colored Pointer

HotSpot 虚拟机的标记实现方案有如下几种:

1.把标记直接记录在对象头上(如 Serial 收集器);

2.将标记记录在与对象相互独立的数据结构上(如 G1,Shenandoah 使用 Remeber Set 结构记录标记信息)

3.直接把标记信息记在引用对象的指针上(如 ZGC)

ZGC 只支持 64 位系统,64 位指针,高 18 位不能用来寻址,4245 位来标识 Finalizable,Remapped,Marked1,Marked0,041 存储元数据。

{Finalizable:只能通过 finalize()方法才能访问

Marked0:表示指针已经被标记、第一次 GC 时标记阶段

Marked1:表示指针已经被标记、第二次 GC 时标记阶段

Remapped:引用已经完成重定向}

读屏障

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

读屏障示例:

1
2
3
4
5
 Object o = obj.FieldA   // 从堆中读取引用,需要加入屏障
 <Load barrier>
 Object p = o  // 无需加入屏障,因为不是从堆中读取引用
 o.dosomething() // 无需加入屏障,因为不是从堆中读取引用
 int i =  obj.FieldB  //无需加入屏障,因为不是对象引用

在对象标记和转移过程中:每次从 GC 堆里的对象的引用类型字段里读取一个指针时,这个指针都会经过读屏障。读屏障确保读出的指针更新到对象的新地址上,并且把堆里的这个指针修正到原本的字段里。这样就不需要通过 STW 的方式来让 GC 与应用之间同步,ZGC 称为指针的自愈(Self-Healing)能力。

ZGC 的过程

与 CMS 中 Young GC 类似,采用标记-复制算法。但是对该算法进行了改进,在标记、转移和重新标记阶段几乎都是并发的。

主要分为以下几个阶段:

  • 并发标记(初始标记、并发标记、再标记):标记阶段会更新指针中 M0,M1 标志位

  • 并发预备重分配(并发转移准备):需要根据条件统计出本次收集清理的 Region,扫描所有 Region

    每次会扫描所有的 Region,用范围更大的扫描成本换取 G1 的 Remeber Set 维护成本。G1 为了收益优先做增量回收,需要用 Remember Set 来记录 Region 之间的对象引用关系。但是 RS 占用内存较大。

  • 并发重分配(初始化转移,并发转移):需要将存活对象复制到新 Region,并未重新发配的每一个 Region 维护一个转发表。当用户线程此时访问转发表中的对象时,会被内存屏障拦截,根据转发表转发到复制的新对象上,并更新该引用的值。复制完毕时,该 Region 会立即释放用于分配新对象,但是转发表不会清除用于自愈。

    {G1 的转移阶段是完全 STW,且暂停时间与活跃对象的大小成正比。但 G1 支持部分 Region 回收,G1 在写引用时,GC 移动对象需要同步更新 RS。}

  • 并发重映射(并发标记):

    初始标记 & 再标记 & 初始转移,这三个阶段会出现 STW。但是初始标记和初始转移只需要扫描 GC Roots。

    ZGC 的暂停几乎只依赖 GC Roots 的大小,停顿时间不会随着堆得大小或者活跃对象的大小而增加。

Mark、Relocate、Remap

读屏障在 Mark 阶段,将要被访问的对象加入标记队列。

在 Mark 阶段结束后,被标记的对象就是存活对象,用于移动。在所有页面中选择一个自己 Relocation Set。RS 中的每个页面有一个 Forwarding Table(用于保存对象的的移动状态)

在 Relocate 阶段,GC 线程会遍历 RS 中的对象进行移动。期间读屏障遇到 Marked 状态的指针时,会检查 FT 中是否存在该引用,如果存在,则修改指针为新地址。如果不存在,就发起转移并修改 FT。

ZGC 在初始化标记的时候会将转移之后的对象的指针进行重定向(Remap).就是在下一轮的标记阶段会利用上一轮的标记信息做区分。

ZGC 实践与比较

JDK 11 下 Mac OS 和 Win10 不支持开启 ZGC,只支持 Linux。Win10 和 Mac OS 下体验可以升级至 JDK14

支付网关服务(PGW)线上的 GC 情况,使用在业务高峰时段,一分钟 6 次的的 minor GC,停顿时间 80ms

生产环境的 JVM 参数配置,用的 JDK8 默认的垃圾回收器Parallel Scavenge + Parallel Old(并行执行,吞吐量优先)

1
2
3
4
 -Xms3998m -Xmx3998m -XX:MetaspaceSize=200m -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=3
 -XX:GCLogFileSize=10m -Xmn1999m -Xloggc:/dev/shm/gc_%p.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps
 -XX:+PrintGCApplicationStoppedTime -XX:SurvivorRatio=8 -XX:-UseAdaptiveSizePolicy
 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/app/logs

ZGC 下本地测试环境的 JVM 参数配置(PS:本机 CPU:2.2GHz i7 16G 内存)

  • XX:+PrintGCDetailsXX:PrintGC选项在 JDK11 中被弃用
1
2
3
4
5
 -Xms3998M -Xmx3998M
 -XX:+UnlockExperimentalVMOptions -XX:+UseZGC
 -XX:ConcGCThreads2
 -XX:+PrintCodeCacheOnCompilation
 -Xlog:safepoint,classhisto*=trace,age*,gc*=info:file=/app/logs/gc-%t.log:time,tid,tags:filecount=5,filesize=50m

分析 GC 日志,停顿时间<10ms

触发 GC 的分类:

Allocation Stall:当垃圾来不及回收,堆区占满,阻塞线程。

Allocation Rate:近期分配对象的速率及 GC 时间达到一定阈值时。ZAllocationSpikeTolerance调整,默认为 2.越大,GC 越早。

Timer:固定时间间隔。通过-XX:ZCollectionInterval参数控制

Proactive:主动触发,类似于 Timer 触发时间由 ZGC 计算,通过ZProactive参数控制

Warmup~~~~:预热,一般是程序刚启动

Metadata GC Threshold:元数据区不足时触发

Parallel

ab 压测:ab -c 50 -n 10000 http://127.0.0.1:8502/creditBorrow/get/test

JVM 参数配置

1
2
3
4
5
6
7
8
9
10
11
 -Xms3998M
 -Xmx3998M
 -XX:MetaspaceSize=200m
 -Xloggc:/app/logs/gc_parallel_%p.log
 -XX:+PrintGCDetails
 -XX:+PrintGCDateStamps
 -XX:+PrintGCApplicationStoppedTime
 -XX:SurvivorRatio=8
 -XX:-UseAdaptiveSizePolicy
 -XX:+HeapDumpOnOutOfMemoryError
 -XX:HeapDumpPath=/app/logs

GC 日志解析

1
2
 2020-09-02T16:37:28.520-0800: 44.149: [GC (Allocation Failure) [PSYoungGen: 1123988K->81491K(1228288K)] 1124108K->81627K(3957760K), 0.0574004 secs] [Times: user=0.11 sys=0.03, real=0.05 secs]
 2020-09-02T16:40:11.598-0800: 207.240: [GC (Allocation Failure) [PSYoungGen: 1228276K->101514K(1228288K)] 1230815K->104108K(3957760K), 0.0499684 secs] [Times: user=0.20 sys=0.01, real=0.05 secs]

CMS

新生代使用标记-复制算法(ParNew 的 CMS 默认的新生代垃圾回收器),Young GC 的全过程都是 STW

1
2
3
4
5
6
7
8
9
10
11
12
 -Xms3998M
 -Xmx3998M
 -XX:MetaspaceSize=200m
 -XX:+UseConcMarkSweepGC
 -Xloggc:/app/logs/gc_cms_%p.log
 -XX:+PrintGCDetails
 -XX:+PrintGCDateStamps
 -XX:+PrintGCApplicationStoppedTime
 -XX:SurvivorRatio=8
 -XX:-UseAdaptiveSizePolicy
 -XX:+HeapDumpOnOutOfMemoryError
 -XX:HeapDumpPath=/app/logs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
 2020-09-02T17:22:56.890-0800: 74.347: [GC (Allocation Failure) 2020-09-02T17:22:56.890-0800: 74.347: [ParNew: 734545K->47735K(766784K), 0.0226810 secs] 786003K->103230K(4008768K), 0.0228692 secs] [Times: user=0.12 sys=0.01, real=0.02 secs]
 2020-09-02T17:24:36.037-0800: 173.492: [GC (Allocation Failure) 2020-09-02T17:24:36.037-0800: 173.492: [ParNew: 727450K->37114K(766784K), 0.0650905 secs] 799141K->109325K(4008768K), 0.0654584 secs] [Times: user=0.29 sys=0.01, real=0.07 secs]
 2020-09-02T17:24:36.103-0800: 173.558: Total time for which application threads were stopped: 0.0703294 seconds, Stopping threads took: 0.0007604 seconds
 2020-09-02T17:24:36.130-0800: 173.584: Total time for which application threads were stopped: 0.0045084 seconds, Stopping threads took: 0.0010931 seconds
 2020-09-02T17:24:40.141-0800: 177.596: Total time for which application threads were stopped: 0.0041642 seconds, Stopping threads took: 0.0009028 seconds
 2020-09-02T17:24:42.955-0800: 180.409: [GC (Allocation Failure) 2020-09-02T17:24:42.955-0800: 180.409: [ParNew: 718714K->35437K(766784K), 0.0185378 secs] 790925K->107647K(4008768K), 0.0186691 secs] [Times: user=0.08 sys=0.00, real=0.02 secs]
 2020-09-02T17:24:42.974-0800: 180.428: Total time for which application threads were stopped: 0.0199768 seconds, Stopping threads took: 0.0002223 seconds
 2020-09-02T17:24:42.980-0800: 180.435: Total time for which application threads were stopped: 0.0014026 seconds, Stopping threads took: 0.0002243 seconds
 2020-09-02T17:24:45.985-0800: 183.439: Total time for which application threads were stopped: 0.0038208 seconds, Stopping threads took: 0.0006331 seconds
 2020-09-02T17:24:48.474-0800: 185.929: Total time for which application threads were stopped: 0.0011093 seconds, Stopping threads took: 0.0001436 seconds
 2020-09-02T17:24:51.073-0800: 188.528: [GC (Allocation Failure) 2020-09-02T17:24:51.074-0800: 188.528: [ParNew: 717037K->38672K(766784K), 0.0688267 secs] 789247K->110882K(4008768K), 0.0691844 secs] [Times: user=0.34 sys=0.01, real=0.07 secs]
 2020-09-02T17:24:51.143-0800: 188.597: Total time for which application threads were stopped: 0.0737748 seconds, Stopping threads took: 0.0006621 seconds
 2020-09-02T17:24:51.163-0800: 188.617: Total time for which application threads were stopped: 0.0066130 seconds, Stopping threads took: 0.0018507 seconds
 2020-09-02T17:24:53.072-0800: 190.526: Total time for which application threads were stopped: 0.0010838 seconds, Stopping threads took: 0.0001050 seconds
 2020-09-02T17:24:53.073-0800: 190.528: Total time for which application threads were stopped: 0.0013641 seconds, Stopping threads took: 0.0000755 seconds
 2020-09-02T17:24:54.079-0800: 191.534: Total time for which application threads were stopped: 0.0052378 seconds, Stopping threads took: 0.0005817 seconds
 2020-09-02T17:24:56.240-0800: 193.694: Total time for which application threads were stopped: 0.0042420 seconds, Stopping threads took: 0.0004183 seconds
 2020-09-02T17:24:58.141-0800: 195.595: [GC (Allocation Failure) 2020-09-02T17:24:58.141-0800: 195.595: [ParNew: 720272K->38427K(766784K), 0.0173455 secs] 792482K->110637K(4008768K), 0.0174675 secs] [Times: user=0.07 sys=0.00, real=0.01

优点:

  • 停顿时间短
  • 基于标记-清除算法

缺点:

  • 浮动垃圾:并发清除时,用户线程产生的垃圾无法在本地清除(通过XX:+CMSInitiatingOccupancyFtraction设置合理的预留内存空间)
  • 空间碎片,GC 之后内存空间不连续(通过XX:+UseCMSCompactAtFullGCCollection在 GC 之后执行压缩操作)

G1(Garbage-First)

新生代使用标记-复制算法,分为三个阶段:标记阶段、转移阶段(主要停顿时间,占 80%的停顿时间)、重定位阶段

Young GCMixed GC都是采用该算法。

G1 的目标是可控的停顿时间内完成垃圾回收,进行分区设计,在回收时采用部分内存回收(YGC 时会回收所有的新生代分区,MGC 时回收所有的新生代和部分老年代)。但是为了部分回收,G1 实现了 RSet 管理对象的引用关系,需要消耗额外的内存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
 -Xms100M -Xmx200M
 -XX:+UseG1GC
 -XX:+UnlockExperimentalVMOptions
 # Young区的最小百分比
 -XX:G1NewSizePercent=8
 # 当占用内存超过30时,G1启用多次MixGC 回收老年代内存碎片
 -XX:InitiatingHeapOccupancyPercent=30
 -XX:ConcGCThreads=4
 -XX:ParallelGCThreads=16
 # Young区晋升至Old区的年龄(gc代数)
 -XX:MaxTenuringThreshold=1
 # G1每个Region切分的大小
 -XX:G1HeapRegionSize=32m
 -XX:G1MixedGCCountTarget=64
 -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintGCCause -Xloggc:/app/logs/gcg1-%t.log

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
 ##说明当前GC为Young GC,cpu耗时0.0197774s;
 [GC pause (G1 Evacuation Pause) (young), 0.0821722 secs]
 #此次GC停顿的实际时间为10.9ms,共有8个线程参与清理工作;
 [Parallel Time: 9.0 ms, GC Workers: 16]
      [GC Worker Start (ms): Min: 121440.9, Avg: 121441.2, Max: 121443.8, Diff: 2.9]
      [Ext Root Scanning (ms): Min: 0.0, Avg: 1.2, Max: 3.2, Diff: 3.2, Sum: 19.1]
      [Update RS (ms): Min: 0.0, Avg: 0.7, Max: 1.4, Diff: 1.4, Sum: 11.6]
          [Processed Buffers: Min: 0, Avg: 3.6, Max: 20, Diff: 20, Sum: 58]
      [Scan RS (ms): Min: 0.1, Avg: 0.3, Max: 0.5, Diff: 0.4, Sum: 4.9]
      [Code Root Scanning (ms): Min: 0.0, Avg: 0.2, Max: 1.8, Diff: 1.8, Sum: 3.8]
      [Object Copy (ms): Min: 3.8, Avg: 5.6, Max: 7.4, Diff: 3.5, Sum: 90.3]
      [Termination (ms): Min: 0.0, Avg: 0.5, Max: 0.7, Diff: 0.7, Sum: 8.2]
          [Termination Attempts: Min: 1, Avg: 1.4, Max: 3, Diff: 2, Sum: 23]
      [GC Worker Other (ms): Min: 0.0, Avg: 0.0, Max: 0.1, Diff: 0.1, Sum: 0.6]
      [GC Worker Total (ms): Min: 6.0, Avg: 8.7, Max: 9.0, Diff: 3.0, Sum: 138.5]
      [GC Worker End (ms): Min: 121449.8, Avg: 121449.8, Max: 121449.9, Diff: 0.1]
    [Code Root Fixup: 0.1 ms]
    [Code Root Purge: 0.0 ms]
    [Clear CT: 0.6 ms]
    [Other: 72.5 ms]
      [Choose CSet: 0.0 ms]
      [Ref Proc: 71.8 ms]
      [Ref Enq: 0.1 ms]
      [Redirty Cards: 0.3 ms]
      [Humongous Register: 0.0 ms]
      [Humongous Reclaim: 0.0 ms]
      [Free CSet: 0.2 ms]
    [Eden: 2240.0M(2240.0M)->0.0B(2272.0M) Survivors: 160.0M->128.0M Heap: 2461.1M(4000.0M)->215.1M(4000.0M)]
  [Times: user=0.13 sys=0.05, real=0.08 secs]

ZGC 的不足

当非常高的对象分配速率(allocation rate)的时,ZGC 会产生很多浮动垃圾,在 GC 过程中,大量创建对象,这些对象本次 GC 无法回收,称为浮动垃圾(通病)。可以考虑增大 GC 堆得大小。

吞吐量就是代码运行时间/(代码运行时间 + 垃圾回收时间)。比如虚拟机运行 100 分钟,垃圾回收耗时 1 分钟,那么吞吐量就是 99%。

{就是在重吞吐的场景下,ZGC 不适用。}

因为是单代垃圾回收器,每次处理的对象更多(没有考虑热点数据与冷数据),更消耗 CPU 资源,读屏障操作要消耗额外的计算资源。{R 大说分代的 C4 能承受的对象分配速率大概是 PGC 的 10 倍}

PGW 升级至 JDK14 修改的 POM

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
 <dependency>
 <groupId>org.springframework.boot</groupId>
 <artifactId>spring-boot-starter-hateoas</artifactId>
 <version>1.5.4.RELEASE</version>
 </dependency>
 
 <!-- https://mvnrepository.com/artifact/org.springframework.hateoas/spring-hateoas -->
 <dependency>
 <groupId>org.springframework.hateoas</groupId>
 <artifactId>spring-hateoas</artifactId>
 <version>0.24.0.RELEASE</version>
 </dependency>
 
 <!-- lombok 开始-->
 <dependency>
 <groupId>org.projectlombok</groupId>
 <artifactId>lombok</artifactId>
 <version>1.18.4</version>
 </dependency>

参考文献