[JVM专题]Java的GC算法种类(一)

[JVM专题]Java的GC算法种类(一)

序言

所谓GC,其实就是Garbage Collection,意思就是垃圾收集的意思,GC算法就是垃圾回收算法。在Java中,GC的主要对象是堆内存(其实还有永久区,位于方法区中,不过在JDK1.8中永久区已经彻底除去了)。

在C++中,在新建了一个指针之后,我们还需要在使用完毕后手动将这个指针释放掉,否则这个内存就会一直被占用着。而在Java中则不需要担心这个问题,因为在JVM中有一个专门用于垃圾回收的线程在后台监控着,自动将一些无用的内存释放,目的是防止一些由程序员的疏忽(忘记释放指针)等人为操作引起内存泄漏,再进一步讨论之前我们先提出几个问题:

  1. 垃圾的定义是什么?(what)
  2. 什么时候回收垃圾?(when)
  3. 回收垃圾的时候才用什么方法?(how)
  4. 为什么要回收这些垃圾?(why)

我们围绕这几个问题浅谈一下关于Java的垃圾回收机制。


垃圾的定义

在Java中,垃圾的定义即为无用的对象,也就是说一个对象实例,如果没有引用再指向它了,那就表示已经没有任何部分需要使用到它,那么它就是一个内存垃圾。这个是针对一个程序运行过程而言的,也就是JVM运行过程,在这个过程从产生的垃圾,JVM会对其进行清理。


为什么要回收垃圾?

堆的大小是有限的,如果在程序运行过程中我们不断地产生对象,在使用完毕后却不将其释放,那么不被释放的对象始终会占着堆内存的资源,随着程序的不断进行,对象越来越多,可是堆内存的空间越来越少,直到某一个时刻,堆内存空间被这些无用的对象占满了,新的对象无处安放,就会造成内存泄漏和程序崩溃。


什么时候需要回收这些垃圾?

当然这些内存垃圾不是时时刻刻都在回收的,那样太浪费时间了,当我们需要向堆内存申请空间而发现堆内存空间不足的时候,后台的监听线程就会自动触发GC算法,帮我们回收无用的垃圾,腾出空间。


下面介绍几种垃圾回收的算法:


  • 引用计数法

这个算法的思想很简单,既然我们说当一个对象A不再被引用时为垃圾对象,那就设置一个计数器,所以对于对象A,只要有任何一个对象引用了对象A,那么引用计数器就加一;而当这个引用失效了之后,引用计数器就减一。当一个对象的引用计数器的值为0的时候,那么这个对象就不可能再被使用了。

但是引用计数法有一个很麻烦的问题,就是它无法处理垃圾对象的循环引用,来看看下面这个图:

由于这些循环引用对象的引用计数器不为0,无法删除。

由于这种算法存在上述缺陷,所以JVM其实并不使用它,而是使用另一种算法,是根搜索算法。这种算法是现代垃圾回收算法的思想基础。它的做法是:把一些对象设为根对象(也可以说是根结点),当任何一个根结点都不可达某一个对象的时候,这个对象就被认为是可以回收的。基于这种思想,上述引用计数法存在的问题就可以解决了,由于根对象无法到达那三个循环引用的对象,所以这三个对象都是视为可回收的垃圾。

那么什么对象可以作为根对象呢?

  1. Java虚拟机栈中引用的对象;
  2. 方法区中的类静态成员引用的对象(static修饰的);
  3. 方法区中的常量引用的对象(主要是final修饰的);
  4. 本地方法栈中JNI(Java Native Interface)引用的对象。

  • 标记-清除法

标记-清除法将垃圾回收分为两个阶段,即标记阶段和清除阶段。做法是,首先在标记阶段,遍历所有的根结点,将这些根结点的可达对象进行标记;而在清除阶段,遍历堆中的所有对象,对那些没有被标记的对象进行清除。

我们来看看一个示意图:

标记-清除算法示意(标记阶段)

上图中的整个表格是我们用来模拟堆内存的,其中表格中存储的是一系列的对象,我们从根结点出发,深蓝色的表格中的对象表示的是从根结点出发的可达的对象,而灰色表格中表示的是从根结点出发不可达的对象,而其余浅蓝色的表格表示的还是空闲的堆内存。上述进行的是标记阶段,由根结点出发可达的对象都进行了标记。

标记阶段完成后,下面就是清除阶段,对没有进行标记的对象进行清除:

标记-清除算法示意(清除阶段)

