Back
Featured image of post JVM 学习笔记 04 - 对象已死

JVM 学习笔记 04 - 对象已死

JVM 学习笔记,如何判定对象的死亡?从引用记数到可达性分析,JVM 使用了什么分析算法?

导航页

上篇文章我们讲解了对象的内存布局,本文将介绍 JVM 如何找出已经无用的对象。

不难回忆 Java 的宏观内存布局。

其中程序计数器、虚拟机栈、本地方法栈 3 个区域是 Thread Local 的,随线程而生,随线程而灭,自然也不用过多关心如何回收的问题。

然而 Heap 和方法区有显著的不确定性,这部分内存更是实际中用得最多的内存,也因此诞生了许多分配与回收算法。

引用记数

引用记数(Reference Counting)是最简单的一种收集方法,许多编程语言使用该方法管理内存(Objective-C、Swift 等),或提供了对应的封装(C++、Rust 等)。它在对象中添加一个引用记数器 i。每当有一个地方引用该对象 i++,引用失效时 i--。当 i == 0 时,则自动释放该对象。

它的缺点和优势都很明显。它的判定效率高,占用低。然而仍然需要程序员在编码时额外配合,必须手动将对象置空,以 Objective-C 为例:

NSObject* obj = [[NSObject alloc] init];  // counter: 1
obj = [obj1 retain];  // counter: 2
[obj release];  // counter: 1
[obj release];  // counter: 0, obj released

除此之外,它无法解决循环引用问题。

比如,obj1 引用了 obj2 为成员变量,obj2 引用了 obj3 为成员变量,obj3 又引用了 obj1 为成员变量:

循环引用示意图

可以用以下代码测试一下 JVM 对循环引用的表现:

public class RecursiveReference {
  public Object instance = null;
  private static final int _10MB = 10 * 1024 * 1024;
  private final byte[] bigField = new byte[_10MB];

  public static void main(String[] args) {
    var obj1 = new RecursiveReference();
    var obj2 = new RecursiveReference();
    obj1.instance = obj2;
    obj2.instance = obj1;

    obj1 = null;
    obj2 = null;
    
    System.gc(); // force gc
  }
}

在手动 Run GC 之后,内存明显下降:

JVM 下仍然能 GC 循环引用

这也说明了,JVM 并不使用引用记数算法判断对象生死。

当然,也可以对引用记数做出改进,比如在 Swift 中新增了 unowned weak 等关键字来解决这一问题。虽然,JVM 实现中也有强弱引用等概念,但不体现在语法层面,完全走的是另一条分支,就不再过多赘述了……

可达性分析

多数 GC 语言会使用可达性分析(Reachability Analysis)来判定对象生死。

可达性分析

如图所示,该算法的主要思路是把一系列被称作「GC Roots」的根对象作为起始节点,从这些节点开始往下搜索,搜索过的路径称作「引用链」(Reference Chain)。如果某个对象和 GC Roots 间没有引用链连接。就说明该对象不可达,也就不可能再被使用。

图中 Object 5、6、7,虽然互相引用,但它们和 GC Roots 不可达。因此会被判定为可回收对象。

在 JVM 中,可作为 GC Roots 的对象有以下几种:

  • VM Stack 中引用的对象,如方法参数、局部变量、临时变量等。
  • 在方法区内的静态属性对象
  • 在方法去内常量引用的对象,如字符串常量池(String Table)中的引用
  • 本地方法栈中 JNI 引用的对象
  • JVM 内部引用,如基本数据类型对应的 Class 对象,一些长驻的异常对象(NPE OOM 等),以及类加载器。
  • 所有被同步锁(synchronized 关键字)持有的对象
  • JMXBean、JVMTI 中的回调、本地代码缓存等

除这些长驻 GC Roots 之外,还可能根据 GC 不同和当前回收的内存区域不同,而有其他对象「临时性」加入,共同构成完整 GC Roots 集合。

实际上,最新的几款垃圾收集器(OpenJDK 中的 G1、Shenandoah、ZGC,Azul 的 PGC、C4 等)都具备了局部回收特征。在细节上做了许多优化。

强弱引用

无论是引用计数算法,还是可达性分析,都是通过「引用」来判断对象是否存活的。在 JDK 1.2 之前,JVM 的引用是很传统的定义,reference 存储着另一块内存的起始地址,只有「被引用」或「未被引用」两种状态。这个定义是完全正确的,然而缺乏细分,在实际场景中可能缺乏灵活性。可能有些对象,我们希望在内存充裕时保留,而紧张时就丢弃它们——很多系统中的缓存都符合该描述。

