[JVM][3][垃圾收集器与内存分配策略]

第3章 垃圾回收器与内存分配策略

3.1 概述

在1960年诞生于麻省理工学院的Lisp是第一门开始使用动态内存分配和垃圾收集技术的语言。当Lisp还在胚胎时期,其作者JohnMcCarthy就思考过垃圾收集需要完成的三件事情

  • 哪些内存需要回收
  • 什么时候回收
  • 如何回收

程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭,栈中的帧栈随着方法的进入和退出而有条不紊地执行着入栈和出栈操作。每个帧栈中分配多少内存基本上是在类结构确定下来时就已知的。因此这几个区域的内存分配和回收都具有确定性,在这几个区域内就不需要过多考虑如何回收的问题,当方法结束或者线程结束时,内存自然就跟随着回收了

Java堆和方法区却有着显著的不确定性

  1. 一个接口的多个实现类需要的内存可能会不一样,一个方法所执行的不同条件分支所需要的内存也可能不一样
  2. 并且只有在运行期间,我们才能知道程序究竟会创建哪些对象,创建多少个对象

3.2 对象已死?

垃圾收集器在对堆进行回收前,第一件事情就是要确定这些对象中哪些还存活着,哪些已经死去

3.2.1 引用计数算法

引用计数算法介绍

  • 在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一
  • 当引用失效时,计数器值就减一
  • 任何时刻计数器为零的对象就是不可能再被使用的

但是在Java领域中,主流的Java虚拟机里面都没有选用引用计数算法来管理内存。

考虑下面这种情况,假如objA.instance = objBobjB.instance = objA。且除此之外,这两个对象再无任何引用,实际上这两个对象已经不可访问了,但是按照引用计数算法却难以将它们删除

3.2.2 可达性分析算法

当前主流商用程序语言的内存管理子系统,都是通过可达性分析(Reachability Analysis)算法来判定对象是否存活的。

可达性分析算法介绍

  • 首先通过一系列称为GC Roots的根对象作为起始节点集
  • 从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为引用链(Reference Chain)
  • 如果某个对象到GC Roots间没有任何引用链相连,或者从图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的

Java中,固定可作为GC Roots的对象包括以下几种

  • 在虚拟机栈(栈帧的本地变量表)中引用的对象
  • 在方法区中类静态属性引用的对象
  • 在方法区中常量引用的对象
  • 在本地方法栈中JNI引用的对象
  • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象等,还有系统类加载器
  • 所有被同步锁(synchronized关键字)持有的对象
  • 反映Java虚拟机内部情况的JMXBeanJVMTI中注册的回调、本地代码缓存等

3.2.3 再谈引用

通过可达性分析算法判定对象是否引用链可达,判定对象是否存活都和"引用"离不开关系

JDK1.2前,引用是很传统的定义:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称该reference数据是代表某块内存、某个对象的引用

JDK1.2后,Java对引用的概念进行了扩充,按照强弱,分为了以下几种

  • 强引用(Strongly Reference):强引用是最传统的“引用”的定义,是指在程序代码中普遍存在的引用赋值,即类似Object obj = new Object()这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象
  • 软引用(Soft Reference)是用来描述一些还有用,但非必要的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象引入回收范围内进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常
  • 弱引用(Weak Reference)是用来描述那些非必须对象,但是它的强度要比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾手机发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象
  • 虚引用(Phantom Reference)是最弱的一种引用关系。一个对象是否有虚引用存在,完全不会对其生存时间构成影响,也无法通过虚引用来获取一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知

3.2.4 生存还是死亡

要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那么它将会第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize方法

如果这个对象被判定为确有必要执行finalize方法,那么该对象将会被放置在一个名为F-Queue的队列中,并在稍后由一条由虚拟机自动建立的、低调度优先级的Finalizer线程去执行它们的finalize()方法

如果对象要在finalize中成功拯救自己,只要重新与引用链上的任何一个对象建立关联即可。任何一个对象的finalize()方法都只会被系统自动调用一次,如果对象面临下一次回收,它的finalize()不会再次执行

但是并不推荐使用这种方法来拯救对象,finalize()能做的所有工作,使用try-finally或者其他方式可以做的更好、更及时

3.2.5 回收方法区

方法区垃圾收集的性价比通常比较低:在Java堆中,尤其是新生代,对常规应用进行一次垃圾收集通常可以回收70%99%的内存空间,相比之下,方法区回收囿于苛刻的判定条件,其区域垃圾回收的回收成果远远低于此

方法区的垃圾回收主要回收两部分内容:废弃的常量和不再使用的类型。

