深入理解JVM垃圾回收机制 - GC Roots枚举

JVM的垃圾回收算法,从如何判定对象消亡的角度可以分为两类:

  • 引用计数式垃圾回收 ( Reference Counting GC )
  • 追踪式垃圾回收 ( Tracing GC )

这两类也常被称为“直接垃圾回收”和“间接垃圾回收”。目前,所有主流JVM所采用的垃圾回收算法均属于 Tracing GC 范畴。Tracing GC 的基本的思路是给定一个集合的引用作为根节点,然后从根节点出发,通过引用关系向下搜索,能被遍历到的 (可到达的) 对象就被判定为存活,其余对象 (也就是没有被遍历到的) 自然被判定为死亡,这组引用的集合就被称为 GC Roots。

想要实现语义正确的 Tracing GC,有一个重要的前提就是要能够完整枚举出所有的 GC Roots,否则就可能会漏掉本应存活的对象,导致GC错误的回收这些被漏扫的活对象。这里请注意,Tracing GC的本质是找出活的对象来把其余空间判定为“无用”,而不是找出所有死掉的对象并回收它们占用的空间。所以,漏扫会导致错误的回收本应活着的对象,而不是少回收死亡的对象。这部分内容更详细的讲解可以参考:深入理解JVM垃圾回收机制 - 何为垃圾?

这就引出两个非常重要的问题:哪些引用可以作为 GC Roots ?JVM 是如何找到 GC Roots 的?

GC Roots 包含哪些引用

GC Roots 作为 Tracing GC 的起点,其必须是一组活跃的引用。固定可作为 GC Roots 的对象主要在全局的引用 ( 例如常量或类静态属性 ) 与执行上下文 ( 例如栈桢中的本地变量表 ) 中。简单来说就是,GC Roots 中包含了所有无须跟踪引用就可以得到的对象。

关于这点应该比较好理解,大多数情况下,都是在类中定义常量与静态变量,在方法中定义局部变量,这些都是堆中对象的起点。那么在垃圾回收时,这些引用自然而然就成为了 Tracing GC 的起点。

总结起来,可作为 GC Roots 的引用大致包含:

  • 当前活跃线程的栈桢里指向堆中对象的引用,即当前所有正在被调用方法的引用类型参数、局部变量等。
  • 类的引用类型静态变量,这里指的是引用类型,像 int 等基本数据类型的静态变量肯定不能作为 GC Roots。
  • 当前所有已被加载的Java类和类加载器。
  • JNI 句柄,包括 JNI Local Handles 和 JNI Global Handles。
  • 在方法区中常量引用的对象,譬如字符串常量池 ( String Table ) 里的引用。
  • 所有被同步锁 ( synchronized关键字 ) 持有的对象引用。
  • ……

关于 GC Roots 的具体分类,不同的语言以及不同的垃圾回收算法都有些许差异,比如 .NET Framework 中有Stack referenceStatic referenceFinalizer referenceHandles 等类别;再比如,IBM的内存分析工具,对 GC Roots 的分类就更详尽一些:IBM Monitoring And Diagnostic Tools - GC Roots。正是由于这些差异,在网上搜索的各种资料可能存在相互冲突的 GC Roots 分类,这都是挺正常的。

如果你实在很想知道自己写的程序里有哪些对象引用是GC Roots,可以借助一些工具来查看,比如常用的 MAT 和 VisualVM 都提供 GC Roots 的查找功能。

在 VisualVM 选择要监控的进程后,点击 Monitor -> Heap Dump 后在 Heap 选项卡中可以看到对象概要统计 ( 如下图所示 ),从图中可以看到 GC Roots 的总数量是1902,然后可以切换查看具体的对象、线程信息等。

切换到 Objects 后,可以查看对应类型的对象信息及其 GC Roots 分类信息,如下图所示。

除了使用 VisualVM,也可以使用MAT ( Eclipse Memory Analyzer Tool ),具体的使用方法就不赘述了,其大致的界面如下图所示。

除了这些固定的 GC Roots 集合外,根据用户所使用的垃圾收集器以及当前回收的内存区域,还可以有其他对象引用临时性地加入,共同构成完整的 GC Roots 集合。比如 JVM 的分代回收中,在某个区域中已被判定死亡的对象,可能还被其他区域的对象引用 ( 比如,新生代中的某个对象,可能被老年代的对象引用 ),这时候就需要将这些关联区域的对象的引用也一并加入 GC Roots 集合中去,才能保证可达性分析的正确性。

