垃圾回收算法的原理及应用

概述

有java开发经历的小伙伴必然对垃圾回收不陌生。垃圾回收简单来说就是一种自动的内存管理机制,当一个电脑上的某一块内存不在被使用时,就应该释放,供其他应用利用,而对该内存的回收过程,就称之为垃圾回收

那如何进行垃圾回收那?

简单来说只有两步:

第一步,找到垃圾。

第二步,将垃圾扔掉。

好,既然需要找到垃圾,那肯定需要对垃圾有一个判别标准,即"什么是垃圾?"

什么是垃圾?

就计算机而言,不在使用的内存空间就都可以称之为垃圾。在java中一切皆为对象,因而在java中,那些已经“死去”不再被使用的对象就可以称之为垃圾

如何找到垃圾?

我们知道 JVM在运行程序的过程中,必然会伴随着对象的创建与消亡,并且具体创建哪些对象,创建多少对象,这部分在程序运行前是不知道的,因而针对对象的分配和回收是动态的。所以在做垃圾回收之前,我们必须要有算法来区分这些“活着”的对象以及“死去”的对象。

引用计数算法

该算法整个过程如下:

首先在对象中添加一个计数器,每当有一个地方用到该对象的时候就将该计数器数加一;当引用失效时,计数器的值减一;当一个计数器的值减为零时,则认为该对象是不可能被使用的,即为“垃圾”对象。

客观来讲,虽然引用计数算法占据了一些额外的空间,但其算法原理简单,而且判定效率很高,因此它有着广泛的应用。

但该算法也有着其不足的一面,比如该算法很难解决对象的相互引用问题

例如下边的两个对象,对象A和对象B,两者相互引用。

image.png

因而导致即使对象A和对象B都不再使用,它们的引用计数器也不为零,如果单纯的使用引用计数算法,那么对象A和对象B永远不可能被定义为垃圾对象而被回收。

基于以上原因,当前主流的商用的程序语言的内存管理系统,都是通过可达性分析(Reachability Analysis)算法来判断对象是否存活的。

可达性分析算法

可达性分析算法判断一个对象是否存活的过程,就像一颗森林前序遍历,首先以一系列(注意根节点不止一个)称之为GC Root的根对象作为起始节点,根据对象的引用关系开始向下搜索,搜索过程中所走过的路径,称为引用链,如果一个对象到GC Root间没有任何引用链,则认为该对象是可被回收的

以下图为例

image.png

Object5、Object6、Object7即使相互引用,但由于和GC Roots没有任何引用链相连,这三个对象也是可回收的。

前边我们说了GC Roots是一系列根节点,那究竟什么样的对象可以作为GC Roots

在java技术体系中,固定可以作为GC Roots的对象包括以下几种:

  • 在虚拟机栈中所引用的对象,譬如各个线程被调用的方法堆栈中所使用到的参数、局部变量、临时变量。
  • 在方法区中类静态属性引用的对象,譬如java类的引用类型静态变量。
  • 本地方法中常量引用的对象,比如字符串常量池中的引用。
  • 本地方法栈中JNI(Native 方法)引用的对象。
  • java虚拟机的内部引用,比如基本数据类型对应的Class对象,一些常驻的异常对象等,还有类加载器。
  • 所有被同步锁(synchronized关键字)持有的对象。
  • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

当然除了这些固定的GC Roots集合之外,根据用户所选用的垃圾收集器以及当前回收的内容区域,也有一些其他的对象会被放到GC Roots集合中,此处不再详述。

"再谈引用"

纵观可达性分析算法执行的整个过程,对象的引用关系是及其重要的,但是否所有的引用都能够构成引用链?

JDK 1.2之前Java中对引用的定义十分传统,如果reference类型的数据中存储的数值代表另一块内存的起始地址,则该reference数据就代表了某块内存或者某个对象的引用。按照这个定义,一个对象的引用状态只有两种,被引用或者未被引用

