垃圾回收机制详解

垃圾回收机制

判断对象不再被使用

常用 的算法有两个引用计数法可达性分析法

引用计数法

引用计数法思路很简单:当对象被引用则+1,但对象引用失败则-1。当计数器为0时,说明对象不再被引用,可以被可回收

引用计数法最明显的缺点就是:如果对象存在循环依赖,那就无法定位该对象是否应该被回收(A依赖B,B依赖A)

可达性分析

另一种就是可达性分析法:它从GC Roots开始向下搜索,当对象到GC Roots都没有任何引用相连时,说明对象是不可用的,可以被回收

img

GC Roots是一组必须活跃的引用。从GC Root出发,程序通过直接引用或者间接引用,能够找到可能正在被使用的对象

GC Roots

比如JVM内存结构中的虚拟机栈,虚拟机栈里不是有栈帧吗,栈帧不是有局部变量吗?局部变量不就存储着引用嘛。

那如果栈帧位于虚拟机栈的栈顶,是不是就可以说明这个栈帧是活跃的(换言之,是线程正在被调用的)。

既然是线程正在调用的,那栈帧里的指向的对象引用,是不是一定是活跃的引用?

所以,当前活跃的栈帧指向堆里的对象引用就可以是GC Roots

当然了,能作为GC Roots也不单单只有上面那一小块

比如类的静态变量引用是GC Roots,被Java本地方法所引用的对象也是GC Roots等等…

安全点和安全区域以及oopmap

可达性分析可以分成两个阶段

  1. 根节点枚举
  2. 从根节点开始遍历对象图

前文我们在介绍垃圾收集算法的时候,简单提到过:标记-整理算法(Mark-Compact)中的移动存活对象操作是一种极为负重的操作,必须全程暂停用户应用程序才能进行,像这样的停顿被最初的虚拟机设计者形象地描述为 “Stop The World (STW)”。

显然 STW 并不是一件好事,能够避免那就需要尽可能避免。

在可达性分析中,第一阶段 ”根节点枚举“ 是必须 STW 的,而第二阶段 ”从根节点开始遍历对象图“,如果不进行 STW 的话,会导致一些问题,由于第二阶段时间比较长,长时间的 STW 很影响性能,���以大佬们设计了一些解决方案,从而使得这个第二阶段可以不用 STW,大幅减少时间

根节点枚举

迄今为止,所有收集器在根节点枚举这一步骤时都是必须暂停用户线程的,枚举过程必须在一个能保障 “一致性” 的快照中才得以进行。

通俗来说,整个枚举期间整个系统看起来就像被冻结在某个时间点上,不会出现在分析过程中,用户进程还在运行,导致根节点集合的对象引用关系还在不断变化的情况,若这点都不能满足的话,可达性分析结果的准确性显然也就无法保证。

也就是说,根节点枚举与我们之前提到的标记-整理算法(Mark-Compact)中的移动存活对象操作一样会面临相似的 “Stop The World” 的困扰。

另外,众所周知,可作为 GC Roots 的对象引用就那么几个,主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如虚拟机栈中引用的对象)中,尽管目标很明确,但查找过程要做到快速高效其实并不是一件容易的事情

现在 Java 应用越做越庞大,光是方法区的大小就常有数百上千兆,里面的类、常量等更是一大堆,要是把这些区域全都扫描检查一遍显然太过于费事。

那有没有办法减少耗时呢?

一个很自然的想法,空间换时间

把引用类型和它对应的位置信息用哈希表记录下来,这样到 GC 的时候就可以直接读取这个哈希表,而不用一个区域一个区域地进行扫描了。Hotspot 就是这么实现的,这个用于存储引用类型的数据结构叫 OopMap(我们之前 保守式 GC 与准确式 GC,如何在堆中找到某个对象的具体位置? (ope ns new window)也提到过)。

下图是 HotSpot 虚拟机客户端模式下生成的一段 String::hashCode() 方法的本地代码,可以看到在 0x026eb7a9 处的 call 指令有 OopMap 记录,它指明了 EBX 寄存器和栈中偏移量为 16 的内存区域中各有一个 OopMap 的引用,有效范围为从 call 指令开始直到0x026eb730(指令流的起始位置)+ 142(OopMap 记录的偏移量)= 0x026eb7be,即 hlt 指令为止。

