ZGC窥探(翻译a first look into zgc)

ZGC目的是为了减少空间整理的耗时。像GC的基本算法里,标记清除算法就是不进行空间整理,会产生空间碎片。复制清除和标记整理算法都会进行空间整理,不会产生空间碎片。虽然空间整理减少产生空间碎片,但是需要用户线程全部暂停,也就是我们所说的stop the world现象。只有gc结束之后,用户线程才可以恢复。根据堆空间大小不同,有的停顿时间长达几秒。

有几种方法可以减少停顿时间:

  • 多线程并行的进行空间整理。并行空间整理

  • 空间整理划分为多个阶段进行,渐进式整理

  • 与用户线程并发的进行空间整理,并发整理

  • 不进行整理

 

ZGC采用了并发的方式,这个实现起来是非常困难的:

  • 我们需要把一个对象拷贝到另一个地址,同时另外一个线程可能会读取或者写入原来这个老对象

  • 即使我们拷贝成功,在堆中依然会有很多引用指向老的地址,这些引用需要更新为新地址

 

GC屏障

理解ZGC如何进行并发空间整理的关键是读屏障。如果GC有读屏障,GC在堆中读取一个引用的时候需要做一些额外的工作。在java中类似于obj.filed的代码会产生读屏障。有的gc也会需要写屏障。这里的读写屏障与内存屏障不是一个概念。

因为读写堆是非常常见的操作,所以gc屏障需要极高的效率。这意味着一般情况下只需要几个汇编指令。读屏障要比写屏障的可能性多一个数量级,所以读屏障对性能更加敏感。分代GC都会采用写屏障而不用读屏障。而zgc采用了读屏障。

另外一个需要考虑的因素是:即使gc采用了某种屏障,也只有在读取或者写堆的时候需要。读或写原生对象比如int、double不需要这些屏障。

 

指针标记

在x64中引用有64位大小,zgc在存储了一些元信息。所以zgc不支持指针对象和类指针。64位中的48位用来表示虚拟内存。准确来说只有47位,因为47位决定了48到63位的值。zgc只使用了前42位来表示对象的实际地址。在zgc中42位地址理论上最大堆空间可以达到4TB。剩下的用来表示这些标记:finalizable,remapped,marked1,marked0(剩下一位保留)。如下表示了64位的作用。

 6                 4 4 4  4 4                                             0
 3                 7 6 5  2 1                                             0
+-------------------+-+----+-----------------------------------------------+
|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)

 

存储元信息在引用中使得解除引用更加昂贵,因为地址需要进行掩码才能获得实际的地址。zgc使用了一个小技巧来避免这些事:当读取的内存地址marked0、marked1或者remapped位被设置了值,当在偏移位置x处分配页时,zgc把同一个页映射到三个地址:

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

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

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

因此zgc需要从4TB开始额外的16TB的空间,但是不会都使用。

+--------------------------------+ 0x0000140000000000 (20TB)

| Remapped View |

+--------------------------------+ 0x0000100000000000 (16TB)

| (Reserved, but unused) |

+--------------------------------+ 0x00000c0000000000 (12TB)

| Marked1 View |

+--------------------------------+ 0x0000080000000000 (8TB)

| Marked0 View |

+--------------------------------+ 0x0000040000000000 (4TB)

 

任何时间点三个视图中只有一个会被使用。

 

 

页&物理&虚拟内存

Shenandoah垃圾收集器把堆划分成了若干相同大小的区域。一般一个对象不会跨越多个区域,除非是一个大对象,单个区域存放不下。这些大对象需要分配在连续的多个区域中,这种方式非常简单。

zgc在这点上和Shenandoah相似。在zgc中区域被成为页。与Shenandoah最主要的区别在于:zgc中的页有不同的大小(但是一定是2MB倍数)。zgc中有三种不同类型的页:小页(2mb),中页(32mb)和大页(2mb的倍数)。小的对象(最大256kb)被分配在小页上,中等大小的对象(最大4mb)分配到中页上,其他大于4mb的对象都被分配在大页上。一个大页只能存储一个对象,与小页和中页相反。所以有时候大页反而比中页实际要小。(比如分配了一个6mb大小的对象)

另外一个zgc比较赞的属性是,zgc把物理内存和虚拟内存作了区分。这背后的思想是,虚拟内存通常很大(zgc中有4tb),而物理内存比较稀缺。物理内存可以扩展到最大堆大小(用-Xmx参数设置),所以通常这要比4tb虚拟内存小很多。在gzc中分配一个固定大小的页,需要同时分配物理内存和虚拟内存。zgc中的物理内存不需要连续,仅仅虚拟内存连续即可。

分配一块连续的虚拟内存很容易,因为我们有很多的虚拟内存。很容易想到这样一个场景,在物理内存中的某些位置有三个2mb大小的空闲页,但是我们需要6mb大小的连续空间来分配一个大对象。有足够的物理内存但是不连续,zgc可以把这些不连续的物理内存映射到一个连续的虚拟内存中。如果这无法实现,我们就会内存溢出了。

 

标记&重新分配对象

收集器简单的被分为两个主要阶段:标记和重分配。

