深入理解Java虚拟机—低延迟垃圾收集器

上一篇:深入理解Java虚拟机—垃圾收集器

下一篇:深入理解Java虚拟机—垃圾收集器适用场景

这一篇讲的是低延迟垃圾收集器

  • 衡量垃圾收集器的三项最重要的指标是:内存占用(Footprint)、吞吐量(Throughput)和延迟(Latency),三者共同构成了一个“不可能三角[插图]”。三者总体的表现会随技术进步而越来越好,但是要在这三个方面同时具有卓越表现的“完美”收集器是极其困难甚至是不可能的,一款优秀的收集器通常最多可以同时达成其中的两项。
  • 在内存占用、吞吐量和延迟这三项指标里,延迟的重要性日益凸显,越发备受关注。其原因是随着计算机硬件的发展、性能的提升,我们越来越能容忍收集器多占用一点点内存;硬件性能增长,对软件系统的处理能力是有直接助益的,硬件的规格和性能越高,也有助于降低收集器运行时对应用程序的影响,换句话说,吞吐量会更高。但对延迟则不是这样,硬件规格提升,准确地说是内存的扩大,对延迟反而会带来负面的效果,这点也是很符合直观思维的:虚拟机要回收完整的1TB的堆内存,毫无疑问要比回收1GB的堆内存耗费更多时间。由此,我们就不难理解为何延迟会成为垃圾收集器最被重视的性能指标了。现在我们来观察一下现在已接触过的垃圾收集器的停顿状况,如图3-14所示。

图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-TimeGarbage Collector)。

一. Shenandoah 收集器

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

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

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

1. 与G1相比的优点

从代码的历史渊源上来看,Shenandoah收集器更像是G1的下一代继承者,两者相似的堆内存布局,在初始标记、并发标记等许多阶段的处理思路都高度一致。
但是Shenandoah相比G1还是至少有三个明显的不同之处。

  1. 支持并发的整理算法,G1的回收阶段是可以多线程并行的,但却不鞥呢与用户线程并发。
  2. Shenandoah是默认不使用分代收集的,不会有专门的新生代Region或者老年代Region的存在。
  3. Shenandoah摒弃了在G1中耗费大量内存和计算资源去维护的记忆集,改用名为“连接矩阵”(Connection Matrix)的全局数据结果来记录夸Region的引用关系降低了夸代维护的消耗。
    Shenandoah收集器的跨代“连接矩阵”示意图:
    在这里插入图片描述

连接矩阵可以简单的理解为一张二维表格,如果Region N有对象指向Region M,就在表格的N行M列中打上一个标记,如上图所示,如果Region 5中的对象Object C引用了Region 3 的Object B,Object B又引用了Region 1 的Object A,那么连接矩阵就中就会在5行3列、3行1列中打上标记。在回收时通过这张表格就可以得出哪些Region 之间产生了跨代引用。

2. 收集过程

