低延迟垃圾收集器

HotSpot的垃圾收集器从Serial发展到CMS再到G1,经历了逾二十年时间,经过了数百上千万台服务器上的应用实践,已经被淬炼得相当成熟了,不过它们距离“完美”还是很遥远。怎样的收集器才算是“完美”呢?这听起来像是一道主观题,其实不然,完美难以实现,但是我们确实可以把它客观描述出来。

衡量垃圾收集器的三项最重要的指标是:内存占用(Footprint)、吞吐量(Throughput)和延迟(Latency),三者共同构成了一个“不可能三角[1]”。三者总体的表现会随技术进步而越来越好,但是要在这三个方面同时具有卓越表现的“完美”收集器是极其困难甚至是不可能的,一款优秀的收集器通常最多可以同时达成其中的两项。

在内存占用、吞吐量和延迟这三项指标里,延迟的重要性日益凸显,越发备受关注。其原因是随着计算机硬件的发展、性能的提升,我们越来越能容忍收集器多占用一点点内存;硬件性能增长,对软件系统的处理能力是有直接助益的,硬件的规格和性能越高,也有助于降低收集器运行时对应用程序的影响,换句话说,吞吐量会更高。但对延迟则不是这样,硬件规格提升,准确地说是内存的扩大,对延迟反而会带来负面的效果,这点也是很符合直观思维的:虚拟机要回收完整的1TB的堆内存,毫无疑问要比回收1GB的堆内存耗费更多时间。由此,我们就不难理解为何延迟会成为垃圾收集器最被重视的性能指标了。现在我们来观察一下现在已接触过的垃圾收集器的停顿状况,如图3-14所示。
低延迟垃圾收集器_第1张图片
图3-14中浅色阶段表示必须挂起用户线程,深色表示收集器线程与用户线程是并发工作的。由图 3-14可见,在CMS和G1之前的全部收集器,其工作的所有步骤都会产生“Stop The World”式的停顿;CMS和G1分别使用增量更新和原始快照(见3.4.6节并发的可达性分析)技术,实现了标记阶段的并发,不会因管理的堆内存变大,要标记的对象变多而导致停顿时间随之增长。但是对于标记阶段之后的处理,仍未得到妥善解决。CMS使用标记-清除算法,虽然避免了整理阶段收集器带来的停顿,但是清除算法不论如何优化改进,在设计原理上避免不了空间碎片的产生,随着空间碎片不断淤积最终依然逃不过“Stop The World”的命运。G1虽然可以按更小的粒度进行回收,从而抑制整理阶段出现时间过长的停顿,但毕竟也还是要暂停的。

读者肯定也从图3-14中注意到了,最后的两款收集器,Shenandoah和ZGC,几乎整个工作过程全部都是并发的,只有初始标记、最终标记这些阶段有短暂的停顿,这部分停顿的时间基本上是固定的,与堆的容量、堆中对象的数量没有正比例关系。实际上,它们都可以在任意可管理的(譬如现在ZGC只能管理4TB以内的堆)堆容量下,实现垃圾收集的停顿都不超过十毫秒这种以前听起来是天方夜谭、匪夷所思的目标。这两款目前仍处于实验状态的收集器,被官方命名为“低延迟垃圾收集器”(Low-Latency Garbage Collector或者Low-Pause-Time Garbage Collector)。

[1] 不可能三角:https://zh.wikipedia.org/wiki/三元悖论。

Shenandoah收集器

在本书所出现的众多垃圾收集器里,Shenandoah大概是最“孤独”的一个。现代社会竞争激烈,连 一个公司里不同团队之间都存在“部门墙”,那Shenandoah作为第一款不由Oracle(包括以前的Sun)公司的虚拟机团队所领导开发的HotSpot垃圾收集器,不可避免地会受到一些来自“官方”的排挤。在笔者撰写这部分内容时[1],Oracle仍明确拒绝在OracleJDK 12中支持Shenandoah收集器,并执意在打包OracleJDK时通过条件编译完全排除掉了Shenandoah的代码,换句话说,Shenandoah是一款只有OpenJDK才会包含,而OracleJDK里反而不存在的收集器,“免费开源版”比“收费商业版”功能更多,这 是相对罕见的状况[2]。如果读者的项目要求用到Oracle商业支持的话,就不得不把Shenandoah排除在选择范围之外了。

最初Shenandoah是由RedHat公司独立发展的新型收集器项目,在2014年RedHat把Shenandoah贡献 给了OpenJDK,并推动它成为OpenJDK 12的正式特性之一,也就是后来的JEP 189。这个项目的目标是实现一种能在任何堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内的垃圾收集器,该目标意味着相比CMS和G1,Shenandoah不仅要进行并发的垃圾标记,还要并发地进行对象清理后的整理动作。

从代码历史渊源上讲,比起稍后要介绍的有着Oracle正朔血统的ZGC,Shenandoah反而更像是G1的下一代继承者,它们两者有着相似的堆内存布局,在初始标记、并发标记等许多阶段的处理思路上都高度一致,甚至还直接共享了一部分实现代码,这使得部分对G1的打磨改进和Bug修改会同时反映在Shenandoah之上,而由于Shenandoah加入所带来的一些新特性,也有部分会出现在G1收集器中,譬 如在并发失败后作为“逃生门”的Full GC[3],G1就是由于合并了Shenandoah的代码才获得多线程Full GC的支持。

