内存回收算法与 Hot Spot 算法实现细节

文章目录

内存回收算法概述

内存回收算法,主要包括对象存活判定算法垃圾收集算法

对象存活判定算法

对象存活判断,即判断一个对象是否还处于存活状态。解决的是哪些内存需要回收的问题

  • 程序中存在有效引用的对象,即还可能被使用到的对象,称之为存活的对象
  • 程序中不存在有效引用的对象,即已经不能再被使用到的对象,称之为死亡的对象

对象存活判定算法主要有引用计数算法可达性分析算法两种

引用计数算法

比较简单粗暴的对象存活判定算法,算法的主要逻辑为:

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

但是,如果出现了循环引用的变量,引用计数法是没有办法正确地判断它们的存活与否的。举例说明:

public class TestObject {
    Object obj;
}
public void method(String[] args) {
    TestObject a = new TestObject();
    TestObject b = new TestObject();
    // a 和 b 相互循环引用
    a.obj = b;
    b.obj = a;
}

在上面的例子中,两个对象相互循环引用,如果采用引用计数算法,那么在方法结束后两个对象的计数器值也都不为 0,所以它们永远也没办法被判定为死亡的对象,对应的内存空间也永远没办法回收掉。在这种情况下,就发生了内存泄漏

可达性分析算法

目前主流的对象存活判定算法,算法的主要逻辑为:

通过一系列 GC Roots 对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为**引用链**,如果某个对象到 GC Roots 间没有任何引用链相连,则说明从 GC Roots 到这个对象之间是不可达的,即证明这个对象不可能再被使用。

Java 计数体系中,固定可以作为 GC Roots 的对象包括以下几种:

  • JVM 栈的栈帧的本地变量表中引用的对象,即正在运行的方法中所使用的局部变量。
  • 类静态变量
  • JDK 自带的类加载器
  • Native 方法引用的对象
  • synchronized 关键字修饰的对象
  • 正在运行的线程对象

垃圾收集算法

在解决了哪些内存需要回收的问题之后,现在来看看如何回收的问题。显然,垃圾收集算法,就是解决如何回收的问题的。

分代收集理论

分代收集理论,就是将需要回收的区域,划分成不同的子区域之后,在不同的子区域采用不同的收集策略。
分代收集理论建立在三个分代假说上:

  • 弱分代假说:绝大多数对象都是朝生夕死的
  • 强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡
  • 跨代引用假说:跨代引用相对于同代引用来说仅占极少数

这个理论奠定了很多垃圾回收器的共同设计原则:

收集器应该将 Java 堆划分出不同的区域,然后将回收对象依据其年龄(对象幸存下来的垃圾收集次数),分配到不同的区域之中存储

标记-清除算法

