Fork me on GitHub

ZGC 特性解读

ZGC 特性解读

英文原文地址:A FIRST LOOK INTO ZGC

网友译文:ZGC窥探

ZGC好文推荐: Oracle 发布全新的 Java 垃圾收集器 ZGC

一语道破Java 11的ZGC为何如此高效

The Z Garbage Collector (ZGC) 【2】

Per 大大写的官方 PPT:The Z Garbage Collector An Introduction

总结如下:

  1. ZGC 目标:减少停顿时间(号称10ms 以下)、压缩堆(移动存活对象,可能伴随 STW)
  2. 针对 STW 问题,可以采取以下措施:
  3. 多线程执行压缩(整理)——parallel compaction
  4. 整理分阶段进行,增量式——incremental compaction
  5. 并行整理,不进行 STW 或仅停顿少量时间——concurrent compaction
  6. 不整理
  7. ZGC 采用 concurrent compaction 的方式减少 STW,但 GC 线程与 app 线程并发的方式又存在很多问题:① 刚整理过的内存地址,可能又被别的线程读写;② 大量指向老地址的引用需要更新到新地址。

ZGC 全过程速览

有不懂的名词,后文会解释,速览的内容参考自 ZGC回收器到底有多变态?

  1. 第一处 STW,只标记 GC Roots 直接连通的对象(为方便理解,笔者把它们记为 direct objects)。

  2. 第一处 Concurrent,把全部可达的对象全部标记出来(为方便理解,笔者把它们记为active objects)。

  3. 第二处 STW,也就是 Pause mark end,主要用来处理一些边缘 case,比如弱引用等。

  4. 第二处 Concurrent, 也就是 Concurrent prepare for reloc,会选择出pages 放入 Relocation Set中,这些 pages 会对应自己的 Forwarding table。

  5. 第三处 STW,也就是 Pause Relocate Start,会扫描direct objects,一旦发现其中某对象是属于Relocation set 中时,会将该对象转移到新的 Region 中,并在 Forwarding table 中做个记录。

  6. 第三处 Concurrent,也就是 Concurrent Relocate,会将除 direct objects 以外的剩余 active objects 存入新的 Region 中,完成后,Relocation set 中的 pages 就被腾空了。至于对应的 Forwarding table ,会在下一次 GC cycle 时被删。

ZGC 的特性解读

  1. GC barriers,通常是读屏障,不同于 CPU 的内存屏障
  • 一般的 GC 需要的是写屏障,但是 ZGC 仅需要读屏障。
  • 写屏障目的是为了记录新引用(一个引用指向新生代对象,并该引用存储在老年代对象中,这个老年代对象叫 remebered sets,数据结构中由卡表实现,使用卡表减少老年代的全堆内存扫描)。写屏障发现了类似someObject.field = otherObject类似的声明时,就会在该老年代对象中存储引用。minor GC时,垃圾收集器会检查卡表,从而找到存活的新生代对象。参考:卡表、写屏障、脏卡
  • 读屏障:跟写屏障类似,但在 GC 算法中很少被采用。读屏障的触发条件:仅发生在从堆上加载一个对象引用时,后续使用该引用不会触发读屏障。读屏障的任务是① 检查引用的状态,② 并在将引用返回给 app 之前执行一些任务。
    • 比如在 ZGC 中,会对加载的引用进行测试,② 查看是否设置了某些位(查看着色指针,是“bad color”还是“good color”),② 如果是“bad color”,要走“slow path”,并执行特定的操作(比如mark、relocate、remap 等操作),将“bad color ”转变为“good color”,这样一来,下次load 时就可以走“fast path”了。下文有对读屏障较详细的介绍。