Shenandoah收集器的工作过程大致可以划分为以下九个阶段:

  1. 初始标记:与G1一样,首先标记与GC Roots直接关联的对象,这个阶段仍是“Stop The World”的,但停顿时间与堆大小无关,至于GC Roots的数量相关。
  2. 并发标记:与G1一样,编辑对象图,标记出全部可达的对象,与用户线程一起并发,时间长短与堆中存活对象的数量以及对象图的结构复杂程度有关。
  3. 最终标记:与G1一样,处理剩余的SATB扫描,并在这个阶段统计出回收价值最高的Region,将这些Region构成一组回收集。此阶段也会有一小段短暂的停顿。
  4. 并发清理:这个阶段用于清理那些整个区域内连一个存活对象都没有找到的Region。
  5. 并发回收:这个阶段是Shenandoah与之前HotSpot中其他收集器的核心差异。在这个阶段,Shenandoah要把回收集里面的存活对象先复制一份到其他未被使用的Region中。但是有个难点是在移动对象的同时,用户线程仍然可能不停的对被移动的对象进行读写访问,移动对象之后整个内存中所有指向该对象的引用都还是旧对象的地址,这是很难一瞬间全部改变过来的。对于这个难点,Shenandoah将会通过读屏障和被称为“Brooks Pointers”的转发指针来解决。
    并发回收阶段运行时间的长短取决于回收集的大小。
  6. 初始引用更新:并发回收阶段复制对象结束后,还需要把堆中所有指向旧对象的引用修正蛋糕复制后的新地址,这个操作称为引用更新。这个阶段就是对这个操作进行初始化的,初始引用更新时间很短,会产生一个非常短暂的停顿。
    并发引用更新:真正开始进行引用更新操作,这个阶段是与用户线程一起并发的,时间长短取决于内存中涉及的引用数量的多少。
  7. 最终引用更新:解决了堆中的引用更新后,还要修正存在于GC Roots 中的引用。这个阶段是Shenandoah的最后一次停顿,时间长短与GC Roots的数量有关。
  8. 并发清理:经过并发回收和引用更新之后,整个回收集中所有的Region已再无存活对象,最后再调用一次并发清理过程来回收这些Region 的内存空间,供以后新对象分配使用。
    这九个阶段的工作过程可能拆的比较琐碎,只要抓住其中三个最重要的并发节点(并发标记、并发回收、并发引用更新)就好理解Shenandoah的运作过程了。

3. 转发指针(Brooks Pointer)

Shenandoah收集器的并发回收的核心是,转发指针。
转发指针的核心内容就是,在原有对象布局结构的最前面统一增加一个新的引用字段,在正常不处于并发移动的情况下,该引用指向对象自己。
如下图:
在这里插入图片描述

转发指针加入后带来的收益自然是当对象拥有了一份新的副本时,只需要修改一处指针的值,即旧对象上转发指针的引用位置,使其指向新对象,便可将所有对该对象的访问转发到新的副本上。这样只要对象的内存仍然存在,未被清理掉,虚拟机内存中所有通过旧引用地址访问的代码仍然可用,都会被自动转发到新对象上继续工作。

如下图:
在这里插入图片描述

扫描二维码关注公众号,回复: 12421366 查看本文章

Brooks Pointers 转发指针在设计上决定了它是必然会出现多线程竞争问题的。Shenandoah收集器是通过比较交换(Compare And Swap,CAS)操作来保证并发时堆中的访问正确性的。

4. 总结

  1. Shenandoah收集器保证了收集垃圾的低延迟。
  2. 但是使用了过多的写屏障,所以导致Shenandoah收集器的弱项很明显,当数据量大的时候会产生高运行负担而使得吞吐量下降。

二. ZGC收集器

ZGC(Z Garbage Collector)是一款由Oracle公司研发的,以低延迟为首要目标的一款垃圾收集器。它是基于动态Region内存布局,(暂时)不设年龄分代,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-整理算法的收集器。在JDK 11新加入,还在实验阶段,主要特点是:回收TB级内存(最大4T),停顿时间不超过10ms。

1.ZGC布局

与Shenandoah和G1一样,ZGC也采取基于Region的堆内存布局,但与他们不同的是,ZGC的Region具有动态性(动态的创建和销毁,以及动态的区域容量大小)

ZGC的Region可以分为三类:

  • 小型Region:容量固定为2MB,用于放置小于256KB的小对象。
  • 中型Region:容量固定为32MB,用于放置大于等于256KB但小于4MB的对象。
  • 大型Region:容量不固定,可以动态变化,但必须为2MB的整数倍,用于存放4MB或以上的大对象。并且每个大型Region只会存放一个对象。

布局图:
在这里插入图片描述

2. 染色指针

HotSpot的垃圾收集器,有几种不同的标记实现方案。

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

染色指针是一种直接将少量额外的信息存储在指针上的技术。
目前Linux下64位指针的高18位不能用来寻址,但剩余的46位指针所能支持的64TB内存仍然鞥呢够充分满足大型服务器的需要。鉴于此,ZGC将其高4位提取出来存储四个标志信息。
通过这些标志虚拟机就可以直接从指针中看到器引用对象的三色标记状态(Marked0、Marked1)、是否进入了重分配集(是否被移动过——Remapped)、是否只能通过finalize()方法才能被访问到(Finalizable)。由于这些标志位进一步压缩了原本只有46位的地址空寂,导致ZGC能够管理的内存不可以超过4TB。
染色指针示意图:
在这里插入图片描述

