Java虚拟机 -- 垃圾回收器(下篇)

文章目录

      • 1. 引入
      • 2. Shenandosh回收器
        • 2.1 简介
        • 2.2 运行过程
        • 2.3 转发指针
        • 2.4 效果对比
      • 3. ZGC回收器
        • 3.1 简介
        • 3.2 染色指针
        • 3.3 运行过程
        • 3.4 效果对比


1. 引入

垃圾回收器(上篇)中的性能指标中说到,衡量垃圾回收器最重要的三项指标如下所示:

  • 内存占用(Footprint)
  • 吞吐量(Throughput)
  • 停顿时间(Latency),或延迟

Java虚拟机 -- 垃圾回收器(下篇)_第1张图片

如上所示,不管是哪一种垃圾回收器,它们都是朝着优化上述的三个指标的方向进行发展。如今硬件技术的发展,内存占用逐渐的没有那么令人担忧,而吞吐量和延迟成为一个难以权衡的矛盾所在。硬件性能的提升有助于降低垃圾回收器运行时对于应用程序的影响,从而可以有效的提高吞吐量。但是所使用的内存空间越大,垃圾回收器在执行垃圾回收时的负担就越重,故而停顿时间就会越长。

随着微服务等理念的广泛使用,用户体验逐渐成为了一个应用程序能否被用户认可的一种重要因素。因此,如何降低垃圾回收器运行时的停顿时间的重要性日益凸显。为了更好的接近这个目标,市面上推出了两款主打低延迟的垃圾回收器,分别是RedHat的Shenandoah和Oracle的ZGC。下面就逐个介绍下这两款回收器,在理解它们设计理念的同时,也要对比之前的回收器看一下为什么可以达到所谓的低延迟。


2. Shenandosh回收器

2.1 简介

Shenandosh回收器是由RedHat独立发展的一款垃圾回收器,它的目标是希望实现能在任何对内存大小下都可以把垃圾收集的停顿时间限制在10毫秒以内,这意味着之前并发标记所降低的停顿时间已经不够了,在最后的清理阶段同样需要和用户程序并发的执行。

Shenandosh回收器在实现原理上和前面的G1回收器大致上是相同的,因此,Shenandosh回收器的发展也进一步的推动了G1的迭代更新。但两者之间仍有部分地方是不同的,如下所示:

  • Shenandosh回收器不仅支持并发标记、而且还支持并发整理

  • Shenandosh回收器默认并不使用分代收集

  • Shenandosh回收器不再使用记忆集来解决跨代引用问题,而是采用了称为连接矩阵的全局数据结构记录跨region的引用问题

    因为Shenandosh回收器默认不使用分代收集,所以更准确是是解决跨region引用,而不是跨代引用~

2.2 运行过程

Java虚拟机 -- 垃圾回收器(下篇)_第2张图片
Shenandosh回收器的运行过程大致可以细分为九个阶段,虽然看起来很吓人,但是和之前的几款回收器在执行过程上有很多的重合处,具体为:

  • 初始标记:只标记和GC Roots直接可达的对象,此阶段会出现STW
  • 并发标记:从GC Roots出发遍历对象图,标记所有可达对象,此阶段和用户程序并发执行
  • 最终标记:修改并发标记阶段的标记,同时会统计出回收价值最高的region,将这些region构成回收集(Collection Set)
  • 并发清理:如果某个region中的所有对象都被标记为垃圾,则会执行此阶段的操作
  • 并发回收:这是为了进一步降低延迟所采取的改进操作,同时也是和G1相比的核心差异。Shenandosh会把最终标记阶段构建的回收集中的存活对象先复制一份到其他未被使用的region中,此阶段是和用户线程并发执行的。为了解决复制对象所导致出现的引用问题,Shenandosh采用了读屏障转发指针(Brooks Pointer)
  • 初始引用更新:建立一个线程集合点,确保上一阶段中进行垃圾回收的线程都已完成自己的对象移动任务
  • 并发引用更新:按照内存物理地址的顺序,线程的搜索出引用类型,将引用的旧值更新为新值,此阶段同样是和用户线程并发执行
  • 最终引用更新:修正存在于GC Roots中的引用,确保移动对象后所用的引用都修改正确
  • 并发清理:经过并发回收和引用更新后,此时的回收集中将不再包含存活对象,最后调用并发清理操作回收回收集中的region区间