如何查找 GC Roots

当前,所有的垃圾收集器在 GC Roots 枚举时都必须暂停用户线程,这是因为整个枚举的分析过程必须在一个能保证一致性的快照中进行。这里的一致性是指整个枚举期间,系统看起来就像被冻结在某个时间点上,不会出现 GC Roots 的对象引用关系还在不断变化的情况,否则,分析结果的准确性也就无法保证。

首先来看第一个问题,假定当前所有线程已经暂停执行,已经得到一份保证一致性的快照,如何枚举出所有的 GC Roots?

一种简单的思路就是从一些已知位置 ( 比如 JVM 栈 ) 开始扫描内存,扫描的时候每看到一个数字就看看它是否是一个指向GC堆中的指针( 引用 )。某些保守式垃圾回收器会把所有看起来像是对象引用的数据都当成引用来处理,比如,像1或100这样值可以简单地认为是整型数据,但那些看起来像是指针地址的值就必须要检查,查看能否在堆中对应位置找到内容,并对内容进行检查。这种方式存在较大的性能损耗,且还可能存在意外持有垃圾对象和对象移动的问题 ( 比如,在垃圾回收过程中对内存进行整理,那么就必须更新持有这些对象的应用,将其指向新的位置 ) 。

OopMap

HotSpot 在设计之初就使用准确式垃圾回收,它能够判断出所有位置上的数据是不是指向GC堆里的引用,比如内存中有一个 32bit 的整数123456,HotSpot是可以分辨出它究竟是一个整数还是一个对象的内存地址。至于虚拟机是如何精确的找到栈帧中的引用,可以阅读参考资料3;如果想更深入的理解这部分的实现原理,比如:如何在对象中精确查找指针,如何在栈和寄存器中精确查找指针等,可以阅读 垃圾回收算法手册 的第11章第2小节。

如今单个Java应用管理的堆内存至少都是GB起步,里面的类、常量、变量等数不胜数。即使 JVM 可以准确的准确地判断某个内存中某个位置的数据具体是什么类型,在GC时,也不可能真的去扫描所有的 JVM 栈、方法区、寄存器等空间,不然耗时就太长了。

Hotspot 解决办法也很直接,用空间换时间,即使用额外的数据结构从外部记录下栈和寄存器中哪些位置是引用,这个数据结构被称为 OopMap。

在之前已经介绍过,栈帧 ( Stack Frame ) 中局部变量表用于存放方法参数和局部变量。它的容量是以变量槽为最小单位,更具体的内容请移步:运行时栈帧的内存变化,比如:

// javac -v 得到编译后的字节码
LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      10     0   bar   Ljava/lang/String;
            9       1     1   baz   Ljava/lang/Integer;

这部分信息在编译后就已经存储在字节码中,是 JVM 可以直接使用的。因此,HotSpot 在类的加载过程中,就可以利用这些信息把对象内什么偏移量是什么类型的数据计算出来,存放到 OopMap 中。

除此之外,在即时编译过程中,也会在特定的位置记录下栈里和寄存器里哪些位置是引用。

这是因为经过 JIT ( just in time,即时编译器 ) 编译后的代码,其引用的位置可能会发生变化,比如原来需要从主存中读取数据,经过 JIT 优化后可以直接从寄存器中读取数据,这时候,就需要把这样的变化同步到 OopMap 中去。

寄存器的使用是编译器的一个非常普遍的优化,很多堆中对象的引用都存放在寄存器中,所以,寄存器也是 GC Roots 枚举发生的一个非常重要的区域。更多关于 JIT 相关的内容可以阅读参考资料4,

可以在启动时增加VM参数 -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly 来查看方法编译后的本地代码,以此来了解在哪些地方会生存 OopMap,以及里面的具体内容。比如,下面这段代码是String.hashCode() 方法编译后的本地代码,摘自 深入理解Java虚拟机 第三章4小节。