染色指针的优势

  • 染色指针可以使得一旦某个Region的存活对象被移走之后,这个Region立即就能够被释放和重用掉,而不必等待整个堆中所有指令向该Region的引用都被修正后才能清理。
  • 染色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量,设置内存屏障,尤其是在写屏障的目的通常是为了记录对象引用的变动情况,如果将这些信息直接维护在指针中,显然就可以省去一些专门的记录操作。
  • 染色指针可以作为一种可扩展的存储结构用来记录更多与对象标记、重定位过程相关的数据,以便日后进一步提高性能。

内存多重映射
Linux/x86-64平台上ZGC使用了多重映射(Multi-Mapping)将多个不同的虚拟内存地址映射到同一物理内存地址上,这是一种多对一映射,意味着ZGC在虚拟内存中看到的地址空寂要比实际的堆内存容量来的更大。把染色指针中的标志位看作是地址的分段符,那只要将这些不同的地址段都映射到同一物理内裤空间,经过多重映射转换后,就可以使用染色指针正常进行寻址了。
多重映射下的寻址:
在这里插入图片描述

ZGC的运作过程
ZGC的运作过程大致可划分为以下四个大的阶段。四个阶段都是可以并发执行的,仅是两个阶段中间会存在短暂的停顿小阶段。
运作过程如下:
在这里插入图片描述

  • 并发标记(Concurrent Mark): 与G1、Shenandoah一样,并发标记是遍历对象图做可达性分析的阶段,前后也要经过类似于G1、Shenandoah的初始标记、最终标记的短暂停顿,而且这些停顿阶段所做的事情在目标上也是相类似的。
  • 并发预备重分配( Concurrent Prepare for Relocate): 这个阶段需要根据特定的查询条件统计得出本次收集过程要清理哪些Region,将这些Region组成重分配集(Relocation Set)。
  • 并发重分配(Concurrent Relocate): 重分配是ZGC执行过程中的核心阶段,这个过程要把重分配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表(Forward Table),记录从旧对象到新对象的转向关系。
  • 并发重映射(Concurrent Remap): 重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用,ZGC的并发映射并不是以一个必须要“迫切”去完成的任务。ZGC很巧妙地把并发重映射阶段要做的工作,合并到下一次垃圾收集循环中的并发标记阶段里去完成,反正他们都是要遍历所有对象的,这样合并节省了一次遍历的开销。

ZGC的优劣势

缺点:浮动垃圾
当ZGC准备要对一个很大的堆做一次完整的并发收集,驾驶其全过程要持续十分钟以上,由于应用的对象分配速率很高,将创造大量的新对象,这些新对象很难进入当次收集的标记范围,通常就只能全部作为存活对象来看待(尽管其中绝大部分对象都是朝生夕灭),这就产生了大量的浮动垃圾。
目前唯一的办法就是尽可能地去增加堆容量大小,获取更多喘息的时间。但若要从根本上解决,还是需要引入分代收集,让新生对象都在一个专门的区域中创建,然后针对这个区域进行更频繁、更快的收集。

优点:高吞吐量、低延迟。
ZGC是支持“NUMA-Aware”的内存分配。NUMA(Non-Uniform Memory Access,非统一内存访问架构)是一种多处理器或多核处理器计算机所设计的内存架构。
现在多CPU插槽的服务器都是Numa架构,比如两颗CPU插槽(24核),64G内存的服务器,那其中一颗CPU上的12个核,访问从属于它的32G本地内存,要比访问另外32G远端内存要快得多。
ZGC默认支持NUMA架构,在创建对象时,根据当前线程在哪个CPU执行,优先在靠近这个CPU的内存进行分配,这样可以显著的提高性能,在SPEC JBB 2005 基准测试里获得40%的提升。

猜你喜欢

转载自blog.csdn.net/haiyanghan/article/details/108674960