Java JVM 对象回收判断


Java 对象回收判断是程序设计中至关重要的一环。在面向对象的编程中, 合理的对象回收策略直接影响着程序的性能和内存利用效率。
因此, 深入了解和准确判断 Java 对象的回收时机, 不仅可以优化程序的运行性能, 还能有效避免内存泄漏和资源浪费。
本文将简单的分析一下 JVM 中对象回收的判断机制, 了解一下整体的对象回收过程。

1 对象回收算法

在 JVM 中, 如果一个对象不被任何对象所引用的话, 那么这个对象就是可回收对象。
那么如何判断一个对象是可回收的话, 现在常用的有 2 种方式。

1.1 引用计数算法 (Reference Counting)

在对象中添加一个引用计数器, 每当有一个地方引用它时, 计数器值就加一, 当引用断开时, 计数器值就减一。
任何时刻计数器为零的对象就是不可能再被使用的, 可以判定为可以回收的对象。

优点: 实现简单, 判定的效率也很高
缺点: 需要占用一下额外的内存空间, 很多复杂的情景没有考虑, 很难解决对象之间相互循环引用的问题。

比如:

Obj a = new Obj();
Obj b = new Obj();

a.attr = b;
b.attr = a;

上面 2 个对象实际已经没有作用了, 但是互相持有对方的引用, 导致他们的引用计数不为 0, 无法回收。
所以在主流的 Java 虚拟机中没有选用引用计数法作用内存管理的方式。

1.2 可达性分析算法 (Reachability Analysis)

先人为地将一批对象设为根节点, 作为起始节点, 从这些起始节点开始, 根据对象间的引用关系向下寻找其他的对象, 通过这些引用关系找到的对象就是需要的对象, 不可以回收。
同理如果某个对象跟这些根节点间没有任何直接或间接的引用关系, 则证明此对象是不可能再被使用, 可以回收的。

Alt '可达性分析算法过程'

如图: 从 GC Root 出发, 可以依次找到 obj1, obj2, obj3, 所以它们属于不可回收对象,
而 obj4, obj5, obj6 之间虽然有引用关系, 但是没有和 GC Root 相同的链路, 所以为可回收对象。

2 GC Roots

在上面可达性分析算法的介绍中, 整个算法的前提的需要先设定一批根节点, 而在 JVM 中这些根节点被称为 “GC Roots”。
官方的定义如下:

A pointer into the Java object heap from outside the heap. 
These come up, e.g., from static fields of classes, local references in activation frames, etc.

从堆外部指向 Java 对象堆的指针。例如: 类的静态字段, 激活帧中的局部引用等。

那么具体哪些对象是可以作为 “GC Roots” 呢?
网上的说法有很多 (暂时未找到官方的定义), 但是大体的方向是一样的, 在细节上有些不同而已。

《深入理解java虚拟机》 中对 GC Roots 的分类如下:

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

Java 语言里, 可作为 GC Roots 对象的包括如下几种

  • 虚拟机栈 (栈帧中的本地变量表) 引用的对象
  • 方法区中的类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中 JNI (即 Native 方法) 引用的对象

Eclipse Memory Analyzer (MAT) 文章 中对 GC Roots 的分类如下:

System Class
    Class loaded by bootstrap/system class loader. For example, everything from the rt.jar like java.util.* .

JNI Local
    Local variable in native code, such as user defined JNI code or JVM internal code.

JNI Global
    Global variable in native code, such as user defined JNI code or JVM internal code.

Thread Block
    Object referred to from a currently active thread block.

Thread
    A started, but not stopped, thread.

Busy Monitor
    Everything that has called wait() or notify() or that is synchronized. For example, by calling synchronized(Object) or by entering a synchronized method. Static method means class, non-static method means object.

Java Local
    Local variable. For example, input parameters or locally created objects of methods that are still in the stack of a thread.

Native Stack
    In or out parameters in native code, such as user defined JNI code or JVM internal code. This is often the case as many methods have native parts and the objects handled as method parameters become GC roots. For example, parameters used for file/network I/O methods or reflection.

Finalizable
    An object which is in a queue awaiting its finalizer to be run.

Unfinalized
    An object which has a finalize method, but has not been finalized and is not yet on the finalizer queue.

Unreachable
    An object which is unreachable from any other root, but has been marked as a root by MAT to retain objects which otherwise would not be included in the analysis.