假如一个字符串java曾经进入常量池中,但现在没有任何字符串对象引用这个常量,且虚拟机中也没有任何地方引用这个字面量。如果过在此时发生垃圾回收,它就会被清理出常量池

判断一个类型是否属于“不再被使用的类”需要同时满足以下三个条件

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

3.3 垃圾收集算法

从如何判定对象消亡的角度出发,垃圾收集算法可以划分为“引用计数式垃圾收集”和“追踪式垃圾收集”两大类,也常被称为“直接垃圾收集”和“间接垃圾收集”

3.3.1 分代收集理论

分代收集建立在两个分代假说上

  1. 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的
  2. 强分代假说(String Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡

由此我们得出一种设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄分配到不同的区域中存储,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用

设计者一般至少把Java堆划分为新生代和老年代两个区域。在新生代中,每次垃圾收集时都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放

然后分代收集并非是简单的划分内存区域这么容易,它至少存在一个困难:对象不是孤立的,对象之间会存在跨代引用。假如要现在进行一次只局限于新生代区域的收集,但新生代中的对象完全可能被老年代所引用,不得不在固定的GC Root之外,再额外遍历整个老年代的所有对象来确保可达性分析的正确性,这无疑增加了很多开销,这时,我们需要第三个分代假说

  1. 跨代引用假说:跨代引用相对于同代引用来说仅占极少数,存在互相引用关系的两个对象是应该倾向于同时生存或同时消亡的。

为了避免扫描整个老年代,需要在新生代建立一个全局的数据结构,记忆集(Remembered Set),这个结构将老年代划分为若干小块,标记出老年代的哪一块内存会存在跨代引用。此后当发生MinorGC时,只有包含了跨代引用的小块内存里的对象才会被加入到GC Root中扫描

3.3.2 标记-清除算法

简单来说就是首先标记所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象。标记过程就是对象是否属于垃圾的判定过程

它的主要缺点是

  1. 执行效率不稳定,如果Java堆中存在大量对象,而其中大部分是需要被回收的,这时必须进行大量的标记和清除工作
  2. 内存空间的碎片化问题:标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作

3.3.3 标记-复制算法

1969年Fenichel提出了一种称为“半区复制”的垃圾收集算法,它将可用内存按容量分为大小相同的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活的对象复制到另外一块上,然后再另一半的内存空间一次清理掉。

这种算法既可以解决需要回收大量对象的问题(因为它只处理存活对象),也解决了碎片化(因为将存活对象移动到一起)

但是缺点是过于浪费空间。后来IBM的一项研究表明,新生代中的对象有98%都熬不过第一轮收集。因此并不需要按照1:1的比例来划分新生代的内存空间

1989年,Appel提出了一种回收方式:把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存时只使用Eden空间和一块Survivor空间。当垃圾收集时,直接将存活的对象一次性移动到另一块Survivor空间上,清理掉Eden和用过的那块Survivor。HotSpot默认EdenSurvivor的比例是8:1

然而任何人都无法保证每次回收都只有10%的对象存活,因此我们需要一个“逃生门”设计:当Survivor不足以容纳所有存活对象时,将启用老年代来处理剩余对象

3.3.4 标记-整理算法

针对老年代对象的存亡特征,Lueders提出了另外一种有针对性的标记-整理算法。首先标记所有需要回收的对象,然后让所有存活对象向内存空间的一端移动,这样就可以避免碎片化问题

然而移动存活对象并更新所有引用这些对象的地方是一种及其负重的操作,而且这种对象在移动时必须暂停用户应用程序。

是否移动对象都存在弊端,移动则垃圾回收时更为复杂,不移动则垃圾分配时更复杂。

有一种和稀泥式的方式:让虚拟机平时多数时间采用标记-清除算法,暂时容忍内存碎片的存在,直到碎片化程度大到影响对象分配时,再采用标记-整理算法收集一次,以获得更规整的内存空间

3.4 HotSpot的算法细节实现

3.4.1 根节点枚举

固定可作为GC Roots的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中

迄今为止,所有收集器在根节点枚举这一步骤上都是必须暂停用户线程的,因为根节点枚举必须在一个冻结的时间点上,不会出现分析过程中,根节点集合的对象引用关系还在不断变化的情况,即使号称几乎不会发生停顿的CMSG1ZGC等收集器,枚举根节点时也是必须要停顿的

当用户线程停顿下来之后,其实并不需要一个不漏地检查完所有执行上下文和全局地引用位置,虚拟机可以使用一组称为OopMap的数据结构,直接得到哪些地方存放着对象引用。

3.4.2 安全点

但是导致OopMap内容变化的指令非常多,如果为每条指令都生成对应的OopMap会消耗很多内存,因此HotSpot只是在特定的称为“安全点”的位置记录这些信息,因此这也决定了只有在安全点才可以进行GC

对于安全点,还需要考虑,如何在垃圾收集发生时让所有线程都跑到最近的安全点,这里有两种方案

  • 抢先式中断:在GC时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点
  • 主动式中断:当GC时,不直接对线程操作,仅仅设置一个标志位,各个线程在执行过程中不断主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起

3.4.3 安全区域

安全区域是指在一段代码片段之中,引用关系不会发生变化。在这个区域中的任意地方开始GC都是安全的、我们也可以把Safe Region看做是被扩展了的Savepoint

当用户线程执行到安全区域里面的代码时,首先会标志自己已经进入了安全区域,那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已经声明自己在安全区域内的线程了

3.4.4 记忆集与卡集

记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。

但是如果记录所有的引用记录是极耗内存的,因此我们采用了比较粗的粒度

  • 字长精度:每个记录精确到一个机器字长
  • 对象精度:每个记录精确到一个对象
  • 卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针

卡精度是我们最常用的方式,其实现方式称为卡表,它定义了记忆集的记录精度、与堆内存的映射关系等。卡表每个元素都对应着其标记的内存区域中一块特定大小的内存块,称为卡页

而一个卡页的内存中通常不止包含一个对象,只要卡页内有一个对象的字段存在跨代指针,那就将对应卡表的数组元素的值标注为1,称为这个元素变脏(Dirty)

3.4.5 写屏障

我们还需要解决卡表如何维护的问题,例如它们何时变脏、谁来让它们变脏

卡表变脏的时间很明确,应该是发生在引用类型字段赋值的那一刻

我们通过写屏障的方式来使得卡表变脏。写屏障可以看作在虚拟机层面上对“引用类型字段赋值”这个动作的AOP切面,赋值的前后,都在写屏障的覆盖范围内,只是这样每次赋值操作会产生额外的开销

3.4.6 并发的可达性分析

由于GC Roots相比整个Java堆中的全部对象算是极少数,从GC Roots向下遍历对象图时,假如Java堆过大,对象图过于复杂,则标记带来的停顿时间会很长。现代的GC一般都采用扫描对象图的操作和用户线程并发工作。但这将造成对象消失问题。

为了理解对象消失,首先引入三色标记工具来辅助推导

  • 白色:表示对象尚未被垃圾收集器访问过
  • 黑色:对象及这个对象的所有引用都被访问过
  • 灰色:对象被访问过,但是引用没有完全被访问过

下图演示了对象消失是如何发生的

Wilson证明了,当且仅当以下两个条件同时满足时,会产生“对象消失”的问题

  • 赋值器插入了一条或多条从黑色对象到白色对象的新引用
  • 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用

因此我们要解决对象消失问题,只需破坏两个条件之一

  • 增量更新:它要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次
  • 原始快照:当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一遍

3.5 垃圾收集器

如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。Java虚拟机规范中对垃圾收集器应该如何实现并没有任何规定,因此不同的厂商、不同版本的虚拟机所提供的垃圾收集器都可能会有很大差别,经典收集器如下图所示。

如果两个收集器之间存在连线,就说明它们可以搭配使用。虚拟机所处的区域,则表示它是属于新生代收集器还是老年代收集器。

3.5.1 Serial收集器

Serial收集器是一个单线程的收集器,它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束。在用户不可见的情况下把用户正常工作的线程全部停掉,这对很多应用来说都是难以接受的。

它也有着优于其他收集器的地方:简单而高效(与其他收集器的单线程比),对于限定单个CPU的环境来说, Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。

3.5.2 ParNew收集器

ParNew收集器其实就是Serial收集器的多线程版本

3.5.3 Parallel Scavenge收集器

Parallel Scavenge收集器的持点是它的关注点与其他收集器不同,CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput)。所谓吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。

