系统相关
首页 > 系统相关> > 内存回收算法与 Hot Spot 算法实现细节

内存回收算法与 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 的对象包括以下几种:

垃圾收集算法

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

分代收集理论

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

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

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

标记-清除算法

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

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

标记-复制算法

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

半区复制算法

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

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

Appel 式复制算法

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

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

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

标记-整理算法

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

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

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

HotSpot 虚拟机实现细节

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

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 的维护时机有以下几种:

安全点与安全区域

安全点

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

安全点实际应用

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

安全区域

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

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

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

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

记忆集与卡表

为了解决跨代引用的问题,垃圾收集器在新生代中创建了一个名叫记忆集的数据结构,用以避免根节点枚举的时候把整个老年代对象都扫描一遍。
记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。在 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 线程在进行可达性分析时,用户线程也在同时(并发地)执行,可能会导致下面的问题:

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

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

增量更新

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

原始快照

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

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

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

标签:标记,对象,Spot,Hot,算法,GC,引用,线程
来源: https://blog.csdn.net/qiongjingpang9161/article/details/121688548