[Verified Entry Point]
0x026eb730: mov    %eax,-0x8000(%esp)
…………
;; ImplicitNullCheckStub slow case
0x026eb7a9: call   0x026e83e0       ; OopMap{ebx=Oop [16]=Oop off=142}
                                    ; *caload
                                    ; - java.lang.String::hashCode@48 (line 1489)
                                    ;   {runtime_call}
    0x026eb7ae: push   $0x83c5c18   ;   {external_word}
    0x026eb7b3: call   0x026eb7b8
    0x026eb7b8: pusha
    0x026eb7b9: call   0x0822bec0   ;   {runtime_call}
    0x026eb7be: hlt

OopMap输出的大致格式是:

OopMap{零到多个“数据位置=内容类型”的记录 off=该OopMap关联的指令的位置}  

在这个例子中:

  • EBX寄存器有一个普通对象指针 (OOP) 的引用
  • [16] 表示栈顶指针 + 偏移量16 的位置,也有一个普通对象指针的引用

off 表示这个 OopMap 记录关联的指令在方法的指令流的偏移量,这里表示这个 OopMap 与偏移量为142的位置上的指令关联在一起。

我们也可以通过 OopMap 的源码注释来了解其大致的作用:为编译后的代码生成 frame map,而 frame map 用于描述寄存器和栈帧 slot 中存放数据的数据类型,比如可以是 Oop ( 普通对象指针 ),这里的注释其实更直白一些,直接说是当前栈帧的 GC Root;还可以是 Value,即非oop,非浮点数的 int类型的数据;还可以是 Dead,一些用于调试的数据……

// Interface for generating the frame map for compiled code.  A frame map
// describes for a specific pc whether each register and frame stack slot is:
//   Oop         - A GC root for current frame
//   Value       - Live non-oop, non-float value: int, either half of double
//   Dead        - Dead; can be Zapped for debugging
//   CalleeXX    - Callee saved; also describes which caller register is saved
//   DerivedXX   - A derived oop; original oop is described.
//
// OopMapValue describes a single OopMap entry

class frame;
class RegisterMap;
class DerivedPointerEntry;

class OopMapValue: public StackObj {
}

总结起来,HotSpot 利用 OopMap 快速准确地完成 GC Roots 枚举,从而避免扫描整个栈空间和寄存器。

安全点和安全区域

前面都在说一个问题,那就是得到一致性的快照后,如何枚举GC Roots?而第二个问题就是 如何得到一致性的快照?

HotSpot 采用 JIT compile 技术来提高性能,大量的指令会导致引用关系发生变化,如果为每条指令都生成对应的 OopMap,就需要很大的额外空间来存储。这在理论上可能导致空间成本高昂而无法接受,但实际上,这个成本也没有那么离谱,因为确实有语言是这么干的。况且,OopMap都是压缩了存在内存里的,在GC的时候才按需解压出来使用。

但不管如何,HotSpot 并没有采用这种方式,而是在特定的位置来记录 OopMap,这些位置即被称为 安全点(Safepoint)。有了安全点的设定,也就决定了用户程序执行时并非在代码指令流的任意位置都能够停顿下来开始垃圾收集,而是强制要求必须执行到达安全点后才能够暂停。

达到安全点后,就可以得到一份一致性的快照。诸如,循环末尾、方法临返回前、可能抛出异常的位置都是常见的安全点。

至于安全点的选择标准,大家可以阅读 深入理解Java虚拟机 第三章3.4.2节。

关于安全点,另外一个需要考虑的问题就是,如何在垃圾收集发生时让所有线程 ( 不包含执行JNI调用线程 ) 都跑到安全点,然后停顿下来。其有两种实现方式:

  • 抢断式中断:GC 发生时,先中断所有线程,若线程未达安全点,则恢复线程让其继续执行直到达到安全点。
  • 主动式中断:GC 需要中断线程时,设定全局中断标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。

Hotspot 采用第二种方式。但这有个前提是,线程一直在运行,如果用户线程处于 Sleep 或者 Blocked 状态,它是没有办法响应虚拟机的中断请求的,很长一段时间都不会走到安全点。这种情况下,虚拟机也不能说一直等到线程重新被激活后再进行垃圾回收,就引入了 安全区域(Safe Region) 来解决这个问题。

处于安全区域,对象的引用关系不会发生变化,就比如 Sleep 的线程。在这个区域中任意地方开始垃圾回收都是安全的。