那Shenandoah相比起G1又有什么改进呢?虽然Shenandoah也是使用基于Region的堆内存布局,同样有着用于存放大对象的Humongous Region,默认的回收策略也同样是优先处理回收价值最大的 Region……但在管理堆内存方面,它与G1至少有三个明显的不同之处,最重要的当然是支持并发的整理算法,G1的回收阶段是可以多线程并行的,但却不能与用户线程并发,这点作为Shenandoah最核心的功能稍后笔者会着重讲解。其次,Shenandoah(目前)是默认不使用分代收集的,换言之,不会有专门的新生代Region或者老年代Region的存在,没有实现分代,并不是说分代对Shenandoah没有价值,这更多是出于性价比的权衡,基于工作量上的考虑而将其放到优先级较低的位置上。最后,Shenandoah摒弃了在G1中耗费大量内存和计算资源去维护的记忆集,改用名为“连接矩阵”(Connection Matrix)的全局数据结构来记录跨Region的引用关系,降低了处理跨代指针时的记忆集维护消耗,也降低了伪共享问题(见3.4.4节)的发生概率。连接矩阵可以简单理解为一张二维表格,如果Region N有对象指向Region M,就在表格的N行M列中打上一个标记,如图3-15所示,如果Region 5中的对象Baz 引用了Region 3的Foo,Foo又引用了Region 1的Bar,那连接矩阵中的5行3列、3行1列就应该被打上标 记。在回收时通过这张表格就可以得出哪些Region之间产生了跨代引用。
低延迟垃圾收集器_第2张图片
Shenandoah收集器的工作过程大致可以划分为以下九个阶段(此处以Shenandoah在2016年发表的原始论文[4]进行介绍。在最新版本的Shenandoah 2.0中,进一步强化了“部分收集”的特性,初始标记之前还有Initial Partial、Concurrent Partial和Final Partial阶段,它们可以不太严谨地理解为对应于以前分代收集中的Minor GC的工作):

  • 初始标记(Initial Marking):与G1一样,首先标记与GC Roots直接关联的对象,这个阶段仍 是“Stop The World”的,但停顿时间与堆大小无关,只与GC Roots的数量相关。
  • 并发标记(Concurrent Marking):与G1一样,遍历对象图,标记出全部可达的对象,这个阶段 是与用户线程一起并发的,时间长短取决于堆中存活对象的数量以及对象图的结构复杂程度。
  • 最终标记(Final Marking):与G1一样,处理剩余的SATB扫描,并在这个阶段统计出回收价值 最高的Region,将这些Region构成一组回收集(Collection Set)。最终标记阶段也会有一小段短暂的停 顿。
  • 并发清理(Concurrent Cleanup):这个阶段用于清理那些整个区域内连一个存活对象都没有找到 的Region(这类Region被称为Immediate Garbage Region)。
  • 并发回收(Concurrent Evacuation):并发回收阶段是Shenandoah与之前HotSpot中其他收集器的 核心差异。在这个阶段,Shenandoah要把回收集里面的存活对象先复制一份到其他未被使用的Region之 中。复制对象这件事情如果将用户线程冻结起来再做那是相当简单的,但如果两者必须要同时并发进 行的话,就变得复杂起来了。其困难点是在移动对象的同时,用户线程仍然可能不停对被移动的对象 进行读写访问,移动对象是一次性的行为,但移动之后整个内存中所有指向该对象的引用都还是旧对象的地址,这是很难一瞬间全部改变过来的。对于并发回收阶段遇到的这些困难,Shenandoah将会通 过读屏障和被称为“Brooks Pointers”的转发指针来解决(讲解完Shenandoah整个工作过程之后笔者还要 再回头介绍它)。并发回收阶段运行的时间长短取决于回收集的大小。
  • 初始引用更新(Initial Update Reference):并发回收阶段复制对象结束后,还需要把堆中所有指 向旧对象的引用修正到复制后的新地址,这个操作称为引用更新。引用更新的初始化阶段实际上并未 做什么具体的处理,设立这个阶段只是为了建立一个线程集合点,确保所有并发回收阶段中进行的收 集器线程都已完成分配给它们的对象移动任务而已。初始引用更新时间很短,会产生一个非常短暂的 停顿。
  • 并发引用更新(Concurrent Update Reference):真正开始进行引用更新操作,这个阶段是与用户 线程一起并发的,时间长短取决于内存中涉及的引用数量的多少。并发引用更新与并发标记不同,它 不再需要沿着对象图来搜索,只需要按照内存物理地址的顺序,线性地搜索出引用类型,把旧值改为 新值即可。
  • 最终引用更新(Final Update Reference):解决了堆中的引用更新后,还要修正存在于GC Roots 中的引用。这个阶段是Shenandoah的最后一次停顿,停顿时间只与GC Roots的数量相关。
  • 并发清理(Concurrent Cleanup):经过并发回收和引用更新之后,整个回收集中所有的Region已 再无存活对象,这些Region都变成Immediate Garbage Regions了,最后再调用一次并发清理过程来回收 这些Region的内存空间,供以后新对象分配使用。