Java Stack Frame
    A Java stack frame, holding local variables. Only generated when the dump is parsed with the preference set to treat Java stack frames as objects.

Unknown
    An object of unknown root type. Some dumps, such as IBM Portable Heap Dump files, do not have root information. For these dumps the MAT parser marks objects which are have no inbound references or are unreachable from any other root as roots of this type. This ensures that MAT retains all the objects in the dump.

各种说法, 但是大体的方向是一样的, 就是细节的区分而已。

3 可达性分析算法在 JVM 中的大体步骤

总体的步骤就 2 步:

  1. 根节点枚举 (GC Roots Enumeration)
  2. 引用链查询标记

3.1 根节点枚举

整个可达性分析的过程, 第一步就是先确定哪些对象的是 GC Roots, 也就是根节点枚举 (有了 GC Roots 就能通过引用链查找可回收对象了)。

从上面列举的可作为 GC Roots 的对象列表可知, 可以作为 GC Roots 的主要在全局性的引用 (例如常量或类静态属性) 与执行上下文 (例如栈帧中的本地变量表) 中。
但是

  1. 尽管目标明确, 但是在程序实际运行中, 真正的 GC Roots 集合会随着程序的运行变更的
  2. 根节点枚举期间要求在一个能保障一致性的快照中才得以进行, 这里的一致性指定是: 在分析过程, 根节点集合的对象引用关系不会发生变化, 所以需要暂停所有的线程

所以在根节点枚举的过程, 如果一个个的遍历所有符合条件的对象, 将是一个耗时的过程。
现在主流的 Java 虚拟机使用了一组称为 OopMap 的数据结构来达到优化查找的过程, 避免一个不漏的检查所有的的对象。
通过扫描 OopMap 存储的引用类型的指针, 也就是 GC Roots 集合, 就能通过引用链找到存活的对象。

注: 现在所有的收集器, 在 GC Roots 这一步骤时都是必须暂停用户线程的, 也就是 “Stop the world”, 而耗时更长的查找引用链的过程已经可以做到与用户线程一起并发。

3.1.1 OopMap 是怎么样工作的

在上面的分析中, 可以知道 GC Roots 的主要在全局性的引用与执行上下文中, 对这个结论在更具体的说明, GC Roots 主要存在于

  1. 方法区中类的常量和静态属性
  2. Java 虚拟机栈中的本地变量表记录的引用对象
  3. 本地方法栈中 JNI (即 Native 方法) 引用的对象
3.1.1.1 方法区中类的常量和静态属性

在 HotSpot 中, 对象的类型信息 (即 Klass 对象, Java 的 .class 文件加载到 JVM 中就会形成一个 Klass 对象) 里有记录自己的 OopMap,
记录了在该类型的对象内什么偏移量上是什么类型的数据, 这些数据是在类加载过程中计算得到的, 后续直接从这个类型对象开始向外的扫描即可。

所以, 方法区内的静态属性引用, 常量, 这些不太会改变的 GC Roots 会在类加载成功后, 就确定好了。

3.1.1.2 Java 虚拟机栈中的本地变量表记录的引用对象

对于虚拟机方法栈的 GC Roots 的话, 则是这样的:
每个被 JIT 编译过后的方法会在一些特定的位置更新这个方法栈帧的 OopMap。 记录执行到这个位置时, 方法栈上和寄存器里哪些位置是引用。

上面说的特定的位置主要在:

  1. 循环的末尾
  2. 方法临返回前
  3. 调用方法的 call 指令后面
  4. 可能抛异常的位置

这种位置被称为 “安全点” (safe point)。

安全点的选定标准

  1. 不能太少以至于让收集器等待时间过长 (太少, 2 个安全点之间的间隔会变大, 也就是 2 个安全点之间达到的时间也会变大)
  2. 不能太过频繁以至于过分增大运行时的内存负荷

所以选用一些比较关键的位置来记录就能有效的缩小需要记录的数据量, 但仍然能达到区分引用的目的。
同时, HotSpot 中进行 GC 也不是在任意位置都可以进入, 同样也是需要所有线程达到 safe point 处才会 GC, 所以选择这些位置基本足够记录完整的 OopMap

通过上面的分析, 可以知道安全点的作用:
线程执行到 安全点

  1. 更新这个方法栈帧的 OopMap
  2. 如果当前正在 GC, 当前线程进行挂起

