深入JVM--垃圾回收机制全面解析

提起Java的垃圾回收机制大家应该都有所了解,它不仅是面试的常客,也是Java体系中相当重要的一块知识。深入理解Java的GC机制,不仅有助于我们在开发中提高程序的性能,更有了在面试官面前炫(zhuang)技(X)的资本。本篇文章将全面且深入的分析JVM的垃圾回收机制,同时还会对常用的垃圾收集器(包括最前沿的ZGC收集器和Shenandoah收集器)进行讲解。

一、GC机制概述

《深入JVM–Java运行时内存区域详解》这篇文章中我们对JVM的运行时内存区域进行了详细的分析。我们知道对象的创建是由JVM完成的,在对象创建的时候JVM会在Java堆中开辟一块空间用来存储这个对象。而当对象“死亡”的时候,同样是由JVM来处理的,JVM处理“死亡”对象的过程就是我们今天要讲的垃圾回收机制。

1.堆内存的区域划分

关于堆内存区域的划分,其实是由垃圾收集器的特性决定的。本节将要讲到的内存区域划分主要是指的G1收集器之前的经典垃圾收集器对堆内存的划分。

为了方便JVM更好的管理和回收对象,Java的设计者们将Java的堆内存成为了两大块,分别为:新生代(Young Generation)老年代(Old Generation)。而根据新生代的特性,又将新生代分成了一块较大的Eden区域和两块较小但大小相等的Survivor区域。至于新时代和老年代这两块区域,是我们今天要探讨的重点,后文中将会多次出现。

了解了堆内存的划分后我们再来看垃圾回收的特点。垃圾收集器在执行一次垃圾回收时,可能是部分收集(Partical GC)也可能是整堆收集(Full GC),部分收集又可以分为新生代收集(Minor GC/Young GC)老年代收集(Major GC/Old GC)。既然有这样的划分,那收集器回收区域的规则是根据什么条件确定的呢? 在JDK6 update 24之后,回收区域的规则为:只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行Minor GC,否则将进行Full GC。

对象通常是在Eden区域被创建,JVM会给每个对象定义一个年龄(Age)计数器,存储在对象头中。如果经过第一次Minor GC后对象仍然存活,并且能被Survivor区域容纳的话,对象则会被移动到Survivor区域,同时会将对象的年龄设置为1岁。接下来,该对象会经历多次的垃圾回收,Survivor区中的对象每熬过一次Minor GC,它的年龄就会增加一岁。如果对这个象增加到一定年龄(默认15,可通过-XX:MaxTenuringThreshold参数设置),就会被移动到老年代中。

当然,为了更好的适应不同程序的内存情况,HotSpot虚拟机并不是绝对要求对象年龄达到-XX:MaxTenuringThreshold后才能转移到老年代,特殊情况有如下两种:

  • 如果Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,则年龄大于或等于该年龄的对象就可以直接进入老年代。
  • 另外,对于大对象,HotSpot虚拟机可通过-XX:PretenureSizeThreshold参数进行设置,当对象内存大于设定的值的话,这个对象会绕过Eden区域直接被分配到老年代。

正所谓条条大路通罗马,而有些人的家就在罗马。看来即便是计算机也绕不过特权阶级这个坎儿呀。。。

2.永久代(Permanent Generation)

在JDK7以及之前,HotSpot虚拟机还有另外一块叫永久代(Permanent Generation) 的存储区域,这块区域并不属于堆内存,而是对于方法区的实现。主要用于存放Class和Meta(元数据)的信息,Class在类加载的时候被放入永久代。永久代和存放实例的堆内存区域不同,GC不会在主程序运行期对永久代进行清理。所以这也导致了永久代的区域会随着加载的Class的增多而爆满,最终抛出OOM异常。

虽然被称为永久代,但这块内存区域也会进行垃圾回收。永久代的垃圾收集主要包废弃常量和无用的类(被类加载器卸载的Class)。永久代触发垃圾回收的条件比较困难,需要同时满足以下三点:

  • 该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例;
  • 加载该类的ClassLoader已经被回收;
  • 该类对应的java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法;

3.元空间(MetaSpace)

由于永久代可能存在内存溢出的问题,在JDK8之后永久代已经不复存在,取而代之的是元空间(MetaSpace)

