zgc

 

一、背景&简介

Java11 推出的最新垃圾收集器,ZGC,主要为了减少JVM停顿时间。

ZGC 收集器是一款基于Region内存布局的,(暂时)不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-整理算法的,以低延迟为首要目标的一款垃圾收集器。

image.png

ZGC 全称是Z Garbage Collector,是一款可伸缩的、低延迟、并发垃圾回收器,旨在实现以下几个目标:

  • 停顿时间不超过10ms
  • 停顿时间不随heap大小或存活对象大小增大而增大
  • 可以处理从几百兆到几T的内存大小(最大4T)

如何减少JVM停顿时间?

  1. GC可以在压缩时使用多个线程。(并行压缩)
  2. 压缩工作也可以分解为多个阶段。(增量压缩)
  3. 将堆压缩,却不停止正在运行的应用程序(或只是短时间停止)。(并发压缩)
  4. 完全不压缩。

如何进行标记?

  1. 把标记直接记录在对象头上(如Serial收集器)
  2. 把标记记录在与对象相互独立的数据结构上(如G1、Shenandoah使用了一种相当于堆内存的1/64大小的,称为BitMap的结构来记录标记信息)
  3. 直接把标记信息记在引用对象的指针上(如ZGC)

二、ZGC的堆内存布局

  • 目前不分代
  • 与Shenandoah和G1一样,ZGC也采用基于Region的堆内存布局。
  • ZGC的Region具有动态性。
  • 动态创建和销毁
  • 动态的区域容量大小

分类如下:

小型Region(Small Region):容量固定为2MB,用于放置小于256KB的小对象。

中型Region(Medium Region):容量固定为32MB,用于放置大于等于256KB但小于4MB的对象。

大型Region(Large Region):容量不固定,可以动态变化,但必须为2MB的整数倍,用于放置4MB或以上的大对象。每个大型Region中只会存放一个大对象,所以实际容量可能小于中型Region,最小容量可低至4MB。大型Region在ZGC的实现中是不会被重分配的,因为复制一个大对象的代价非常高昂。

三、名词/技术点详解

虚拟内存映射技术(Memory-Mapped I/O, MMIO)和分页机制(一级页表)(以32位为例)

目的:通过给的虚拟地址(一个32位数字) 来求出 物理地址(一个32位数字)

1. 页

相对物理块来说,页是逻辑地址空间(虚拟内存空间)的划分,是逻辑地址空间顺序等分而成的一段逻辑空间,并依次连续编号。页的大小一般为 512B~8KB。

例如:一个 32 位的操作系统,页的大小设为 2^12=4Kb,那么就有页号从 0 编到 2^20 的那么多页逻辑空间。

2. 物理块

物理块则是相对于虚拟内存对物理内存按顺序等大小的划分。物理块的大小需要与页的大小一致。

例如:2^32=4Gb 的物理内存,按照 4Kb/页的大小划分,可以划分成物理块号从 0 到 2^20 的那么多块的物理内存空间。

3. 页表

页表是记录逻辑空间(虚拟内存)中每一页在内存中对应的物理块号。

页表是一个数组,数组长度和逻辑空间数量一致(2^20),值是一个32位数值,其中高20位为物理块号,低12位存储该物理块属性信息(例如是否有读写权限、是否已经分配物理内存、是否被换出到硬盘等)

4. 逻辑地址结构(虚拟内存地址/指针)

逻辑地址是一个32位数值,高20位存储页表的索引(index),低12位存储在对应物理块的偏移量(offset)。

5. 寻址流程

从逻辑地址高20位(index)访问页表取得页表存储的值(value),从页表存储的值(value)的高20位拿到物理块地址,然后用逻辑地址的低12位(offset)对该物理块进行偏移就可以得到。

*6. 为什么32位系统可用内存不足4g(2^32)

因为需要将4GB逻辑地址中一部分要划分出来与BIOS ROM、CPU寄存器、I/O设备这些部件的物理地址进行映射