实话实说,这段不理解也就算了,知道 OopMap 是这么一个东西就行了。

img

安全点 Safe Point

在 OopMap 的协助下,HotSpot 可以快速完成根节点枚举了,但一个很现实的问题随之而来:由于引用关系可能会发生变化,这就会导致 OopMap 内容变化的指令非常多,如果为每一条指令都生成对应的 OopMap,那将会需要大量的额外存储空间,这样垃圾收集伴随而来的空间成本就会变得无法忍受的高昂。

所以实际上 HotSpot 也确实没有为每条指令都生成 OopMap,只是在 “特定的位置” 生成 OopMap,换句话说,只有在某些 “ 特定的位置”上才会把对象引用的相关信息给记录下来,这些位置也被称为安全点(Safepoint)。

有了安全点的设定,也就决定了用户程序执行时并不是随便哪个时候都能够停顿下来开始 GC 的,而是强制要求程序必须执行到达安全点后才能够进行 GC(因为不到达安全点话,没有 OopMap,虚拟机就没法快速知道对象引用的位置呀,没法进行根节点枚举)。

如下图所示:

img

因此,安全点的设定既不能太少以至于让垃圾收集器等待时间过长,也不能太多以至于频繁进行垃圾收集从而导致运行时的内存负荷大幅增大。所以,安全点的选定基本上是以 “是否具有让程序长时间执行的特征” 为标准进行选定的,最典型的就是指令序列的复用:例如方法调用、循环跳转、异常跳转等,所以只有具有这些功能的指令才会产生安全点。

对于安全点,另外一个需要考虑的问题是,如何在 GC 发生时让所有用户线程都执行到最近的安全点,然后停顿下来呢?。这里有两种方案可供选择:

  1. 抢先式中断(Preemptive Suspension):这种思路很简单,就是在 GC 发生时,系统先把所有用户线程全部中断掉。然后如果发现有用户线程中断的位置不在安全点上,就恢复这条线程执行,直到跑到安全点上再重新中断。

    抢先式中断的最大问题是时间成本的不可控,进而导致性能不稳定和吞吐量的波动,特别是在高并发场景下这是非常致命的,所以现在几乎没有虚拟机实现采用抢先式中断来暂停线程响应 GC 事件

  2. 主动式中断(Voluntary Suspension):主动式中断不会直接中断线程,而是全局设置一个标志位,用户线程会不断的轮询这个标志位,当发现标志位为真时,线程会在最近的一个安全点主动中断挂起。现在的虚拟机基本都是用这种方式

安全区域 Safe Region

安全点机制保证了程序执行时,在不太长的时间内就会遇到可进入垃圾收集过程的安全点。

对于主动式中断来说,用户线程需要不断地去轮询标志位,那对于那些处于 sleep 或者 blocked 状态的线程(不在活跃状态的线程)来说怎么办?

这些不在活跃状态的线程没有获得 CPU 时间,没法去轮询标志位,自然也就没法找到最近的安全点主动中断挂起了。

换句话说,对于这些不活跃的线程,我们没法掌控它们醒过来的时间。很可能其他线程都已经通过轮询标志位到达安全点被中断了,然后虚拟机开始根节点枚举了(根节点枚举需要暂停所有用户线程),但是这时候那些本不活跃的用户线程又醒过来了开始执行,破坏了对象之间的引用关系,那显然是不行的。

对于这种情况,就必须引入安全区域(Safe Region)来解决。

安全区域的定义是这样的:确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中的任意地方开始 GC 都是安全的。

可以简单地把安全区域看作被拉长了的安全点

当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域。那样当这段时间里虚拟机要发起 GC 时,就不必去管这些在安全区域内的线程了。当安全区域中的线程被唤醒并离开安全区域时,它需要检查下主动式中断策略的标志位是否为真(虚拟机是否处于 STW 状态),如果为真则继续挂起等待(防止根节点枚举过程中这些被唤醒线程的执行破坏了对象之间的引用关系),如果为假则标识还没开始 STW 或者 STW 刚刚结束,那么线程就可以被唤醒然后继续执行。

垃圾回收的过程

JVM用的就是可达性分析算法来判断对象是否垃圾。