以上对Shenandoah收集器这九个阶段的工作过程的描述可能拆分得略为琐碎,读者只要抓住其中三个最重要的并发阶段(并发标记、并发回收、并发引用更新),就能比较容易理清Shenandoah是如 何运作的了。图3-16[5]中黄色的区域代表的是被选入回收集的Region,绿色部分就代表还存活的对 象,蓝色就是用户线程可以用来分配对象的内存Region了。图3-16中不仅展示了Shenandoah三个并发阶段的工作过程,还能形象地表示出并发标记阶段如何找出回收对象确定回收集,并发回收阶段如何移动回收集中的存活对象,并发引用更新阶段如何将指向回收集中存活对象的所有引用全部修正,此后回收集便不存在任何引用可达的存活对象了。
低延迟垃圾收集器_第3张图片
学习了Shenandoah收集器的工作过程,我们再来聊一下Shenandoah用以支持并行整理的核心概念——Brooks Pointer。“Brooks”是一个人的名字。1984年,Rodney A.Brooks在论文《Trading Data Space for Reduced Time and Code Space in Real-Time Garbage Collection on Stock Hardware》中提出了使用转发 指针(Forwarding Pointer,也常被称为Indirection Pointer)来实现对象移动与用户程序并发的一种解决 方案。此前,要做类似的并发操作,通常是在被移动对象原有的内存上设置保护陷阱(Memory Protection Trap),一旦用户程序访问到归属于旧对象的内存空间就会产生自陷中段,进入预设好的异 常处理器中,再由其中的代码逻辑把访问转发到复制后的新对象上。虽然确实能够实现对象移动与用 户线程并发,但是如果没有操作系统层面的直接支持,这种方案将导致用户态频繁切换到核心态[6], 代价是非常大的,不能频繁使用[7]。
低延迟垃圾收集器_第4张图片
Brooks提出的新方案不需要用到内存保护陷阱,而是在原有对象布局结构的最前面统一增加一个新的引用字段,在正常不处于并发移动的情况下,该引用指向对象自己,如图3-17所示。 从结构上来看,Brooks提出的转发指针与某些早期Java虚拟机使用过的句柄定位(关于对象定位 详见第2章)有一些相似之处,两者都是一种间接性的对象访问方式,差别是句柄通常会统一存储在专 门的句柄池中,而转发指针是分散存放在每一个对象头前面。 有了转发指针之后,有何收益暂且不论,所有间接对象访问技术的缺点都是相同的,也是非常显 著的——每次对象访问会带来一次额外的转向开销,尽管这个开销已经被优化到只有一行汇编指令的 程度,譬如以下所示:
mov r13,QWORD PTR [r12+r14*8-0x8]

不过,毕竟对象定位会被频繁使用到,这仍是一笔不可忽视的执行成本,只是它比起内存保护陷阱的方案已经好了很多。转发指针加入后带来的收益自然是当对象拥有了一份新的副本时,只需要修改一处指针的值,即旧对象上转发指针的引用位置,使其指向新对象,便可将所有对该对象的访问转发到新的副本上。这样只要旧对象的内存仍然存在,未被清理掉,虚拟机内存中所有通过旧引用地址访问的代码便仍然可用,都会被自动转发到新对象上继续工作,如图3-18所示。
低延迟垃圾收集器_第5张图片
需要注意,Brooks形式的转发指针在设计上决定了它是必然会出现多线程竞争问题的,如果收集器线程与用户线程发生的只是并发读取,那无论读到旧对象还是新对象上的字段,返回的结果都应该是一样的,这个场景还可以有一些“偷懒”的处理余地;但如果发生的是并发写入,就一定必须保证写操作只能发生在新复制的对象上,而不是写入旧对象的内存中。读者不妨设想以下三件事情并发进行时的场景:
1)收集器线程复制了新的对象副本;
2)用户线程更新对象的某个字段;
3)收集器线程更新转发指针的引用值为新副本地址。

如果不做任何保护措施,让事件2在事件1、事件3之间发生的话,将导致的结果就是用户线程对对象的变更发生在旧对象上,所以这里必须针对转发指针的访问操作采取同步措施,让收集器线程或者用户线程对转发指针的访问只有其中之一能够成功,另外一个必须等待,避免两者交替进行。实际上Shenandoah收集器是通过比较并交换(Compare And Swap,CAS)操作[8]来保证并发时对象的访问正确性的。

转发指针另一点必须注意的是执行频率的问题,尽管通过对象头上的Brooks Pointer来保证并发时原对象与复制对象的访问一致性,这件事情只从原理上看是不复杂的,但是“对象访问”这四个字的分量是非常重的,对于一门面向对象的编程语言来说,对象的读取、写入,对象的比较,为对象哈希值计算,用对象加锁等,这些操作都属于对象访问的范畴,它们在代码中比比皆是,要覆盖全部对象访问操作,Shenandoah不得不同时设置读、写屏障去拦截。

之前介绍其他收集器时,或者是用于维护卡表,或者是用于实现并发标记,写屏障已被使用多 次,累积了不少的处理任务了,这些写屏障有相当一部分在Shenandoah收集器中依然要被使用到。除此以外,为了实现Brooks Pointer,Shenandoah在读、写屏障中都加入了额外的转发处理,尤其是使用读屏障的代价,这是比写屏障更大的。代码里对象读取的出现频率要比对象写入的频率高出很多,读屏障数量自然也要比写屏障多得多,所以读屏障的使用必须更加谨慎,不允许任何的重量级操作。 Shenandoah是本书中第一款使用到读屏障的收集器,它的开发者也意识到数量庞大的读屏障带来的性 能开销会是Shenandoah被诟病的关键点之一[9],所以计划在JDK 13中将Shenandoah的内存屏障模型改进为基于引用访问屏障(Load Reference Barrier)[10]的实现,所谓“引用访问屏障”是指内存屏障只拦 截对象中数据类型为引用类型的读写操作,而不去管原生数据类型等其他非引用字段的读写,这能够 省去大量对原生类型、对象比较、对象加锁等场景中设置内存屏障所带来的消耗。

