垃圾回收器(上篇)中的性能指标中说到,衡量垃圾回收器最重要的三项指标如下所示:
如上所示,不管是哪一种垃圾回收器,它们都是朝着优化上述的三个指标的方向进行发展。如今硬件技术的发展,内存占用逐渐的没有那么令人担忧,而吞吐量和延迟成为一个难以权衡的矛盾所在。硬件性能的提升有助于降低垃圾回收器运行时对于应用程序的影响,从而可以有效的提高吞吐量。但是所使用的内存空间越大,垃圾回收器在执行垃圾回收时的负担就越重,故而停顿时间就会越长。
随着微服务等理念的广泛使用,用户体验逐渐成为了一个应用程序能否被用户认可的一种重要因素。因此,如何降低垃圾回收器运行时的停顿时间的重要性日益凸显。为了更好的接近这个目标,市面上推出了两款主打低延迟的垃圾回收器,分别是RedHat的Shenandoah和Oracle的ZGC。下面就逐个介绍下这两款回收器,在理解它们设计理念的同时,也要对比之前的回收器看一下为什么可以达到所谓的低延迟。
Shenandosh回收器是由RedHat独立发展的一款垃圾回收器,它的目标是希望实现能在任何对内存大小下都可以把垃圾收集的停顿时间限制在10毫秒以内,这意味着之前并发标记所降低的停顿时间已经不够了,在最后的清理阶段同样需要和用户程序并发的执行。
Shenandosh回收器在实现原理上和前面的G1回收器大致上是相同的,因此,Shenandosh回收器的发展也进一步的推动了G1的迭代更新。但两者之间仍有部分地方是不同的,如下所示:
Shenandosh回收器不仅支持并发标记、而且还支持并发整理
Shenandosh回收器默认并不使用分代收集
Shenandosh回收器不再使用记忆集来解决跨代引用问题,而是采用了称为连接矩阵的全局数据结构记录跨region的引用问题
因为Shenandosh回收器默认不使用分代收集,所以更准确是是解决跨region引用,而不是跨代引用~
Shenandosh回收器的运行过程大致可以细分为九个阶段,虽然看起来很吓人,但是和之前的几款回收器在执行过程上有很多的重合处,具体为:
Shenandoah GC - Part I: The Garbage Collector That Could
并发回收阶段的对象移动必然会导致对象之间引用的变化,为了使对象的移动不影响对象的访问,就需要使用某种操作来保证移动后仍能正常进行对象访问。其中一种方式是保护陷阱(Memory Protection Trap),当访问到被移动的对象时,就会进入预设的异常处理逻辑,将对象的访问操作直接转发到复制后的新对象上。但是这种方式会导致用户态和核心态的频繁切换,会有很大的开销。
转发指针采用了一种更为巧妙的解决思路,对于每个对象来说,直接在对象的布局结构的最前统一增加一个新的引用字段。如果该对象没有执行移动操作,那就没有它的副本在其他的region中存在,故直接将其指向自身;如果该对象发生了移动操作,则指向移动后生成的对象副本。
采用了转发指针后,如果是多个线程同时访问某个执行了移动操作的对象,那么不管是访问原对象,还是对象副本,最终的效果并没有差别。但是如果同时执行写入操作,那么就会出现竞争问题。为了解决转发指针所导致的多线程竞争问题,Shenandosh回收器通过比较和交换(Compare and Swap,CAS)操作保证并发时对象访问的正确性。
有关CAS操作可阅读:非阻塞同步算法与CAS(Compare and Swap)无锁算法
既然Shenandosh回收器主打的就是低延迟,那么相比于它之前的其他的回收器,效果究竟如何呢?RedHat的报告中的实验结果图如下所示:
从上图可以看出,在不同数量的对象的情况下,Shenandosh回收器的延迟相比于其他的回收器确实是蛮低的。
the z garbage collector:low latency gc for openjdk
ZGC(Z Garbage Collector)回收器是Oracle推出的一款主打低延迟的垃圾回收器,它也是JDK未来将主推的垃圾回收器。它的目标同样是希望在尽可能对吞吐量影响不大的前提下,实现在任意堆内存大小下都可以把垃圾收集的停顿时间限制在10毫秒以内的低延迟。
ZGC也采用了基于region的堆内存布局形式,但是ZGC的region具有两个动态性:动态的创建和销毁、动态的区域容量大小。它的region具有三个尺寸:
既然ZGC和Shenandosh回收器主打的都是低延迟,那么必然都要在最后的整理阶段做到并发,这样如何来解决并发过程中的多线程竞争问题同样很重要。不同于Shenandosh回收器采用的转发指针和读屏障的方式,ZGC提出了一种更加巧妙的解决方式-染色指针(Colored Pointer)。
对于一个具体的对象来说,除了对象本身所拥有的属性等信息主题外,如果想为对象添加一些例如分代收集中的年龄计数值、处理并发问题的锁记录等相关信息时,往往选择的都是在对象的对象头中添加这些信息。但是这些信息是否每次访问对象都会被使用到,如果不是,那么自然就额外的增加了内存开销。另一个问题,垃圾收集阶段判断对象是否存活只看对象的引用指针,而不关心对象本身的内容。因此,如果程序想在不访问对象自身的基础上从引用指针中获取某些信息,那么指针中能携带的信息量就至关重要了。
所以,染色指针并没有继承前人所提出的使用额外的数据结构来记录对象信息的方式,而是直接将这些信息使用引用对象的指针进行表示。那么为什么可以使用指针来直接表示某些信息呢?根据计算机组成原理中的知识可知,操作系统寻址指针的每一位并不都是物尽其用。例如Linux系统的64位指针中只要后46位可以做寻址使用,因此,染色指针就是取的这46位的高4位来存储和对象相关的四个标志信息:
finalize()
进行访问如上所示,Mark0和Mark1用于指示对象是否被标记过;Remapped用于表示对象是否进入了重分配集;最后的Finalizable表示是否只能通过finalize()
进行访问。
染色指针的使用除了可以不使用额外的内存来记录对象的信息外,它还具有如下的优势:
了解了染色指针的原理和优势后,最后一个重要的问题就是,如何让操作系统正常的处理添加了染色指针的地址?我们知道,不管高层使用的使用技术,最终都要转换为机器指令执行。因此,不管是否使用了染色指针,操作系统只认地址本身。为了不影响机器指令的正常执行,ZGC在Linux/x86-64平台上使用多重映射(Multi-Mapping)。将染色指针中的标志位看做是地址的分段符,只要将不同的地址段都映射到同一个物理内存空间中,经过多重映射转换后们就可以使用染色指针进行正常的寻址。如何理解呢?虽然标志位不同的染色指针彼此不同,但是只要存在一个映射表,可以将其转换到真实的内存空间地址上,操作系统实际上使用的是最终转换后的地址。如果类比于虚拟地址到物理地址的转换过程,就很好理解了。
ZGC的执行过程大致可以分为如下的几个阶段:
那么ZGC相比于前面的几款回收器效果究竟有多大提升呢?首先看一下吞吐量的表现,如下所示,从中可以看出相同条件下,ZGC的吞吐量相比于Parallel回收器和G1回收器有着显著的提升。
除了吞吐量之外,主打低延迟的ZGC在低延迟上远远的甩开了Parallel回收器和G1回收器,而且差距可不是一点半点,你细品。不管是平均停顿、95%停顿、99%停顿、99.9%停顿还是最大停顿时间,ZGC都可以将其控制在10毫秒之内。