元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制,但可以通过-XX:MetaspaceSize这个参数来指定初始空间大小,当达到设置的最大值就会触发垃圾收集进行类型卸载,同时GC会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过MaxMetaspaceSize时,适当提高该值。可以通过-XX:MaxMetaspaceSize来设置元空间能够使用的最大内存,默认是没有限制的。

除了上面两个指定大小的选项以外,还有两个与 GC 相关的属性:
  -XX:MinMetaspaceFreeRatio,在GC之后,最小的Metaspace剩余空间容量的百分比,减少为分配空间所导致的垃圾收集
  -XX:MaxMetaspaceFreeRatio,在GC之后,最大的Metaspace剩余空间容量的百分比,减少为释放空间所导致的垃圾收集

有关垃圾回收的区域如下图所示:
在这里插入图片描述

上图中的Permanet Generation区域,在Jdk8中,被MetaSpace区取代了。

二、垃圾收集的标记算法

垃圾收集器回收垃圾的第一步先要确定哪些对象是可以被回收的。因此,JVM会扫描堆内存中的所有对象,并标记出可被回收的对象。而垃圾收集的标记算法有以下两种:

1.引用计数算法

引用计数算法通过在每个对象中添加一个计数器,当有一个地方引用它的时候计数器的值就会增加1;当引用失效的时候计数器的值则会减1。当计数器的值为0时,则可认为这个对象已经不再使用。因此对于引用计数算法,垃圾收集器只需要回收计数器为0的对象即可。

引用计数算法的优点是效率很高,不需要遍历所有对象。但它是存在一个致命的缺点,即无法解决对象之间循环引用的问题。比如对象A引用了对象B,对象B也引用了对象A,除此之外,A、B两个对象再也没有被其他地方引用。此时对象A和对象B的计数器均不为0,所以A、B两个对象都无法被回收。所以,目前商用的Java虚拟机都没有选用引用计数算法来进行标记。

2.可达性分析算法

可达性分析算法也被称为根搜索算法。这一算法的基本思路是用一系列的“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径被称为”引用链“(Reference Chain)。如果一个对象到”GC Roots”没有任何的引用链相连,则证明此对象可能不再被使用。

如下图所示,灰色部分的对象没有关联到引用链上,此时这些对象就会被判定为可回收对象。
在这里插入图片描述
哪些对象可以被作为GC Roots呢?主要包括以下几种:

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象。
  • 方法区中类静态属性引用的对象。
  • 在方法区中引用的对象,如字符串常量池(String Table)里的引用
  • 本地方法栈中JNI引用的对象
  • Java虚拟机内部的引用,如基本数据类型对应的Class对象以及一些常驻的异常对象等。
  • 所有同步锁持有的对象
  • 反应Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

三、垃圾收集算法

1.标记-清除算法(Mark-Sweep)

标记-清除算法是最早出现也是最基础的一种垃圾收集算法。该算法分为“标记”和”清除“两个阶段,标记阶段就是上边讲到的对垃圾的标记。首先会通过可达性分析算法标记出所有需要回收的对象,然后统一回收掉所有被标记的对象。标记-清除算法的执行过程如下图所示:
在这里插入图片描述

图中深灰色区域为可回收区域,在标记完成后直接将深灰色区域进行清理。这一算法很容易理解,实现起来也很便捷,但是也存在两个缺点:

1.执行效率会随对象增多而降低。如果Java堆中包含大量需要回收的对象。此时需要进行大量标记和清除操作。导致标记和清除这两个过程需要大量的时间,降低了执行效率。

2.引起严重的内存碎片化问题。标记、清除之后会产生大量不连续的内存空间,这可能会导致在需要分配大对象时无法找到足够的连续空间,进而引发GC。

2.标记-复制算法(Copying)

标记-复制算法也被简称为复制算法。它是对标记-清除算法的改进。复制算法将内存划分为大小相等的两块,分配对象时只使用其中的一块。当这块内存用完时,就将存活的对象复制到另外一块上面,然后把已使用的这块内存一次性清理掉。复制算法的执行过程如下图所示:
在这里插入图片描述

复制算法虽然解决了标记-清除算法的一些问题。但其缺陷也显而易见,直接导致了可用内存变为原来的一半,内存使用率太低!