最后来谈谈Shenandoah在实际应用中的性能表现,Shenandoah的开发团队或者其他第三方测试者在 网上都公布了一系列测试,结果各有差异。笔者在此选择展示了一份RedHat官方在2016年所发表的 Shenandoah实现论文中给出的应用实测数据,测试内容是使用ElasticSearch对200GB的维基百科数据进 行索引[11],如表3-2所示。从结果来看,应该说2016年做该测试时的Shenandoah并没有完全达成预定 目标,停顿时间比其他几款收集器确实有了质的飞跃,但也并未实现最大停顿时间控制在十毫秒以内 的目标,而吞吐量方面则出现了很明显的下降,其总运行时间是所有测试收集器中最长的。读者可以 从这个官方的测试结果来对Shenandoah的弱项(高运行负担使得吞吐量下降)和强项(低延迟时间) 建立量化的概念,并对比一下稍后介绍的ZGC的测试结果。
低延迟垃圾收集器_第6张图片
Shenandoah收集器作为第一款由非Oracle开发的垃圾收集器,一开始就预计到了缺乏Oracle公司那 样富有经验的研发团队可能会遇到很多困难。所以Shenandoah采取了“小步快跑”的策略,将最终目标 进行拆分,分别形成Shenandoah 1.0、2.0、3.0……这样的小版本计划,在每个版本中迭代改进,现在已经可以看到Shenandoah的性能在日益改善,逐步接近“Low-Pause”的目标。此外,RedHat也积极拓展 Shenandoah的使用范围,将其Backport到JDK 11甚至是JDK 8之上,让更多不方便升级JDK版本的应用 也能够享受到垃圾收集器技术发展的最前沿成果。

[1] 这部分内容的撰写时间是2019年5月,以后的版本中双方博弈可能存在变数。相关内容可参见:
https://bugs.openjdk.java.net/browse/JDK-8215030。 [2]
这里主要是调侃,OpenJDK和OracleJDK之间的关系并不仅仅是收费和免费的问题,详情可参见本 书第1章。 [3] JEP
307:Parallel Full GC for G1。
[4] 论文地址:https://www.researchgate.net/publication/306112816_Shenandoah_An_open-source_concurrent_compacting_garbage_collector_for_OpenJDK。
[5] 此例子中的图片引用了Aleksey Shipilev在DEVOXX 2017上的主题演讲:《Shenandoah GC Part
I: The Garbage Collector That
Could》,地址为https://shipilev.net/talks/devoxx-Nov2017-shenandoah.pdf。因
本书是黑白印刷,颜色可能难以分辨,读者可以下载原文查看。
[6] 用户态、核心态是一种操作系统内核模式,具体见:https://zh.wikipedia.org/wiki/核心态。
[7] 但如果能有来自操作系统内核的支持的话,就不是没有办法解决,业界公认最优秀的Azul C4收集 器就使用了这种方案。
[8] 关于临界区、锁、CAS等概念,是计算机体系的基础知识,如果读者对此不了解的话,可以参考第 13章中的相关介绍。
[9] Roman Kennke(JEP 189的Owner):It resolves one major point of criticism against
Shenandoah,that is their expensive primitive read-barriers。
[10] 资料来源:https://rkennke.wordpress.com/2019/05/15/shenandoah-gc-in-jdk13-part-i-load-reference-barriers/。
[11] 该论文是以2014~2015年间最初版本的Shenandoah为测试对象,在2017年,Christine Flood在Java-One的演讲中,进行了相同测试,Shenandoah的运行时间已经优化到335秒。相信在读者阅读到这段文字时,Shenandoah的实际表现在多数应用中均会优于结果中反映的水平。

ZGC收集器

ZGC(“Z”并非什么专业名词的缩写,这款收集器的名字就叫作Z Garbage Collector)是一款在 JDK 11中新加入的具有实验性质[1]的低延迟垃圾收集器,是由Oracle公司研发的。2018年Oracle创建了JEP 333将ZGC提交给OpenJDK,推动其进入OpenJDK 11的发布清单之中。

ZGC和Shenandoah的目标是高度相似的,都希望在尽可能对吞吐量影响不太大的前提下[2],实现在任意堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内的低延迟。但是ZGC和Shenandoah的实现思路又是差异显著的,如果说RedHat公司开发的Shen-andoah像是Oracle的G1收集器的实际继承者的话,那Oracle公司开发的ZGC就更像是Azul System公司独步天下的PGC(Pauseless GC)和C4(Concurrent Continuously CompactingCollector)收集器的同胞兄弟。

早在2005年,运行在Azul VM上的PGC就已经实现了标记和整理阶段都全程与用户线程并发运行的垃圾收集,而运行在Zing VM上的C4收集器是PGC继续演进的产物,主要增加了分代收集支持,大幅提升了收集器能够承受的对象分配速度。无论从算法还是实现原理上来讲,PGC和C4肯定算是一脉相承的,而ZGC虽然并非Azul公司的产品,但也应视为这条脉络上的另一个节点,因为ZGC几乎所有的关键技术上,与PGC和C4都只存在术语称谓上的差别,实质内容几乎是一模一样的。相信到这里读者应该已经对Java虚拟机收集器常见的专业术语都有所了解了,如果不避讳专业术语的话,我们可以给ZGC下一个这样的定义来概括它的主要特征:ZGC收集器是一款基于Region内存布局的,(暂时)不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-整理算法的,以低延迟为首要目标的一款垃圾收集器。接下来,笔者将逐项来介绍ZGC的这些技术特点。

