内存管理:判断对象是否存活

在堆里面存放着 Java 世界中几乎所有的对象实例,垃圾收集器在对 Java 堆进行回收前,第一件事情就是要确定这些对象之中哪些还“存活”着,哪些已经“死去”(“死去”即不可能再被任何途径使用的对象)。

有两种判断对象是否存活的算法:引用计数算法、可达性分析算法。

  • 引用计数算法判断对象是否存活的基本思路是:在对象中添加一个引用计数器,每当有一个地方引用该对象时,计数器的值就加一;当引用失效时,计数器的值就减一;任何时刻计数器为零的对象就是不可能再被使用的对象。
  • 可达性分析算法判断对象是否存活的基本思路是:通过一系列被称为 “GC Roots” 的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径被称为 “引用链”(Reference Chain),如果某个对象到 GC Roots 间没有任何引用链相连(用图论的话来说就是从 GC Roots 到这个对象不可达)时,则证明此对象是不可能再被使用的对象。

引用计数算法

引用计数算法(Reference Counting)判断对象是否存活的基本思路是:在对象中添加一个引用计数器,每当有一个地方引用该对象时,计数器的值就加一;当引用失效时,计数器的值就减一;任何时刻计数器为零的对象就是不可能再被使用的对象。


客观地说,引用计数算法虽然占用了一些额外的内存空间来进行计数,但引用计数算法的原理简单,判定效率也很高,在大多数情况下它都是一个不错的算法。也有一些比较著名的应用案例, 例如微软 COM(Component Object Model)技术、使用 ActionScript3 的 FlashPlayer、Python 语言以及在游戏脚本领域得到许多应用的 Squirrel 中都使用了引用计数算法进行内存管理。

但是,在 Java 领域,至少主流的 Java 虚拟机里面都没有选用引用计数算法进行内存管理,主要原因是,这个看似简单的算法有很多例外情况要考虑,必须要配合大量的额外处理才能保证正确地工作,譬如单纯的引用计数就很难解决对象之间相互循环引用的问题。

举个简单的例子,请看代码清单 3-1 的 testGC() 方法:对象 objA 和 objB 都有字段 instance,赋值令 objA.instance=objB 及 objB.instance=objA,除此之外,这两个对象再无任何引用,实际上这两个对象已经不可能再被访问,但是因为它们互相引用着对方, 导致它们的引用计数都不为零,引用计数算法也就无法回收它们。

代码清单 3-1 引用计数算法的缺陷

/**
 * testGC()方法执行后, objA和objB会不会被GC呢?
 *
 * @author zzm
 */
public class ReferenceCountingGC {
    public Object instance = null;
    private static final int _1MB = 1024 * 1024;
    /**
     * 这个成员属性的唯一意义就是占点内存, 以便能在GC日志中看清楚是否有回收过
     */
    private byte[] bigSize = new byte[2 * _1MB];

    public static void testGC() {
        ReferenceCountingGC objA = new ReferenceCountingGC();
        ReferenceCountingGC objB = new ReferenceCountingGC();
        objA.instance = objB;
        objB.instance = objA;
        objA = null;
        objB = null;
		// 假设在这行发生GC, objA和objB是否能被回收?
        System.gc();
    }
}

可达性分析算法

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

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

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

image-20230222100921648.png

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

  • Java 虚拟机栈(栈帧中的本地变量表) 中引用的对象,譬如各个线程调用的方法堆栈中使用到的参数变量(方法定义时声明的变量)引用的对象、局部变量(定义在方法中的变量)引用的对象、临时对象(没有变量引用的对象)等。

  • 本地方法栈中 JNI(即通常所说的 Native 方法)引用的对象(非 Java 代码中的对象)。

  • 方法区中引用的对象:

    • 方法区中类的静态属性(static 关键字)引用的对象,譬如 Java 类的引用类型静态变量。
    • 方法区中常量(static 和 final 关键字)引用的对象,譬如字符串常量池(String Table)里的引用。
  • 所有被同步锁(synchronized 关键字)持有的对象。

  • Java 虚拟机内部的引用,如基本数据类型对应的 Class 对象,一些常驻的异常对象(比如 NullPointExcepiton、 OutOfMemoryError)等,还有系统类加载器。

  • 反映 Java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存等。

除了这些固定的 GC Roots 集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象 “临时性” 地加入,共同构成完整的 GC Roots 集合。譬如后文将会提到的分代收集和局部回收(Partial GC),如果只针对 Java 堆中某一块区域发起垃圾收集时(如最典型的只针对新生代的垃圾收集),必须考虑到内存区域是虚拟机自己的实现细节(在用户视角里任何内存区域都是不可见的),更不是孤立封闭的,所以某个区域里的对象完全有可能被位于堆中其他区域的对象所引用,这个时候就需要将这些关联区域的对象也一并加入 GC Roots 集合中去,这样才能保证可达性分析的正确性。

参考资料

《深入理解 Java 虚拟机》第 3 章:垃圾收集器与内存分配策略 3.2 对象已死?

posted @ 2023-04-03 17:41  真正的飞鱼  阅读(269)  评论(0编辑  收藏  举报