垃圾回收的第一步就是标记,标记哪些没有被GC Roots引用的对象

20220722165011

标记完之后,我们就可以选择直接清除,只要不被GC Roots关联的,都可以干掉

过程非常简单粗暴,但也存在很明显的问题

直接清除会有内存碎片的问题:可能我有10M的空余内存,但程序申请9M内存空间却申请不下来(10M的内存空间是垃圾清除后的,不连续的)

20220722165103

那解决内存碎片的问题也比较简单粗暴,标记完,不直接清除

我把标记存活的对象复制到另一块空间,复制完了之后,直接把原有的整块空间给干掉!这样就没有内存碎片的问题了

这种做法缺点又很明显:内存利用率低,得有一块新的区域给我复制(移动)过去

还有一种折中的办法,我未必要有一块大的完整空间才能解决内存碎片的问题,我只要能在当前区域内进行移动

把存活的对象移到一边,把垃圾移到一边,那再将垃圾一起删除掉,不就没有内存碎片了嘛,这种专业的术语就叫做整理

img

分代的原因

经过研究表明:大部分对象的生命周期都很短,而只有少部分对象可能会存活很长时间

又由于垃圾回收是会导致stop the world(应用停止访问)

理解stop the world应该很简单吧:回收垃圾的时候,程序是有短暂的时间不能正常继续运作啊。不然JVM在回收的时候,用户线程还继续分配修改引用,JVM怎么搞

为了使stop the world持续的时间尽可能短以及提高并发式GC所能应付的内存分配速率

在很多的垃圾收集器上都会在物理或者逻辑上,把这两类对象进行区分,死得快的对象所占的区域叫做年轻代,活得久的对象所占的区域叫做老年代

img

但也不是所有的垃圾收集器都会有,只不过我们现在线上用的可能都是JDK8,JDK8及以下所使用到的垃圾收集器都是有分代概念的。

要值得注意的是,高版本所使用的垃圾收集器的ZGC是没有分代的概念的

在前面更前面提到了垃圾回收的过程,其实就对应着几种垃圾回收算法,分别是:

标记清除算法、标记复制算法和标记整理算法【标记、清除、复制、整理

分代收集和分代假说

两种判定对象是否死亡的两种方法:引用计数法和可达性分析法,判断对象死亡后,咱就得收集它,基于前者的垃圾收集算法称为 引用计数式垃圾收集(Reference Counting GC),基于后者的垃圾收集算法称为 追踪式垃圾收集(Tracing GC),这两类垃圾收集算法也常被称作 直接垃圾收集间接垃圾收集

由于主流 JVM 都没有使用引用计数法,所以一般我们讨论的垃圾收集算法就是基于可达性分析的追踪式垃圾收集。

本文先把具体的垃圾收集算法放一放,我们来讲下各种垃圾收集算法的基础:分代收集理论

有了这个理论的指导,我们才可以针对不同的区域,设计出不同的垃圾收集算法

20220723151850

弱分代假说和强分代假说

当前主流商业 JVM 的垃圾收集器,大多数都遵循了 分��收集(Generational Collection)的理论进行设计,这里需要解释下,很多博客都会把分代收集当成一种具体的垃圾收集算法,其实并不是,分代收集只是一种理论,一套指导方针,一套符合大多数程序运行实际情况的经验法则,它建立在两个分代假说之上:

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

这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:垃圾收集器应该将 Java 堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储

  • 根据弱分代假说,一个区域中大多数对象都是朝生夕灭、难以熬过一次垃圾收集过程的,那么把它们集中放在一起,每次回收时只需要关注另外的哪些对象(非常少量)能够存活下去,其他的直接无脑回收就行了,这样就能以较低代价回收到大量的空间
  • 根据强分代假说,如果剩下的都是难以消亡的老东西,那把它们集中放在一个区域,虚拟机就可以不用频繁地对这个区域进行垃圾��集了,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用

分代分代,分的是哪块区域,是的,就是堆。

具体放到现在的商用 JVM 里,设计者一般至少会把 Java 堆划分为新生代(Young Generation)和老年代(Old Generation)两个区域:

  • 在新生代中,每次垃圾收集时都会有大批对象死去
  • 而每次回收后存活的少量对象,将会逐步晋升到老年代中存放