但这样对引用的狭义定义在描述一些“可有可无”对象时,就会显得“力不从心”。比如描述一个对象在内存充足时可以驻留内存;当内存空间不足时,可被回收,释放空间。这里可能有些同学会有疑问,我们为什么会需要一个“可有可无”的对象的呢?

我们以一个例子来说明这个“可有可无对象”存在的必要性:

当我们点击一个浏览器的回退按钮时,回退时显示的网页应该重新请求还是应该从缓存中取?

这和浏览器的具体实现策略有关,如果一个网页在浏览结束就被回收,那么只能重新请求。如果浏览过的网页存于缓存中,那便可以从缓存中取。但如果全部都存缓存中,可能会造成大量内存的浪费,甚至会造成OOM。

但如果我们把一个网页对象定义成一个“可有可无”的对象,岂不完美的解决了这个问题?如果空间足够就缓存网页,增强用户体验,如果内存不足,回收空间,提高空间利用率😄 !!!

按照引用存在的必要性,将引用分成了四种:

强引用

强引用是最传统的引用,也就是程序代码之间普遍存在的引用赋值`,比如下边的代码:

Object obj = new Object();

内存空间不足时,JVM虚拟机宁可跑出OutOfMemoryError错误,使程序异常终止也不会随意会有具有强引用的对象来解决内存不足的问题。

如果强引用对象不使用时,需要弱化引用从而能够使GC回收,例如为引用赋空值。

obj = null;

软引用

软引用就是前边所说的"可有可无对象" ,当内存空间充足时,GC不会回收它,当内存空间不足时才会进行回收,并且在回收时,会按照那些长时间不用的弱引用对象,有点类似于"LRU算法"。

软引用创建可以通过SoftReference类来创建:

    // 强引用
    String strongReference = new String("abc");
    // 软引用
    String str = new String("abc");
    SoftReference<String> softReference = new SoftReference<String>(str);

弱引用

弱引用对象也是用来描述那些可有可无的对象,但它的强度比软引用要更弱一些,它只能生存到下一次垃圾收集发生之前,当垃圾收集器开始工作时,无论当前内存是否足够,弱引用对象都会被回收。我们可以通过WeakReference来创建一个弱引用对象:

 WeakReference<String> weakReference = new WeakReference<>(str);

虚引用

虚引用 顾名思义,就是形同虚设。与其他几种引用不同,虚引用完全不会影响其所指向对象的生命周期,也无法通过该引用获取对象的实例,它存在的主要作用就是用来跟踪对象 被垃圾回收器回收 的活动,即为了能在这个对象被垃圾回收时,收到一个通知。可以通过PhantomReference类来创建一个虚引用。

垃圾一定马上就会被回收吗?

那些被可达性分析算法,判定为不可达的对象,就一定会被回收吗?

答案明显是否定的,真正宣告一个对象死亡,需要经过两次标记过程。

我们借助一个流程图来说明

image.png

首先一个对象在进行可达性分析后发现没有与GC Root相连的引用链,就会对该对象就行第一次标记,随后进行一次筛选,筛选条件是否有必要执行finilize()方法。而判定finilize()需要被执行的情况只有一种:finilize()方法被重写过,且从来没有被调用过 。这两条任何一条不满足,虚拟机都会认为finilize()方法不需要执行。

这里的finilize()方法也是逃脱死亡命运的最后机会,后续对象就会正式进入回收流程无法拯救。具体流程如下:

  1. 将待回收对象放到到F-Queue队列中
  2. 等待收集器F-Queue队列中的对象进行第二次标记。
  3. 标记后的对象会进入到待回收集合中,等待收集器回收。
  4. 至此对象回收结束。

前边我们说了finilize()方法是对象回收自己的最后一次机会,那我们该如何“抓住”这个机会来拯救对象呢?

简单来说,只要我们在重写finilize()后,将该对象重新与引用链上的任意对象建立引用链即可,比如将自己的this关键字赋值给类变量或者对象的成员变量即可。具体代码如下:

Public class FinalizeEscapeGC {
	public static FinalizeEscapeGC SAVE_HOOK = null;
	@Override
	protected void finilize() throws Exception {
		super.finalize();
		FinalizeEscapeGC.SAVE_HOOK = this;
	}
}

上述对象在第一次被垃圾回收时,就会触发finilize()方法完成对对象的第一次拯救。但其拯救行动只会又一次。当下一次垃圾回收被触发时,对象依然会被回收,因为在第二次垃圾回收时,finilize()方法已经被执行过一次,会被判定没有必要被执行,直接进入回收流程。

如何对垃圾进行收集?

前边我们已经知道了如何判定某个对象是否是垃圾,但如果想要对垃圾进行回收还需要借助于垃圾回收算法来对垃圾进行收集。

具体垃圾收集算法关系如下图所示:

- 垃圾回收算法
  - 引用计数式垃圾收集
    - java虚拟机中未使用
  - 追踪式垃圾收集
    - 标记-清除算法
    - 标记-复制算法
    - 标记-整理算法

在正式垃圾具体的垃圾算法,我们需要先了解一下垃圾的分代收集理论。因为当前的垃圾收集器都基于该理论进行设计。

"分代垃圾回收理论"

分代收集理论名为理论`实际上就是一套符合大多数程序运行实际情况的经验法则,它建立在两个分代假说之上:

  1. 弱分代假说: 绝多数对象都是朝生夕灭的,即存活时间很短。
  2. 强分代假说:熬过越多次垃圾回收的对象,就越难以消亡。

