3分钟秒懂 JVM 中垃圾对象的回收算法!

01、背景介绍

在之前的文章中,我们介绍了 JVM 内部布局对象的创建过程以及运行期的相关优化手段

今天通过这篇文章,我们一起来了解一下对象回收的判定方式以及垃圾对象的回收算法等相关知识。

02、对象回收判定方式

当一个对象被创建时,虚拟机会优先分配到堆空间中,当对象不再被使用了,虚拟机会对其进行回收处理,以便释放内存空间,这个过程也被称为垃圾对象回收。

那么如何找到对象是否可以进行回收呢?一般有两种方式。

  • 引用计数法
  • 可达性分析法

下面我们一起来了解下相关知识。

2.1、引用计数法

这个方法的实现思路是:在对象中维护一个引用计数器,每当一个地方引用这个对象时,计数器值+1;当引用失效时,计数器值-1。当对象的计数器值为 0,表示这个对象不再被使用了,可以被回收。

这种方法使用场景很多,但很少有垃圾收集器会使用这种方式。

原因在于:这种方式存在一个致命的缺陷,比如堆中的两个对象相互引用,此时他们的计数器值是 1,但这两个对象并没有被外部使用,因此不会被回收,容易造成内存泄露。

2.2、可达性分析法

这个方法的实现思路是:从“GC Roots”(这个 GC Roots 可以是栈中的引用变量,也可以是方法区的引用变量或常量)开始扫描堆中的对象,沿着 GC Roots 一路扫描,被扫描的所有对象全部标记为存活对象;扫描完成之后,没有被标记的视为垃圾对象,可以被回收。

比如对象 A 被线程占中的变量 a 引用着,对象 A 中引用着对象 B,对象 B 又引用着 C 等,沿着 a 开始扫描,会扫描到对象 A,B,C 等,并把它们标记为存活对象。全部扫描完成之后,当一个对象到 GC Roots 没有任何引用链时,表示此对象是不可用的,等待被 GC 回收。

在 JVM 中,可以作为 GC Roots 的对象包括:

  • 虚拟机栈中引用的对象
  • 方法区中静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中 JNI(即 Native 方法)引用的对象

03、垃圾回收算法

当一个对象被判定为垃圾对象之后,剩下的工作就是如何进行回收了。

下面我们一起来看看常见的几种垃圾回收算法的思想。

3.1、标记-清除算法

标记-清除算法如同它的名字一样,分为“标记”和“清除”两个阶段,也是最基础的算法。

首先标记出所有需要回收的对象,标记完成后统一回收所有被标记的对象。之所以说它是最基础的收集算法,是因为后续的收集算法都是基于这种思路并对其缺点进行改进而得到的。

这个算法也有很多的不足,主要体现在效率和空间。

  • 从效率的角度讲,标记和清除两个过程的效率都不高;
  • 从空间的角度讲,标记清除后会产生大量不连续的内存碎片,空间碎片太多可能会导致后面的程序运行过程中分配较大对象时,无法找到足够的连续内存而不得不提前触发一次垃圾收集动作

标记-清除算法执行过程,可以用如下图来概括:

3.2、复制算法

复制算法是为了解决效率问题而出现的,它将可用的内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

这样每次只需要对整个半区进行内存回收,内存分配时也不需要考虑内存碎片等复杂情况,只需要移动指针,按照顺序分配即可。

这个算法也有缺点,操作的时候内存会缩小为了原来的一半,代价很高;其次,持续复制长生存期的对象会导致回收效果不佳,效率较低。

一般的商用虚拟机会采用这种算法来回收新生代(也称为年轻代)的对象,不过研究表明1:1的比例不是很科学,因此新生代的内存空间被细划分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor;每次回收时,将 Eden 和 Survivor 空间中还存活的对象一次性复制到另外一块 Survivor 空间上,最后清理掉之前的 Eden 和 Survivor 空间。

HotSpot 虚拟机默认 Eden 和 Survivor 区的比例是8 : 1 : 1,期望每次回收后只有不到 10% 的对象存活,如果出现 Survivor 空间不够用时,需要依赖老年代进行分配担保。

复制算法执行过程,可以用如下图来概括:

3.3、标记-压缩算法

在上面我们提到了复制算法的优点和缺点,针对对象存活率较高的场景,进行大量的复制操作时,效率很低下。如果不想浪费 50% 的空间,当对象 100% 存活时,那么需要有额外的空间进行分配担保。

在 HotSpot 虚拟机中,堆空间划分成两个不同的区域:新生代老年代,目的是为了更有效率的回收对象。新生代的对象存活率低,会优先被回收,如果多次执行依然没有被回收,就会转移到老年代。老年代都是不易被回收的对象,对象存活率高,因此一般不能直接选用复制算法。

根据老年代的特点,有人提出了另外一种标记-整理算法,也称为标记-压缩算法,过程与标记-清除算法一样,不过不是直接对可回收对象进行清理,而是让所有存活对象都向一端移动,然后直接清理掉边界外的内存。

标记-整理算法执行过程,可以用如下图来概括:

3.4、分代收集算法

分代收集算法,可以看成以上内容的延伸。它的实现思路是根据对象的生命周期的不同,将内存划分为几块,比如把堆空间划分为新生代老年代,然后根据各块的特点采用最适当的收集算法。

在新生代中,存在大批对象死去、少量对象存活的特点,会采用“复制算法”,只需要付出少量存活对象的复制成本就可以完成垃圾对象收集,效率高;在老年代中,存在对象存活率高、没有额外空间对它进行分配担保的特点,会采用“标记-清理”或者“标记-整理”算法来进行回收。

可以用如下图来概括堆内存的空间布局:

04、小结

最后总结一下,对象的回收判断方式,JVM 主要采用可达性分析法来处理。

至于对象的回收方式,会根据对象所在内存区域的不同,采用不同的垃圾收集算法来处理。对于年轻代的对象,主要采用复制算法;对于老年代的对象,标记-压缩算法应用比较广泛。

05、参考

1.https://zhuanlan.zhihu.com/p/267223891

2.https://www.cnblogs.com/xrq730/p/4836700.html

3.https://zhuanlan.zhihu.com/p/248709769

4.http://www.ityouknow.com/jvm/2017/09/28/jvm-overview.html

2