首先从ZGC的内存布局说起。与Shenandoah和G1一样,ZGC也采用基于Region的堆内存布局,但 与它们不同的是,ZGC的Region(在一些官方资料中将它称为Page或者ZPage,本章为行文一致继续称为Region)具有动态性——动态创建和销毁,以及动态的区域容量大小。在x64硬件平台下,ZGC的 Region可以具有如图3-19所示的大、中、小三类容量:

  • 小型Region(Small Region):容量固定为2MB,用于放置小于256KB的小对象。
  • 中型Region(Medium Region):容量固定为32MB,用于放置大于等于256KB但小于4MB的对 象。
  • 大型Region(Large Region):容量不固定,可以动态变化,但必须为2MB的整数倍,用于放置 4MB或以上的大对象。每个大型Region中只会存放一个大对象,这也预示着虽然名字叫作“大型Region”,但它的实际容量完全有可能小于中型Region,最小容量可低至4MB。大型Region在ZGC的实现中是不会被重分配(重分配是ZGC的一种处理动作,用于复制对象的收集器阶段,稍后会介绍到) 的,因为复制一个大对象的代价非常高昂。
    低延迟垃圾收集器_第7张图片
    接下来是ZGC的核心问题——并发整理算法的实现。Shenandoah使用转发指针和读屏障来实现并发整理,ZGC虽然同样用到了读屏障,但用的却是一条与Shenandoah完全不同,更加复杂精巧的解题 思路。

ZGC收集器有一个标志性的设计是它采用的染色指针技术(Colored Pointer,其他类似的技术中可能将它称为Tag Pointer或者Version Pointer)。从前,如果我们要在对象上存储一些额外的、只供收集 器或者虚拟机本身使用的数据,通常会在对象头中增加额外的存储字段(详见2.3.2节的内容),如对 象的哈希码、分代年龄、锁记录等就是这样存储的。这种记录方式在有对象访问的场景下是很自然流 畅的,不会有什么额外负担。但如果对象存在被移动过的可能性,即不能保证对象访问能够成功呢? 又或者有一些根本就不会去访问对象,但又希望得知该对象的某些信息的应用场景呢?能不能从指针 或者与对象内存无关的地方得到这些信息,譬如是否能够看出来对象被移动过?这样的要求并非不合 理的刁难,先不去说并发移动对象可能带来的可访问性问题,此前我们就遇到过这样的要求——追踪 式收集算法的标记阶段就可能存在只跟指针打交道而不必涉及指针所引用的对象本身的场景。例如对 象标记的过程中需要给对象打上三色标记(见3.4.6节),这些标记本质上就只和对象的引用有关,而 与对象本身无关——某个对象只有它的引用关系能决定它存活与否,对象上其他所有的属性都不能够 影响它的存活判定结果。HotSpot虚拟机的几种收集器有不同的标记实现方案,有的把标记直接记录在 对象头上(如Serial收集器),有的把标记记录在与对象相互独立的数据结构上(如G1、Shenandoah使 用了一种相当于堆内存的1/64大小的,称为BitMap的结构来记录标记信息),而ZGC的染色指针是最 直接的、最纯粹的,它直接把标记信息记在引用对象的指针上,这时,与其说可达性分析是遍历对象图来标记对象,还不如说是遍历“引用图”来标记“引用”了。

染色指针是一种直接将少量额外的信息存储在指针上的技术,可是为什么指针本身也可以存储额 外信息呢?在64位系统中,理论可以访问的内存高达16EB(2的64次幂)字节[3]。实际上,基于需求 (用不到那么多内存)、性能(地址越宽在做地址转换时需要的页表级数越多)和成本(消耗更多晶 体管)的考虑,在AMD64架构[4]中只支持到52位(4PB)的地址总线和48位(256TB)的虚拟地址空 间,所以目前64位的硬件实际能够支持的最大内存只有256TB。此外,操作系统一侧也还会施加自己 的约束,64位的Linux则分别支持47位(128TB)的进程虚拟地址空间和46位(64TB)的物理地址空 间,64位的Windows系统甚至只支持44位(16TB)的物理地址空间。

尽管Linux下64位指针的高18位不能用来寻址,但剩余的46位指针所能支持的64TB内存在今天仍 然能够充分满足大型服务器的需要。鉴于此,ZGC的染色指针技术继续盯上了这剩下的46位指针宽 度,将其高4位提取出来存储四个标志信息。通过这些标志位,虚拟机可以直接从指针中看到其引用对 象的三色标记状态、是否进入了重分配集(即被移动过)、是否只能通过finalize()方法才能被访问 到,如图3-20所示。当然,由于这些标志位进一步压缩了原本就只有46位的地址空间,也直接导致 ZGC能够管理的内存不可以超过4TB(2的42次幂)[5]。
低延迟垃圾收集器_第8张图片
虽然染色指针有4TB的内存限制,不能支持32位平台,不能支持压缩指针(-XX:+UseCompressedOops)等诸多约束,但它带来的收益也是非常可观的,在JEP 333的描述页[7]中, ZGC的设计者Per Liden在“描述”小节里花了全文过半的篇幅来陈述染色指针的三大优势:

  • 染色指针可以使得一旦某个Region的存活对象被移走之后,这个Region立即就能够被释放和重用 掉,而不必等待整个堆中所有指向该Region的引用都被修正后才能清理。这点相比起Shenandoah是一个 颇大的优势,使得理论上只要还有一个空闲Region,ZGC就能完成收集,而Shenandoah需要等到引用 更新阶段结束以后才能释放回收集中的Region,这意味着堆中几乎所有对象都存活的极端情况,需要 1∶1复制对象到新Region的话,就必须要有一半的空闲Region来完成收集。至于为什么染色指针能够导 致这样的结果,笔者将在后续解释其“自愈”特性的时候进行解释。
  • 染色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量,设置内存屏障,尤其是写屏障的 目的通常是为了记录对象引用的变动情况,如果将这些信息直接维护在指针中,显然就可以省去一些 专门的记录操作。实际上,到目前为止ZGC都并未使用任何写屏障,只使用了读屏障(一部分是染色指针的功劳,一部分是ZGC现在还不支持分代收集,天然就没有跨代引用的问题)。内存屏障对程序 运行时性能的损耗在前面章节中已经讲解过,能够省去一部分的内存屏障,显然对程序运行效率是大 有裨益的,所以ZGC对吞吐量的影响也相对较低。
  • 染色指针可以作为一种可扩展的存储结构用来记录更多与对象标记、重定位过程相关的数据,以 便日后进一步提高性能。现在Linux下的64位指针还有前18位并未使用,它们虽然不能用来寻址,却可 以通过其他手段用于信息记录。如果开发了这18位,既可以腾出已用的4个标志位,将ZGC可支持的 最大堆内存从4TB拓展到64TB,也可以利用其余位置再存储更多的标志,譬如存储一些追踪信息来让 垃圾收集器在移动对象时能将低频次使用的对象移动到不常访问的内存区域。