基于以上原则,java收集器将java堆分成了不同的区域,然后需要回收的对象根据其年龄分配到不同的区域中进行存储。这样,如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,就把它们放在一起,每次回收都重点关注那些少量存活的对象,而不需要去标注那些大量需要被回收的对象,这样就可以以较低的代价回收大量的空间。对于那些难以消亡的对象,把它们放在一起,虚拟机便可以以较低的频率来回收这个区域,从而就兼顾了垃圾回收的时间开销和空间利用率。

另外由于对象进行了分代,因而免不了会有跨代引用的情况发生,为了解决该问题,就提出了第三条经验法则:

  1. 跨代引用假说: 跨代引用相对于同代引用来说仅仅占了极少数

基于这条理论,我们就没有必要为了少量的跨代引用区扫描整个老年区,也没有必要梁飞空间专门记录每一个对象是否存在以及存在哪些跨带引用,只需要在新生代上建立起一个全局的数据结构--"记忆集" 来解决。

标记-清除算法

首先最早出现也是最基础的垃圾回收算法是“标记-清除”算法,它回收的整个过程如下图所示:

image.png

该算法如其名称一样,分成“标记”和“清除”两个阶段:

首先标记处所有需要回收的对象,然后统一进行回收,当然反之也是可以的,标记存活的对象,然后统一回收未被标记的对象。

但该算法有两个明显的缺点:

  1. 执行效率不稳定,标记和清除的执行效率会随着对象数量的增长降低
  2. 空间问题,可能造成大量的内存碎片。

标记-复制算法

为了解决标记-回收算法存在的两个问题,有人提出了半区复制算法,它将内存空间分成大小相等的两块,每次只是用其中的一块,当这一块内存空间使用完后,一次性将该块空间的内容复制到另一块空间中,然后清空该块空间。

image.png

这个算法虽然引入了复制开销,但是有效的解决了空间碎片的问题,但这种算法的缺陷也显而易见,就是将可用的内存缩小到了原来的一半,空间利用率降低。

为了提高空间的利用率,大神Appel根据对象“朝生夕灭”的特点,提出了一种更加优化的分区复制分代策略,称之为Appel式回收。

虚拟机将java堆分成新生代老年代,新生代又被分成Eden区From区To区三块区域。

其中我们把 Eden : From Survivor : To Survivor 空间大小设成 8 : 1 : 1,新创建的对象总是Eden区创建,From区存放当前存活的对象。To区为空,一次gc发生后:

Eden区中存活的对象和From区中的对象复制到to区,然后清空Eden区和From区;交换From区和To区的逻辑关系,即From区变成To区,To区变成From区。

整个过程中,可以看出只有Eden区快慢的时候才会触发Miror GC(新生代垃圾回收),而Eden区占整个新生代的大多数,因而Miror GC的频率大为降低。

这里可能会有小伙伴问,为何要预留一个To空间来复制

这个主要是为了保证程序运行的实时性,执行的过程中可以不停顿。因为当前使用的空间如果直接回收,强制终止当前运行的进程,影响程序执行的实时性

同时能够解决在垃圾回收过程中产生的内存碎片的问题,提供空间的利用率

那这个8:1:1这个比例又是怎么来的?

这个是HotSpot虚拟机的默认设置,也即为新生代中可被回收的内存空间为整个新生代容量的90%。当然由于这个比例是在仅仅是在“普通场景”下测试得来的,在实际运行时,谁也无法保证每次只有报超过10%的对象可被回收。因此Appeal式回收又增加了一个逃生门设计。当Survior空间不足以容纳一次Minor GC之后存活的对象时,就需要依赖其他内存区域(大多数情况下老年代)来进行担保分配

具体分配时就是在to Surivor空间没有足够空间存放上一次新生代收集下来的存活对象,这些对象就可以通过担保分配机制直接进入老年代

当然新生代对象直接进入老年代会有一些风险,因此在时间分配时,虚拟机会执行以下逻辑:

  1. 判断老年代连续空间是否大于新生代所有对象总空间,如果满足该条件,则可以保证此次Minor GC是安全的。
  2. 如果不成立则查看-XX:HandlePromotionFailture参数是否允许担保失败,不允许的话,直接进行一次Minor GC
  3. 如果允许则继续检查老年代最大可用空间是否大于历次晋升到老年代对象的平均大小,如果大于,就进行Minor GC,如果小于则要进行一次Full GC

标记-整理算法

优化后的Appel算法似乎非常好用,但它必须要要有空间来进行"担保分配",比如新生代在使用该算法时,可以通过老年代来进行担保。但老年代使用的时候怎么办?似乎并没有额外的空间可以为老年代提供担保,因而老年代一般不能直接使用该算法。

针对老年代对象的存亡特征,一位大神提出了标记-整理算法,其中标记过程和原来一样,但是后续步骤不是直接对可回收的对象进行回收,而是把所有存活的对象向内存的一端进行移动,然后直接清理掉边界以外的内存。 具体的过程如下图所示:

image.png

当然由于这个标记-整理算法是一个移动式回收算法,因而每次回收开销极大,会出现“Stop The World”现象,会影响程序的实时性,造成延迟。

因而该算法会用在关注高吞吐量的收集器上,比如Parallel Scavenge收集器,而关注低延迟的收集器比如CMS收集器则会使用标记-清除算法,在空间碎片过多时再通过标记-整理算法来对碎片进行整理。

垃圾收集算法工程实现

前边讲的各种垃圾回收算法更多的只是停留的理论层面之上,那它们是如何在工程上得到应用的呢?

下边我们展开来说:

- 垃圾收集器
	- Serial收集器
	- ParNew收集器
	- Parallel Scavenge收集器 
	- CMS收集器
	- G1收集器

Serial 收集器

Serial收集器是最古老的垃圾收集器,从它的名字Serial(串行)就大概能够猜出它是一个单线程工作的垃圾收集器,它在工作时,必须暂停其他线程的工作直到它完成收集工作。其工作的示意图如下所示:

image.png

很明显对于该收集器会有Stop The World情况发生,但它也不是一无是处,由于其简单而高效(与其他收集器的单线程相比) 。Serial 收集器由于没有线程交互的开销,自然可以获得很高的单线程收集效率。Serial 收集器对于运行在 Client 模式下的虚拟机来说是个不错的选择。

ParNew收集器

ParNew 收集器其实就是 Serial 收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等等)和 Serial 收集器完全一样。

新生代采用标记-复制算法,老年代采用标记-整理算法。