在 JDK 1.2 之后,JVM 对引用的概念做了扩充和细分:

  • 强引用(Strongly Reference),最传统的引用定义,即普遍存在的引用赋值。在任何情况下只要强引用关系存在,GC 就不会回收这些对象。
  • 软引用(Soft Reference),描述一些还有用但非必要的对象。在系统接近 OOM 之前,JVM 会把这些对象收集起来回收第二次。JDK 提供了 SoftReference 来实现软引用。
  • 弱引用(Weak Reference)是比软引用更弱。无论内存是否足够,它都只能生存到下一次垃圾收集发生为止。可通过 WeakReference 实现。
  • 虚引用(Phantom Reference)是最弱的一种引用关系。虚引用关联的唯一目的,就是为了在被回收时收到通知。可通过 PhantomReference 实现。

Finalize 方法

即使被 GC 判定为可回收对象,也不是非死不可的。还会进行一次 finalize 的判断。如果对象未覆盖 finalize() 方法,或者已经被调用过一次,JVM 都会直接收集该对象。

否则,JVM 中将它加入 F-Queue 中,并在一条低优先级的 Finalizer 线程中顺序执行。当然,如果某个 finalize() 方法执行缓慢,甚至是死循环。虚拟机会直接清理该对象。

finalize() 中,对象可以尝试重新与 GC Roots 连接。从而避免被回收。

以下代码展示了一个对象的自救过程:

public class Finalize {
  public static Finalize SAVE_HOOK = null;

  public void isAlive() {
    System.out.println("Yes, I'm alive! :)");
  }

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

  private static void test() throws InterruptedException {
    SAVE_HOOK = null;
    System.gc();
    Thread.sleep(500);
    if (SAVE_HOOK != null) {
      SAVE_HOOK.isAlive();
    } else {
      System.out.println("No, I'm dead :(");
    }
  }

  public static void main(String[] args) throws InterruptedException {
    SAVE_HOOK = new Finalize();
    test();
    test();
  }
}

输出如下:

Finalize method executed!
Yes, I'm alive! :)
No, I'm dead :(

可以看到,第一次尝试自救成功了,而第二次却失败了。原因之前提到过,finalize() 对于一个对象,最多只能被执行一次。

然而,finalize() 已经在 JDK 9 及以后被标记为弃用,在 JDK 18 中被标记为 for removal,无法通过编译,但运行时仍然保留,相信离正式删除不远了。它并不等同于 C、C++ 中的析构函数。反而运行代价高昂,不确定性大,容易出现意想不到的问题。

finalize() 能做的工作,建议都使用 try-finally 块或其他方式来完成。避免使用该方法。

方法区回收

在日常开发中,不用太关心方法区(比如 HotSpot VM 中的 Metaspace 或永久代)中的回收行为。然而如果大量使用反射、动态代理、CGLib 等字节码框架,就需要注意这一点了。

JVM 规范中不强制要求虚拟机在方法区中实现垃圾收集,实质上也有未完整实现类卸载的收集器存在(如 JDK 11 时的 ZGC)。因为回收方法区确实性价比较低,条件也极为苛刻。但不代表方法区不可能存在垃圾收集。

可收集的内容主要有:

  • 废弃的常量,如一个字符串 "JVM" 曾进入过常量池,但当前系统中没有任何一个字符串对象为 "JVM"。如果此时发生垃圾回收,并且收集器判断确实有必要的话,"JVM" 就会被清理出常量池。类、接口、方法、字段的符号引用回收方法与此类似。
  • 不再使用的类型,这种情况比较严苛,需要同时满足 3 大条件:
    • 该类所有实例都被回收,Heap 中不存在任何该类及其派生子类的实例。
    • 加载该类的类加载器已被回收。通常到这一步,已经没有类可以被回收了。然而在 OSGi、JSP 的可替换类加载器场景下,还是存在的。
    • 该类的的 java.lang.Class 对象没有被任何地方引用,无法在任何地方通过反射访问该类。

JVM 对类的回收提供了 -Xnoclassgc参数控制,还可通过 -verbose:class -XX:TraceClassLoading 查看详细的加载、卸载日志。

本篇文章的内容就到这里,下篇文章会介绍 JVM 是如何清理内存的。

comments powered by Disqus