至此标记-清除算法就执行完毕。


  • 标记-压缩算法

基于上一种算法的缺点,标记-压缩算法是对标记-清除算法的一种改良,它们的工作原理差不多,也是分为两个阶段:标记阶段和压缩阶段。标记阶段和标记-清除算法一样;而在压缩阶段的时候,它不是将所有的未标记的对象清除,而是将所有的标记对象压缩熬堆内存的一段,然后清除边界以外的所有空间。

标记-压缩算法示意(标记阶段)

标记完后,把对象进行压缩:

标记-压缩算法示意(压缩阶段):压缩对象

压缩完毕:

标记-压缩算法示意(压缩阶段):压缩完毕

清除边界以外的区域:

标记-压缩算法示意(压缩阶段):清除边界外区域

至此标记-压缩算法执行完毕。


  • 复制算法

复制算法的思想是,将堆内存分为两块大小完全相同的内存(不一定是全部堆内存空间),每一次只用一块(活跃空间),另一块(空闲空间)闲置不用。当其中的活跃空间使用完后,就将活跃空间中还存活的对象复制到空闲空间里去,按照地址整齐排好序。之后清除活跃空间中所有的对象。然后两者交换使用职位,现在空闲空间成为了活跃空间,活跃空间成为了空闲空间。

首先将存活对象复制到空闲空间:

复制算法(复制存活对象)

转换两个空间的角色,以及清空原来的活跃空间:

至此,复制算法执行完毕。


接下来我们来比较一下这三种算法:

标记-清除算法:

  1. 首先是速度慢,因为标记-清除算法在标记阶段需要使用递归的方式从根结点出发,不断寻找可达的对象;而在清除阶段又需要遍历堆中的所有对象,查看其是否被标记,然后清除;并且其实在程序进行GC的时候,JVM中所有的Java程序都要进行暂停,俗称stop-the-world,后面会提到。
  2. 其次是其最大的缺点,使用这种算法进行清理而得的堆内存的空闲空间一般是不连续的,我们知道对象实例在堆中是随机存储的,所以在清理之后,会产生许多的内存碎片,如果这个时候来了一个很大的对象实例,尽管显示内存还足够,但是已经存不下这个大对象了,内存碎片太多会导致当程序需要为较大对象分配内存时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。再者,这种零散的碎片对于数组的分配也不是很方便。

标记-压缩算法:

  1. 首先这种算法克服了标记-清除算法中会产生内存碎片的缺点,也解决了复制算法中内存减半使用的不足。
  2. 而其缺点则是速度也不是很快,不仅要遍历标记所有可达结点,还要一个个整理可达存活对象的地址,所以导致其效率不是很高。

复制算法:

  1. 复制算法很明显的缺点就是浪费内存空间,因为将内存分为两块,一次只能使用一块,这也意味着分的块越大,浪费的内存越多。而且当对象的存活率很高的时候,不断的复制操作也会显得费时和不可忽视。
  2. 但是也是拖了浪费内存的福,复制算法执行的速度较快,典型的空间换时间。

关于stop-the-world

这个是在GC算法执行的时候产生的现象:在GC算法执行的时候,所有正在执行中的 Java程序都会被挂起(被暂停 ),只有native方法可以执行,但是也不能和JVM进行交互,这样一来似乎整个Java世界都停止了,这也就是为什么叫做stop-the-world。等到GC程序执行完毕后,Java程序才会重新恢复执行。

这个其实很好理解,因为GC程序是一个线程,Java程序也是一个线程,它们操作的堆内存是一片共享的区域,假设一种情况,Java程序A新建了一个对象object,new Object()被存放在堆区,但是很不巧的是,堆区刚刚执行过复制算法,前一步存活的对象已经被转移到另一块空间了,而new Object()就留在了原来的空间,无辜地被清除了。这显然是不可接受的,因为线程不安全。


总结

对于引用计数法,我们了解一下就好,因为它并不适用于Java这门语言(对于很多语言也是如此)。

对于根搜索算法,整体上来说,复制算法是三种算法中效率最高的,但是它太浪费内存了;而标记-压缩算法尽管在空间利用上比复制算法好,但是它在效率上还是吃了亏;而对于标记清除算法,它的速度比标记-压缩算法还要慢一些,并且会产生内存碎片,所以尽管它是一个相当于爷爷级的算法,但是还是将它放入历史进程吧。


下一篇将要介绍的是现在最常用的垃圾回收算法:分代收集算法。

编辑于 2018-08-09 21:19