3.标记-整理算法(Mark-Compact)

标记整理算法在标记了存活对象之后,会让所有存活的对象向内存的一端移动,然后直接清除掉边界外的内存。该算法的示意图如下图所示:

在这里插入图片描述

移动存活对象并更新所有被移动对象的引用是一个比较耗时的操作。而且,在移动对象时必须暂停所有用户线程才能进行(这一操作有个专有名词叫“Stop The World”,简称STW)。拖累了用户程序的执行效率

4.分代收集(Generational Collection)

分代收集不能称得上是一种算法,它会根据堆内存的不同区域采用不同的收集算法,因地制宜嘛。

比如上边我们已经说过的,在G1收集器之前,所有的收集器都是将Java堆划分为新生代和老年代,由于新生代中对象存活率比较低,因此在新时代采用优化了的复制算法。HotSpot虚拟机中将Eden和Survivor的大小大小划分为8:1的比例,分配对象只使用Eden和其中的一块Surivor区域,在标记完成后将存活的对象复制到另外一块Survior空间中,然后清除Eden和使用的一块Surivor。这样,新生代的空间利用率就达到了90%。

对于老年代每次垃圾回收存活的对象比较多,因此这一区域采用的是标记-整理算法进行垃圾回收。

四、垃圾收集器

通过前面几节我们认识了垃圾收集的原理,那么,本节就来认识一下各具特色的垃圾收集器。垃圾收集器其实就是对于前面讲到的原理的实现,只不过在Java的发展史中出现了一代又一代的垃圾收集器,而新一代的垃圾收集器都是对上一代垃圾收集器缺点的弥补。直到前几天(2020年9月15日),在Oracle JDK15中又引入了新的垃圾收集器Shenandoah。可见直到今天Java的设计者们依然还在对收集器进行优化。

我们先来通过一张图片看下经典的几款垃圾收集器,图中连线表示这两款收集器可以配合使用。
在这里插入图片描述
接下来我们我们来认识下这几款收集器,另外除了图中的这几款收集器还会详细讲解ZGC和Shenandoah。

1.新生代收集器

1.1 Serial收集器

Serial收集器是最基础、发展历史最悠久的收集器。它是一个单线程工作的收集器,对于早期的单核处理器或处理器核心数较少的情况下,Serial收集器由于没有线程交互的开销,所以收集效率比较高。但是,Serial收集器整个收集过程是需要STW的。这也是导致了早期的Java程序慢的主要原因之一。Serial收集器新生代采用的是标记-复制算法,运行过程如下图所示:
在这里插入图片描述

1.2 ParNew收集器

ParNew收集器实质上是Serial收集器的多线程版本。除了使用多条线程进行垃圾收集之外,其它特性包括Serial收集器的所有控制参数、收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一致。ParNew收集器的工作过程如下图所示:
在这里插入图片描述
ParNew是JDK 7之前的遗留系统中首选的新生代收集器,因为除了Serial收集器外它是唯一能和CMS收集器配合工作的新生代收集器。

1.3 Parallel Scavenge收集器

Parallel Scavenge收集器也是一款新生代收集器,它同样是基于标记-复制算法实现。也是能够并行收集的多线程收集器,从表面上看它与ParNew非常相似。Parallel Scavenge收集器的目标是达到一个可控制的吞吐量(Throughput)。

吞吐量=(运行用户代码的时间)/(运行用户代码时间+运行垃圾收集时间)

Parallel Scavenge收集器运行过程如下图所示:
在这里插入图片描述

2.老年代收集器

2.1 Serial Old收集器

Serial Old是Serial收集器的老年代版本,它与Serial一样都是单线程收集器。Serial Old使用的是标记-整理算法。它的主要意义也是提供客户端模式下的HotSpot虚拟机使用。

2.2 Parallel Old收集器

Parallel Old 是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法。这个收集器是在JDK 6时开始提供。

2.3 CMS收集器

CMS(Concurrent Mark Sweep)收集器是一款具有划时代意义的收集器。前面我们提到的几款收集器在工作期间全程都需要STW,而CMS第一次实现了垃圾收集的并发处理。因此,这款收集器可以有效的减少垃圾收集过程中的停顿时间。CMS收集器是基于标记-清除算法实现的。我们来详细了解一下CMS的工作过程:

(1)初始标记: 从GC Roots出发标记全部直接子节点的过程,该阶段是STW的。由于GC Roots数量不多,通常该阶段耗时非常短。

(2)并发标记: 并发标记阶段是指从GC Roots开始对堆中对象进行可达性分析,找出存活对象。该阶段是并发的,即应用线程和GC线程可以同时活动。并发标记耗时相对长很多,但因为不是STW,所以我们不太关心该阶段耗时的长短。

(3)重新标记: 重新标记那些在并发标记阶段发生变化的对象。该阶段是STW的。

(4)并非清除: 并行清理, 开启用户线程,同时GC线程开始对为标记的区域做清扫。

CMS运行过程如下图所示:
在这里插入图片描述
从上面描述可以看到,CMS能够并发收集,有效减少停顿时间。但CMS并不是一款完美的垃圾收集器,不然也不会在JDK15中将其移除。它的缺点主要有以下几个:

  • 并发收集占用CPU资源。 虽然并发阶段不会导致用户停顿,并发时的收集线程却占用了一部分CPU资源,导致应用程序变慢,降低了吞吐量。
  • 无法处理浮动垃圾。 CMS的并发标记和并发清理阶段,用户线程是继续运行的,这期间必然会有新的垃圾对象产生。对于已经收集过的区域,CMS无法再去回头处理它们,只能等到下一次垃圾收集时再清理掉。
  • 并发清理阶段需要保证内存充足。 由于在垃圾收集阶段用户线程依然在运行,所有不得不预留足够的空间提供给用户线程使用。因此CMS收集器在垃圾收集开始时需要预留足够的内存。JDK 5的默认设置,当老年代使用了68%的空间后就垃圾收集会被激活。虽然可以通过参数-XX:CMSInitiatingOccupancyFraction来调高CMS的触发百分比,但这样又会导致CMS运行期间可能出现预留内存不足的情况。此时,CMS就会出现一次”并发失败“(Concurrent Mode Failure),虚拟机不得不启动后备预案,停止用户线程的执行,启动Serial Old收集器重新进行老年代的垃圾收集。
  • 产生大量碎片空间 。 由于CMS使用的是“标记-清除”算法,因此会导致大量空间碎片产生。

3.全局收集器

3.1 Garbage Firs 收集器

Garbage First收集器简称G1收集器,它是在JDK 6中被添加到Hotspot虚拟机的。与其它收集器相比G1收集器引入了很多新特点。如下:

(1)独具特色的分代收集方式。

G1收集器在堆内存的分代上做了很大的改变。它不再将堆内存简单的分为新生代和老年代,而是将堆划分为若干个大小相等、内存连续的Region。每个Region都可以根据需要扮演Eden空间、Survivor空间 、Old空间或者Humongous。如下图所示:
在这里插入图片描述
其中Humomngous区域我们比较陌生,它是G1收集器独有的用于存放大对象的区域。如果对象的大小超过了Region容量一半即可被判定为大对象放入Humongous空间。如果一个非常大的对象超过了整个Humongous的内存,则这个对象会被存放在N个连续的Humongous Region之中。这么看来,Humongous更像是分担了部分老年代的功能。

Region的大小可以通过参数-XX:G1HeapRegionSize设置,取值范围为1~32MB,且应该为2的N次幂。

当然,别具一格的分代方式也会带来别具一格的问题。垃圾收集器应该如何跨Region进行收集?因为一个对象被分配到某个Region中,这个对象不可能只被本Region的对象引用,而是可能与堆中任意一个Region中的对象建立引用关系。这种情况其实在新生代与老年代中一样会碰到,只不过在G1中会显得更加突出。对于这种问题的处理虚拟机都是使用Remembered Set来避免全堆扫描的。在G1的每个Region区都维护着一个Remembered Set。当虚拟机发现程序在堆引用类型的数据进行写操作时会产生一个写屏障(Write Barrier),检查引用的对象是否处于不同的Region。如果是,便通过CardTable将引用信息记录到被引用对象所属的Region的Remembered Set中。当垃圾回收启动时,在GC跟节点的枚举范围中加入Remembered Set即可保证不对全堆扫描,同时也不会有遗漏。