在 Java 堆划分出不同的区域之后,垃圾收集器就可以每次只回收其中某一个或者某些部分的区域,因而才有了 Minor GCMajor GCFull GC 这样的回收类型的划分,解释下这三个概念:

1)部分收集 (Partial GC):指目标不是完整收集整个Java堆的垃圾收集,其中又分为:

  • 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集

  • 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有 CMS 收集器会有单独收集老年代的行为

    注意 “Major GC” 这个说法现在有点混淆,在不同资料上常有不同所指,读者需按上下文区分到底是指老年代的收集还是整堆收集。

  • 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有 G1 收集器会有这种行为

2)整堆收集 (Full GC):收集整个 Java 堆和方法区的垃圾收集

也因此呢,针对不同的区域,才能够设计出与里面存储对象存亡特征相匹配的不同的垃圾收集算法:

  • 标记-清除算法(最基础的垃圾收集算法)
  • 标记-复制算法(新生代)
  • 标记-整理算法(老年代)

这里的 “标记” 就是指使用可达性分析来标记死亡/存活对象

跨代引用假说

其实我们只要仔细思考一下,很容易发现分代收集并非只是简单划分一下内存区域那么容易,它存在一个非常明显的困难:对象不是孤立的,对象之间会存在跨代引用

假如现在要进行一次只局限于新生代区域内的收集(Minor GC),但新生代中的对象是完全有可能被老年代所引用的,那么这个存储在新生代中被老生代引用的对象,就不应该被标记为死亡对象,所以,我们就不得不在固定的 GC Roots 之外,再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性,反过来也是一样。

遍历整个老年代所有对象的方案虽然理论上可行,但无疑会为内存回收带来很大的性能负担。为了解决这个问题,就需要对分代收集理论添加第三条经验法则:

  • 跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数

这其实是可根据前两条假说逻辑推理得出的隐含推论:存在互相引用关系的两个对象,是应该倾向于同时生存或者同时消亡的

举个例子,如果某个新生代对象存在跨代引用,由于老年代对象难以消亡,该引用会使得新生代对象在收集时同样得以存活,进而在年龄增长之后晋升到老年代中,这时跨代引用也随即被消除了。

依据这条假说,我们就不必为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构,这个结构被称为 记忆集 ,Remembered Set)

记忆集把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。这样,当发生 Minor GC 时,对于跨代引用问题,不会遍历整个老生代加入 GC Roots 中,只会把记忆集中包含了跨代引用的少量对象加入到 GC Roots 进行扫描。

当然,并不只是新生代、老年代之间才有跨代引用的问题,所有涉及部分区域收集(Partial GC)行为的垃圾收集器(比如 G1、ZGC 和 Shenandoah 收集器这种面向 Region 的收集器)都会面临相同的问���。

卡表

需要注意的是,我们这里所说的记忆集其实只是一种逻辑概念,一种用于记录从非收集区域指向收集区域的指针集合的数据结构,并不是一种具体的实现,就好比 Map 和 HashMap 的关系一样,目前最常见的实现方式是卡表(Card Table)

卡表最简单的形式可以只是一个字节数组,以下这行代码是 HotSpot 默认的卡表标记逻辑:

CARD_TABLE [this address >> 9] = 0;

CARD_TABLE 数组的每一个元素都对应着老年代中一块特定大小的内存块,这个内存块被称作 卡页(Card Page)。

一般来说,卡页大小都是以 2 的 N 次幂的字节数,通过上面代码可以看出 HotSpot 中每个卡页的大小是 2 的 9 次幂,即 512 字节(地址右移 9 位,相当于用地址除以 512)。

那如果卡表标识内存区域的起始地址是 0x0000 的话,数组 CARD_TABLE 的 第 0 号元素就对应了地址范围 0x0000~0x01FF(十进制 511)的卡页内存块,第 1 号元素就对应了地址范围 0x0200(十进制 512)~0x03FF(1023)的卡页内存块

卡表具体是怎么用的呢?

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

在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,然后把它们加入 GC Roots 中一并扫描

img

写屏障

我们已经解决了如何使用记忆集来缩减 GC Roots 扫描范围的问题,但还没有解决卡表元素如何维护的问题,换句话说,在对象赋值的那一刻,谁来把卡表元素变脏呢