image.png

它是许多在Server模式下的虚拟机的首要选择,除了Serial收集器之外,它只能和"CMS收集器"配合工作。

Parallel Scavenge收集器

Parallel Scaveng收集器从表面的一些特性跟PraNew收集器一样,比如新生代都是通过标记-复制算法来收集,老年代则采用标记-整理算法来收集

但它最大的不同点在于:与CMS收集器尽可能缩短垃圾收集时用户线程的停顿时间,Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(高效地利用CPU资源)

所谓吞吐量就是 CPU 中用于运行用户代码的时间与 CPU 总消耗时间的比值。

Serial Old收集器

Serial 收集器的老年代版本 ,它同样是一个单线程收集器。它主要有两大用途:一种用途是在 JDK1.5 以及以前的版本中与 Parallel Scavenge 收集器搭配使用,另一种用途是作为 CMS 收集器的后备方案。

Parallel Old收集器

Parallel Scavenge 收集器的老年代版本 。使用多线程和“标记-整理”算法。在注重吞吐量以及 CPU 资源的场合,都可以优先考虑 Parallel Scavenge 收集器和 Parallel Old 收集器。

CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用。

CMS(Concurrent Mark Sweep)收集器是 HotSpot 虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。

从名字中的Mark Sweep 这两个词可以看出,CMS 收集器是一种 “标记-清除”算法 实现的,它的运作过程相比于前面几种垃圾收集器来说更加复杂一些。整个过程分为四个步骤:

  • 初始标记: 暂停所有的其他线程,标记与GC Roots直接相连的对象,速度很快 ;
  • 并发标记: 同时开启 GC 和用户线程,从GC Roots直接相连的对象出发,遍历整个对象图
  • 重新标记: 重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短
  • 并发清除: 开启用户线程,同时 GC 线程开始对未标记的区域做清扫。

image.png

从它的名字就可以看出它是一款优秀的垃圾收集器,主要优点:并发收集、低停顿 。但是它有下面三个明显的缺点:

  • 对 CPU 资源敏感; 当CPU核心数少的时候,导致应用程序执行速度突然大幅度变慢。
  • 无法处理浮动垃圾,因为它在回收的过程中,用户进程继续运行会伴随这新的垃圾产生,而这部分垃圾需要等到下次GC时才能被回收。
  • 它使用的回收算法-“标记-清除”算法会导致收集结束时会有大量空间碎片产生。

G1收集器

G1 (Garbage-First) 是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征.

在空间划分上,较之于之前的收集器最大不同在于,G1收集器不再坚持固定大小以及固定数量的分代区域划分而是把连续的Java堆划分成多个大小相等且独立的区域(Region),每个区域都可以根据其需要扮演Eden空间,Surivor空间或者老年代空间。

同时因为它将Region作为单次回收目标,它可以建立可预测的时间模型,来提高回收的效率,优先回收那些价值高的空间(单位时间内回收的空间大)。

image.png

具体来说G1收集器的运作过程分为以下四个步骤:

  1. 初始标记:标记与GC Roots直接相连的对象,修改TAMS指针。
  2. 并发标记
  3. 最终标记
  4. 筛选回收:负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,然后将需要回收的那部分Region复制到空的Region中,然后清理掉旧的Region

CMS回收期和G1回收器都十分优秀,但具体场景上,如果内存较小(小于6个G)那么CMS收集器会更占优势,反之则G1收集器会更好。

总结

本篇文章我们主要总结jvm虚拟机在进行垃圾回收时所使用的算法和原理以及其工程实现,纵观这么多垃圾回收算法,我们发现并没有一个万金油式的算法,每种算法以及垃圾收集器都是为了解决某一类问题而设计出来的,都有对应的Trade Off,需要我们根据应用场景加以甄别使用。

引用

  1. 理解Java的强引用、软引用、弱引用和虚引用
  2. JVM垃圾回收
posted @ 2021-04-07 23:37  vcjmhg  阅读(345)  评论(0编辑  收藏  举报