标记-清除(Mark-Sweep算法,是比较常用的垃圾收集算法。算法的执行流程正如其名,分为标记清除两个过程。

  • 首先标记出所有需要回收的对象
  • 回收所有被标记的对象

当然,也可以标记存活的对象,再回收未被标记的对象。算法的思想比较简单,但是可能存在两个问题:

  • 执行效率不稳定:假设某次执行回收的过程中,需要标记的元素特别多,那么相应的,标记和清除的执行效率都会大幅下降。
  • 内存空间碎片化的问题:标记和清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后程序运行过程中需要分配大对象时,无法找到足够的连续内存空间而不得不提前触发一次新的垃圾收集动作。

标记-复制算法

标记-复制算法,通常被简称为复制算法。目前,复制算法通常分为半区复制算法,和 Appel 式复制算法

半区复制算法

半区复制算法,是最基本的复制算法。其算法的主要流程为:

  • 将内存区域根据容量划分为大小相等的两块,每次分配内存只使用其中的一半(即将内存分为正在使用备用两块)
  • 如果正在使用的这一半内存放满了,执行垃圾回收
  • 将还存活的对象复制到备用块上面
  • 将正在使用的这一块内存空间一全清理掉
  • 将备用块和正在使用块的身份对调,即由当前的备用块负责接下来的内存分配

半区复制算法简单高效,但是存在一个巨大的问题,就是每次只使用一半的内存,对于内存空间的浪费太严重了。

Appel 式复制算法

针对搬去复制算法的空间浪费问题,后续开发了一种新的复制算法,即 Appel 式复制算法。算法的主要流程为:

  • 将内存区域根据容量划分为一块较大的 Eden 区,和两块较小的且大小相等的 Survivor 区,每次分配内存只是用 Eden 区和其中一块 Survivor
  • Eden 区空间放满时,执行垃圾回收
  • Eden 区和正在使用的那一块 Survivor 区的存活对象一次性复制到另一块 Survivor
  • 直接清理掉 Eden 区和已经使用过的那块 Survivor
  • 将两块 Survivor 区的身份对调,即由上次未使用的那块 Survivor 区和 Eden 区一起负责接下来的内存分配

Appel 式复制算法的逃生门设计

目前 HotSpot 虚拟机默认的 Eden 区和 Survivor 区的比例为 8 : 1,即每次新生代中可用内存空间为整个新生代的 90%
这种内存布局情况下,可能会出现空闲的 Survivor 区的容量不足以容纳 Eden 区和正在使用的 Survivor 区剩余存活的对象容量总和的情况。
针对这种情况,这个算法还有一个类似逃生门的安全设计:如果发生这种情况,则会将这些这些对象直接移动到其他区域。在 HotSpot 的实现中,会将对象直接移动到老年代中,这就是 HotSpot 中的老年代空间分配担保机制

标记-整理算法

回顾标记-清除复制算法,发现两者都有缺点:

  • 标记-清除算法最严重的问题就是可能会导致大量空间碎片
  • 复制算法,如果采用半区复制,那么会浪费一半的空间;如果采用 Appel 式复制算法,那么还需要有额外的空间担保来应对存活对象容量大于空闲 Survivor 区容量的极端情况

基于强分代假说,两种方案似乎都不是很合适用来清理存放存活年龄较长的对象的区域。标记-整理算法应运而生,其主要流程为:

  • 标记的过程仍然与标记-清除算法一致
  • 标记完成后,让所有存活的对象都向内存空间的一端进行移动
  • 然后直接清理掉边界以外的内存

可以看出来,标记-整理和标记-清除算法的本质差异在于,标记-整理是一种移动式的回收算法,而标记-清除是非移动的。
但是移动式有一个非常大的问题:在移动过程中需要暂停所有用户线程(STW),相当于增加了每次收集时处于 STW 的时间
为了解决这个问题,可以在使用一次或几次标记-清除后,再进行一次标记-整理,这种做法(相当于将两个方案进行了整合)在一定程度上兼顾了两种方案的优点。

HotSpot 虚拟机实现细节

HotSpot 是目前使用比较广泛的虚拟机,它对于内存回收算法的实现细节主要有:

  • GC Root 枚举
  • 安全点与安全区域
  • 记忆集与卡表
  • 并发的可达性分析

GC Root 枚举

我们知道实施垃圾清理算法的前提是判定对象是否存活(对象存活判定算法),而 Hot Spot 虚拟机中使用的是可达性分析算法来判定对象存活的。而使用可达性分析算法来作为对象存活判定算法,那么首先需要知道哪些对象是 GC Root,再从这些 GC Root 出发,去寻找引用可达的对象
那么引发出来一个问题:Hot Spot 虚拟机是如何找到所有的 GC Root 并进行遍历枚举的呢?

Hot Spot 实现 GC Root 枚举

Hot Spot 虚拟机使用的是一种叫做 Oop Map 的结构来进行 GC Root(根节点枚举)的。意思就是说,Hot Spot 虚拟机会把所有的根节点都放到 Oop Map 中,等要执行 GC 时,会从 Oop Map 中把所有的 GC Root 遍历一遍,这样就可以完成所有对象的可达性分析了。
Oop Map 的维护时机有以下几种:

  • JVM 开始初始化类加载器时:此时会把类加载器对象加入到 Oop Map
  • 类被加载的时候:此时类中的静态变量将会被加入到 Oop Map
  • 类被卸载的时候:此时类中的静态变量将会从 Oop Map移除
  • 到达安全点/安全区域时:此时会把当前虚拟机栈/本地方法栈的运行中的栈帧中的局部变量对象加入到 Oop Map 中,或者把已经销毁的栈帧(已经退出执行的方法)中的局部变量对象从 Oop Map 中移除
  • 开启并运行一个线程时(调用一个线程的 start() 方法)时:此时会将这个运行的线程对象加入到 Oop Map
  • 一个线程结束运行时:此时会将这个运行的线程对象从 Oop Map 中移除

安全点与安全区域

安全点

安全点,即程序运行到这个位置时,引用关系一般来说会发生变化的代码位置,所以在。在安全点可以停下来维护 Oop Map
也正因为安全点是停下来维护 Oop Map 的位置,所以安全点也是停下来执行 GC 的位置。
常见的安全点有:

  • 开始进行方法调用时
  • 单次循环结束时
  • 异常被捕捉时
  • 方法执行结束返回时

安全点实际应用

由上一节我们知道,安全点是停下来维护 Oop Map 的位置,也是所有用户线程停下来执行 GC 的位置。那么在要执行 GC 时,如何让所有线程都跑到最近的安全点然后暂停下来呢?有下面两种方案可以解决:

  • 抢先式中断:在需要执行 GC 时,JVM 把所有用户线程全部暂停,然后逐个检查线程是否暂停在安全点上
    • 如果发现线程正好暂停在安全点上,那么不用再做额外处理
    • 如果发现线程没有暂停在安全点上,那么就恢复这个线程的执行,一直到最近一个安全点,然后再进行暂停
    • 当所有线程都暂停处理完毕之后,开始执行 GC
    • GC 执行结束后,唤醒所有暂停的线程
  • 主动式中断:当需要执行 GC 时,JVM 先简单设置一个标记位(标记为当前需要执行 GC
    • 每个线程在执行过程中会去不断地轮询这个标志位
    • 一旦发现这个标志位已经标记为当前需要执行 GC,那么就继续执行到离当前最近的一个安全点把自己给暂停
    • JVM 发现所有线程都已经暂停后,开始执行 GC
    • GC 执行结束后,唤醒所有暂停的线程,并将标志位还原

安全区域

某一段代码片段中,当前线程的对象引用关系不会发生变化,也正因为如此,在这个区域中的任何地方开始执行垃圾收集都是安全的,这样的代码片段就叫做安全区域。
安全区域的引入,主要时为了解决当要执行 GC 时,某些线程可能正在睡眠或者被阻塞,那么这些线程将无法正常响应 GC 相关动作。
安全区域的主要工作原理为:

  • 当用户线程执行到进入安全区域的代码时,首先标识自己已经进入了安全区域(进入阻塞或者睡眠状态一定是进入了安全区域)
  • 当要执行 GC 时,如果发现某些线程正处于睡眠阻塞状态,或者正在安全区域时,将直接跳过不进行处理
  • 当这些安全区域中的线程要离开安全区域时,首先判断当前是否处于需要全部线程暂停(STW)的状态
    • 如果是,那么将会一直等到 JVM 释放可以离开安全区域的信号(STW 结束),才会离开安全区域继续执行后面的代码
    • 如果不是,那么直接离开安全区域继续执行后面的代码

安全点与安全区域在执行 GC 如何发挥作用

任意时刻,用户线程只可能会有两种运行状态:

  • 正在运行:处于 Running 状态的线程
  • 非正在运行:处于 WaitingTimed WaitBlocked 状态的线程

所以,在执行 GC 时,这两种状态的用户线程分别的行为是:

  • 正在运行状态的线程,发现 GC 标志位为需要执行 GC 时,会运行到到最近的一个安全点或者安全区域,然后阻塞自己
  • 非正在运行状态的线程,一定处于安全区域,如果在正在执行 GC 时自身的阻塞状态结束,又重新恢复了运行
    • 此时会先判断标志位是否为需要执行 GC,如果是,那么将继续阻塞一直到收到 JVM 释放可以离开安全区域的信号(STW 结束)

记忆集与卡表

为了解决跨代引用的问题,垃圾收集器在新生代中创建了一个名叫记忆集的数据结构,用以避免根节点枚举的时候把整个老年代对象都扫描一遍。
记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。在 Hot Spot 虚拟机中,是采用卡表去实现的。
卡表的记录精度为精确到一块内存区域,该区域内的对象是否包含有跨代指针,如果包含,则该卡表的标志置为 dirty
所以在执行 GC 时,只要筛选出哪些卡表的标志位是 dirty,接着把这些卡表所对应的老年代区域中的所有对象都加入根节点枚举即可解决跨代引用问题。

卡表的维护时机

Hot Spot 虚拟机通过写屏障来维护卡表状态。写屏障,即在把每一个赋值操作,都用一个中间层给包起来(可以参考代理模式的设计思想),这个中间层的结构就叫写屏障。写屏障的伪代码示意如下所示:

void proxyFieldAssign(Object value) {
    //执行实际的赋值操作
    actualFieldAssign(Object value);
    //检查卡表是否需要更新
    writeBarrierForCheckCardTable();
}

可以看到,真正的赋值操作被代理了,而这个代理的结构内,就执行了维护卡表的逻辑。这就是写屏障的作用。

并发的可达性分析

在执行 GC 之前,我们需要把所有用户线程都在安全点上,或安全区域内停下来,然后再让 GC 线程在进行可达性分析。换句话说,GC 线程在工作之前,必须先让所有用户线程都必须阻塞在一个引用关系不会发生变化的一致性快照上,然后 GC 线程再开始进行可达性分析,这看起来好像是一个必然的事情。
原因也很容易理解,因为如果在 GC 线程进行可达性分析的同时,用户线程也在运行,那么很可能会出现对象存活状态判定错误,导致 GC 机制出现严重 Bug
例如,GC 线程在判定一个对象已经死亡后,如果用户线程又对象进行了有效的引用,那么就会出现错误。
所以,从常规的角度来看,用户线程和 GC 线程,是没有办法并发执行的。
所以在常规的垃圾收集器中,用户线程和 GC 线程在同一时间肯定是只有一个是处于正在工作的状态的。这也就导致了常规的垃圾收集器的垃圾收集停顿时间往往比较高。
所以,为了减少垃圾收集停顿时间,必须得想办法强行让用户线程和 GC 线程并发执行,那么这个时候有没有什么解决办法呢?

三色标记法

为了帮助理解上述问题的解决方案,我们引入一个三色标记法来进行辅助推导。三色标记法把参与 GC 过程中的所有对象,都分为三种颜色:

  • 白色:表示尚未被垃圾收集器分析过。即表示尚未被垃圾收集器访问的对象
  • 灰色:表示对象已经被垃圾收集器分析过,但是在这个对象中还至少存在一个引用没有被分析过。即表示被垃圾收集器访问过,但是还没有被访问透彻的对象
  • 黑色:表示对象已经被垃圾收集器分析过,而且这个对象中的所有引用都已经被分析过了。即表示被垃圾收集器访问透彻的对象

从上面的定义可以得出:

  • GC 可达性分析的初始阶段,所有对象都应该是白色
  • 在一个对象被垃圾收集器分析了一半的时候,这个对象的颜色是灰色的,表示从 GC Root 到这个对象的引用路径是可达的,但是这个对象内部的所有引用还没有被分析完成,处于一个分析半成品的状态
  • 在一个对象被垃圾收集器完全分析之后,这个对象的颜色是黑色的,表示从 GC Root 到这个对象的引用路径是可达的,即代表这个对象处于存活状态
  • 当一个灰色对象,被垃圾收集器访问完全后,将会变成黑色
  • 可达性分析结束后,仍处于白色状态的对象,表示从 GC Root 到这些对象的引用路径是不可达的,代表这些对象都已经处于非存活状态

用户线程与 GC 线程并发并发问题原理分析

如果 GC 线程在进行可达性分析时,用户线程也在同时(并发地)执行,可能会导致下面的问题:

  • 把死亡的对象标记为存活(白色的对象误标成黑色):这是一种可以容忍的错误。这种对象称为浮动垃圾,虽然是误判,但是并不会直接引发问题。在后面的垃圾收集过程中,这个对象还是有机会被清理掉的。
  • 把存活的对象标记为死亡(黑色的对象误标成白色):这是不能容忍的错误。这种现象一旦出现,几乎肯定会出现问题,直接引发非常致命的问题

通过上面的两种情况,我们可以知道只有出现把存活的对象标记为死亡这种情况,我们才需要进行处理。进一步分析,只有在同时出现下面两种场景,才会出现这种问题:

  • 将灰色对象对于一个白色对象的引用全部删除(条件 A)
  • 重新插入一条从黑色对象到这个白色对象的引用(条件 B)

解决方案有两种,分别是增量更新原始快照

增量更新

增量更新(Incremental Update)要破坏的是条件 B。
当黑色对象插入新的对于白色对象的引用关系时,垃圾收集器将会把这个新插入的引用给记录下来。等并发扫描结束后,再重新以这个黑色对象为根,重新扫描一遍。
可以简单地理解为如果出现了条件 B,那么这个黑色对象就变成了灰色对象,在并发结束后统一将这些灰色对象再重新扫描一遍。

原始快照

原始快照(Snapshot At The Beginning)要破坏的是条件 A。
当灰色对象要删除对于白色对象的引用关系时,垃圾收集器将会把这个要删除的引用关系给记录下来。等并发扫描结束后,再将刚刚记录的这个删除的引用关系的根对象(即当时删除应用的那个灰色对象)为根,按照记录下来的被删除的引用关系重新扫描一次。
由上面的描述可以看出,当首次并发扫描结束后,原始快照机制开始发生作用时,垃圾收集器将会按照删除应用之前的引用关系(当时的引用快照)再次进行扫描,这也意味着删除之前的对象引用图中的对象将肯定会被重新扫描到,相当于在删除引用关系的那一刻,这些对象就都被标成了黑色
可以简单地理解为如果出现了条件 A,那么被删除的白色对象以及白色对象后面的引用图上的所有对象都被标成了黑色

并发的可达性分析解决方案总结

从上面我们可以得出一个结论:解决并发的可达性分析的核心思路,就是宁可多标(存活的对象),不能少标(存活的对象)。

上一篇:每天一道面试题(14) - webpack热更新使用及其原理


下一篇:Android NDK 初探,生成so文件以及调用so文件方法