不过,要顺利应用染色指针有一个必须解决的前置问题:Java虚拟机作为一个普普通通的进程, 这样随意重新定义内存中某些指针的其中几位,操作系统是否支持?处理器是否支持?这是很现实的 问题,无论中间过程如何,程序代码最终都要转换为机器指令流交付给处理器去执行,处理器可不会 管指令流中的指针哪部分存的是标志位,哪部分才是真正的寻址地址,只会把整个指针都视作一个内 存地址来对待。这个问题在Solaris/SPARC平台上比较容易解决,因为SPARC硬件层面本身就支持虚拟 地址掩码,设置之后其机器指令直接就可以忽略掉染色指针中的标志位。但在x86-64平台上并没有提 供类似的黑科技,ZGC设计者就只能采取其他的补救措施了,这里面的解决方案要涉及虚拟内存映射 技术,让我们先来复习一下这个x86计算机体系中的经典设计。

在远古时代的x86计算机系统里面,所有进程都是共用同一块物理内存空间的,这样会导致不同进 程之间的内存无法相互隔离,当一个进程污染了别的进程内存后,就只能对整个系统进行复位后才能 得以恢复。为了解决这个问题,从Intel 80386处理器开始,提供了“保护模式”用于隔离进程。在保护模 式下,386处理器的全部32条地址寻址线都有效,进程可访问最高也可达4GB的内存空间,但此时已不 同于之前实模式下的物理内存寻址了,处理器会使用分页管理机制把线性地址空间和物理地址空间分 别划分为大小相同的块,这样的内存块被称为“页”(Page)。通过在线性虚拟空间的页与物理地址空 间的页之间建立的映射表,分页管理机制会进行线性地址到物理地址空间的映射,完成线性地址到物 理地址的转换[8]。如果读者对计算机结构体系了解不多的话,不妨设想这样一个场景来类比:假如你 要去“中山一路3号”这个地址拜访一位朋友,根据你所处城市的不同,譬如在广州或者在上海,是能够 通过这个“相同的地址”定位到两个完全独立的物理位置的,这时地址与物理位置是一对多关系映射。

