深入理解Java垃圾回收——虚拟机高效回收的背后

在《深入理解垃圾回收——对象已死?》中,我们知道了对象如何判定为死亡,这一章节,我们来深入剖析一下虚拟机垃圾回收子系统的背后的思想和衍生的收集算法。

从如何判定对象消亡的角度来看,垃圾收集算法可以划分为:“引用计数式垃圾收集”和“追踪式垃圾收集”,也称为“直接垃圾收集”和“间接垃圾收集”。由于引用计数算法无法解决循环引用,所以几乎所有Java虚拟机都采用的是“间接垃圾收集”的方式。

分代理论

经典的垃圾收集器的设计都是遵循了“分代收集”(年轻代、老年代),之所以这样,是因为存在这两个经典的分代假说理论:
1.弱分代假说理论:绝大数对象都是朝生夕灭的;
2.强分代假说理论:熬过越多次垃圾收集过程的对象就越难以消亡。

这两个分代假说理论共同奠定了经典的垃圾收集器的设计原则:垃圾收集器应该将Java堆划分出不同的收集区域,然后依据对象依据其回收年龄分配到不同的内存中存储。

对象的年龄指的的对象熬过垃圾收集过程的次数。
如果一个区域的绝大多数的对象都是朝生夕灭的,那么在垃圾回收时只需要关注那少部分存活的对象而不是去标记那大量将要被回收的对象,那么就能以较低的代价回收掉大量的内存空间。
在Java堆中划分出不同的区域之后,垃圾收集器才能得以针对部分区域进行回收,因此才有了“Minor GC”、“Magor GC”、“Full GC”这样的回收类型的划分;并且针对不同区域存储的对象的特征发展出了与之匹配的垃圾收集算法——“标记-清除算法”、“标记-复制算法”、“标记-整理算法”。
以上涉及到的回收类型划分、垃圾清除算法都是基于“分代假说理论”,由此可见,分代假说理论是垃圾收集器的发展基石!

在现代实际应用的Java虚拟机中,一般至少会将Java堆内存划分为“新生代”和“老年代”这两个区域。
垃圾收集器在回收新生代时,新生代中将有大量的对象被回收掉,剩下少部分存活的对象将逐渐晋升到老年代中。
垃圾收集器在回收老年代时,由于老年代中的对象大部分都已经熬过了多次垃圾收集过程,因此对于老年代的回收肯定没有新生代的效果那么好。

跨代引用

在回收新生代时还存在一个问题:新生代完全有可能被老年代所引用的,为了垃圾收集的完整性,必须找出这部分区域的对象并判断是否存活。因此不得不在固定的GC Roots之外,遍历老年代中所有的对象以确保本次垃圾回收的完整性。(在回收老年时也存在同样的问题)。遍历整个老年代理论上是可行的,但是这种操作必然将大大降低垃圾回收的性能!
为了解决这个问题,添加了第三条分代收集理论,用于解决跨代引用问题:

3.跨代引用假说理论:跨代引用比起同代引用占极少数。

我们以回收新生代为例,依据跨代引用假说理论,可以得出这样一个结论:不应该为了这极少数的跨代引用而去扫描整个老年代,也不必浪费空间专门记录对象是否存及存在哪些跨代引用。只需在新生代上建立一个全局的数据结构,这个数据结构把老年代划分为若干块,标识着老年代中哪一块内存中存在跨代引用,这样,在发生Minor GC时,只有包含了跨代引用的小块内存中的对象需要加入到GC Roots进行扫描,从而避免了扫描整个老年代。

这个数据结构被称为“记忆集”,在本文章后面会专门介绍,此处只需理解其作用即可。
“记忆集”这种方式会带来另外一个问题:当对象改变了引用关系时,必须维护记忆集。这虽然会增加一些开销,但比起扫描整个老年代,仍然是十分划算的!

上面我们提到了Minor GC,依据分代理论,垃圾收集分为以下几种:
1.新生代收集(Minor GC / Young GC):垃圾收集器只回收新生代;
2.老年代收集(Major GC / Old GC):垃圾收集器值回收老年代;
3.混合收集(Mixed GC):垃圾收集器回收整个新生代及部分老年代,目前只有G1垃圾收集器有这种行为。
4.整堆收集(Full GC):回收整个Java对及方法区。