当线程执行到安全区域代码时,会标识自己已进入安全区,JVM 在垃圾收集时就无须关注这些线程。当线程离开安全区域时,它要检查 JVM 是否已经完成 GC Roots 的枚举,如果没有完成,它需要一直等待,直到收到可以离开安全区域的信号为止。

JNI 引用的垃圾回收机制

你应该特别熟悉 Java 中被标记为 native 的方法,当在 Java 代码中调用 native 方法时, JVM 将通过 JNI 调用对应的 C/C++ 函数。同样地,JNI 也提供对应的机制在 C/C++ 代码中,使用 Java 的语言特性。比如,可以在 C 语言中创建一个 Java 对象。显然,这些对象会受到垃圾回收器的影响,但 JVM 又不知道 C 语言是如何使用这些对象的,回不回收这些对象,都有问题。因此,JVM 需要一种机制,来告诉垃圾回收器,不要回收这些对象,因为它还可能正在被使用。

这种机制便是 JNI 的局部引用和全局引用,JVM 会将被这两种引用指向的对象标记为不可回收。

比如下面 JNI 函数中传入的引用类型参数和返回的引用类型对象都属于局部引用:

// Java native 方法
public native Object bar(String s, Object o);
// JNI 会将 Java 层面的基本类型以及引用类型映射为另一套可供 C 代码使用的数据结构
// 比如Java 中的 long 映射为 C 中的 jlong
// 这里为了方便观察,对 JNI 函数名作了删减
JNIEXPORT jobject JNICALL Java_org_example_Foo_bar(JNIEnv *, jobject, jstring, jobject);

一旦从 C 函数中返回至 Java 方法中,那么局部引用将会失效,JVM 在整个 Tracing 过程中就不再考虑这些局部引用,也就是说,一段时间后,局部引用占用的内存将会被回收。

如果想让某些局部引用在从 C 函数返回后不被 JVM 回收,则可以借助 JNI 函数 NewGlobalRef,将该局部引用转换为全局引用。被全局引用的对象,不会被 JVM 回收,只能通过 JNI 函数 DeleteGlobalRef 消除全局引用后,才可以被回收。

同样地,如果 C 函数运行时间很长,导致大量无用的局部引用无法被回收,这时候,可以通过 JNI 函数 DeleteLocalRef 消除局部引用,以便回收被引用的对象。

另一方面,由于 GC 可能会移动对象在内存中的位置,JVM 需要另外一种机制来保证局部引用和全局引用能够正确的指向移动过后的对象。

HotSpot 虚拟机是通过句柄来完成上述需求的。这里句柄指的是内存中 Java 对象的指针的指针。当发生垃圾回收时,如果 Java 对象被移动了,那么句柄指向的指针值也将发生变动,但句柄本身保持不变。

事实上,所有经过 JNI 调用边界(调用 JNI 函数传入的参数、从 JNI 函数传回的返回值)的引用都必须用句柄包装起来,也就是说,无论是局部引用还是全局引用,都是句柄。因此在 GC 时,并不需要扫描 JNI 函数的栈帧,而只需要扫描句柄表就可以得到所有从 JNI 函数能访问到的 GC 堆里的对象。

最后

关于 GC Roots 枚举的资料挺少的,大多数资料对这部分内容都是几句话带过,而我也不怎么会看 JVM 源码,所以请谨慎阅读本文的内容,特别是关于 OopMap 的部分观点。JNI 这部分内容在个人工作中鲜有接触,也不是很熟悉,所以这小节的大部分内容参考郑雨迪在极客时间的专栏:深入拆解 Java 虚拟机 - 第32讲,如果感兴趣,可自行订阅查看。

深入理解JVM系列的第10篇,完整目录请移步:深入理解JVM系列文章目录

参考资料

  1. Java GC为什么要分代?
  2. GC safe-point (or safepoint) and safe-region
  3. 找出栈上的指针/引用
  4. 深入浅出 JIT 编译器
  5. 深入拆解 Java 虚拟机 - 第32讲
  6. 深入理解Java虚拟机 - 第3章4小节
  • 发表于:
  • 本文为 InfoQ 中文站特供稿件
  • 首发地址https://www.infoq.cn/article/ba113294bb20a41614502a063
  • 如有侵权,请联系 yunjia_community@tencent.com 删除。

扫码关注云+社区

领取腾讯云代金券