在 HotSpot 虚拟机里是通过 写屏障(Write Barrier)技术维护卡表状态的

注意这里提到的“写屏障” 与 volatile 解决并发乱序执行问题中的 “内存屏障” 是不一样的,这里的写屏障我觉得可以简单理解为一个虚拟机调用的具体方法。

写屏障可以看作虚拟机层面对 “引用类型字段赋值” 这个动作的 AOP 切面

在引用对象赋值时会产生一个环形通知,在赋值前的部分的通知叫作 写前屏障(Pre-Write Barrier),在赋值后的通知则叫作 写后屏障(Post-Write Barrier)

img

HotSpot 虚拟机的许多收集器中都有使用到写屏障,但直至 G1 收集器出现之前,其他收集器都只用到了写后屏障(即在完成引用字段的赋值操作之后,更新卡表状态)。下面这段代码是一段更新卡表状态的简化逻辑:

// 写后屏障更新卡表
void oop_field_store(oop* field, oop new_value) {
    // 引用字段赋值
    *field = new_value;
    // 写后屏障,在这里完成卡表状态更新
    post_write_barrier(field, new_value);
}

这就是写屏障更新维护卡表元素的逻辑,很简单对吧~

不过,使用写屏障后,其实会带来两个问题:

  1. 额外的开销
  2. 伪共享问题

应用写屏障后,虚拟机就需要为所有赋值操作生成相应的指令,一旦收集器在写屏障中增加了更新卡表操作,无论更新的是不是老年代对新生代对象的引用,每次只要对引用进行更新,就会产生额外的开销。

不过当然,这个开销与 Minor GC 时扫描整个老年代的代价相比还是低很多的。

除了写屏障的开销外,卡表在高并发场景下还面临着 伪共享(False Sharing)问题。另一篇文章介绍过这个问题,阿里云一面:并发场景下的底层细节 - 伪共享问题 (opens new window),简单说下,伪共享是处理并发底层细节时一种经常需要考虑的问题,现代中央处理器的缓存系统中是以缓存行(Cache Line)为单位存储的,当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此之间产生影响。

假设处理器的缓存行大小为 64 字节,由于一个卡表元素占 1 个字节,64 个卡表元素将共享同一个缓存行,对吧。

这 64 个卡表元素对应的卡页总的内存为 32KB(64×512字节),也就是说如果不同线程更新的对象正好处于这 32 KB的内存区域内,就会导致更新卡表时正好写入同一个缓存行而导致伪共享问题。

为了避免伪共享问题,一种简单的解决方案就是更改下写屏障的执行逻辑,在将卡表元素变脏之前,加个判断,就是先检查下卡表标记,只有当该卡表元素未被标记过时才将其标记为变脏。可以简单用以下代码所示:

if (CARD_TABLE [this address >> 9] != 0) {
    CARD_TABLE [this address >> 9] = 0;
}

在 JDK 7 之后,HotSpot 虚拟机增加了一个新的参数 -XX:+UseCondCardMark,用来决定是否开启卡表更新的条件判断。

开启会增加一次额外判断的开销,但能够避免伪共享问题,两者各有性能损耗,是否打开需要根据实际情况来进行权衡

垃圾回收器

年轻代的垃圾收集器有:Seria、Parallel Scavenge、ParNew

老年代的垃圾收集器有:Serial Old、Parallel Old、CMS

看着垃圾收集器有很多,其实还是非常好理解的。Serial是单线程的,Parallel是多线程

这些垃圾收集器实际上就是实现了垃圾回收算法(标记复制、标记整理以及标记清除算法)

CMS是JDK8之前是比较新的垃圾收集器,它的特点是能够尽可能减少stop the world时间。在垃圾回收时让用户线程和 GC 线程能够并发执行!

img

又可以发现的是,年轻代的垃圾收集器使用的都是标记复制算法

所以在堆内存划分中,将年轻代划分出Survivor区(Survivor From 和Survivor To),目的就是为了有一块完整的内存空间供垃圾回收器进行拷贝(移动),而新的对象则放入Eden区

下面是堆内存的图,它们的大小是有默认比例的。

20220722165606

进入老年代时机