垃圾回收算法

Java虚拟机中对于垃圾回收的具体做法分为两部分:
1.标记:即标记出哪些对象是垃圾对象;所有的垃圾回收算法都包含了这一步;
2.回收:回收阶段,不同的算法侧重点不一样。

标记—清除算法

先标记,后清除。
标记清除算法简单粗暴。简单粗暴的处理方式优点很明显:简单;缺点也很明显:粗暴。清除之后,内存空间将产生大量不连续的空间碎片,程序运行过程中可能会产生较大的对象,但由于空间碎片太多找不到一块连续的空间来分配,不得不触发一次垃圾回收,频繁进行垃圾回收显然不是我们想要的。

标记—复制算法

先标记,再复制存活的对象到其他空间,然后将本空间全部清除。
为了解决标记—清除算法回收效率低下的问题,便诞生了“半区复制”的算法。它的处理方式是典型的以空间换时间的思想体现。具体实现方式如下:
把内存容量划分为大小相等的两块,只使用其中一块来分配内存,在进行垃圾回收时,将存活的对象按照顺序复制到另一边空间,然后将当前内存空间全部清除。基于分代理论思想,每次只有少量存活的对象需要进行移动复制,因此效率很高,且解决了空间碎片的问题。
标记—复制算法的缺点也很明显,空间利用率不高。
还是基于分代理论思想,我们得知每次垃圾回收过程中,只有少量的存活对象。基于这一特征,思考一个问题:基于标记—复制算法时,内存空间必须按照1:1来划分成两块内存么?

IBM公司曾经对对象朝生夕灭的特征进行了更加量化的研究,研究结论:新生代中有98%的对象熬不过第一轮垃圾回收,因此内存完全不必按照1:1来划分新生代内存空间。

在Hotspot虚拟机中,新生代的垃圾收集器Serial、PerNew等新生代垃圾收集器,采用了优化版的划分方式将新生代划分为一块较大的Eden空间和两块较小的Survivor空间,它的回收策略如下:
1.每次只用Eden区和一块Survivor区域;
2.当发生垃圾回收时,将存活的对象复制到另一块未使用的Survivor区域,然后直接清理掉Eden区域和使用的Survivor区域;
3.Hotspot虚拟机下E区域和两块Survivor区域的默认比例为8:1:1,即每次都有90%的空间时可用,仅有10%的空间是被“浪费”的;
4.虽然大多数情况下,一次Minor GC正常情况下可以回收绝大部分空间,但这并不能保证每次回收后,可用的那一块Survivor空间能够容纳存活的对象,因此必须有一个安全的“逃生门”设计:必须有其他空间进行内存担保(绝大多数情况下都是老年代进行内存担保)。

内存的担保机制就好比我们去银行贷款,如果我们信誉很好,98%的情况下是可以还上款的,于是银行就默认为我们下一次也能按时按量的进行还款,只需要有一个担保人保证在我们下一次还不上款时,可以从担保人的账户进行扣款,那么银行就认为没有什么风险了。
内存担保机制也是同样的道理,当可用的那一块Survivor空间无法容纳存活的对象,只需通过内存担保机制,将这些对象存放在老年代,这对于虚拟机来说是没有风险的。

标记—整理算法

由于年轻代中大多数对象朝生夕灭的特征,因此十分适合采用标记—复制算法。但对于老年代而言,情况就完全不一样了。
基于分代理论思想得知,老年代中的对象大多数都是很难消亡的,因此如果采用标记—复制算法进行垃圾回收,每次都将移动复制大量的对象,将会大大浪费空间,回收效率将会大大降低,所以老年代基本不会采用标记—复制算法。
针对老年代的特征,提出了另一个针对性的算法:标记—整理算法。
标记整理算法的步骤分为:
1.标记:标记出可回收的对象;
2.整理:将不可回收的对象向内存空间的一端移动;
3.清除:移动完成之后,直接清除掉边界以外的空间。