image.png

着色指针

染色指针是一种直接将少量额外的信息存储在指针上的技术。 目前在Linux下64位的操作系统中高18位是不能用来寻址的,但是剩余的46为却可以支持64T的空间,到目前为止我们几乎还用不到这么多内存。于是ZGC将46位中的高4位取出,用来存储4个标志位,剩余的42位可以支持4TB(2^42)的内存,也直接导致ZGC可以管理的内存不超过4TB。显然32位不够,故zgc无法在32位系统工作。

更详细的 ASCII 图如下

+-------------------+-+----+-----------------------------------------------+
|00000000 00000000 0|0|1111|11 11111111 11111111 11111111 11111111 11111111|
+-------------------+-+----+-----------------------------------------------+
|                   | |    |
|                   | |    * 41-0 Object Offset (42-bits, 4TB address space)
|                   | |
|                   | * 45-42 Metadata Bits (4-bits)  0001 = Marked0
|                   |                                 0010 = Marked1
|                   |                                 0100 = Remapped
|                   |                                 1000 = Finalizable
|                   |
|                   * 46-46 Unused (1-bit, always zero)
|
* 63-47 Fixed (17-bits, always zero)

1. 状态位详解

  • Marked0/marked1: 判断对象是否已标记。
  • Remapped: 判断该对象是否在relocation set中
  • Finalizable: 判断对象是否只能被Finalizer方法访问

2. 为什么两个mark位?

每一个GC周期开始时,会交换使用的标记位,使上次GC周期中修正的已标记状态失效,所有引用都变成未标记。

  • GC周期1:使用mark0, 则周期结束所有引用mark标记都会成为01。
  • GC周期2:使用mark1, 则mark标记10,所有引用都能被重新标记。

3. 为什么逻辑地址可以用多个地址(不同的指针颜色)指向同一个物理内存?

由于存在虚拟内存映射,将不同的分段index所在页表的值改成同一个即可。 image.png

4. 着色指针的作用

  • 一旦某个Region的存活对象被移走之后,这个Region立即就能够被释放和重用掉
  • 指针直接存储了信息,减少大量的读屏障次数
  • 4位设计后续可做更多的扩展

读屏障

1. 什么是读屏障

gc屏障是一个类似aop的功能,当进行对应操作时,在操作前(X前屏障)或者后(X后屏障)完成某些功能。(区别于volatile带来的内存屏障)

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

ZGC使用read barrier,即对指向堆的引用进行读取时,会发生read barrier,比如

    obj.field  //加载堆中对象的引用,触发load barrier

ZGC不使用write barrier:

    obj.field = value //不使用write barrier,此时不会触发load barrier

2. 指针的自愈能力

在ZGC中,当读取处于重分配集的对象时,会被读屏障拦截,通过转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象。ZGC将这种行为叫做指针的“自愈能力”。

好处是:第一次访问旧对象访问会变慢,但也只会有一次变慢,当“自愈”完成后,后续访问就不会变慢了。

   Object a = obj.x; 
   Object b = obj.x;

两行代码都插入了读屏障,但ZGC在第一个读屏障之后,不但a的值是新的,self healing下obj.x的值自身也会修正,第二个读屏障时就直接进入FastPath,没有消耗了; 而Shenandoah 则不会修正obj.x的值,第二个读屏障又要SlowPath一次。

四、工作流程/原理

image.png

流程

逻辑上一次ZGC分为Mark(标记)、Relocate(迁移)、Remap(重映射)三个阶段:

  • Mark: 所有活的对象都被记录在对应region的Livemap(活对象表,bitmap实现)中,以及对象的Reference(引用)都改成已标记(Marked0或Marked1)状态
  • Relocate: 根据页面中活对象占用的大小选出的一组region,将其中的活对象都复制到新的region,并在额外的forward table(转移表)中记录对象原地址和新地址对应关系
  • Remap: 所有Relocated的活对象的引用都重新指向了新的正确的地址
  • 由于想要将所有引用都修正过来需要跟Mark阶段一样遍历整个对象图,所以这次的Remap会与下一次的Remark阶段合并。所以在GC的实现上是2个阶段,即Mark&Remap阶段和Relocate阶段