简单可以分为两种情况:

  1. 如果对象太大了,就会直接进入老年代(对象创建时就很大 || Survivor区没办法存下该对象)
  2. 如果对象太老了,那就会晋升至老年代(每发生一次Minor GC ,存活的对象年龄+1,达到默认值15则晋升老年代 || 动态对象年龄判定 可以进入老年代)

img

Minor GC触发时机

当Eden区空间不足时,就会触发Minor GC

Minor GC分代扫描

JVM里也有解决办法的,HotSpot 虚拟机老的GC(G1以下)是要求整个GC堆在连续的地址空间上,所以会有一条分界线(一侧是老年代,另一侧是年轻代),所以可以通过地址就可以判断对象在哪个分代上,当做Minor GC的时候,从GC Roots出发,如果发现老年代的对象,那就不往下走了(Minor GC对老年代的区域毫无兴趣)

img

跨代扫描

HotSpot虚拟机下 有card table(卡表)来避免全局扫描老年代对象

堆内存的每一小块区域形成卡页,卡表实际上就是卡页的集合。当判断一个卡页中有存在对象的跨代引用时,将这个页标记为脏页

那知道了卡表之后,就很好办了。每次Minor GC 的时候只需要去卡表找到脏页,找到后加入至GC Root,而不用去遍历整个老年代的对象了。

20220722170033

三色标记法

可达性分析可以分成两个阶段

  1. 根节点枚举
  2. 从根节点开始遍历对象图

前文 可达性分析深度剖析:安全点和安全区域 提到过,在可达性分析中,第一阶段 “根节点枚举” 是必须 STW 的,不然如果分析过程中用户进程还在运行,就可能会导致根节点集合的对象引用关系不断变化,这样可达性分析结果的准确性显然也就无法保证了;而第二阶段 “从根节点开始遍历对象图”,如果不进行 STW 的话,会导致一些问题,由于第二阶段时间比较长,长时间的 STW 很影响性能,所以大佬们设计了一些解决方案,从而使得这个第二阶段可以不用 STW,大幅减少时间

上篇文章已经介绍过第一阶段 “根节点枚举”,本篇就来分析第二阶段 “从根节点开始遍历对象图”

前言

事实上,GC Roots 相比起整个 Java 堆中全部的对象毕竟还算是极少数,且在各种优化技巧(比如 OopMap)的加持下,它带来的停顿已经是非常短暂且相对固定的了,也就是说,“根节点枚举” 阶段的停顿时间不会随着堆容量的增长而增加

当我们枚举完了所有的 GC Roots,就得进入第二阶段继续往下遍历对象图了,这一步骤同样需要 STW,并且停顿时间与 Java 堆容量直接成正比例关系:堆越大,存储的对象越多,对象图结构越复杂,要标记更多对象而产生的停顿时间自然就更长,这是理所当然的事情

也就是说,“从根节点开始遍历对象图” 阶段的停顿时间随着堆容量的增长而增加

要知道包含“标记”阶段(也就是可达性分析)是所有追踪式垃圾收集算法的共同特征,如果这个阶段会随着堆变大而等比例增加停顿时间,其影响就会波及几乎所有的垃圾收集器。如果能够减少这部分停顿时间的话,那收益也将会是巨大的

想降低 STW 时间甚至是避免 STW,我们就要先搞清楚为什么必须在一个能保障一致性的快照上才能进行对象图的遍历

为了能解释清楚这个问题,大佬们引入了三色标记法(Tri-color Marking)这个工具

需要注意的是,三色标记法只是辅助我们分析的工具,并不是某个垃圾收集器具体使用的算法!!!!!更不是降低 STW 时间 or 消除 STW 的方法,具体解决方法下面还会介绍

在这里,三色标记法可以帮助我们搞清楚在可达性分析的第二阶段(也就是遍历对象图),如果用户线程和垃圾收集线程同时进行,会出现什么问题

辅助分析的工具:三色标记法

所谓三色标记法,就是把遍历对象图过程中遇到的对象,按照 “是否访问过” 这个条件标记成以下三种颜色:

  • 白色:表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达(可达性分析到不了的对象,就是死亡对象,需要被回收)

  • 黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。

  • 灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过

    灰色可能不好理解,这里举个例子:A(GC roots) → B → C,如果 B 已经被扫描过,但是 B 的引用 C 还没有被扫描过,那么 B 就是灰色的,C 由于还没有被扫描,所以是白色的