标记—整理算法实际上是一个非常“纠结”的算法,纠结的地方在于移动对象这一动作。

**移动对象的情况:**老年代中的对象绝大多数都是难以消亡的,每次垃圾回收必然会有大量的对象需要移动,而对象在移动的过程中必须暂停所有的用户线程(ZGC等新的垃圾收集器采用了读屏障实现了用户线程并发,此处暂不讨论)。此时,**会增大延迟。**暂停用户线程这一动作有一个十分形象的表述:Stop The Word.
**不移动对象的情况:**如果不移动对象,其实就是回退到了标记—清除算法。垃圾回收效率高,但触发垃圾回收的频率也增大了。此时,会降低系统的吞吐率。

如何决断是否需要移动对象?
基于以上解释,是否移动对象都存在弊端:移动对象则会增大延迟,不移动对象则会降息系统的吞吐率。现在我们先不纠结是否需要移动对象,先看两个实际情况:
1.内存的访问是用户线程最频繁的操作,甚至没有之一。
2.如果应用程序是面向用户的,那么低延迟性相较于高吞吐量更重要。
基于以上两种情况,是否需要移动对象这个问题似乎有了决断的依据。
由于内存的分配和访问是最频繁的操作之一,如果不移动对象,虽然垃圾收集的效率会提高,但是内存分配访问的耗时增大(原因是频繁的垃圾收集导致用户线程停顿),这部分的耗时往往会覆盖掉垃圾收集器提升的效率,从而导致系统整体的吞吐量依然是下降的。
以上分析在垃圾收集子系统中得以证明:关注吞吐量的Parallel Scavenge收集器就是基于标记—整理算法的,而关注低延迟的CMS垃圾收集器则采用的标记—清除算法。

虚拟机的垃圾收回子系统背后的设计思想到这里就剖析完了,除了这些理论思想之外,还有一些必要的实现细节,理论思想和这些实现细节共同奠定了垃圾收集子系统的顶层设计。接下来继续深入理解这些实现细节。

根节点枚举

在《深入理解Java垃圾回收——对象已死?》可达性分析算法的具体实现在逻辑上可以分为两步:
1.找到所有的GC Root,即根节点;
2.从根节点开始进行遍历引用链,最终确定不可达的对象。

随着Java垃圾收集器的发展,经典的垃圾收集器CMS和新一代的垃圾收集器G1、ZGC等在遍历引用链的过程中都采用不同的方式实现了与用户线程并发执行了,但是迄今为止,所有的垃圾收集器在枚举查找GC Roots的过程中,都是必须要暂停用户线程的,即Stop The Word.

枚举根节点这一过程必须在一个可以保证枚举过程中,根节点的引用关系不会发生变化,否则其得出的分析结果没有任何意义。因此必须暂停所有用户线程。

在枚举根节点时,如果对整个上下文和全局的引用位置进行遍历来查找根节点的话,效率必然低下,随着Java应用程序越来越庞大这一事实,这种低效率的方式是不能被接收的,虚拟机应当有方法能够直接得知哪些地方存放着引用。HotSpot虚拟机的解决方案是使用一组称为OopMap的数据结构来达到这个目的。这样,在收集器扫描的过程中能够直接获取到GC Roots的信息,不必再去堆、方法区遍历查找GC Roots了。

安全点

在OopMap的协助下,虚拟机可以快速准确的完成GC Roots的根节点枚举,但存在这样一个问题:可能导致引用关系变化的指令非常多,虚拟机应该为哪些指令生成对应的OopMap?

如果为每一个指令都生成对应的OopMap,将需要大量的额外空间,为了垃圾收集而带来高昂的内存空间成本,这显然违背了垃圾收集器的初衷;
如果为很少的指令生成对应的OopMap,虽然不会带来高昂的成本空间,但GC Roots根节点枚举的的效率就会变低,这显然违背了OopMap的设计初衷。
好难!