1. 初始标记 STW

  • 与G1、Shenandoah一样,标记出root节点。停顿时间和堆大小无关,只和GC Roots数量有关。 image.png

2. 并发标记(Concurrent Mark):

  • 与G1、Shenandoah一样,并发标记是遍历对象图做可达性分析的阶段
  • 如果load barrier感知到了未mark的对象,也会mark这个对象 image.png

3. 最终标记 STW

  • 与G1、Shenandoah一样,处理一些极端情况,确保所有对象都被标记

4. 并发预备重分配(Concurrent Prepare for Relocate):

  • 这个阶段需要根据特定的查询条件统计得出本次收集过程要清理哪些Region,将这些Region组成重分配集(Relocation Set)。ZGC每次回收都会扫描所有的Region,用范围更大的扫描成本换取省去G1中记忆集的维护成本。
  • ZGC的重分配集只是决定里面的存活对象会被复制到其他的Region。不是为了效益回收。
  • *JDK12的ZGC中开始支持的类卸载以及弱引用的处理,也是在这个阶段完成的。 image.png

5. 初始重分配 STW

  • 转移root中的对象 image.png

6. 并发重分配(Concurrent Relocate):

  • 重分配是ZGC执行过程中的核心阶段,这个过程要把重分配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表(Forward Table),记录从旧对象到新对象的转向关系。
  • ZGC收集器能仅从引用上就明确得知一个对象是否处于重分配集之中,如果用户线程此时并发访问了位于重分配集中的对象,这次访问将会被预置的内存屏障所截获,然后立即根据Region上的转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象,ZGC将这种行为称为指针的“自愈”(Self-Healing)能力。
  • ZGC与load barrier同时访问需要冲分配对象,通过CAS方式决定谁来改变 image.png

7. 并发重映射(Concurrent Remap):

  • 重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用,但是ZGC中对象引用存在“自愈”功能,所以这个重映射操作并不是很迫切。ZGC很巧妙地把并发重映射阶段要做的工作,合并到了下一次垃圾收集循环中的并发标记阶段里去完成,反正它们都是要遍历所有对象的,这样合并就节省了一次遍历对象图的开销。

五、 实现细节

着色指针和读屏障协作

1. 如果是 mark&remap 阶段(以mark0为例):

  • 如果mark0位置已经赋值,则直接返回。 (001,101)
  • 如果mark0位置未赋值,则协助gc进行mark。 (010,110)

2. 如果是relocate阶段

image.png

  • 如果Remapped有值,则直接返回。 (1XX) 代表此指针不在重分配集(Relocation Set)中,当前结果就是正确的。
  • 如果Remapped无值,且判断不在分配集中,则修改该指针颜色为1XX,返回。
  • 如果Remapped无值,且判断在分配集中,如果未完成重分配,则cas协助gc完成重分配。将对应最新地址返回(完成自愈)。

六、 美和美中不足

美滋滋

  • 没有G1占内存的Remember Set,没有Write Barrier的开销
  • 支持Numa架构,在cpu最近的内存进行分配,提高性能
  • 短暂的仅root相关的stw;均摊gc时间复杂度至用户线程

不足

  • 不分代,每次都做全扫描,产生浮动垃圾

    如果对整个堆做一个完整并发收集周期,持续的时间可能很长比如几分钟,而此期间新创建的对象,大致上只能当作活对象来处理,即使它们在这周期里其实早就死掉可以被收集了。如果有分代算法,新生对象都在一个专门的区域创建,专门针对这个区域的收集能更频繁更快,意外留活的对象更也少。


如有错误,欢迎指正!


参考资料