不同层次的虚拟内存到物理内存的转换关系可以在硬件层面、操作系统层面或者软件进程层面实 现,如何完成地址转换,是一对一、多对一还是一对多的映射,也可以根据实际需要来设计。 Linux/x86-64平台上的ZGC使用了多重映射(Multi-Mapping)将多个不同的虚拟内存地址映射到同一个物理内存地址上,这是一种多对一映射,意味着ZGC在虚拟内存中看到的地址空间要比实际的堆内存容量来得更大。把染色指针中的标志位看作是地址的分段符,那只要将这些不同的地址段都映射到同一个物理内存空间,经过多重映射转换后,就可以使用染色指针正常进行寻址了,效果如图3-21所 示。
![在这里插入图片描述](https://img-blog.csdnimg.cn/20201118232401752.png =800xr)
在某些场景下,多重映射技术确实可能会带来一些诸如复制大对象时会更容易这样的额外好处, 可从根源上讲,ZGC的多重映射只是它采用染色指针技术的伴生产物,并不是专门为了实现其他某种 特性需求而去做的。

接下来,我们来学习ZGC收集器是如何工作的。ZGC的运作过程大致可划分为以下四个大的阶 段。全部四个阶段都是可以并发执行的,仅是两个阶段中间会存在短暂的停顿小阶段,这些小阶段, 譬如初始化GC Root直接关联对象的Mark Start,与之前G1和Shenandoah的Initial Mark阶段并没有什么差异,笔者就不再单独解释了。ZGC的运作过程具体如图3-22所示。
低延迟垃圾收集器_第9张图片
-并发标记(Concurrent Mark):与G1、Shenandoah一样,并发标记是遍历对象图做可达性分析的 阶段,前后也要经过类似于G1、Shenandoah的初始标记、最终标记(尽管ZGC中的名字不叫这些)的 短暂停顿,而且这些停顿阶段所做的事情在目标上也是相类似的。与G1、Shenandoah不同的是,ZGC 的标记是在指针上而不是在对象上进行的,标记阶段会更新染色指针中的Marked 0、Marked 1标志 位。

  • 并发预备重分配(Concurrent Prepare for Relocate):这个阶段需要根据特定的查询条件统计得出 本次收集过程要清理哪些Region,将这些Region组成重分配集(Relocation Set)。重分配集与G1收集器 的回收集(Collection Set)还是有区别的,ZGC划分Region的目的并非为了像G1那样做收益优先的增量回收。相反,ZGC每次回收都会扫描所有的Region,用范围更大的扫描成本换取省去G1中记忆集的 维护成本。因此,ZGC的重分配集只是决定了里面的存活对象会被重新复制到其他的Region中,里面 的Region会被释放,而并不能说回收行为就只是针对这个集合里面的Region进行,因为标记过程是针对 全堆的。此外,在JDK 12的ZGC中开始支持的类卸载以及弱引用的处理,也是在这个阶段中完成的。
  • 并发重分配(Concurrent Relocate):重分配是ZGC执行过程中的核心阶段,这个过程要把重分 配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表(Forward Table),记录从旧对象到新对象的转向关系。得益于染色指针的支持,ZGC收集器能仅从引用上就明 确得知一个对象是否处于重分配集之中,如果用户线程此时并发访问了位于重分配集中的对象,这次 访问将会被预置的内存屏障所截获,然后立即根据Region上的转发表记录将访问转发到新复制的对象 上,并同时修正更新该引用的值,使其直接指向新对象,ZGC将这种行为称为指针的“自愈”(Self- Healing)能力。这样做的好处是只有第一次访问旧对象会陷入转发,也就是只慢一次,对比 Shenandoah的Brooks转发指针,那是每次对象访问都必须付出的固定开销,简单地说就是每次都慢, 因此ZGC对用户程序的运行时负载要比Shenandoah来得更低一些。还有另外一个直接的好处是由于染 色指针的存在,一旦重分配集中某个Region的存活对象都复制完毕后,这个Region就可以立即释放用于 新对象的分配(但是转发表还得留着不能释放掉),哪怕堆中还有很多指向这个对象的未更新指针也 没有关系,这些旧指针一旦被使用,它们都是可以自愈的。
  • 并发重映射(Concurrent Remap):重映射所做的就是修正整个堆中指向重分配集中旧对象的所 有引用,这一点从目标角度看是与Shenandoah并发引用更新阶段一样的,但是ZGC的并发重映射并不 是一个必须要“迫切”去完成的任务,因为前面说过,即使是旧引用,它也是可以自愈的,最多只是第 一次使用时多一次转发和修正操作。重映射清理这些旧引用的主要目的是为了不变慢(还有清理结束 后可以释放转发表这样的附带收益),所以说这并不是很“迫切”。因此,ZGC很巧妙地把并发重映射 阶段要做的工作,合并到了下一次垃圾收集循环中的并发标记阶段里去完成,反正它们都是要遍历所 有对象的,这样合并就节省了一次遍历对象图[9]的开销。一旦所有指针都被修正之后,原来记录新旧 对象关系的转发表就可以释放掉了。

ZGC的设计理念与Azul System公司的PGC和C4收集器一脉相承[10],是迄今垃圾收集器研究的最 前沿成果,它与Shenandoah一样做到了几乎整个收集过程都全程可并发,短暂停顿也只与GC Roots大 小相关而与堆内存大小无关,因而同样实现了任何堆上停顿都小于十毫秒的目标。

相比G1、Shenandoah等先进的垃圾收集器,ZGC在实现细节上做了一些不同的权衡选择,譬如G1 需要通过写屏障来维护记忆集,才能处理跨代指针,得以实现Region的增量回收。记忆集要占用大量 的内存空间,写屏障也对正常程序运行造成额外负担,这些都是权衡选择的代价。ZGC就完全没有使 用记忆集,它甚至连分代都没有,连像CMS中那样只记录新生代和老年代间引用的卡表也不需要,因 而完全没有用到写屏障,所以给用户线程带来的运行负担也要小得多。可是,必定要有优有劣才会称 作权衡,ZGC的这种选择[11]也限制了它能承受的对象分配速率不会太高,可以想象以下场景来理解 ZGC的这个劣势:ZGC准备要对一个很大的堆做一次完整的并发收集,假设其全过程要持续十分钟以 上(请读者切勿混淆并发时间与停顿时间,ZGC立的Flag是停顿时间不超过十毫秒),在这段时间里 面,由于应用的对象分配速率很高,将创造大量的新对象,这些新对象很难进入当次收集的标记范 围,通常就只能全部当作存活对象来看待——尽管其中绝大部分对象都是朝生夕灭的,这就产生了大 量的浮动垃圾。如果这种高速分配持续维持的话,每一次完整的并发收集周期都会很长,回收到的内 存空间持续小于期间并发产生的浮动垃圾所占的空间,堆中剩余可腾挪的空间就越来越小了。目前唯
一的办法就是尽可能地增加堆容量大小,获得更多喘息的时间。但是若要从根本上提升ZGC能够应对 的对象分配速率,还是需要引入分代收集,让新生对象都在一个专门的区域中创建,然后专门针对这 个区域进行更频繁、更快的收集。Azul的C4收集器实现了分代收集后,能够应对的对象分配速率就比 不分代的PGC收集器提升了十倍之多。

ZGC还有一个常在技术资料上被提及的优点是支持“NUMA-Aware”的内存分配。NUMA(Non- Uniform Memory Access,非统一内存访问架构)是一种为多处理器或者多核处理器的计算机所设计的 内存架构。由于摩尔定律逐渐失效,现代处理器因频率发展受限转而向多核方向发展,以前原本在北 桥芯片中的内存控制器也被集成到了处理器内核中,这样每个处理器核心所在的裸晶(DIE)[12]都有 属于自己内存管理器所管理的内存,如果要访问被其他处理器核心管理的内存,就必须通过Inter- Connect通道来完成,这要比访问处理器的本地内存慢得多。在NUMA架构下,ZGC收集器会优先尝 试在请求线程当前所处的处理器的本地内存上分配对象,以保证高效内存访问。在ZGC之前的收集器 就只有针对吞吐量设计的Parallel Scavenge支持NUMA内存分配[13],如今ZGC也成为另外一个选择。

在性能方面,尽管目前还处于实验状态,还没有完成所有特性,稳定性打磨和性能调优也仍在进 行,但即使是这种状态下的ZGC,其性能表现已经相当亮眼,从官方给出的测试结果[14]来看,用“令 人震惊的、革命性的ZGC”来形容都不为过。

图3-23和图3-24是ZGC与Parallel Scavenge、G1三款收集器通过SPECjbb 2015[15]的测试结果。在 ZGC的“弱项”吞吐量方面,以低延迟为首要目标的ZGC已经达到了以高吞吐量为目标Parallel Scavenge 的99%,直接超越了G1。如果将吞吐量测试设定为面向SLA(Service Level Agreements)应用 的“Critical Throughput”的话[16],ZGC的表现甚至还反超了Parallel Scavenge收集器。

而在ZGC的强项停顿时间测试上,它就毫不留情地与Parallel Scavenge、G1拉开了两个数量级的差 距。不论是平均停顿,还是95%停顿、99%停顿、99.9%停顿,抑或是最大停顿时间,ZGC均能毫不费 劲地控制在十毫秒之内,以至于把它和另外两款停顿数百近千毫秒的收集器放到一起对比,就几乎显 示不了ZGC的柱状条(图3-24a),必须把结果的纵坐标从线性尺度调整成对数尺度(图3-24b,纵坐 标轴的尺度是对数增长的)才能观察到ZGC的测试结果。
低延迟垃圾收集器_第10张图片
ZGC原本是Oracle作为一项商业特性(如同JFR、JMC这些功能)来设计和实现的,只不过在它横 空出世的JDK 11时期,正好适逢Oracle调整许可证授权,把所有商业特性都开源给了OpenJDK(详情 见第1章Java发展史),所以用户对其商业性并没有明显的感知。ZGC有着令所有开发人员趋之若鹜的 优秀性能,让以前大多数人只是听说,但从未用过的“Azul式的垃圾收集器”一下子飞入寻常百姓家, 笔者相信它完全成熟之后,将会成为服务端、大内存、低延迟应用的首选收集器的有力竞争者。

[1] 这里的“实验性质”特指ZGC目前尚未具备全部商用收集器应有的特征,如暂不提供全平台的支持 (目前仅支持Linux/x86-64),暂不支持类卸载(JDK 11时不支持,JDK 12的ZGC已经支持),暂不支持新的Graal编译器配合工作等,但这些局限主要是人力资源与工作量上的限制,可能读者在阅读到 这部分内容的时候已经有了新的变化。
[2] 在JEP 333中把ZGC的“吞吐量下降不大”明确量化为相比起使用G1收集器,吞吐量下降不超过15%。不过根据Oracle公开的现阶段SPECjbb 2015测试结果来看,ZGC在这方面要比Shenandoah优秀得多,测得的吞吐量居然比G1还高,甚至已经接近了Parallel Scavenge的成绩。
[3] 1EB=1024PB,1PB=1024TB。
[4] AMD64这个名字的意思不是指只有AMD的处理器使用,它就是现在主流的x86-64架构,由于Intel Itanium的失败,现行的64位标准是由AMD公司率先制定的,Intel通过交叉授权获得该标准的授权,所 以叫作AMD64。
[5] JDK 13计划是要扩展到最大支持16TB的,本章撰写时JDK 13尚未正式发布,还没有明确可靠的信息,所以这里按照ZGC目前的状态来介绍。
[6] 此图片以及后续关于ZGC执行阶段的几张图片,均来自Per Liden在Jfokus VM 2018大会上的演讲: 《The Z Garbage Collector:Low Latency GC for OpenJDK》。
[7] 页面地址:https://openjdk.java.net/jeps/333。
[8] 实际上现代的x86操作系统中的虚拟地址是操作系统加硬件两级翻译的,在进程中访问的逻辑地址要通过MMU中的分段单元翻译为线性地址,然后再通过分页单元翻译成物理地址。这部分并非本书 所关注的话题,读者简单了解即可。
[9] 如果不是由于两个阶段合并考虑,其实做重映射不需要按照对象图的顺序去做,只需线性地扫描整 个堆来清理旧引用即可。
[10] 笔者心中的词语其实是“一模一样”,只是这怎么听起来似乎像是对Oracle的嘲讽?Oracle公司也并 未在任何公开资料中承认参考过Azul System的论文或者实现。
[11] 根据Per Liden的解释,目前ZGC不分代完全是从节省工作量角度所做出的选择,并非单纯技术上的权衡。来源:https://www.zhihu.com/question/287945354/answer/458761494。
[12] 裸晶这个名字用的较少,通常都直接称呼为DIE: https://en.wikipedia.org/wiki/Die_(integrated_circuit)。
[13] 当“JEP 345:NUMA-Aware Memory Allocation for G1”被纳入某个版本的JDK发布范围后,G1也会支持NUMA分配。
[14] 数据来自Jfokus VM 2018中Per liden的演讲《The Z Garbage Collector:Low Latency GC for OpenJDK》。
[15] http://spec.org/jbb2015/。
[16] Critical Throughput就是要求最大延迟不超过某个设置值(10毫秒到100毫秒)下测得的吞吐量。

你可能感兴趣的:(深入理解Java虚拟机)