垃圾回收相关算法

这里介绍的垃圾回收相关算法,主要解决的问题:

判断哪些内存是垃圾(需要回收的)?

常用的两种算法:

  • 引用计数
  • 可达性分析(GC Root)

首先介绍算法前,得定义:

如何判断一个对象的死亡?

我们一般这样定义:当一个对象不再被任何存活的对象继续引用的时候,这个对象就死亡了。

引用计数

引用计数算法,是给每一个对象添加一个计数器,当有对象引用它的时候,计数器+1,当有对象取消对它的引用时,计数就会-1。

当计数器的值为 0 时,即说明没有对象引用它,也就是这个对象死亡了。

这种算法很简单,但是有个重大缺陷,那就是无法解决循环引用的问题。

什么是循环引用问题呢?

比如对象A 引用 对象B,对象B 引用 对象A,那么 对象A 和 对象B 的计数器都为1。但是如果后续的运行环境再也用不到对象A 和 对象B,那么就造成了内存泄漏。

上图就是循环引用的例子。对象引用 Obj1 和 Obj2 在栈中,然后分别指向在堆中的具体实例。然后两个相互实例中的成员互相引用。那么对于堆中的对象而言,就有2个引用。一个是来自Obj1,一个来自堆对象的另一方。

如果,现在将 Obj1 指向 nu l l,那么就如下图:

这个时候,引用已经不可用了,但是堆中的对象仍然相互引用,他们的计数器不为0,所以无法死亡。

但是,Java 没有使用这种算法,而是使用了我们后面说的可达性算法,所以接下来的演示,GC 会将这种情况的内存给其清理。

package GC;

public class ReferenceCountGC {
    public Object instance = null;

    private static final int _1MB = 1024 * 1024;
    // 每个对象中包含2M的成员,方便观察
    private byte[] bigSize = new byte[2 * _1MB];
    public static void main(String[] args) {
        ReferenceCountGC objA = new ReferenceCountGC();
        ReferenceCountGC objB = new ReferenceCountGC();
        objA.instance = objB.instance;
        objB.instance = objA.instance;

        //取消对对象的引用
        objA = null;
        objB = null;
      // 是否进行垃圾回收
        System.gc();
    }
}

这段代码实现的就是上面图片所描述的情况。

首先,我们将 System.gc() 注释掉,也就是我们在默认情况下,不去触发垃圾回收。并在运行的时候,添加参数 -XX:+PrintGCDetails。我们观察输出结果

可以看到,这个时候,占用的空间为8M左右。

如果我们取消注释,也就是主动去调用垃圾回收器,那么运行结果为:

占用空间为2M左右。

可以看出来,Java 的垃圾回收,并非采用我们上面介绍的引用计数方式。

可达性分析

可达性算法,还有一系列的别名:根搜索算法,追踪性垃圾收集,GC Root。

之后,看到原理,其实这些别名都是描述原理的。

首先,我们选取一些对象,这些对象是存活的,也被称为 GC Roots,然后根据这些对象的引用关系,凡是直接或者间接跟 GC Roots 相关联的对象,都是存活的。就像图中的连通性判断一样。

这个算法的想法不难。难的是,如何确定 GC Roots。

我们考虑,我们什么时候需要用到对象?(我们需要对象的时候,肯定需要这个对象是存活的)

  • 栈中保存着,我们当前或者之后需要运行的方法及相关参数,所以,栈上所引用的堆中对象肯定是存活的。
  • 类中的一些属性,比如,静态属性,因为它不依赖于具体的类
  • 一些常用的对象,以免清理后,又要重复加载,比如常用的异常对象,基本数据类型对应的 Class 对象。

除此之外,还有很多零零碎碎的。

在堆结构周围的一些结构,其中引用的对象可以作为GC Roots

具体 GC Roots 可以概括为:

  • 虚拟机栈上(确切的说,是栈帧上的本地变量表)所引用的对象

  • 本地方法栈引用的对象

  • 方法区中的静态属性,常量引用

  • Java 虚拟机的内部引用,常用数据类型的 Class 对象,常驻的异常对象,系统类加载器

  • 所有被同步锁持有的对象

除此之外,还有一些临时的 GC Roots 可以加入进来。这里涉及到新生代老年代。

比如老年代中的对象一般都存活时间比较久,也就是大概率是活着的对象,也可临时作为 GC Roots。

可达性算法的一些细节

前面说了可达性算法,我们根据 GC Roots 来进行标记对象的死活。

但是,被判定为不可达的对象,并不立刻死亡。它仍然有次机会进行自救。

这个自救的机会,是需要重写 finalize()进行自救。

也就是可达性算法的逻辑大致是这样的:

  • 第一次进行标记,凡是不可达 GC Roots 的对象,都暂时判定为死亡,只是暂时
  • 检查暂时被判定为死亡对象,检查是否有重写 finalize()方法,如果有,则触发,对象可以在里面完成自救。

如果没有自救成功 或者 没有重写 finalize()方法,则宣告这个对象的死亡。

除此之外,这个对象中的 finalize()方法,只能被调用一次,一生只有一次自救机会。

这个方法,官方并不推荐,所以不必细究。

接下来,演示下上面的两次标记过程以及自救过程。

(个人认为,《深入理解 Java 虚拟机》中的此章节代码,略有点不够完善,故略微改动)

package GC;

import javax.swing.tree.VariableHeightLayoutCache;

public class FinalizeEscapeGC {
    public static FinalizeEscapeGC SAVE_HOOK = null;

    private byte[] bigSize = new byte[5*1024*1024];

    public void isAlive(){
        System.out.println("Yes, i am alive");
    }

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("Finalize method executed");
        FinalizeEscapeGC.SAVE_HOOK = this;
    }

    public static void main(String[] args) throws InterruptedException {
        SAVE_HOOK = new FinalizeEscapeGC();
        SAVE_HOOK = null;
        System.gc();
        Thread.sleep(500);
        if(SAVE_HOOK != null){
            SAVE_HOOK.isAlive();
            System.gc();
        }else {
            System.out.println("Dead");
            System.gc();
        }
    }
}

在这个程序中,我们给这个类,添加名为 bigSize 的属性,其占用 4M 大小的空间。

大致分析下代码逻辑:

  • 创建了一个对象,其中有成员占用 4M 的空间
  • 取消对这个对象的引用
  • 调用垃圾回收(第一次标记)
  • 调用 finalize 方法进行自救
  • 之后再次调用垃圾回收(第二次标记)

所以演示的时候,分为两种情况:

  • FinalizeEscapeGC.SAVE_HOOK = this; 未注释,完成自救

运行时,参数仍然设置为 +XX:PrintGCDetails,可以看到输出结果:

第一次调用垃圾回收,仍然占用 5M,说明此时即便失去引用,但是仍然未被清理。

在 finalize()中完成自救后,第二次调用垃圾回收的时候,仍然占用 5M 的内存大小。说明自救成功。

  • FinalizeEscapeGC.SAVE_HOOK = this; 注释,无法完成自救

第一次垃圾回收,占用 5M,保留了对象。无法完成自救,然后第二次被清理掉。

所以我发现以下表述也许更为确切:

  • 当对象重写了 finalize()方法的时候,第一次垃圾回收的时候,如果为不可达对象,对其进行暂缓,并不清理。
  • 当对象没有重写 finalize()方法的时候,且为不可达对象的时候,直接判定死亡。