至于如何让线程在 安全点 时挂起的, 在附录中再简单分析。

安全点的优化点
上面提到安全点的位置中有一个是循环的末尾, HotSpot 虚拟机为了避免安全点过多带来过重的负担, 对循环还做了一项优化措施:
认为循环次数较少的话, 执行时间应该也不会太长, 所以使用 int 类型或范围更小的数据类型作为索引值的循环默认是不会被放置安全点的, 这种优化被称为: 可数循环 (Counted Loop)。
相应的, 使用 long 或者范围更大的数据类型作为索引值的循环就被称为不可数循环 (Uncouted Loop), 将会被放置安全点。

但是循环执行的次数少, 并不是代表了循环执行的时间短, 如果每次执行里面的操作很耗时, 可能会导致线程执行到安全点需要消耗很多时间。
HotSpot 原本提供了 -XX:+UseCountedLoopSafepoints 参数去强制在可数循环中也放置安全点, 不过这个参数在 JDK8 下有 Bug。

3.1.1.3 本地方法栈中 JNI (即 Native 方法) 引用的对象

对 Java 线程中的 JNI 方法, 它们既不是由 JVM 里的解释器执行的, 也不是由 JVM 的 JIT 编译器生成的, 所以会缺少OopMap 信息。那么GC碰到这样的栈帧该如何维持准确性呢?

HotSpot 的解决方法是: 所有经过 JNI 调用边界 (调用 JNI 方法传入的参数, 从 JNI 方法传回的返回值) 的引用都必须用 “句柄” (handle) 包装起来, JNI 需要调用 Java API 的时候也必须自己用句柄包装指针。
在这种实现中, JNI 方法里写的 “jobject” 实际上不是直接指向对象的指针, 而是先指向一个句柄, 通过句柄才能间接访问到对象。
这样在扫描到 JNI 方法的时候就不需要扫描它的栈帧了 – 只要扫描句柄表就可以得到所有从 JNI 方法能访问到的 GC 堆里的对象。
但这也就意味着调用 JNI 方法会有句柄的包装/拆包装的开销, 是导致 JNI 方法的调用比较慢的原因之一。

Java 虚拟机栈中的本地变量表记录的引用对象

所以, 方法区内的静态属性引用, 常量, 这些不太会改变的 GC Roots 会在类加载成功后, 就确定好了。

对于方法栈的 GC Roots 的话, 则是这样的:
每个被 JIT 编译过后的方法会在一些特定的位置更新这个方法栈帧的 OopMap。 记录执行到这个位置时, 方法栈上和寄存器里哪些位置是引用。

3.2 引用链查询标记

通过根节点枚举, 得到当前应用所有的 GC Roots, 接下来通过这些 GC Roots 的引用, 就能确定出当前哪些对象是不可回收, 哪些是可回收的了。
但是根据引用链 (引用关系) 标记可回收对象, 随着堆的增大, 整个过程会随之增长。
为了解决这个问题, JVM 的解决方案是让用户线程和垃圾收集器并发并行, 但是并发的过程, 存在修改引用链的情况, 导致标记的结果不正确。

为了这个问题, 引入了 “三色标记 (Tri-color Marking)” 作为补助手段, 把遍历对象过程中遇到的对象, 按照 “是否访问过” 这个条件标记成以下三种颜色

白色: 对象尚未被垃圾收集器访问过。在可达性分析刚刚开始的阶段, 所有的对象都是白色的, 若在分析结束的阶段, 仍然是白色的对象, 即代表不可达, 对象可回收
黑色: 对象已经被垃圾收集器访问过, 且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过, 它是安全存活的, 如果有其他对象引用指向了黑色对象, 无须重新扫描一遍。
黑色对象不可能直接 (不经过灰色对象) 指向某个白色对象
灰色: 对象已经被垃圾收集器访问过, 但这个对象上至少存在一个引用还没有被扫描过

标记的过程, 基本就是从根节点, 找到下一个引用, 标记为黑色或者灰色, 如果为黑色, 这里的引用结束了。如果为灰色, 从这个节点向下继续标记。

一般情况是这样的
Alt '三色标记法正常过程'

但是扫描的过程中, 用户线程与收集器是并发工作的过程, 收集器在对象标记了颜色, 同时用户线程在修改引用关系, 这会导致

  1. 把原本消亡的对象错误标记为存活, 这种情况, 是可以容忍的, 只不过产生了一点逃过本次收集的浮动垃圾而已, 下次收集清理掉就好。
  2. 把原本存活的对象错误标记为已消亡, 这种情况的话, 则会导致我们程序的异常了。

