likes
comments
collection
share

JVM-Shenandoah垃圾收集器

作者站长头像
站长
· 阅读数 18

一、Shenandoah简介

Shenandoah是第一款不由Oracle(包括以前的Sun)公司的虚拟机团队所领导开发的HotSpot垃圾收集器,由RedHat公司独立发展的新型收集器项目,在2014年RedHat把Shenandoah贡献给了OpenJDK,并推动它成为OpenJDK 12的正式特性之一。

Shenandoah收集器与G1收集器相似,二者有着许多相似之处:

  • 相似的堆内存布局:二者都使用基于Region的堆内存布局,且有着用于存放大对象的Humongous Region。
  • 相同的回收策略:二者都优先处理回收价值最大的Region。
  • 相似的思路:在初始标记、并发标记等阶段的思路都高度一致,甚至直接共享了一部分代码。
  • 默认的回收策略也同样是优先处理回收价值最大的Region。

Shenandoah相比起G1的改进之处:

  • 最重要的是支持并发的整理算法,在GC线程垃圾收集的过程可以和用户线程并发执行,G1的回收阶段是可以多线程并行的,但却不能与用户线程并发。

  • Shenandoah(目前)是默认不使用分代收集的,换言之,不会有专门的新生代Region或者老年代Region的存在,没有实现分代,并不是说分代对Shenandoah没有价值,这更多是出于性价比的权衡,基于工作量上的考虑而将其放到优先级较低的位置上。

  • Shenandoah摒弃了在G1中耗费大量内存和计算资源去维护的记忆集,改用名为“连接矩阵”(Connection Matrix)的全局数据结构来记录跨Region的引用关系,降低了处理跨代指针时的记忆集维护消耗,也降低了伪共享问题的发生概率。

    连接矩阵可以简单理解为一张二维表格,如果Region N有对象指向Region M,就在表格的N行M列中打上一个标记。

    如下图所示:右边 Region4中的obj1对象引用了Region3中的obj2对象,Region3中的obj2对象引用了Region2中的obj3对象;所以在左边的连接矩阵中 4行3列、3行2列被打上了标记。在回收时通过这张表格就可以得出哪些Region之间产生了跨代引用。

JVM-Shenandoah垃圾收集器

二、Shenandoah垃圾收集过程

Shenandoah垃圾收集过程分为下面几个阶段:

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

下图描述了Shenandoah垃圾回收的几个阶段,我们可以重点关注3个STW阶段(初始标记,最终标记,初始引用更新)和3个并发阶段(并发标记,并发回收,并发引用更新):

JVM-Shenandoah垃圾收集器

三、Brooks Pointers 转发指针

在并发回收阶段,我们讲了利用 读屏障和““Brooks Pointers”的转发指针来解决 GC线程在回收阶段和用户线程并发时,用户线程对移动对象的读写访问问题。“Brooks”是一个人的名字。1984年,Rodney A.Brooks在论文《Trading Data Spacefor Reduced Time and Code Space in Real-Time Garbage Collection on Stock Hardware》中提出了使用转发指针(Forwarding Pointer,也常被称为Indirection Pointer)来实现对象移动与用户程序并发的一种解决方案。

此前,要做类似的并发操作,通常是在被移动对象原有的内存上设置保护陷阱(Memory Protection Trap),一旦用户程序访问到归属于旧对象的内存空间就会产生自陷中段,进入预设好的异常处理器中,再由其中的代码逻辑把访问转发到复制后的新对象上。虽然确实能够实现对象移动与用户线程并发,但是如果没有操作系统层面的直接支持,这种方案将导致用户态频繁切换到核心态,代价是非常大的,不能频繁使用。

Brooks提出的新方案不需要用到内存保护陷阱,而是在原有对象布局结构的最前面统一增加一个新的引用字段,在正常不处于并发移动的情况下,该引用指向对象自己,如下图所示:

JVM-Shenandoah垃圾收集器

从结构上来看,Brooks提出的转发指针与某些早期Java虚拟机使用过的句柄定位有一些相似之处,两者都是一种间接性的对象访问方式,差别是句柄通常会统一存储在专门的句柄池中,而转发指针是分散存放在每一个对象头前面。有了转发指针之后,有何收益暂且不论,所有间接对象访问技术的缺点都是相同的,也是非常显著的——每次对象访问会带来一次额外的转向开销,尽管这个开销已经被优化到只有一行汇编指令的程度,譬如以下所示:

mov r13,QWORD PTR [r12+r14*8-0x8]

不过,毕竟对象定位会被频繁使用到,这仍是一笔不可忽视的执行成本,只是它比起内存保护陷阱的方案已经好了很多。

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

JVM-Shenandoah垃圾收集器

这种转发指针在设计上决定了它是必然会出现多线程竞争问题的:
  • 并发读取:

    如果收集器线程与用户线程发生的只是并发读取,那无论读到旧对象还是新对象上的字段,返回的结果都应该是一样的,这个场景还可以有一些“偷懒”的处理余地。

    Shenandoah收集器 才用读屏障来解决读取时的同步问题。

  • 并发写入:

    如果发生的是并发写入,就一定必须保证写操作只能发生在新复制的对象上,而不是写入旧对象的内存中。如果下面三件事情并发进行时:

    • 1)收集器线程复制了新的对象副本;

    • 2)用户线程更新对象的某个字段;

    • 3)收集器线程更新转发指针的引用值为新副本地址。

      如果不做任何保护措施,让事件2在事件1、事件3之间发生的话,将导致的结果就是用户线程对对象的变更发生在旧对象上。

      所以,这里必须针对转发指针的访问操作采取同步措施,让收集器线程或者用户线程对转发指针的访问只有其中之一能够成功,另外一个必须等待,避免两者交替进行。实际上Shenandoah收集器是通过比较并交换(Compare And Swap,CAS)操作来保证并发时对象的访问正确性的。

参考文献: