美文网首页大数据JavaJVM · Java虚拟机原理 · JVM上语言·框架· 生态系统
清华大牛精心整理:JAVA最前线—来自于未来的技术ZGC

清华大牛精心整理:JAVA最前线—来自于未来的技术ZGC

作者: 该用户已秃头 | 来源:发表于2020-04-08 15:20 被阅读0次

    前言

    ZGC是最近由Oracle为OpenJDK开源的新垃圾收集器。它主要由Per Liden编写。ZGC类似于Shenandoah或Azul的C4,专注于减少暂停时间的同时仍然压缩堆 。

    “压缩堆”只是意味着将仍然存活的对象移动到堆的其他区域.这样做有助于减少碎片,但通常这也意味着整个应用程序(包括其所有线程)需要暂停,这通常被称为Stop the world 。只有GC完成后,才能恢复应用程序。

    ZGC原理

    ZGC所采用的算法就是Azul Systems很多年前提出的Pauseless GC

    而实现上它介乎早期Azul VM的Pauseless GC与后来Zing VM的C4之间。

    虽然Oracle出的各种介绍资料上都完全没有提及ZGC与Azul的Pauseless GC(下面简称Azul PGC)之间的关系,而且我们从外部也无法证实或否认Oracle GC团队在研发ZGC的时候是否参考了Azul的论文,所以还不至于扣上抄袭啊克隆啊之类的帽子,但就结果来看ZGC确实就是换了一通术语、纯软件实现的Azul PGC。

    这周在Oracle的Santa Clara园区(旧Sun园区)刚开了JVMLS 2018,我也找机会跟ZGC的领队Per大大聊了下,抽样问了若干设计点细节之后更加确认了ZGC与Azul PGC之间的对应性——核心算法没有差异,所以想要了解原理的话只要读上面的Azul论文即可。

    Azul PGC简单来说是:它是一个mark-compact GC,但是GC过程中所有的阶段都设计为可以并发的,包括移动对象的阶段,所以GC正常工作的时候除了会在自己的线程上吃点CPU之外并不会显著干扰应用的运行。为了实现上方便,PGC虽然算法上可以做成完全并发,Azul PGC在Azul VM里的实现还是有三个非常短暂的safepoint,其中第一个是做根集合(root set)扫描,包括全局变量啊线程栈啊啥的里面的对象指针,但不包括GC堆里的对象指针,所以这个暂停就不会随着GC堆的大小而变化(不过会根据线程的多少啊、线程栈的大小之类的而变化)。另外两个暂停也同样不会随着堆大小而变化。

    这样,Azul一般在宣称PGC / C4的时候会很保守地说“暂停不会超过10ms”,实际上维持在最大暂停时间1ms并不是难事。注意是最大暂停时间,而不是平均、90%、99%。

    ZGC采用了同样的原理,于是也拥有相似的特性。

    这种并发算法的核心思想就是:

    在标记阶段,与其说是标记对象(记录对象是否已经被标记),不如说是标记指针(记录GC堆里的每个指针是否已经被标记)。这就与传统的三色标记对象的GC算法有非常大的区别,虽然两者从收敛性上看是等价的——最终所有对象以及所有指针都会被遍历过。

    在标记和移动对象的阶段,每次从GC堆里的对象的引用类型字段里读取一个指针的时候,这个指针都会经过一个“Loaded Value Barrier”(LVB)。这是一种“Read Barrier”(读屏障),会在不同阶段做不同的事情。最简单的事情就是,在标记阶段它会把指针标记上并把堆里的这个指针给“修正”到新的标记后的值;而在移动对象的阶段,这个屏障会把读出的指针更新到对象的新地址上,并且把堆里的这个指针“修正”到原本的字段里。这样就算GC把对象移动了,读屏障也会发现并修正指针,于是应用代码就永远都会持有更新后的有效指针,而不需要通过stop-the-world这种最粗粒度的同步方式来让GC与应用之间同步。

    LVB中有一点很重要,就是“self healing”性质:如果堆上有指针当前处于“尚未更新”的状态,一旦经过LVB之后就会被就地更新,于是在同一个GC周期内再次访问这个字段的话就不需要再修正了。这样LVB带来的性能开销(吞吐量的下降)就是非常短暂的,而不像Shenandoah GC所使用的Brooks indirection pointer那样一直都慢。

    Azul PGC 与 Azul Zing VM里的C4 GC之间最大的区别就是,前者不分代,而后者是分两代的pauseless GC。在Zing的内部代码里,其实C4是叫做GPGC——Generational Pauseless GC。C4的New Generation与Old Generation采用的是完全一样的Pauseless算法,两代都同样(几乎)不暂停,New GC并不会导致完全stop-the-world。这跟HotSpot VM里的分代式GC实现们很不一样——那些Young GC都是会stop-the-world的。这是因为在Zing的应用场景里,New Generation可能就已经有几十GB了,如果完全stop-the-world那根本受不了。

    ZGC目前不分代,所以跟Azul PGC更相似,而离C4还有距离。

    至于为何ZGC目前不分代,有什么技术上的考量,在JVMLS 2018的ZGC Workshop里Per大大也给出了明确的回答:因为分代实现起来麻烦,想先实现出比较简单可用的版本;后续正在考量是添加分代版ZGC好还是添加一个Thread-Local GC作为ZGC的“前端”好,目前还在探索中。Per大大毫无遮掩地表示当前的ZGC如果遇到非常高的对象分配速率(allocation rate)的话会跟不上,目前唯一有效的“调优”方式就是增大整个GC堆的大小来让ZGC有更大的喘息空间。而添加分代或者Thread-Local GC则可以有效降低这种情况下对堆大小(喘息空间)的需求。

    看不懂?

    没关系,我们有马士兵老师,来看看马老师是怎样讲ZGC给你讲的明明白白的

    对马士兵老师ZGC视频感兴趣的朋友可以帮忙转发文章后,关注私信回复【学习】来免费获取

    ZGC详解

    在GC相关的文献中,应用程序通常称为mutator ,因为从GC的角度来看,应用程序会改变堆(mutates the heap)。根据堆的大小,这样的暂停可能需要几秒钟,这对于交互式应用程序来说可能是难以接受的。

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

    GC可以在压缩时使用多个线程(并行压缩 parallel compaction)

    压缩工作也可以分为多个暂停(增量压缩 incremental compaction)

    压缩堆的同时不暂停应用程序,或者只是很短时间暂停(并发压缩 concurrent compaction)

    Go的GC就是完全不压缩堆

    如前所述,ZGC会进行并发压缩,这当然不是一个简单的实现功能,因此我想描述一下这是如何工作的。为什么这很复杂?

    你需要将对象复制到另一个内存地址,同时另一个线程仍然可以读写旧对象。

    如果对象已经复制成功,那么堆中仍有许多指向旧地址的引用需要更新到新地址。

    虽然并发压缩(concurrent compaction)似乎是上述方案中降低暂停时间的最佳解决方案,但肯定会涉及一些权衡。因此,如果您不关心暂停时间,那么最好使用专注于吞吐量的GC。

    GC屏障 (GC Barriers)

    理解ZGC如何进行并发压缩的关键是Load barrier (通常在GC文献中称为Read barrier).这里简单介绍一下,详细的描述请看下面的Load Barrier一节。

    如果GC有读取屏障(Load barrier),则在从堆读取引用时,GC需要执行一些额外操作。在Java中,也就是像执行这样的代码Object xxx=obj.field时需要额外操作。

    对于像obj.field = value这样的操作,GC也可能需要写入屏障(叫做Write Barrier或者Store Barrier)[译注:在分代GC还有引用计数中会用到写入屏障].

    这两个操作都比较特殊因为它们在每次读取或写入堆时发生的。Load Barrier和Store Barrier的名称有点令人困惑,但注意这个屏障与CPU的内存障碍是完全不同的两个概念

    堆中的读取和写入都非常常见,因此两种GC屏障都需要非常高效,在常见情况下就是一些汇编代码。Read barrier通常比Write Barrier大一个数量级(可能会因应用程序而异),因此Read Barrier对性能要求更高。

    例如,分代GC通常只需要一个写屏障,不需要读屏障。ZGC则需要一个读屏障但没有写屏障。对于并发压缩,我没有看到没有读取障碍的解决方案。

    这里需要注意:即使GC需要某种类型的屏障,只有在读取或写入堆中的引用时需要它们。读取或写入像int或double这样的基本类型是不需要屏障的.

    指针标记(Pointer tagging Or Colored Pointers )

    ZGC在堆引用中存储额外的元数据 ,在x64上是64 bit(ZGC目前不支持compressed oops和 class pointers)。64位中的48位用做x64上的虚拟内存地址 。虽然确切地说只有47位,因为第47位确定了位48-63的值(目前这些位都是0)。ZGC保留对象实际地址的前42位(在源代码中称为偏移量 )。42位地址理论上就会有4TB的堆大小限制。其余的位用于这些标志: finalizable , remapped , marked1和marked0 (保留一位用于将来使用)。如下图所示:

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

    在堆引用中具有元数据信息使得解引用更加昂贵,因为需要mask地址以获得没有元信息的真实地址。ZGC采用了一个很好的技巧来避免这种情况:

    当从内存中读取时,会设置marked0 , marked1或remapped中的一个。

    在偏移x处分配页面(allocating a page)时,ZGC将同一页面映射到3个不同的地址 :

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

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

    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)

    在任何时间点,只使用这三个视图中的一个。调试时可以取消映射(unmapped)未使用的视图来验证正确性。

    Pages & Physical & Virtual Memory

    Shenandoah将堆分成大量同样大小的区域 。除了不适合单个区域的大对象外,对象通常不会跨越多个区域。大对象被分配在多个连续区域中。我非常喜欢这种方法,因为它非常简单。

    在这方面,ZGC与Shenandoah非常相似。在ZGC的说法中,区域称为页面Pages 。

    与Shenandoah的主要区别:ZGC中的页面可以有不同的大小(但在x64上总是2MB的倍数)。

    ZGC有3种不同的页面类型: 小型 (2MB大小), 中型 (32MB大小)和大型 (2MB的倍数)。

    在小页面中分配小对象(最大256KB大小),在中型页面中分配中型对象(最多4MB)。大页面中分配大于4MB的对象。大页面只能存储一个对象.小页面或中间页面可以分配多个。

    有些令人困惑的是大页面实际上可能小于中等页面(例如,对于大小为6MB的大对象)。

    ZGC的另一个不错的特性是,它还可以区分物理内存和虚拟内存。这背后的想法是通常有足够的虚拟内存(ZGC总是4TB),而物理内存更稀缺。物理内存可以扩展到最大堆大小(使用-Xmx设置),因此这比4 TB的虚拟内存要小得多。在ZGC中分配特定大小的页面意味着分配物理和虚拟内存。在ZGC中,物理内存不需要是连续的,虚拟内存空间是连续的。

    为什么说这是一个不错的属性?

    分配连续范围的虚拟内存是很容易的,因为我们通常有足够的虚拟内存。但在物理内存中有3个大小为2MB的空闲页面的情况很普通,但是对于大型对象分配我们需要6MB的连续内存。有足够的空闲物理内存,但不幸的是这个内存是不连续的。ZGC能够将这些非连续的物理页面映射到单个连续的虚拟内存空间。如果无法映射,我们就会耗尽内存(发生OOM)

    标记和重新安置对象(Marking & Relocating objects)

    垃圾回收主要分为两个阶段:标记和重新安置(实际上不止这两个阶段,你可以查阅源码)。

    [译注:重新安置(Relocating)指的是把对象从一个内存区域移到另外一个区域,重映射(Remapping)只的是把指向老的地址的引用更新到新的地址]

    一次GC从标记阶段开始,标记所有可到达的对象。在这个阶段结束时,我们知道哪些对象仍然存活,哪些对象是垃圾。ZGC将此信息存储在每个页面的Live Map中。Live Map是一个位图(bitmap) ,用于存储给定索引处的对象是否可达和/或最终可达(对于具有finalize method的对象而言)。

    在标记阶段,应用程序线程中的load-barrier将未标记的引用推送到线程局部标记缓冲区。只要此缓冲区已满,GC线程就可以获得此缓冲区的所有权,并以递归方式遍历此缓冲区中的所有可到达对象。在应用程序线程中标记只是将引用推送到缓冲区,GC线程负责遍历对象图并更新Live map.

    标记阶段结束后,ZGC要重新安置 Relocation set中的所有活动对象。

    Relocation Set表示一组需要被回收的页面(Pages),例如那些垃圾最多的页面。存活的对象由GC线程或应用程序线程通过读取屏障(Load Barrier)重新安置(relocated)(也就是放到新的地址去).ZGC为Relocation set中的每个页面分配Forwarding table.

    Forwarding table基本上是一个hash map,它存储一个对象已被重新安置到的地址(如果该对象已经被重新安置)。

    ZGC方法的优点是我们只需要为relocation set中的页面分配forwarding table的空间.相比之下,Shenandoah将转发指针存储在每个对象本身,这样就谁有一些额外的内存开销。

    GC线程遍历 Relocation set中的存活对象,并重新安置(relocate)尚未重新安置的对象。这时可能发生应用程序线程和GC线程同时重新安置(relocate)同一个对象,在这种情况下,谁先relocate谁获胜,ZGC使用原子CAS操作来确定胜者。

    当不处于marking阶段时,load-barrier会重新安置(relocates )/重新映射(remaps )从堆加载的所有引用。这确保了mutator看到的每个新引用都已指向对象的最新副本。重新映射(remaps)对象就是在forwarding table中查找新的对象地址。

    一旦GC线程完成了relocation set的处理,重新安置阶段就完成了。虽然这意味着所有对象都已重新安置,但通常仍会有引用指向relocation set,需要将其重新映射(remapped )到新地址。这些引用会被Load-Barrier自我修复。如果对于这些引用的读取发生的不够快,(也就是这段时间内,应用程序没有读到这些指向relocation set的引用),这些引用会在下一次mark阶段给修复。这意味着标记阶段还需要检查 forward table以重新映射(remap) (但不重新安置 ,所有对象之前阶段都保证被重新安置)对象到它们的新地址。

    这也解释了为什么对象引用中有两个标记位(marked0 和marked1 )。标记阶段在标记的marked0和marked1位之间交替。在重新安置阶段之后,仍可能存在未重定向(remapped)的引用,所以我们需要知道上一个gc周期的情况。如果新的标记阶段使用相同的标记位,则Load-Barrier就知道该引用为已标记。

    ps:这里看起来像是GC周期remap和mark可以重叠,实际上确实是重叠的。

    Load-Barrier

    从堆中读取引用时,ZGC需要一个所谓的load-barrier(也称为read-barrier)。每次Java程序访问对象类型的字段时,我们都需要插入此load-barrier,例如obj.field 。访问某些其他原始类型的字段不需要屏障,例如obj.anInt或obj.anDouble 。ZGC不需要obj.field = someValue存储/写入障碍。

    根据GC当前所处的阶段(存储在全局变量ZGlobalPhase中 ),如果尚未标记或重新安置对象,则屏障会标记对象或重新安置它

    全局变量ZAddressGoodMask和ZAddressBadMask存储对应的掩码,该掩码确定引用是否已被认为是好的(这意味着已经标记或重新映射/重新安置remapped/relocated)或者是否仍然需要一些操作。这些变量仅在标记开始阶段和重新安置阶段同时改变.ZGC源代码中的这个表格可以很好地概述这些掩码的状态:

    GoodMask        BadMask          WeakGoodMask    WeakBadMask              --------------------------------------------------------------Marked0001110101010Marked1010101110001Remapped100011100011

    屏障的汇编代码可以在MacroAssembler for x64中看到,我只会为这个屏障显示一些伪汇编代码:

    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寄存器中。

    然后针对当前的坏掩码测试该引用(这只是一个位与)。此处不需要同步,因为ZAddressBadMask仅在STW时才更新。如果结果不为零,我们需要执行屏障。

    屏障需要根据我们当前所处的GC阶段标记或重新安置对象。在此操作之后, 他需要更新存储在r10 + some_field_offset中的引用来指向新引用。这步操作是必要的,以便来该字段的后续加载返回正确的引用。

    由于我们可能需要更新引用地址,因此我们需要使用两个寄存器r10和rax作为加载的引用和对象地址。正确的引用也需要存储到寄存器rax中 ,这样在后面的执行过程中我们就已经加载了正确的引用。

    由于每个引用都需要标记或重新安置,因此在开始标记或重新安置阶段后,吞吐量可能会立即降低。当大多数引用被修复时,这应该会变得更快。

    Stop-the-World 停顿

    ZGC并没有彻底摆脱STW。收集器在开始标记,结束标记和开始重新安置时需要暂停。但这种暂停通常很短,只有几毫秒。

    当开始标记时,ZGC遍历所有线程堆栈以标记root set。root set是遍历对象图的开始的地方。root set通常由本地和全局变量组成,但也包括其他内部VM结构(例如JNI句柄)。

    结束标记阶段时需要再次暂停。在此暂停中,GC需要清空并遍历所有线程局部标记缓冲区。由于GC可能会发现一个未标记的大型子图,因此可能需要更长时间。ZGC试图通过在1毫秒后停止标记阶段的结束来避免这种情况。它返回到并发标记阶段,直到遍历整个对象图,然后可以再次开始结束标记阶段

    启动重新安置阶段会再次暂停应用程序。此阶段与开始标记非常相似,不同之处在于此阶段重新安置Root Set中的对象。

    限于平台篇幅限制,同时也为了大家更好的阅读,笔者把JVM相关的资料和马老师ZGC的视频都打包整理好了,感兴趣的朋友可以帮忙转发文章后,关注私信回复【学习】来免费获取

    相关文章

      网友评论

        本文标题:清华大牛精心整理:JAVA最前线—来自于未来的技术ZGC

        本文链接:https://www.haomeiwen.com/subject/bqutmhtx.html