Remembered Set在存储结构的本质上是一种哈希表,Key是别的Region的起始地址,Value是一个集合,里边存储的是CardTable的索引号。CardTable存储的是“我指向谁”,而整个Remembered Set中的一个键值对(Key+Value)其实构成了一个双向卡表,即:”我指向谁,谁指向我“

由于Region的数量要比传统的收集器的分代数量要多的多,因此G1收集器要比其它传统收集器有着更高的内存占用负担。根据相关经验,G1至少要耗费大约相当于Java堆容量10%至20%的额外内存来维持收集工作。

(2)能建立可预测的停顿时间模型。
G1收集器可以有计划的避免整个Java堆进行全区域的垃圾收集。他会跟踪各个Region里面的垃圾堆积的价值大小,在后台维护一个优先列表(Collection Set 简称CSet)。并且允许用户通过-XX:MaxGCPauseMillis参数设定允许的收集停顿时间。G1根据设定的停顿时间优先处理回收价值收益最大的那些Region,这也是Garbage First名字的由来。这种使用Region划分内存空间以及有优先级堆回收方式保证了G1收集器在有限时间内可以获取尽可能高堆收集效率。但是这个停顿时间必须切合实际,如果设置得非常低,会导致每次只能回收很小的一部分内存,最终垃圾慢慢堆积,最终占满整个堆内存,导致Full GC而产生长时间的“STW”,影响了性能。
(3)具有整合空间碎片的能力。
G1收集器从整体来看是基于“标记-整理”算法实现的,从局部(两个Region之间)是基于“复制”算法实现的,这两种算法在执行期间都不会产生内存的空间碎片。
(4)可以并行和并发收集。
G1收集器的运作过程可以分为四个步骤:
初始标记(Initial Marking): 初始标记只会标记GC Roots能直接关联到的对象,这一阶段是需要Stop The World的,但是由于GC Roots数量有限,所以这一阶段并不会消耗太多时间。
并发标记(Concurrent Marking): 从GC Roots开始对堆中对象进行可达性分析,找出存活对象。并发标记阶段耗时较长,但是这一阶段是并发执行的,因此,对性能不会造成影响。
最终标记(Final Marking): 重新标记在并发标记阶段发生变化的对象,这一阶段是需要STW的,但耗时很短。
筛选回收阶段(Live Data Counting And Evacuation) 负责跟新Region跟新的数据,对各个Region的回收加之和成本进行排序,根据用户所期望的停顿时间来制定回收计划。可自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉旧Region的全部空间。这里涉及到存活对象的移动是必须STW,由多个收集器线程并行完成。

G1收集器的执行过程如下图所示:

在这里插入图片描述

3.2 ZGC收集器

ZGC收集器是目前为止垃圾收集器最前沿的成果,它针对的目标是大内存、低延迟的后端服务器。ZGC可以将T级别内存回收的停顿时间控制在10ms以内,并且停顿时间不会随着内存增加而增大。ZGC能有这样的成果主要得益于它使用了读屏障、染色指针、多重映射等技术,实现了可并发的标记-整理算法。

(1)ZGC的堆内存划分

ZGC与G1收集器类似,都是将堆内存划分成了许多Region。但与G1不同的是,ZGC的Region区域大小是不相等的,且具有动态创建和动态销毁的特性。ZGC将Region分为大、中、小三类容量。其中,小型Region的容量固定为2MB,用于存放内存小于256k的小对象;中型Region容量固定为32MB,用于存放大小大于等于256kb但小于4M的对象;而大型Region的容量不固定,但必须是2MB的整数(N大于2)倍,用来存储大于4MB的对象。大Region容量不固定的这一特性也导致了它的内存可能比中型Region还小的情况。

ZGC的堆内存划分如下图所示:
在这里插入图片描述

(2)ZGC的关键技术