所以对象图遍历的过程,其实就是由灰色从黑向白推进的过程,灰色是黑和白的分界线。

标记过程分析

在这里插入图片描述

  • 步骤一:在GC并发标记刚开始时,所以对象均为白色集合;
  • 步骤二:将所有GCRoots直接引用的对象标记为灰色集合;
  • 步骤三:判断灰色集合中的对象:
    • 若对象 不存在 子引用,则将其放入 黑色集合 ;
    • 若对象 存在 子引用对象,则 将其所有的子引用对象放入灰色集合,当前对象放入黑色集合。
  • 步骤四:按照步骤三,以此类推,直至灰色集合中的所有对象变成黑色后,本轮标记完成,且当前白色集合内的对象称为不可达对象,既垃圾对象。

产生问题

下面我们就用三色标记法来分析下,如果在对象图遍历这个阶段用户线程与收集器并发工作会出现什么问题

浮动垃圾

所谓浮动垃圾,就是由于垃圾收集和用户线程是并行的,这个对象实际已经死亡了,已经没有其他人引用它了,但是被垃圾收集器错误地标记成了存活对象

举个例子,a 引用了 b,此时 b 被扫描为可达,但是用户线程随后又执行了 a.b = null,这个时候其实 b 已经是死亡的垃圾对象了,但是由于黑色对象不会被重新扫描,所以在垃圾收集里 b 依然作为存活对象被标记成黑色,因此就成了浮动垃圾。如下图所示:

img

浮动垃圾当然不是一件好事,但其实是可以容忍的,因为这只不过产生了一点逃过本次收集的浮动垃圾而已,反正还会有下一次垃圾收集,到时候就会被标记为垃圾被清理掉了

对象消失

对象消失和浮动垃圾恰恰相反,对象消失是把原本存活的对象错误标记为已消亡,这就是非常致命的后果了,程序肯定会因此发生错误,下面表演示了这样的致命错误具体是如何产生的

20220723151739

如上图所示,b -> c 的引用被切断,但同时用户线程建立了一个新的从 a -> c 的引用,由于已经遍历到了 b,不可能再回去遍历 a(黑色对象不会被重新扫描),再遍历 c,所以这个 c 实际是存活的对象,但由于没有被垃圾收集器扫描到,被错误地标记成了白色。

总结下对象消失问题的两个条件:

  1. 插入了一条或多条从黑色对象到白色对象的新引用
  2. 删除了全部从灰色对象到该白色对象的直接或间接引用

Wilson 于 1994 年在理论上证明了,当且仅当以上两个条件同时满足时,才会产生 “对象消失” 的问题,即原本应该是黑色的对象被误标为白色

遍历对象图不需要 STW 的解决方案

如上所述,如果遍历对象图的过程不 STW 的话,第一个浮动垃圾的问题很好处理,但是第二个对象消失问题就很棘手了。

但是呢,遍历对象图的过程又实在太长,设计 JVM 的大佬们不得不想出一些办法来解决对象消失问题,使得在遍历对象图的过程中不用进行 STW(也就是用户线程和对象线程可以同时工作),从而提升可达性分析的效率

上面总结了对象消失问题的两个条件,所以说,如果我们想要解决并发扫描时的对象消失问题,只需破坏这两个条件的任意一个即可。由此分别产生了两种解决方案:

  1. 增量更新(Incremental Update):增量更新破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时(就是上图中的 a -> c 引用关系),就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象(a)为根,重新扫描一次。这可以简化理解为,黑色对��一��新插入了指向白色对象的引用之后,它就变回灰色对象了
  2. 原始快照(Snapshot At The Beginning,SATB):原始快照要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时(上图中的 b -> c 引用关系),就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象(b)为根,重新扫描一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索

在 HotSpot 虚拟机中,增量更新和原始快照这两种解决方案都有实际应用,CMS 是基于增量更新来做并发标记的,G1、Shenandoah 则是用原始快照来实现

posted @ 2022-07-26 22:20  Faetbwac  阅读(359)  评论(0编辑  收藏  举报