HotSpot虚拟机也确实没有为每一条指令都生成OopMap,而是在“特定的位置”才会为之生成,这些特定的位置被称为“安全点”。
有了安全点的设定,就意味着虚拟机不能随时开始垃圾收集,而是必须等到所有用户线程到达安全点后,才能进行垃圾收集。
安全点的设定不能太多:设置太多将带来高昂的内存空间成本;
安全点的设定不能太少:设置太少收集器等待的时间过长。

如何选取安全点?
安全点的选取基本是以“是否有让程序长时间执行”作为选取标准。符合让程序长时间执行最明显的特征就是指令序列复用,比如:方法调用、循环跳转、异常跳转等。

如何让程序跑到安全点?
让程序跑到附近的安全点有两种思路可供选择:抢先式中断、主动式中断。
抢先式中断:在垃圾收集时,系统首先把所有线程中断,如果发现线程不在安全点,则回复线程的运行,过一会儿再中断,直到跑到安全点为止。
主动式中断:当需要进行垃圾收集时,不中断用户线程,仅仅是设置一个垃圾收集标志。用户现在在执行的过程是,不断去轮询判断这个标志,如果需要进行垃圾收集,则跑到附近的安全点后主动挂起。

主动式中断的话,轮询判断标志将会是非常频繁的一件事情。因此判断必须高效!HotSpot虚拟机使用内存保护陷阱的方式,把轮询判断操作精简至只有一条汇编指令的程度。
内存保护陷阱的实现方式:当需要进行垃圾收集时,虚拟机将0x160100的内存页设置为不可读,当线程执行特定指令(安全点的指令)时,发现这个内存页不可读,就会产生自陷异常信号,然后在预先注册的异常处理器中挂起这个线程。

安全区域

有了安全点的设定,似乎一切都很完美了。但不要忘记,安全点的设定只适用于那些正在运行的用户线程,如果线程没有运行呢?比如:休眠中的线程、阻塞中的线程。当虚拟机发出中断标志后,这些线程是无法响应系统的中断标志,而系统也无法再分配时间片给这些线程让其回复运行。对于这种情况,就必须引入“安全区域”的概念。

安全区域是指:在某一段代码片段中,能够确保对象的引用关系不会发生变化。因此在这个区域中的任一点开始进行垃圾收集都是安全的。
当线程进入安全区域,会标识自己已经进入安全区域,这时进行垃圾收集,就不需要管这些线程了。
当线程要离开安全区域,首先会检查是否已经完成了根节点枚举,如果完成了,那就当做什么都没发生,继续执行;如果没有完成,则会一直等待,直到接收到可以离开安全区域的信号为止。
以上说明能够回答这样一些问题如:线程休眠5s,就一定会休眠5s嘛?

再谈记忆集

前面讲解分代理论时提到了用记忆集的方式来解决跨代引用问题。实际上,只要是“部分区域”垃圾回收的方式都存在跨代引用的问题,截止目前,Java垃圾收集器都是“部分区域”回收(包括G1、ZGC这些新的垃圾收集器)。因此他们都是采用记忆集的方式来解决跨代引用问题的。
记忆集的具体实现的关键在于记录粒度,如果记录的粒度太细,显然会占用更多的额外空间,如果记录的粒度太粗,记忆集的效果效果就没有那么好了,很“纠结”!
常用的记录粒度如下:
1.字长精度:每个记录精确到一个机器字长,该奇迹字长还有跨代引用(粒度很细);
2.对象精度:每个记录精确到一个对象,该对象包含跨代引用(粒度较细);
3.卡精度:每个记录精确到一块内存区域,该区域包含跨代引用。(粒度适中)。
HotSpot虚拟机采用的是“卡精度”。以卡精度来具体实现记忆集的方式称为“卡表”。这是目前最为常见的记忆集实现方式。

卡表之于记忆集,相当于HashMap之于Map。

维护卡表

