JVM 判断对象是否存活

Java 堆里面存放着 Java 程序中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事情就是要确定这些对象是否存活。

1 引用计数算法

JVM 引用计数算法是一种垃圾回收算法,它通过维护每个对象的引用计数器来判断对象是否可被回收。每个对象都有一个引用计数器,当有一个指针指向该对象时,引用计数器加 1,当指针失效时,引用计数器减 1。当引用计数器的值为 0 时,说明该对象已经没有指针指向它,可以被回收。

引用计数算法的优点是简单、实现容易。但是它存在一个致命的缺陷:难以处理循环引用的情况。如果存在两个对象 A 和 B,它们互相引用对方,那么它们的引用计数器值始终不为 0,导致它们永远无法被回收,从而造成内存泄漏。

2 可达性分析算法

当前主流的商用程序语言 (Java, C# …) 的内存管理子系统,都是通过可达性分析 (Reachability Analysis) 算法来判定对象是否存活的。

这个算法的基本思路就是通过一系列称为 “GC Roots” 的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为 “引用链” (Reference Chain),如果某个对象到 GC Roots 间没有任何引用链相连,或者用图论的话来说就是从 GC Roots 到这个对象不可达时,则证明此对象是不可能再被使用的。

如下图所示,对象 object5object6object7 虽然互有关联,但是它们到 GC Roots 是不可达的,因此它们将会被判定为可回收的对象:

判定为可回收的对象

在 Java 技术体系里面,可作为 GC Roots 的对象包括以下几种:

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

除了这些固定的 GC Roots 集合外,不同的垃圾收集器和回收的内存区域,还可能临时加入其他对象,共同构成完整的 GC Roots 集合。例如,分代收集和局部回收中,如果只对 Java 堆中的某个区域发起垃圾收集(如只对新生代进行垃圾收集),必须考虑到内存区域是虚拟机的实现细节,某个区域中的对象可能被堆中其他区域的对象引用。因此,必须将这些关联区域的对象一并加入 GC Roots 集合中,才能确保可达性分析的正确性。

目前较新的几款垃圾收集器无一例外都具备了局部回收的特征,为了避免 GC Roots 包含过多对象而过度膨胀,它们在实现上也做出了各种优化处理。

3 引用原理

无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否引用链可达,判定对象是否存活都和“引用”离不开关系。在 JDK 1.2 版之前,Java 里面的引用是很传统的定义:如果 reference 类型的数据中存储的数值代表的是另外一块内存的起始地址,就称该 reference 数据是代表某块内存、某个对象的引用。这种定义并没有什么不对,只是现在看来有些过于狭隘了,一个对象在这种定义下只有“被引用”或者“未被引用”两种状态,对于描述一些“食之无味,弃之可惜”的对象就显得无能为力。比如我们希望能描述一类对象:当内存空间还足够时,能保留在内存之中,如果内存空间在进行垃圾收集后仍然非常紧张,那就可以抛弃这些对象 —— 很多系统的缓存功能都符合这样的应用场景。

在 JDK 1.2 版之后,Java 对引用的概念进行了扩充,将引用分为强引用 (Strongly Reference)软引用 (Soft Reference)弱引用 (Weak Reference)虚引用 (Phantom Reference) 四种,这四种引用强度依次逐渐减弱。

  • 强引用是最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似 Object obj=new Object() 这种引用关系。在任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
  • 软引用是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。在 JDK 1.2 版之后提供了 SoftReference 类来实现软引用。
  • 弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在 JDK 1.2 版之后提供了 WeakReference 类来实现弱引用。
  • 虚引用也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。在 JDK 1.2 版之后提供了 PhantomReference 类来实现虚引用。

4 对象回收的挽救(了解)

即使在可达性分析算法中判定为不可达的对象,也不一定是“非死不可”的,此时它们仍处于“缓刑”阶段。要真正宣告一个对象死亡,至少需要经历两次标记过程。如果对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那么它将会被进行第一次标记,随后进行一次筛选。筛选的条件是此对象是否有必要执行 finalize() 方法。假如对象没有覆盖 finalize() 方法,或者 finalize() 方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。

如果对象被判定为确有必要执行 finalize() 方法,那么该对象将会被放置在一个名为 F-Queue 的队列中,并稍后由一条由虚拟机自动建立的、低调度优先级的 Finalizer 线程去执行它们的 finalize() 方法。这里所说的“执行”是指虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束。这样做的原因是,如果某个对象的 finalize() 方法执行缓慢,或者更极端地发生了死循环,将很可能导致 F-Queue 队列中的其他对象永久处于等待状态,甚至导致整个内存回收子系统的崩溃。

finalize() 方法是对象逃脱死亡命运的最后一次机会。稍后,收集器将对 F-Queue 中的对象进行第二次小规模的标记。如果对象在 finalize() 方法中成功拯救自己(只要重新与引用链上的任何一个对象建立关联即可,比如将自己(使用 this 关键字)赋值给某个类变量或对象的成员变量),那么在第二次标记时,它将被移出“即将回收”的集合。如果对象这时候仍然无法逃脱,那么基本上它就真的要被回收了。示例代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
public class FinalizeEscapeGC {

    public static FinalizeEscapeGC SAVE_HOOK = null;

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

    /**
     * 重写 finalize 方法
     */
    @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 Throwable {
        SAVE_HOOK = new FinalizeEscapeGC();

        //对象第一次成功拯救自己
        SAVE_HOOK = null;
        System.gc();
        // 因为 Finalizer 方法优先级很低,暂停 0.5 秒,以等待它
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no, i am dead :(");
        }

        // 下面这段代码与上面的完全相同,但是这次自救却失败了
        SAVE_HOOK = null;
        System.gc();
        // 因为 Finalizer 方法优先级很低,暂停 0.5 秒,以等待它
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no, i am dead :(");
        }
    }
}
运行结果
1
2
3
finalize method executed!
yes, i am still alive :)
no, i am dead :(
  • SAVE_HOOK 对象的 finalize() 方法确实被垃圾收集器触发过,并且在被收集前成功逃脱了。
  • 另外一个值得注意的地方就是,代码中有两段完全一样的代码片段,执行结果却是一次逃脱成功,一次失败了。这是因为任何一个对象的 finalize() 方法都只会被系统自动调用一次,如果对象面临下一次回收,它的 finalize() 方法不会被再次执行,因此第二段代码的自救行动失败了。

还有一点需要特别说明,finalize() 并不能等同于 C 和 C++ 语言中的析构函数,而是 Java 刚诞生时为了使传统 C、C++ 程序员更容易接受 Java 所做出的一项妥协。它的运行代价高昂,不确定性大,无法保证各个对象的调用顺序,如今已被官方明确声明为不推荐使用的语法。

5 回收方法区

《Java 虚拟机规范》没有要求虚拟机必须在方法区中实现垃圾收集,而且方法区垃圾收集的“性价比”也是比较低的。在 Java 堆中,尤其是在新生代中,对常规应用进行一次垃圾收集通常可以回收 70% 至 99% 的内存空间。相比之下,方法区回收成果往往远低于此,这是因为方法区的回收条件较为苛刻。

方法区的垃圾收集主要回收两部分内容:废弃的常量不再使用的类型:

  • 回收废弃常量与回收 Java 堆中的对象非常类似。举个常量池中字面量回收的例子,假如一个字符串 "java" 曾经进入常量池中,但是当前系统又没有任何一个字符串对象的值是 "java",换句话说,已经没有任何字符串对象引用常量池中的 "java" 常量,且虚拟机中也没有其他地方引用这个字面量。如果在这时发生内存回收,而且垃圾收集器判断确有必要的话,这个 "java" 常量就将会被系统清理出常量池。常量池中其他类(接口)、方法、字段的符号引用也与此类似。

  • 判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:

    1. 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类及其任何派生子类的实例。
    2. 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如 OSGi、JSP 的重加载等,否则通常是很难达成的。
    3. 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

    Java 虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,没有引用了就必然会回收。关于是否要对类型进行回收,HotSpot 虚拟机提供了 -Xnoclassgc 参数进行控制,还可以使用 -verbose:class 以及 -XX:+TraceClass-Loading-XX:+TraceClassUnLoading 查看类加载和卸载信息,其中 -verbose:class-XX:+TraceClassLoading 可以在 Product 版的虚拟机中使用,-XX:+TraceClassUnLoading 参数需要 FastDebug 版的虚拟机支持。

在大量使用反射、动态代理、CGLib 等字节码框架,动态生成 JSP 以及 OSGi 这类频繁自定义类加载器的场景中,通常都需要 Java 虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。


欢迎关注我的公众号,第一时间获取文章更新:

微信公众号

相关内容