前面我们已经提到ZGC之所以能将停顿时间控制在10ms以内,是因为它使用了读屏障、着色指针以及多重映射等技术。但是由于到目前为止笔者对于这些技术中的很多原理还没完全搞明白,因为它涉及到了很多操作系统层面的知识,而这些知识在期末考试完的那一刻就全部还给了操作系统老师。所以这里我也只能是简单分析,也欢迎大家留言讨论。
染色指针
我们知道如果需要在对象中存储一些额外的信息时,可以在对象头中添加额外的存储字段。比如前边章节中提到的对象的分代年龄,另外还有像对象的hash等都会这样存储。而ZGC的染色指针是直接把标记信息存储在了对象的引用指针上。这样,在进行可达性分析遍历对象图来标记对象的时候其实是遍历了“引用图”来标记了引用。
为什么指针可以存储信息?在64位系统中,理论可以访问的内存高达16EB(2的64次幂)字节。但实际上,基于需求、性能、成本等方面的考虑,在AMD64架构中只支持到52位的地址总线和48位的虚拟地址空间,所以目前64位的硬件实际能够支持的最大内存只有256TB。此外,操作系统中还会施加自己的约束,64位的Linux仅支持47位的进程虚拟地址和46位的物理空间地址,64位的Windows系统甚至只支持44位的物理地址空间。(这里涉及到操作系统分页调度的相关知识,感兴趣的同学可以查阅相关资料)

鉴于此,虚拟机实际仅使用64位地址空间的第0到41位,而第42到45位存储对象的标记信息。通过高四位的这些标志,虚拟机可以直接从指针中看到其引用对象的三色标记状态、是否进入了重新分配(即被移动过)、是否通过finalize()方法才能被访问到,具体如下图所示:

在这里插入图片描述
因为标记信息又额外占用了46位中的四位,导致ZGC能够映射的内存只剩42位,因此,ZGC能够管理的内存不可以超过4TB(2的42次幂,这里指Linux平台下,Windows上实际会更小,只有40位)。

读屏障
读屏障这个技术应该不难理解,因为在G1中我们已经知道了写屏障。

读屏障是JVM向应用代码插入一小段代码的技术。当应用线程从堆中读取对象引用时,就会执行这段代码。需要注意的是,仅“从堆中读取对象引用”才会触发这段代码。

读屏障示例:

1
2
3
4
5
6

Object o = obj.FieldA // 从堆中读取引用,需要加入屏障
<Load barrier>
Object p = o // 无需加入屏障,因为不是从堆中读取引用
o.dosomething() // 无需加入屏障,因为不是从堆中读取引用
int i = obj.FieldB //无需加入屏障,因为不是对象引用

因为有读屏障在,在对象标记和转移过程中,会根据指针染色情况首先确定对象的引用地址是否满足条件,并作出相应动作。

(3)ZGC的并发收集过程

ZGC的回收算法采用的是Mark-Compact ,它会将活着的对象都移动到另一个Region,然后整个回收掉原来的Region。ZGC的执行过程可以分为四个阶段,这四大阶段全部是可以并发执行的,但是在其中两个阶段中会有短暂的停顿。

并发标记(Concurrent Mark )

这一阶段也与G1类似,也会经历初始标记、最终标记的短暂停顿。与G1不同的是,ZGC的标记是在指针上,标记阶段会更新染色指针中的Marked0、Marked1标志位。

初始标记会标记与GCRoots直接关联的对象。

接着会遍历对象图标记处全部可达的对象。这一阶段耗时比较多,但是是并发的,因此不会发生STW。

并发预备重分配(Concurrent Prepare for Relocate)

这一阶段会根据特定的查询条件统计出本次收集过程要清理的Region,将这些Region组成重分配集(Relocation Set)。

发重分配(Concurrent Relocate)

这一阶段是ZGC的核心阶段。这一阶段会把存活的对象复制到新的Region,并未重分配集中的每个Region维护一个转发表(Forward Table),记录旧对象到新对象的转向关系。由于有染色指针的存在,ZGC收集器能从引用上明确知道这个对象是否处于重分配集中,如果此时用户线程并发访问了重分配集中的对象,就会触发读屏障,然后根据Region上的转发记录将访问转发到新复制的对象上。同时,修正更新该引用的值,使其指向新对象。

并发重映射(Concurrent Remap)
重映射就是修正整个堆中指向重分配集中旧对象的所有引用。因为有并发重分配的存在,这里即使不去做重映射也不会出现任何问题。因此,为了节省性能ZGC把这个阶段合并到了下一次垃圾收集循环中的并发标记阶段来完成。这样节省了一次遍历对象图的性能开销。当所有指针被修正后,记录新旧关系的转发表也会被释放掉。

