Java垃圾回收算法详解

1 概述

前一篇文章中讲到了Java虚拟机的基础知识和运行时数据区的划分,在运行时数据区的划分中,可分为线程共享区域和线程私有区域,而Java的垃圾回收就发生在线程共享区域中,更直观的说法就是Java的垃圾回收大部分都发生在Java的堆(Heap)区域内。

1.1 哪些对象需要回收

在了解了Java垃圾回收主要发生在哪些区域之后,然后我们就需要知道在Java中哪些对象是需要被当做垃圾回收的,这是一个很简单的问题,既然是需要回收的,那肯定就是在程序中已经使用过且后续不会再用到的对象,那怎么判断一个对象是否还会被用到呢?答案就是引用。

假设一个对象A,在程序中被其他对象所引用,那么对象A就不能被回收,如果对象A在程序中没有被任何其他对象引用,那么对象A就会在发生垃圾回收的时候被回收掉。

1.1.1 引用计数算法

那么问题又来了,在Java中如何判断一个对象是否被引用呢?其实最简单的一个算法就是引用计数法。引用计数法的原理也很简单,在每一个对象中添加一个引用计数器,每当有其他地方引用到当前对象时,计数器的值就加1;当引用失效时,计数器的值就减1,这样在垃圾回收的时候就直接判断当前对象的引用计数器的值,如果值为0,就表示当前对象已经没有任何地方引用,可以回收,反之若计数器的值不为0,则表示当前对象还被其他地方引用,不回收。

但是引用计数法有一个最大的缺陷,就是循环引用问题,见下图:

image.png

如图所示,有两个对象ObjectA和ObjectB,ObjectA对象引用ObjectB对象,ObjectB对象引用ObjectA对象,两个对象之间形成了一个相互引用的关系,所以两个对象的计数器值都是1,即使这两个对象在程序中已经没有被用到了,但是垃圾回收的时候还是不会去回收这两个对象。

这还只是两个对象的相互引用,如果是成百上千个不使用的对象之间相互引用形成一个循环引用,那么对Java堆空间会造成极大的浪费。

因此,在Java垃圾回收中并没有使用引用计数法,使用的而是另外一种算法:可达性分析算法。

1.1.2 可达性分析算法

可达性分析算法的思路就是通过一系列GC Roots作为根对象,这些GC Roots根对象都是不会被垃圾回收的一些对象,然后根据这些GC Roots的引用关系向下搜索,在搜索过程中所走过的路径就是“引用链”,如果一个对象从GC Roots开始无法通过引用链搜索到的话,那么这个对象就是不可达的,则表示这个对象在程序中不再被使用,可以被回收。

image.png

如上图所示,由多个GC Roots根对象可组成一个GC Root Set集合,只要是从GC Roots根对象通过引用链能够达到的对象就是在使用的对象,而图中的Object5,Object6,Object7三个对象之间虽然相互有引用,但是它们到GC Roots是不可达的,因此这三个对象会被判定为可回收对象。

在Java中,可以被作为GC Roots的对象有以下这些:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象。
  • 方法区中类静态属性引用的对象。
  • 方法区中常亮引用到的对象。
  • 本地方法栈中引用到的对象(也就是Native中引用到的对象)。
  • Java虚拟机的内部引用,如基本数据类型对应的Class对象,异常对象,系统类加载器。
  • synchronized持有的对象。
  • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

2 垃圾回收算法

在了解完垃圾回收主要发生的区域以及如何判断对象是否可被回收之后,再需要了解的就是垃圾回收算法,垃圾回收算法就会使用到以上提到的可达性分析算法标记对象是否可被回收。

2.1 标记清除算法

标记清除算法是最早出现并且也是最简单的一种垃圾回收算法,在整个垃圾回收过程中总共分为标记和清除两个步骤:

  1. 标记需要回收的对象。
  2. 将第一步标记的对象进行统一的回收。

image.png

上图就是标记清除算法的两个步骤,使用标记清除算法虽然简单,但是标记清除算法存在两个缺点:

  1. 执行效率不稳定,如果Java堆中包含有大量的需要回收的对象,那么使用该回收算法的时候就需要去标记大量的对象,然后再去对标记的对象进行回收,导致该回收算法的效率会随着对象数量的增长而降低。
  2. 内存碎片化问题,通过上图可以看到,当完成对象回收之后,未使用的内存区域呈现无规律的随机分布,这就造成了内存碎片化的问题,如果当前Java程序中需要实例化一个大对象,但是由于内存碎片化的问题导致无法分配一个连续的大内存空间,就会导致提前又触发一次垃圾回收。