Shenandoah GC - Part I: The Garbage Collector That Could

2.3 转发指针

Java虚拟机 -- 垃圾回收器(下篇)_第3张图片

并发回收阶段的对象移动必然会导致对象之间引用的变化,为了使对象的移动不影响对象的访问,就需要使用某种操作来保证移动后仍能正常进行对象访问。其中一种方式是保护陷阱(Memory Protection Trap),当访问到被移动的对象时,就会进入预设的异常处理逻辑,将对象的访问操作直接转发到复制后的新对象上。但是这种方式会导致用户态和核心态的频繁切换,会有很大的开销。

转发指针采用了一种更为巧妙的解决思路,对于每个对象来说,直接在对象的布局结构的最前统一增加一个新的引用字段。如果该对象没有执行移动操作,那就没有它的副本在其他的region中存在,故直接将其指向自身;如果该对象发生了移动操作,则指向移动后生成的对象副本。

采用了转发指针后,如果是多个线程同时访问某个执行了移动操作的对象,那么不管是访问原对象,还是对象副本,最终的效果并没有差别。但是如果同时执行写入操作,那么就会出现竞争问题。为了解决转发指针所导致的多线程竞争问题,Shenandosh回收器通过比较和交换(Compare and Swap,CAS)操作保证并发时对象访问的正确性。

有关CAS操作可阅读:非阻塞同步算法与CAS(Compare and Swap)无锁算法

2.4 效果对比

既然Shenandosh回收器主打的就是低延迟,那么相比于它之前的其他的回收器,效果究竟如何呢?RedHat的报告中的实验结果图如下所示:
Java虚拟机 -- 垃圾回收器(下篇)_第4张图片

从上图可以看出,在不同数量的对象的情况下,Shenandosh回收器的延迟相比于其他的回收器确实是蛮低的。


3. ZGC回收器

the z garbage collector:low latency gc for openjdk

3.1 简介

ZGC(Z Garbage Collector)回收器是Oracle推出的一款主打低延迟的垃圾回收器,它也是JDK未来将主推的垃圾回收器。它的目标同样是希望在尽可能对吞吐量影响不大的前提下,实现在任意堆内存大小下都可以把垃圾收集的停顿时间限制在10毫秒以内的低延迟。

ZGC也采用了基于region的堆内存布局形式,但是ZGC的region具有两个动态性:动态的创建和销毁动态的区域容量大小。它的region具有三个尺寸:
Java虚拟机 -- 垃圾回收器(下篇)_第5张图片

  • 小型region:固定为2MB,用于放256KB大小以下的对象
  • 中型region:固定为32MB,用于放[256KB, 4MB)区间大小的对象
  • 大型region:大小动态变化,但必须是2MB的整数倍,用于放4MB及以上大小的对象

3.2 染色指针

既然ZGC和Shenandosh回收器主打的都是低延迟,那么必然都要在最后的整理阶段做到并发,这样如何来解决并发过程中的多线程竞争问题同样很重要。不同于Shenandosh回收器采用的转发指针和读屏障的方式,ZGC提出了一种更加巧妙的解决方式-染色指针(Colored Pointer)。

对于一个具体的对象来说,除了对象本身所拥有的属性等信息主题外,如果想为对象添加一些例如分代收集中的年龄计数值、处理并发问题的锁记录等相关信息时,往往选择的都是在对象的对象头中添加这些信息。但是这些信息是否每次访问对象都会被使用到,如果不是,那么自然就额外的增加了内存开销。另一个问题,垃圾收集阶段判断对象是否存活只看对象的引用指针,而不关心对象本身的内容。因此,如果程序想在不访问对象自身的基础上从引用指针中获取某些信息,那么指针中能携带的信息量就至关重要了。

所以,染色指针并没有继承前人所提出的使用额外的数据结构来记录对象信息的方式,而是直接将这些信息使用引用对象的指针进行表示。那么为什么可以使用指针来直接表示某些信息呢?根据计算机组成原理中的知识可知,操作系统寻址指针的每一位并不都是物尽其用。例如Linux系统的64位指针中只要后46位可以做寻址使用,因此,染色指针就是取的这46位的高4位来存储和对象相关的四个标志信息:

  • 三色标记状态
  • 是否进入了重分配集
  • 是否只能通过finalize()进行访问

Java虚拟机 -- 垃圾回收器(下篇)_第6张图片