可见,ZGC的整个收集过程只有在标记阶段有短暂的停顿,这是为什么ZGC能将停顿控制在10ms以内的原因。在最近发布的JDK15中,ZGC已经结束了实验阶段,成为了JDK15正式的垃圾收集器。

3.3 Shenandoah收集器

其实,开始并没有打算写Shenanoah这个收集器。但是,看到刚刚发布的JDK15中已经将Shenanoah转正了。所以还是花点时间聊一聊这款收集器。Shenanoah收集器其实并非Oracle公司开发的,而是RedHat公司开发,并在2014年将它贡献给了Open JDK。既然在JDK15中Oracle将Shenanoah正式纳入JDK中,必然证明Shenanoah至少是一款可以媲美ZGC的收集器。事实也确实如此,它与ZGC一样可以将垃圾收集时的停顿时间控制在10ms以内。

Shenandoah的堆内存划分

Shenandoah收集器对堆内存的划分与G1更为相似,它也是基于Region的堆内存布局,同样有存放大对象的Humongous Region。但是,Shenandoah没有将Region再分为新生代和老年代。同时,Shenandoah摒弃了G1收集器中耗费大量内存和计算资源维护的记忆集,改用了链接矩阵的全局数据结构来记录跨Region的引用关系。

Shenandoah的关键技术

(1)链接矩阵

上边提到Shenandoah使用了链接矩阵来处理跨Region引用的问题。什么是链接矩阵呢?我们可以将它简单理解为一张二维表格,如果Region N有对象指向RegionM,就在表格的N行M列中打上一个标记,如下图所示,如果Region 5中的对象Baz引用了Region 3的Foo,Foo又引用了Region 1的Bar,那连接矩阵中的5行3列、3行1列就应该被打上标记。在回收时通过这张表格就可以得出哪些Region之间产生了跨代引用。
在这里插入图片描述

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

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

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

Shenandoah的并发收集过程

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

(1)初始标记: 与G1一样,首先标记与GC Roots直接关联的对象,这个阶段仍是STW的,这一阶段停顿时间很短。

(2)并发标记: 与G1一样,从GC Roots开始对堆中对象进行可达性分析,找出存活对象。并发标记阶段耗时较长,但是这一阶段是并发执行的,因此,对性能不会造成影响。

(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 的内存空间,供以后新对象分配使用。

总结

本篇文章从堆的分代到垃圾收集算法再到垃圾收集器都做了比较详细的讲解。关于垃圾收集器这部分内容其实本来没打算写的太详细,因为笔者本身做的是客户端,而客户端对于收集器的性能并没有太高的要求,加之Android端到目前为止仅支持到Java8版本。所以一般情况下,客户端开发者不太会关注到Java8之后比较前沿的Java知识。像ZGC、Shenandoah这种前沿的垃圾收集器更不会关注太多。但是对于而后台开发而言却是完全不同。拿垃圾收集器来说,后台相比客户端会比较迫切的需要能够支持大内存、低延迟的收集器。所以,既然写Java GC的文章,就不能只面向客户端。讲不明白垃圾收集器总觉得会少了点什么。所以在写ZGC和Shenandoah收集器的过程中花了大量时间去查阅了很多资料,往往是看懂了,理解了,然后才能动手来写。因此,免不了的也会有些疏漏或错误,还望大家多多包含。同时,希望大家通过这篇文章能够得到自己想要的知识。

参考&推荐阅读

《深入理解Java虚拟机 第二版/第三版》作者:周志明

一文看懂JVM内存布局及GC原理

深入理解 JVM 垃圾回收机制及其实现原理

Java8内存模型—永久代(PermGen)和元空间(Metaspace)

搞定JVM垃圾回收就是这么简单

Java程序员的荣光,听R大论JDK11的ZGC

新一代垃圾回收器ZGC的探索与实践

深入理解JVM(③)低延迟的Shenandoah收集器


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!

开源库推荐
1.BannerViewPager

一个基于ViewPager2实现的具有强大功能的无限轮播库。支持多种页面切换效果和指示器样式。

2.ViewPagerIndicator

一个适用于ViewPager和ViewPager2的指示器,支持多种滑块样式及滑动模式