3.5.4 Serial Old收集器

Serial oldSerial收集器的老年代版本,它同样是一个单线程牧集器,使用“标记整理”算法。这个收集器的主要意义也是在于给Client模式下的虚拟机使用。

3.5.5 Parallel Old收集器

Parallel oldParallel Scavenge收集器的老年代版本,使用多线程和“标记一整理”算法。

直到Parallel old收集器出现后,“吞吐量优先”收集器终于有了比较名副其实的应用组合,在注重乔吐量以及CPU资源敏感的场合,都可以优先考虑Parallel ScavengeParallel Old收集器。

3.5.6 CMS收集器

CMS(concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。

CMS收集器是基于“标记清除”算法实现的,它的运作过程分为4个步骤

  1. 初始标记:初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,需要STOP THE WORLD
  2. 并发标记:并发标记阶段就是进行GC Root Tracing的过程
  3. 重新标记:重新标记阶段则是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长些,但远比并发标记的时间短。也需要STOP THE WORLD
  4. 并发清除

由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起作,所以,从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。

然后CMS有三个明显缺点

  1. CMS收集器对CPU资源非常敏感
  2. CMS收集器无法处理浮动垃圾,可能出现“Concurrent mode Failure”失败而导致另一次Full GC的产生。
  3. CMS是一款基于“标记一清除”算法实现的收集器,这意味着收集结束时会有大量空间碎片产生。空间碎片过多时,将会给大对象分配带来很大麻烦。

3.5.7 Garbage First收集器

G1收集器是垃圾收集器计数发展史上的里程碑式的成果,开创了收集器面向局部收集的设计思路和基于Region的内存布局形式。它主要面向服务器应用。

在规划JDK10功能目标时,HotSpot虚拟机提出了统一垃圾收集器接口,将内存回收的行为和实现分离,这样日后要移除或加入某一款收集器都会变得容易很多

G1不再坚持固定大小和固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每个Region都可以根据需要扮演新生代的Eden空间、Survivor空间或者老年代空间。然后收集器根据Region扮演的角色来采用不同的策略处理

Region还有一类特殊的Humongous区域,专门用来存储大对象,G1认为只要大小超过了Region容量一半的对象即可判定为大对象。G1的大多数行为都把Humongous作为老年代的一部分来看待

G1并不对整个Java堆进行全区域的垃圾收集,它跟踪各个Region中的垃圾堆积的价值大小,价值通过回收所获得的空间大小及回收所需时间来计算,然后在后台维护一个优先级列表,优先处理回收哪些价值收益最大的Region

G1收集器的运行过程大致分为以下四步

  • 初始标记:标记GCRoots可以直接关联到的对象,并且修改TAMS指针。这个指针是用来保证用户线程并发运行时依然可以正确地在可用地Region中分配新对象。这个阶段需要停顿线程
  • 并发标记:从GCRoots开始对堆中对象进行可达性分析,递归扫描整个堆中的对象图,找到要回收的对象
  • 最终标记:修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段需要停顿线程
  • 筛选回收:负责更新Region的统计数据,对各个Region的回收价值进行排序,指定回收计划。然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这个阶段需要停顿用户线程,然后由多条收集器线程并行完成

G1开始,最先进的垃圾收集器的设计导向都不约而同地变为追求能够应付应用地内存分配速率(Allocation Rate),而不追求一次把整个Java堆全部清理干净。这样,应用在分配,同时收集器在收集,只要收集的速度能跟得上对象分配的速度,那一切就能运行得十分完美

3.6 低延迟垃圾收集器

衡量垃圾收集器的三项最重要的指标是:内存占用、吞吐量和延迟,三者共同构成一个“不可能三角”。要在这三方面同时具有卓越表现的完美收集器是极其困难的,一款好的垃圾收集器一般同时达成其中的两项

在这三项中,延迟的重要性日益凸显,原因是随着计算机硬件的发展和性能的提升,我们越来越能容忍收集器多占用一点点内存

因此下面将介绍两款低延迟收集器,ShenandoahZGC,几乎整个工作都是并发的,只有初始标记和最终标记这些阶段有短暂的停顿

3.6.1 Shenandoah收集器

Shenandoah是一款只有OpenJDK才会包含的,由RedHat开发的收集器,它在很多方面与G1类似,但是它支持并发的内存整理算法;默认不使用分代;并且摒弃了在G1中耗费大量内存和计算资源去维护的记忆集,改用“连接矩阵”的全局数据结构来记录跨Region的引用关系。连接矩阵可以理解为一张二位表格,如果RegionN有对象指向RegionM,就在表格的N行M列中打一个标记

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

  • 初始标记:标记GCRoots可以直接关联到的对象,并且修改TAMS指针。这个指针是用来保证用户线程并发运行时依然可以正确地在可用地Region中分配新对象。这个阶段需要停顿线程
  • 并发标记:从GCRoots开始对堆中对象进行可达性分析,递归扫描整个堆中的对象图,找到要回收的对象
  • 最终标记:修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段需要停顿线程
  • 并发清理:这个阶段用于清理那些整个区域内连一个存活对象都没有找到的Region
  • 并发回收:这个阶段用于把回收集里面的存活对象先复制一份到其他未被使用的Region中。其困难点在于移动对象时,用户线程可能仍对被移动的对象进行读写访问;此外,移动之后整个内存中所有指向该对象的引用还是旧对象的地址,这很难一瞬间改变。shenandoah通过读屏蔽和被称为Brooks Pointers的转发指针来解决
  • 初始引用更新:这个阶段只是为了建立一个线程集合点,确保所有并发回收阶段中进行的收集器线程都已完成分配给它们的对象移动工作
  • 并发引用更新:真正开始进行引用更新操作,这个阶段是与用户线程一起并发的,时间长短取决于内存中涉及的引用数量的多少
  • 最终引用更新:用于修正GCRoots中的引用,这个阶段需要停顿
  • 并发清理:经过并发回收和引用更新,这个回收集中再无存活对象,这些Region都变成Immediate Garbage Regions了,最后再调用一次并发清理过程来回收这些Region的内存空间,供以后新对象分配使用

为了实现对象移动与用户程序并发,我们需要先了解转发指针的概念。

此前,要做类似的操作,通常要在被移动对象原有的内存上设置保护陷阱,一旦用户程序访问到归属于旧对象的内存空间就会产生自陷中断,进入预设好的异常处理器中,再由其中的代码逻辑把访问转发到复制后的新对象上。但这种方式需要操作系统的支持

新方案是在原有对象布局结构的最前面统一增加一个新的引用字段,在对象不处于并发移动的情况下,该引用指向对象自己。而当对象拥有一份新的副本时,让旧对象的转发指针指向新对象。这样只要就对象的内存仍然存在,未被清理,虚拟机内存中所有通过旧对象访问的代码都会转发至新对象

除此,为了支持转发指针,Shenandoah在读、写屏障中都加入了额外的转发处理,这也造成了性能负担。因为对对象的读操作是很频繁的

3.6.2 ZGC收集器

ZGC是一款基于Region内存布局的,不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-整理算法,以低延迟为首要目标的一款垃圾收集器

ZGCRegion具有动态性

  • 小型Region:容量固定为2MB,用于放置小于256KB的小对象
  • 中型Region:容量固定为32MB,用于放置大于等于256KB但小于4MB的对象
  • 大型Region:容量可动态变化,用于放置大于4MB的大对象,每个大型Region中只能放置一个对象

ZGC通过染色指针技术来实现并发的整理算法。染色指针直接把标记信息记在引用对象的指针上。它提取出了地址中的高4位,通过这些标志位可以直接看出:其引用对象的三色标记状态、是否进入了重分配集、是否只能通过finalize()方法才能被访问到。

但是,不是所有操作系统都支持用户进程随意更改内存中某些指针的前几位。对此,ZGC采用了经典的虚拟内存技术。它使用分页管理把线性地址空间和物理地址空间分别划分为大小相同的页,然后通过映射表完成线性地址到物理地址的转换。这样就可以做到将地址高4位不同,其他位相同的线性地址映射到同一物理地址。

ZGC的运行过程大致分为4个大的阶段

  • 并发标记:与G1相同,这个过程也是进行可达性分析,但是不同的是ZGC的标记是在指针上而不是在对象上
  • 并发预备重分配:这个阶段需要根据特定的查询条件统计得出本次收集过程要清理哪些Region,将这些Region组成重分配集
  • 并发重分配:这个过程要把重分配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表,记录从旧对象到新对象的转发关系。得益于染色指针的支持,ZGC收集器能仅仅从引用上就明确得知一个对象是否处于重分配集中,如果用户线程此时并发访问了位于重分配集中的对象,这次访问将会被预置的内存屏障所截获,然后立即根据Region上的转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象,ZGC将这种行为称为指针的“自愈”(Self-Healing)能力。这样做的好处是只慢一次。还有一个好处是一旦重分配集中某个Region的存活对象都复制完毕后,这个Region就立即释放用于新对象的分配,哪怕堆中还有很多指向这个对象的未更新指针也没有关系,这些旧指针一旦被使用就可以自愈
  • 并发重映射:重映射要做的是修正整个堆中指向重分配集中旧对象的所有引用,但是ZGC的并发重映射并不是一个急迫的任务。

ZGC有着令开发人员趋之若鹜的性能,让以前大多数人只是听说,但从未用过的Azul式的垃圾收集器一下子飞入寻常百姓家。当它完全成熟后,将会成为服务端、大内存、低延迟应用首选收集器的有力竞争者