通过卡表的方式能够大幅提升GC Roots的扫描速度。但卡表本身是需要维护的。何时更新卡表?怎么更新卡表?带着这两个问题我们继续深究!
卡表元素何时需要更新是十分确定的:当有其他区域对象引用了本区域的对象(跨代引用)时,就需要更新卡表。问题的难点在于:如何更新。有解决这个问题,需要从字节码执行的角度来看。
1.字节码解释执行:解释执行的场景下,很好处理,虚拟机在执行字节码时,有充分的介入空间来更新卡表。
2.字节码编译执行:经过编译过后的字节码已经变成纯粹的机器指令流了,虚拟机无法再介入,但是必须要找到一个能够在机器码层面的手段来更新卡表。HotSpot虚拟机采用了“写屏障”的技术来实现这一目的

这里的写屏障有3个方面的解释:
1.这里的“写屏障”和低延迟垃圾收集器中的“读屏障”以及并发场景下的“内存屏障”是完全不同的概念!千万不要混淆。
2.这里的写屏障可以看做是虚拟机层面为“引用类型字段赋值”这个动作的AOP切面操作,为其产生了一个Around通知,供程序执行其他额外的操作。在字段赋值前的屏障称为“写前屏障”,子字段赋值后的屏障称为“写后屏障”。
3.在G1垃圾收集器出现之前,经典垃圾收集器只用到了写前屏障。

并发的可达性分析

可发行分析算法分为两步:1.遍历GC Roots;2.从GC Roots开始遍历对象图,找到不可达的对象。
前面我们对于如何提升GC Roots遍历的性能进行了深入的解析,接下来,我们开始探讨可达性分析的后半段。
随着Java应用越来越大,Java虚拟机管理的内存也变得越来越大,随之而来的就是内存中存储的对象越来越多!那么遍历对象图的耗时也会变得越来越大!如何可以将这部分耗时缩减,那么带来的性能提升是系统性的!随着垃圾收集器的发展,并发的进行对象图的遍历技术应运而生。
要进行并发的可达性分析,有一个问题必须要解释:为什么要在一个能保证一致性的快照上才能进行对象的遍历?原因就是“对象消失”问题。

“对象消失”问题的证明过程是一个很复杂的过程,在这里我们只需要知道并理解“对象消失”即可。
引起对象消失问题有两个方面:
1.赋值器插入了从GC Roots到还没有被虚拟机访问过的对象之间的新的引用;
2.赋值器删除了非GC Roots节点但被虚拟机访问过的对象 到 还没有被虚拟机访问过的对象之间的直接或间接引用。

知道了“对象消失”的原因后就可以对症下药。虚拟机解决“对象消失”的方式有两种:增量更新、原始快照。

增量更新

增量更新破坏的是“对象消失”的第一个条件。当插入了从GC Roots到还没有被虚拟机访问过的对象的新的引用时,会记录下来各个GC Roots,等扫描完成后,虚拟机会再次把这写记录的GC Roots重新扫描。

原始快照

原始快照破坏的是“对象消失”的第二个条件。当删除了被虚拟机访问过的对象 到 还没有被虚拟机访问过的对象直接的引用后,会将这个要删除的引用记录下来,等扫描完成后,将再次依据这些引用记录重新扫描一次。可以简化为:无论对象引用关系是否删除,都会按照刚开始扫描的那一刻的对象图快照进行搜索。

增量更新和原始快照在Java虚拟机中都有实际的应用场景,比如CMS垃圾收集器就是基于增量更新,而G1垃圾收集器是基于原始快照。
解决了“对象消失”问题,就可以再一个一致性快照的环境中,并发的遍历对象图。从而提升可达性分析的效率!

本篇文章,探讨了垃圾收集背后的分代理论思想,以及垃圾收集具体的实现算法并探讨了各种算法的优劣以适用场景。除此之外,从安全回收的角度解释了“安全点”和“安全区域”的存在必要性,从高效回收的角度探讨了虚拟机为高效回收垃圾所做出的的各种努力,比如:卡表技术提升GC Roots遍历效率;增量更新、原始快照提升对象图遍历的效率。

实际上,区区8千多字是无法将虚拟机高效回收背后的原理完全解释通透,很多点我们没有也无法进一步探讨,如果实在想进一步探讨,可以研究相关学术论文。

你可能感兴趣的:(深入理解JVM虚拟机,Java基础)