【扩展知识】卡表:大致的标出可能存在老年代到新生代引用的内存区域。减少老年代的全堆内存扫描。该技术将整个堆划分为512字节的卡,维护一个卡表用来存储每张卡的标识位。如果某个标识位存在有指向新生代对象的引用,就认为这张卡是脏的。进行 Minor GC 的时候,就不用扫描整个老年代,在卡表中寻找脏卡,将脏卡中的对象加入 Minor GC 的 GC Roots 中。完成所有脏卡的扫描后,JVM 会将所有的脏卡标识位清零。

  1. Reference coloring 指针着色

  • 这个技术就是为了让指针上携带一些信息(Java 的引用信息)。

  • 64位系统(ZGC 仅支持64位)中一个引用通常是64位的,但只有47位用来表示“虚拟内存的地址”(因为硬件的限制,目前最多支持48位),前42位是对象的内存地址(一般指相对于 source code 的一个 offset,理论上可以指示4TB的物理空间),剩下分别代表:finalizableremappedmarked1marked0(这四位被称为 metadata),然后再保留1位待用。

  • metadata 放在引用中会造成引用及解引用时开销变大,所以 ZGC 使用一种小技巧来避免这种开销(以及平台兼容问题):称为multi-mapping的过程(这个 multi-mapping 就是用来清理颜色的,相当于给 ZGC 清理小尾巴,在 ZGC 的核心流程中不起作用),参考:使用ZGC的时候,为什么JVM堆内存地址空间被限制在42位,即使用 marked0、marked1、remapped,计算方法为:

    for marked0 : (0b0001 << 42) | x

    for marked1 : (0b0010 << 42) | x

    for remapped : (0b0100 << 42) | x

    • 简单说就是,分别取 逻辑地址 x 的第43、44、45位的数字赋给 marked0、marked1、remapped(三个只有一个被置位)。(注: 这么理解可能有点问题?)
    • 分别代表[4TB,8TB],[8TB,12TB],[16TB,20TB]三个逻辑地址区间,这三个区间的逻辑地址都可以映射到同一个物理地址上(只要表示物理地址的42位相同)。
    • 四位分别表示对象在 ZGC 中的四种状态:
      • 如果是 finalizable,说明只能通过Finalizer来访问到,其他的途径已无法访问。
      • 如果是 remapped,说明不要指向 relocation set。
      • marked0、marked1表示已被标记。
      • mark、relocate、remap属于”bad color”,分别对应标记阶段、重分配阶段、读屏障更新地址阶段。
      • repair、heal 属于”good color”。
  1. ZGC pages、物理内存、虚拟内存

    1. ZGC 将内存分为小的 regions ,称为 pages,特点:
      1. pages 大小不一,一般都是2MB 的倍数。
      2. 有常规的 pages type,:small(2MB,进入对象限制256KB),medium(32MB,进入对象限制4MB),large(2MB 的若干倍,大于4MB 对象进入,实际上允许比 medium 还小)。
    2. ZGC 的虚拟内存最大能到4TB,但是物理内存受限于 JVM 的 maximum heap size 大小,ZGC 上一个固定大小的页,要同时确定物理内存跟虚拟内存。
    3. ZGC 使用memfd_create创建物理内存;使用ftruncate扩展物理内存(最多可到最大堆尺寸),使用mmap与虚拟内存地址相连。
  2. ZGC 的标记过程和对象重分配过程(统称 GC 循环)

    1. 首先标记所有的可达对象(使用 mark0 和 mark1 着色):每个 page 上有一个 live map(使用 bitmap 数据结构),记录 page 中的各个对象是否可达,以及记录是否为 final-reachable(带有 finalize 方法的对象)。
    2. 标记时,使用读屏障(load-barrier in application-threads),将未标记的引用放入此 thread-local 的标记缓存区中(只负责 push,不负责遍历)。
      1. 标记后会得到什么?会拿到一个 relocation set,里面是一系列的 page,这些 page 会包含最多的垃圾(经过一系列复杂算法选出的)。relocation set 中的每个 page 会对应一个 forwarding table(hashmap数据结构)。其实就是新旧地址的映射,key 是原 addr,value 是新 addr。
      2. 当此标记缓存区满了时,被 GC 线程接管,GC 线程会① 负责遍历缓存区中所有的引用(遍历结束,意味着重分配过程也已完成),② 更新 bitmap。
    3. 标记完成后,我们已经知道了哪些对象是可达的了。之后,ZGC 会① 遍历relocation set,②重分配其中的对象们。重分配可以由用户线程或者由 GC 线程进行,若发生这两种线程对同一个对象同时重分配,那么先到先得,会通过 CAS 方式进行抢占。
      1. 为什么要重分配?因为要移动部分活动对象(从上文中的 relocation set中找,因为里面的存活对象少,所以移动成本低),用来释放部分堆内存。
      2. 用户线程为什么会参与重分配?解释:发生读屏障堆加载引用时触发,用户线程试图在 GC 重分配前加载它们,这样应用程序看到的所有引用都是更新后的,查看文末的逻辑图更容易看懂。
      3. GC 线程则扫描哪些存活对象,并开始标记。
      4. GC 线程遍历之后,可能仍有某些对象需要重分配,这些对象可能会在触发读屏障时被处理,也可能留待下次标记时再处理。
        1. 下次标记时,如果发现了没有重分配的引用,那么会先将它重分配,然后标记为活动状态。
        2. 使用两个 mark 位来实现上面的说法
  3. 读屏障。java 进行obj.field时都会触发读屏障(field 不是基础数据类型),在 ZGC 中,读屏障用来① 标记对象,② 重分配引用地址。

    1. GC 所处阶段被保存在一个全局变量ZGlobalPhase中。

    2. ZAddressGoodMaskZAddressBadMask两个全局变量是同时被修改的,一般在刚进入标记阶段时,或者刚进入重分配阶段时。前者含义是当前对象已被 marked/remapped/relocated,无需额外操作,后者含义是需要额外的操作。

    3.                GoodMask         BadMask          WeakGoodMask     WeakBadMask
                   --------------------------------------------------------------
      Marked0        001              110              101              010
      Marked1        010              101              110              001
      Remapped       100              011              100              011
      
  4. STW,ZGC 也伴随着少量的 STW 过程:

    1. 初始标记时,需要标记所有的 root set,此时进行短暂的 STW。
    2. 标记结束时,GC 需要清空所有的 thread-local 的标记缓存区,如果 GC 发现一个大的未标记的子图,那么将 STW(中断”标记结束阶段”),返回并发标记阶段,直到整个图被遍历,然后重新开始”标记结束阶段”。
    3. 刚进入重分配阶段时,因为要重新分配根节点,所以需要 STW。

与标记对象的传统算法相比,ZGC在指针上做标记,在访问指针时加入Load Barrier(读屏障),比如当对象正被GC移动,指针上的颜色就会不对,这个屏障就会先把指针更新为有效地址再返回,也就是,永远只有单个对象读取时有概率被减速,而不存在为了保持应用与GC一致而粗暴整体的Stop The World。

———— R 大


看该图,当应用程序访问一个引用,并触发了load barrier时,参考网友介绍 ZGC

  1. 首先检查着色指针的Remap位置
  2. 如果Remap = 1,不用做任何事,返回引用
  3. 如果Remap = 0,判断该引用是否在relocation set中
  4. Remap = 0,不在relocation set中:直接返回引用
  5. Remap = 0,在relocation set中,判断是否已经relocate
  6. Remap = 0,在relocation set中,已经relocate:更新引用至新地址,返回
  7. Remap = 0,在relocation set中,还未relocate:relocate该对象,返回更新过的reference
  8. 以上逻辑都完成后,应用程序才会拿到引用,此时的引用是更新之后的。
-------------The End-------------