如上所示,Mark0和Mark1用于指示对象是否被标记过;Remapped用于表示对象是否进入了重分配集;最后的Finalizable表示是否只能通过finalize()进行访问。

染色指针的使用除了可以不使用额外的内存来记录对象的信息外,它还具有如下的优势:

  • 当某个region中的对象发生了移动后,该region就能立即被释放和重用。这是由于染色指针的使用,使得对对象的访问只依赖于地址空间寻址指针的某几位,而不依赖具体的对象本身。所以,理论上只要有一个空闲的region存在,ZGC就可以完成垃圾收集
  • 大幅度的减少了垃圾收集过程中内存屏障的使用,这不仅依赖于染色指针,同时还受益于ZGC不支持分代收集,所以天然不存在跨代引用问题。由于染色指针并不针对于对象自身,因此无需使用写屏障来保证同时写入的同步控制
  • 可作为一种可扩展的存储结构来记录更多与对象标记、重定位相关的数据,便后后续继续提升垃圾回收器的性能。这是因为染色指针直接使用寻址地址的某几位,只要其中由更多的位数可以使用,就能够用来记录更多其他类型的信息

了解了染色指针的原理和优势后,最后一个重要的问题就是,如何让操作系统正常的处理添加了染色指针的地址?我们知道,不管高层使用的使用技术,最终都要转换为机器指令执行。因此,不管是否使用了染色指针,操作系统只认地址本身。为了不影响机器指令的正常执行,ZGC在Linux/x86-64平台上使用多重映射(Multi-Mapping)。将染色指针中的标志位看做是地址的分段符,只要将不同的地址段都映射到同一个物理内存空间中,经过多重映射转换后们就可以使用染色指针进行正常的寻址。如何理解呢?虽然标志位不同的染色指针彼此不同,但是只要存在一个映射表,可以将其转换到真实的内存空间地址上,操作系统实际上使用的是最终转换后的地址。如果类比于虚拟地址到物理地址的转换过程,就很好理解了。

Java虚拟机 -- 垃圾回收器(下篇)_第7张图片

3.3 运行过程

Java虚拟机 -- 垃圾回收器(下篇)_第8张图片

ZGC的执行过程大致可以分为如下的几个阶段:

  • 初始标记:同样是只标记GC Roots直接可达的对象,会有STW
  • 并发标记:同样是遍历对象图做可达性分析,此阶段会遍历所有和GC Roots可达的对象。不同之处在于,标记操作针对的是染色指针,而不是对象本身。此外,它之后同样还要经历最终标记,确保所有标记的正确性
  • 并发预备重分配:根据特定的查询条件统计出本次收集过程要清理的region区域,并将这些region组成重分配集(Relocation Set)。重分配集中的对象后续会被复制到其他的空闲region中,然后region空间就会被释放
  • 并发重分配:ZGC的核心阶段,将重分配集中的对象复制到新的region中,并为重分配集中的每个region维护一个转发表,用于记录旧对象到新对象的转向关系。根据染色指针中的Remapped标识位就可以知道对象是否处于重分配集,只要用户线程访问的对象位于重分配集,根据转发表将访问转发到新复制的对象上,同时修改更新该引用的值,使其直接指向新对象,ZGC称这个过程为指针的自愈(Self-Healing)。而且自愈操作只会发生在第一次访问旧对象时,后续访问直接针对于新对象,大大提升了访问的效率
  • 并发重映射:修正整个堆中指向重分配集中旧对象的所有引用,而且它会延迟到下一个回收期的并发标记阶段执行,从而避免多一次的对象图遍历操作

3.4 效果对比

那么ZGC相比于前面的几款回收器效果究竟有多大提升呢?首先看一下吞吐量的表现,如下所示,从中可以看出相同条件下,ZGC的吞吐量相比于Parallel回收器和G1回收器有着显著的提升。
Java虚拟机 -- 垃圾回收器(下篇)_第9张图片

除了吞吐量之外,主打低延迟的ZGC在低延迟上远远的甩开了Parallel回收器和G1回收器,而且差距可不是一点半点,你细品。不管是平均停顿、95%停顿、99%停顿、99.9%停顿还是最大停顿时间,ZGC都可以将其控制在10毫秒之内。
Java虚拟机 -- 垃圾回收器(下篇)_第10张图片

你可能感兴趣的:(JVM探秘)