标记清除算法常被使用在老年代中,因为老年代中的对象大部分都不会被清理掉,只存在于少部分的对象会被清理,所以在老年代中不会存在标记大量对象并清除的情况。

2.2 标记复制算法

针对于标记清除算法存在的问题,在标记清除算法的基础之上,又提出了一种算法:标记复制算法,标记复制算法中将内存区域划分为两块大小一致的区域,每次只使用其中一块,当这一块的内存用完了之后就将还存活的对象复制到另一块区域上去,然后将当前块的内存空间一次清理掉,假设内存分为A和B两块,首先使用A内存,B作为保留,则标记复制算法步骤如下:

  1. 在A中标记需要回收的对象
  2. 将A中存活的对象复制到B中
  3. 将A的内存空间一次全部清理掉

image.png

上图是标记复制算法的回收步骤,该算法同样存在以下问题:

  1. 复制回收算法将原有的内存区域划分为两块,且每次只使用其中一块,可使用内存相比标记清除算法缩小了一半,对空间浪费太大。
  2. 如果内存中存活的对象太多,在复制的过程中复制大量的存活对象会造成效率低下,额外开销大,但如果内存中存活的对象为极少数,那么复制算法无疑是很好的回收算法。

标记复制算法适用于Java堆中的新生代部分的垃圾回收,因为在新生代区域中,正常情况下能够存活下来的对象只有极少数(2%或3%?),这样复制算法就不存在大量复制对象的情况,针对于空间浪费问题,将新生代区域划分为一块较大的Eden区域和两块较小的Survivor区域,每次分配对象时使用Eden和其中一块Survivor,当发生垃圾回收时,将Eden中和Survivor中存活的对象复制到另一块Survivor中,然后将Eden和已使用过的Survivor内存空间直接清理掉,下一轮分配对象时使用Eden和之前保存了存活对象的那一块Survivor,依次循环。

关于新生代划分可看上一篇文章:JVM入门

HotSpot虚拟机默认的Eden和Survivor的大小比例为8:1,Eden占新生代80%,两个Survivor各占10%,这样每次可使用的内存空间就是90%,比标记复制算法默认的等分50%要多得多。

逃生门安全设计:当Survivor空间不足以容纳一次垃圾回收之后存活的对象时,就需要依赖于其他区域(老年代)进行分配担保。

2.3 标记整理算法

在老年代中大部分对象都会存活下来,所以不适合标记复制算法,而且使用标记清除算法又会造成大量内存碎片,因此针对于老年代对象的死亡特征,又提出了一种回收算法:标记整理算法,标记整理算法的标记步骤和标记清除算法一致,但是在标记之后不会直接进行清除,而是首先将存活的对象向内存空间的一端进行移动,将所有存活的对象都连续的放在一起,然后直接清理掉边界以外的内存,标记整理算法步骤如下:

  1. 标记需要回收的对象
  2. 将存活的对象向内存空间的一端移动
  3. 直接清理掉边界以外的内存

image.png

在老年代中常使用的垃圾回收算法是标记清除算法和标记整理算法。

如果使用标记整理算法,在老年代区域中每次回收都有大量的对象存活,并且移动对象时需要更新所有对象的引用,所以会造成比较大的系统开销,而且对象移动操作必须全程暂停用户应用程序(Stop The Word)才能进行。

如果使用标记清除算法,则会造成内存碎片。

基于以上两种垃圾回收方式,各自都有优点和缺点,所以不同的垃圾收集器在老年代中所采用的垃圾回收算法也是有区别的,例如Parallel Scavenge收集器采用的就是标记整理算法,而CMS收集器则采用的是标记清除算法。

还有就是两种算法配合使用,首先采用标记清除算法,当内存碎片达到一定程度的时候使用标记整理算法回收一次,整理一下内存碎片,然后再又继续使用标记清除算法,前面提到的CMS收集器就是首先采用标记清除算法,当内存碎片过多然后采用标记整理算法。

关于垃圾收集器下一篇文章详解。

参考资料:《深入理解Java虚拟机 第三版》 周志明

猜你喜欢

转载自juejin.im/post/7019591846770769927