内存回收算法,主要包括对象存活判定算法和垃圾收集算法。
对象存活判断,即判断一个对象是否还处于存活状态。解决的是哪些内存需要回收的问题
对象存活判定算法主要有引用计数算法和可达性分析算法两种
比较简单粗暴的对象存活判定算法,算法的主要逻辑为:
在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就 +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
式复制算法。算法的主要流程为:
Eden
区,和两块较小的且大小相等的 Survivor
区,每次分配内存只是用 Eden
区和其中一块 Survivor
区Eden
区空间放满时,执行垃圾回收Eden
区和正在使用的那一块 Survivor
区的存活对象一次性复制到另一块 Survivor
区Eden
区和已经使用过的那块 Survivor
区Survivor
区的身份对调,即由上次未使用的那块 Survivor
区和 Eden
区一起负责接下来的内存分配目前 HotSpot
虚拟机默认的 Eden
区和 Survivor
区的比例为 8 : 1
,即每次新生代中可用内存空间为整个新生代的 90%
。
这种内存布局情况下,可能会出现空闲的 Survivor
区的容量不足以容纳 Eden
区和正在使用的 Survivor
区剩余存活的对象容量总和的情况。
针对这种情况,这个算法还有一个类似逃生门的安全设计:如果发生这种情况,则会将这些这些对象直接移动到其他区域。在 HotSpot
的实现中,会将对象直接移动到老年代中,这就是 HotSpot
中的老年代空间分配担保机制。
回顾标记-清除和复制算法,发现两者都有缺点:
Appel
式复制算法,那么还需要有额外的空间担保来应对存活对象容量大于空闲 Survivor
区容量的极端情况基于强分代假说,两种方案似乎都不是很合适用来清理存放存活年龄较长的对象的区域。标记-整理算法应运而生,其主要流程为:
可以看出来,标记-整理和标记-清除算法的本质差异在于,标记-整理是一种移动式的回收算法,而标记-清除是非移动的。
但是移动式有一个非常大的问题:在移动过程中需要暂停所有用户线程(STW
),相当于增加了每次收集时处于 STW
的时间
为了解决这个问题,可以在使用一次或几次标记-清除后,再进行一次标记-整理,这种做法(相当于将两个方案进行了整合)在一定程度上兼顾了两种方案的优点。
HotSpot
是目前使用比较广泛的虚拟机,它对于内存回收算法的实现细节主要有:
GC Root
枚举我们知道实施垃圾清理算法的前提是判定对象是否存活(对象存活判定算法),而 Hot Spot
虚拟机中使用的是可达性分析算法来判定对象存活的。而使用可达性分析算法来作为对象存活判定算法,那么首先需要知道哪些对象是 GC Root
,再从这些 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
结束),才会离开安全区域继续执行后面的代码任意时刻,用户线程只可能会有两种运行状态:
Running
状态的线程Waiting
、Timed Wait
、Blocked
状态的线程所以,在执行 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
线程在进行可达性分析时,用户线程也在同时(并发地)执行,可能会导致下面的问题:
通过上面的两种情况,我们可以知道只有出现把存活的对象标记为死亡这种情况,我们才需要进行处理。进一步分析,只有在同时出现下面两种场景,才会出现这种问题:
解决方案有两种,分别是增量更新和原始快照
增量更新(Incremental Update
)要破坏的是条件 B。
当黑色对象插入新的对于白色对象的引用关系时,垃圾收集器将会把这个新插入的引用给记录下来。等并发扫描结束后,再重新以这个黑色对象为根,重新扫描一遍。
可以简单地理解为如果出现了条件 B,那么这个黑色对象就变成了灰色对象,在并发结束后统一将这些灰色对象再重新扫描一遍。
原始快照(Snapshot At The Beginning
)要破坏的是条件 A。
当灰色对象要删除对于白色对象的引用关系时,垃圾收集器将会把这个要删除的引用关系给记录下来。等并发扫描结束后,再将刚刚记录的这个删除的引用关系的根对象(即当时删除应用的那个灰色对象)为根,按照记录下来的被删除的引用关系重新扫描一次。
由上面的描述可以看出,当首次并发扫描结束后,原始快照机制开始发生作用时,垃圾收集器将会按照删除应用之前的引用关系(当时的引用快照)再次进行扫描,这也意味着删除之前的对象引用图中的对象将肯定会被重新扫描到,相当于在删除引用关系的那一刻,这些对象就都被标成了黑色。
可以简单地理解为如果出现了条件 A,那么被删除的白色对象以及白色对象后面的引用图上的所有对象都被标成了黑色
从上面我们可以得出一个结论:解决并发的可达性分析的核心思路,就是宁可多标(存活的对象),不能少标(存活的对象)。