一次gc循环起始于标记阶段,标记所有可达对象。在这阶段结束的时候,我们可以知道哪些对象存活,哪些对象是垃圾。zgc在每个页中把这些信息存储到一个称为live map的结构中。live map是一个位图,保存指定索引位置的对象是强可达还是最终可达(有finalize方法的对象)。

在标记阶段,应用线程中的读屏障会把所有未标记的引用推放到线程本地的标记缓冲区中,如果缓冲区满了,gc线程会接管缓冲区递归的遍历所有可达对象。用户线程只管把对象推到缓冲区中,gc线程负责遍历对象可达和更新live map。

标记阶段结束后,zgc需要重新分配relocation set中的存活对象。relocation set是一系列的页,在标记结束后通过一系列复杂算法选出来的页(这些页会包含最多的垃圾)。对象既可以被用户线程重分配,也可以被gc线程重分配。zgc为每个relocation set中的页都分配了一个重定向表(forwarding table),这个table就是一个简单的hashmap,保存了原对象的地址和重分配后的地址。

zgc的优势在于我们只需要给relocation set中的页分配重定向指针的空间。Shenandoah给每一个对象都保存了重定向指针,那会有一些内存开销。

gc线程遍历relocation set中的对象,对他们进行重新分配。也可能会发生用户线程和gc线程同时要重分配同一个对象,这种情况下第一个到达的线程成功。在zgc中使用原子操作cas来决定哪个成功。

在非标记阶段,读屏障重新分配或者重新映射从堆中读取的对象。这确保所有用户线程看到的引用,都已经指向了新的对象拷贝。重新映射一个对象意味着在重定向表中找到新的地址。

重分配阶段在gc线程结束遍历relocation set后就终止了。尽管这意味着所有对象都已经重分配,但是仍然会有对relocation set的引用,需要被映射到新的地址。这些引用会在出发读屏障时被纠正,或者没来得及纠正的话,会在下一个标记阶段纠正。这意味着标记也需要检查重定向表来重新映射对象到新的地址(但不是重新分配,所有的对象都需要保证已经进行重新分配)。

这也解释了为什么会有两个mark位。标记阶段循环使用这两个位。在重分配阶段结束后,仍然会有一些引用没有被重映射,所以下一个标记阶段可能会有上一个设置的位标记。如果新的标记阶段使用相同的位,读屏障又会重新检查这个已经被标记过的对象。

 

 

读屏障

zgc在读取堆引用的时候需要使用读屏障,在类似于Java代码obj.field的操作中都需要插入读屏障。但是读取元类型不需要,类似于obj.int。zgc不需要写屏障。

根据gc所处的阶段不同(保存在全局变量ZGlobalPhase中),读屏障要不是标记、要不就是重新分配那些没有标记或者没有重新映射的对象。

全局对象ZAddressGoodMask和ZAddressBadMask中保存了掩码,决定这个引用是好的还是需要做一些额外操作。这些变量只在标记和重分配开始的阶段改变。如下展示了这些掩码:

               GoodMask         BadMask          WeakGoodMask     WeakBadMask
               --------------------------------------------------------------
Marked0        001              110              101              010
Marked1        010              101              110              001
Remapped       100              011              100              011

如下用伪指令说明一下读屏障怎么工作:

mov rax, [r10 + some_field_offset]

test rax, [address of ZAddressBadMask]

jnz load_barrier_mark_or_relocate

 

# otherwise reference in rax is considered good

第一行主从堆中读取一个引用:r10保存了对象的引用,some_field_offset是一个常数字段偏移。被读取的引用保存到了rax寄存器。然后利用现在的bad掩码检测这个引用(按位与)。这里不需要做同步处理,因为ZAddressBadMask只有在STW阶段才会被更新。如果结果不是零,我们就需要执行屏障处理。屏障需要根据gc处于的不同阶段标记或者重分配对象。通过这些操作时候,需要把r10+some_filed_offset位置中的引用更新为正确的新引用。这个是有必要的,因为后续读取这个变量会返回一个正确的引用。接下来我们可能需要更新引用的地址,我们需要两个寄存器r10和rax作为加载对象的引用和对象的地址。rzx寄存器中的地址也需要更新,操作才可以继续,直到我们读取了一个好的引用。

在标记和重分配的刚开始阶段吞吐量会变得很低,因为每个引用都需要被标记或者重分配。当大多数引用完成后情况会变好很多。

 

STW阶段

zgc也没有完全摆脱STW。收集器需要在初始标记、结束标记和初始重分配阶段产生STW。这些STW会很短,通常是几毫秒。

初始标记阶段zgc遍历所有的线程栈,标记应用的root set。根节点是遍历对象图的起始引用集合。通常包括局部或者全局变量,也包括一些其他的vm结构。

另一个STW阶段是标记结束阶段。在这个阶段gc需要清空并且遍历所有的线程本地的标记缓冲区。由于gc可能会发现一个大的未标记的子图,这会耗费更长时间。zgc在1毫秒之后停止标记阶段结束。然后返回到并发标记阶段直到整个图都被遍历,然后标记结束阶段在重新开始,来避免这种情况。

初始重分配阶段也需要暂停用户线程。这个阶段和初始标记阶段类似,不同的是该阶段是重新分配根节点。

你可能感兴趣的:(翻译)