3.2.1 标记过程中的特殊情况

3.2.1.1 情况一: 多标了

假设已经遍历到 E (变为灰色了), 此时应用程序执行了 D.E = null (D -> E 的引用断开):

Alt '三色标记法多标过程'

此时, 对象 E/F/G 是 “应该” 被回收的。但是因为 E 已经变为灰色了, 其仍会被当作存活对象继续遍历下去。
最终的结果是: 这部分对象仍会被标记为存活, 即本轮 GC 不会回收这部分内存。
这部分本应该回收, 但是没有回收到的内存, 被称之为 “浮动垃圾(Floating Garbage)”。

另外, 在并发标记开始后, 创建的新的对象, 通常的做法是直接全部当成黑色, 本轮不会进行清除。
这部分对象期间可能会变为垃圾, 这也算是浮动垃圾的一部分。

3.2.1.2 情况二: 漏标了

假设已经遍历到 E (变为灰色了), 此时应用程序执行了

G g = E.g;
E.g = null;
D.g = g;

Alt '三色标记法漏标过程'

GC 线程继续跑, 因为 E 已经没有对 G 的引用了, 所以不会将 G 放到灰色集合, 尽管因为 D 重新引用了 G, 但因为 D 已经是黑色了, 不会再重新做遍历处理。
最终导致的结果是:G 会一直停留在白色集合中, 最后被当作垃圾进行清除。

但是通过分析上面的过程, 可以发现出现对象漏标的话, 需要同时满足 2 个条件

  1. 灰色对象断开了白色对象的直接或间接引用
  2. 黑色对象重新引用了该白色对象

因此, 我们要解决并发扫描时的对象消失问题, 只需破坏这两个条件的任意一个即可。
由此分别产生了两种解决方案: 原始快照 (Snapshot At The Beginning, SATB) 和增量更新 (Incremental Update)。

3.2.3 写屏障 (Writer Barrier)

原始快照和增量更新两种解决方案都是基于写屏障实现的, 具体是怎么样呢?

给某个对象的成员变量赋值时, 其底层代码大概长这样:

void oop_field_store(oop* field, oop new_value) { 
   // 赋值操作
   *field = new_value; 
}

这里的写屏障不是解决并发的读写屏障, 看作在虚拟机层面对 “引用类型字段赋值” 这个动作的 AOP 切面, 在引用对象赋值时会产生一个环形 (Around) 通知,
供程序执行额外的动作, 也就是说赋值的前后都在写屏障的覆盖范畴内。
在赋值前的部分的写屏障叫作写前屏障 (Pre-Write Barrier), 在赋值后的则叫作写后屏障 (Post-Write Barrier)。

void oop_field_store(oop* field, oop new_value) {  
    // 写屏障-写前操作
    pre_write_barrier(field); 
    
    // 赋值操作
    *field = new_value; 
    
    // // 写屏障-写后操作
    post_write_barrier(field, value);
}

HotSpot 虚拟机只用到了写后屏障

3.2.3 漏标情况的解决

3.2.3.1 原始快照 (Snapshot At The Beginning, SATB)

原始快照要破坏的是第一个条件, 当灰色对象要删除指向白色对象的引用关系时, 就将这个要删除的引用记录下来, 在并发扫描结束之后, 再将这些记录过的
引用关系, 标记为灰色, 然后以这些灰色对象为根, 重新扫描一次。
这可以简化理解为, 无论引用关系删除与否, 都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。

因为从灰色对象删除指向白色对象的引用关系时, 就把这个引用记录下来。那么存在的确是要删除这个引用关系的, 后续不在会对其做任何变更了, 那么重新把
这个引用当做灰色对象, 就会造成 “浮动垃圾”。

在 HotSpot 中, G1 和 Shenandoah 则是用原始快照来实现。

3.2.3.2 增量更新 (Incremental Update)

增量更新要破坏的是第二个条件, 当黑色对象插入新的指向白色对象的引用关系时, 就将这个新插入的引用记录下来, 等并发扫描结束之后, 再将这些记录过的
引用关系为根, 重新扫描一次。
这可以简化理解为, 黑色对象一旦新插入了指向白色对象的引用之后, 它就变回灰色对象了。

在 HotSpot 中, CMS 是基于增量更新来做并发标记的。

2 种方式, 都是通过写屏障实现的。

4 finalize 让对象再活一次

理论上通过可达性分析算法, 可以判定出一个对象是否可以回收。
可回收的对象会在最后执行一次他的 finalize 方法, 可以通过这个方法让这个对象再次活一次。

判定一个对象是否需要回收, 可以实际需要经过 2 次标记

  1. 对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链, 那它将会被第一次标记
  2. 在 1 的基础上, 再进行一次筛选, 筛选的条件是此对象是否有必要执行 finalize() 方法, 对象没有覆盖 finalize() 方法, 或者 finalize() 方法
    已经被虚拟机调用过 (每个对象的 finalize 方法只会被执行一次), 那么虚拟机将这两种情况都视为 “没有必要执行”, 标记为可回收

如果对象判定为需要执行 finalize 方法, 该对象将会先被放置在一个名为 F-Queue 的队列, 并在稍后由一条由虚拟机自动建立的, 低调度优先级
的 Finalizer 线程去执行它们的 finalize 方法 (虚拟机会触发这个方法开始运行, 但不承诺一定会等待它运行结束, 如果某个对象的 finalize方法执行缓慢,
或者死循环等, 这会导致 F-Queue 队列的对象消除的很慢 / 一直处于等待, 最终可能导致系统崩溃)。

对象可以在 finalize 让自己不被回收。
收集器将对 F-Queue 中的对象进行第二次小规模的标记时, 判定需要执行 finalize 方法。只要对象将自己和引用链上的任意一个对象进行关联, 比如把
自己 (this 关键字) 赋值给某个类变量或者对象的成员变量, 那在第二次标记时它将被移出 “可回收” 的集合。

5 附录: 线程挂起

对于安全点, 另外一个需要考虑的问题是, 如何在垃圾收集发生时让所有线程 (这里其实不包括执行 JNI 调用的线程) 都跑到最近的安全点, 然后停顿下来。

这里有两种方案可供选择: 抢先式中断 (Preemptive Suspension) 和主动式中断 (Voluntary Suspension) 。

抢先式中断不需要线程的执行代码主动去配合, 在垃圾收集发生时, 系统首先把所有用户线程全部中断, 如果发现有用户线程中断的地方不在安全点上, 就恢复这条线程执行, 让它一会再重新中断, 直到跑到安全点上。
现在几乎没有虚拟机实现采用抢先式中断来暂停线程响应 GC 事件

主动式中断的思想是当垃圾收集需要中断线程的时候, 不直接对线程操作, 仅仅简单地设置一个标志位, 各个线程执行过程时会不停地主动去轮询这个标志, 一旦发现中断标志为真时, 就在自己最近的安全点上主动中断挂起。

5.1.3 安全区域 (Safe Region)

安全点机制保证了程序执行时, 在不太长的时间内就会遇到可进入垃圾收集过程的安全点。
但是线程还有可能存在不在执行的状态, 比如 Sleep 状态或者 Blocked 状态, 这些线程无法走到安全点的位置, 然后中断挂起自己。

而虚拟机也不会等待这些线程唤醒然后执行的, 这种情况虚拟机通过 “安全区域” 的方式进行解决。
安全区域是指能够确保在某一段代码片段之中, 引用关系不会发生变化, 因此, 在这个区域中任意地方开始垃圾收集都是安全的。

当用户线程执行到安全区域里面的代码时, 首先会标识自己已经进入了安全区域, 那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了。
当线程要离开安全区域时, 它要检查虚拟机是否已经完成了根节点枚举

如果完成了, 那线程就当作没事发生过, 继续执行
否则它就必须一直等待, 直到收到可以离开安全区域的信号为止

可以认为: 可以使线程挂起的代码, 就是在安全区域中。

另一种情况: 当一个线程在执行 native 方法时, 由于此时该线程在执行 JVM 管理之外的代码, 不能对 JVM 的执行状态做任何修改, 因而 JVM要 进入 safe point 不需要关心它。
所以也可以把正在执行 native 函数的线程看作 “已经进入了 safe point”, 或者把这种情况叫做 “在 safe-region 里”。

6 参考

《深入理解Java虚拟机》- 周志明
找出栈上的指针/引用
JVM系列十六(三色标记法与读写屏障).


  目录