本文为《深入学习 JVM 系列》第十一篇文章
文章更新履历:
20220317:补充了垃圾回收的内容,通过 finalize()复活对象增加了一个代码示例,补充完善了垃圾收集算法
Java 虚拟机的自动内存管理,将原本需要由开发人员手动回收的内存,交给垃圾回收器来自动回收。因为是自动机制,我们平时不会直接接触,但还是有必要了解与垃圾回收实现相关的问题。下文先从基础开始学习垃圾回收。
垃圾回收的目的
垃圾回收的目的是回收堆内存中不再使用的对象所占的内存,释放资源。
垃圾回收的时间
回收时间:即触发 GC 的时间,在新生代的 Eden 区满了,会触发新生代 GC(Minor GC),经过多次触发新生代 GC 存活下来的对象就会升级到老年代,升级到老年代的对象所需的内存大于老年代剩余的内存,则会触发老年代 GC(Full GC),或者小于时被 HandlePromotionFailure 参数强制 Full GC。当程序调用 System.gc()时也会触发 Full GC。
垃圾回收的内容
垃圾回收的重点就是关注堆和方法区中的内存了,堆中的回收主要是对象的回收,方法区的回收主要是废弃常量和无用的类的回收。
回收内容:方法区中无用的类和废弃常量池(运行时常量池)、堆中判定为死亡的对象。
JVM 的永久代中会发生垃圾回收么?(如何判断一个类是无用的类?)
方法区和永久代的关系很像 Java 中接口和类的关系,类实现了接口,而永久代就是 HotSpot 虚拟机对虚拟机规范中方法区的一种实现方式。因此方法区也被人们称为永久代。
永久代的垃圾回收主要包括类型的卸载和废弃常量池(运行时常量池)的回收。当没有对象引用一个常量的时候,该常量即可以被回收。而类型的卸载更加复杂。必须满足以下三点:
- 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
- 加载该类的 ClassLoader 已经被回收。
- 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
虚拟机可以对满足上述3个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样不使用了就会必然被回收。
如果对象的引用被置为null,垃圾收集器是否会立即释放对象占用的内存?
不会立即释放对象占用的内存。 如果对象的引用被置为 null,只是断开了当前线程栈帧中对该对象的引用关系,而垃圾收集器是运行在后台的线程,只有当用户线程运行到**安全点(safe point)或者安全区域(safe region)**才会扫描对象引用关系,扫描到对象没有被引用则会标记对象,这时候仍然不会立即释放该对象内存,因为有些对象是可恢复的(在 finalize 方法中恢复引用 )。只有确定了对象无法恢复引用的时候才会清除对象内存。
那么如何判定对象是否死亡呢?
如何判定对象是否死亡
关于判断对象是否存活有两种方式。引用计数法和可达性分析。
很多教科书判断对象是否存活的算法是这样的:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。上述说法是不正确的,单纯的引用计数很难解决对象之间相互循环引用的问题,如下述案例所示:
上述代码的结果显示内存被回收了,意味着虚拟机并没有因为这两个对象互相引用就放弃回收它们,这也从侧面说明了 Java 虚拟机并不是通过引用计数算法来判断对象是否存活的。
当前 Java 通过可达性分析(Reachability Analysis)算法来判定对象是否存活的。通过一系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的。
即使在可达性分析算法中判定为不可达的对象,也不是“ 非死不可”的,这时候它们暂时还处于“ 缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:
- 如果对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那它将会被第一次标记;
- 随后进行一次筛选,筛选的条件是此对象是否有必要执行 finalize()方法。假如对象没有覆盖 finalize()方法,或者 finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“ 没有必要执行”。如果这个对象被判定为有必要执行 finalize()方法,执行结束后仍然没有复活的对象,则该认为该对象死亡。
这里我们通过一个案例来演示对象复活的情形:
public class CanReliveObj { public static CanReliveObj obj; @Override protected void finalize() throws Throwable { super.finalize(); System.out.println("CanReliveObj finalize called"); System.out.println("obj 被复活了"); obj = this; } @Override public String toString() { return "CanReliveObj"; } public static void main(String[] args) throws InterruptedException { obj = new CanReliveObj(); System.out.println("第一次gc"); obj = null; System.gc(); Thread.sleep(1000); if(obj == null){ System.out.println("obj为null"); }else{ System.out.println("obj不为null"); } System.out.println("第二次gc"); obj = null; System.gc(); Thread.sleep(1000); if(obj == null){ System.out.println("obj为null"); }else{ System.out.println("obj不为null"); } } } 复制代码
执行结果为:
第一次gc CanReliveObj finalize called obj 被复活了 obj不为null 第二次gc obj为null 复制代码
可以看到,第一次 GC 后,obj 对象被复活了。虽然系统中 obj 的引用已经被清除了,但是在 finalize 方法中,对象的 this 引用被传入到方法内部,如果引用外泄,对象就会复活。当然 finalize 方法只会被调用一次,所以第二次 GC 时 obj 对象就无法被复活了。
一般而言,GC Roots 包括(但不限于)如下几种:
- 在虚拟机栈(栈帧中的本地变量表) 中引用的对象, 譬如各个线程被调用的方法堆栈中使用到的 参数、 局部变量、 临时变量等。
- 在方法区中类静态属性引用的对象, 譬如 Java类的引用类型静态变量。
- 在方法区中常量引用的对象, 譬如字符串常量池(String Table) 里的引用。
- 在本地方法栈中JNI(即通常所说的Native方法) 引用的对象。
- Java虚拟机内部的引用, 如基本数据类型对应的 Class 对象, 一些常驻的异常对象(比如 NullPointExcepiton、 OutOfMemoryError) 等, 还有系统类加载器。
- 所有被同步锁(synchronized关键字) 持有的对象。
- 反映Java虚拟机内部情况的JMXBean、 JVMTI中注册的回调、 本地代码缓存等。
除了这些固定的 GC Roots 集合以外, 根据用户所选用的垃圾收集器以及当前回收的内存区域不同, 还可以有其他对象“临时性”地加入, 共同构成完整 GC Roots 集合。
虽然可达性分析的算法本身很简明,但是在实践中还是有不少其他问题需要解决的。
比如说,在多线程环境下,其他线程可能会更新已经访问过的对象中的引用,从而造成误报(将引用设置为 null)或者漏报(将引用设置为未被访问过的对象)。误报并没有什么伤害,Java 虚拟机至多损失了部分垃圾回收的机会。漏报则比较麻烦,因为垃圾回收器可能回收事实上仍被引用的对象内存。一旦从原引用访问已经被回收了的对象,则很有可能会直接导致 Java 虚拟机崩溃。
还比如说如何快速找到 GC Roots。
枚举GC Roots
固定可作为 GC Roots 的节点主要在全局性的引用(例如常量或类静态属性) 与执行上下文(例如栈帧中的本地变量表) 中, 尽管目标明确, 但查找过程要做到高效并非一件容易的事情, 现在Java应用越做越庞大, 光是方法区的大小就常有数百上千兆, 里面的类、 常量等更是不计其数, 若要逐个检查以这里为起源的引用肯定得消耗不少时间。
现在可达性分析算法耗时最长的查找引用链的过程已经可以做到与用户线程一起并发 ,但根节点枚举始终还是必须在一个能保障一致性的快照中才得以进行——这里“一致性”的意思是整个枚举期间执行子系统看起来就像被冻结在某个时间点上, 不会出现分析过程中, 根节点集合的对象引用关系还在不断变化的情况, 若这点不能满足的话, 分析结果准确性也就无法保证。
目前 HotSpot 虚拟机使用的都是准确式垃圾收集(HotSpot 基于准确式内存管理,准确式内存管理是指虚拟机可以知道内存中某个位置的数据具体是什么类型) , 所以当用户线程停顿下来之后, 其实并不需要一个不漏地检查完所有执行上下文和全局的引用位置, 虚拟机应当是有办法直接得到哪些地方存放着对象引用的。
在 HotSpot 的解决方案里, 是使用一组称为 OopMap 的数据结构来达到这个目的。 一旦类加载动作完成的时候,HotSpot 就会把对象内什么偏移量上是什么类型的数据计算出来, 在即时编译过程中, 也会在特定的位置记录下栈里和寄存器里哪些位置是引用。 这样收集器在扫描时就可以快速通过 OopMap 找到 GC Roots。
安全点SafePoint
每个被 JIT 编译过后的方法也会在一些特定的位置记录下 OopMap,记录了执行到该方法的某条指令的时候,栈上和寄存器里哪些位置是引用。这样 GC 在扫描栈的时候就会查询这些 OopMap 就知道哪里是引用了。这些特定的位置主要在:
1、循环的末尾
2、方法临返回前 / 调用方法的call指令后
3、可能抛异常的位置
这种位置被称为**“安全点”(safepoint)**。
可以看出,HotSpot 采用 OopMap 的数据结构其实是一种空间换时间的方法,但并没有为每条指令(的位置)都生成 OopMap,那将会需要大量的额外存储空间, 导致空间成本消耗增大。
安全点的选择标准:是否具有让程序长时间执行的特征为标准。
安全点的选定既不能太少,让 GC 等待时间太长,也不能太多,过分增大运行时的负荷。
只有到达安全点的时候才会停止当前正在执行的程序(Stop the world),去进行 GC。
这里就涉及到一个新的概念——Stop-the-world。
Stop-the-world
Stop-the-world,即停止其他非垃圾回收线程的工作,直到完成垃圾回收。这也就造成了垃圾回收所谓的暂停时间(GC pause)。Stop-the-world 是通过安全点(safepoint)机制来实现的。当 Java 虚拟机收到 Stop-the-world 请求,它便会等待所有的线程都到达安全点,才允许请求 Stop-the-world 的线程进行独占的工作。
对于安全点, 另外一个需要考虑的问题是, 如何在垃圾收集发生时让所有线程(这里其实不包括执行JNI调用的线程) 都跑到最近的安全点, 然后停顿下来。
这里有两种方案可供选择: 抢先式中断(Preemptive Suspension) 和主动式中断(Voluntary Suspension) ,
- 抢先式中断不需要线程执行相关的代码主动去配合,在 GC 的时候,首先让所有的线程全部中断,如果发现有的线程没有到达安全点,就恢复线程,直到它跑到安全点上。 现在几乎没有虚拟机实现采用抢先式中断来暂停线程响应 GC 事件。
- 主动式中断的思想是当垃圾收集需要中断线程的时候, 不直接对线程操作, 仅仅简单地设置一个标志位, 各个线程执行过程时会不停地主动去轮询这个标志, 一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。
对 Java 线程中的 JNI 方法,它们既不是由 JVM 里的解释器执行的,也不是由 JVM 的JIT编译器生成的,所以会缺少 OopMap 信息。那么GC 碰到这样的栈帧该如何维持准确性呢?
HotSpot 的解决方法是:所有经过 JNI 调用边界(调用JNI方法传入的参数、从JNI方法传回的返回值)的引用都必须用“句柄” (handle)包装起来。JNI 需要调用 Java API 的时候也必须自己用句柄包装指针。在这种实现中,JNI 方法里写的“object”实际上不是直接指向对象的指针,而是先指向一个句柄,通过句柄才能间接访问到对象。这样在扫描到 JNI 方法的时候就不需要扫描它的栈帧了——只要扫描句柄表就可以得到所有从 JNI 方法能访问到的 GC堆里的对象。
但这也就意味着调用JNI方法会有句柄的包装/拆包装的开销,是导致 JNI 方法的调用比较慢的原因之一。
举个例子,当 Java 程序通过 JNI 执行本地代码时,如果这段代码不访问 Java 对象、调用 Java 方法或者返回至原 Java 方法,那么 Java 虚拟机的堆栈不会发生改变,也就代表着这段本地代码可以作为同一个安全点。只要不离开这个安全点,Java 虚拟机便能够在垃圾回收的同时,继续运行这段本地代码。
安全点机制保证了程序执行时, 在不太长的时间内就会遇到可进入垃圾收集过程的安全点。 但是, 程序“不执行”的时候呢? 所谓的程序不执行就是没有分配处理器时间, 典型的场景便是用户线程处于Sleep状态或者Blocked状态, 这时候线程无法响应虚拟机的中断请求, 不能再走到安全的地方去中断挂起自己, 虚拟机也显然不可能持续等待线程重新被激活分配处理器时间。 对于这种情况, 就必须引入安全区域(Safe Region) 来解决。
安全区域SafeRegion
Safepoint 机制保证程序执行时,短时间内就会遇到可进入 GC 的 Safepoint,但是也有一些特例,比如说 JNI 方法、sleep、block等,这些时候 JVM 无法掌控执行能力,也就无法响应 GC 事件。
安全区域是指能够确保在某一段代码片段之中, 引用关系不会发生变化, 因此, 在这个区域中任意地方开始垃圾收集都是安全的。 我们也可以把安全区域看作被扩展拉伸了的安全点。
当用户线程执行到 SafeRegion 里面的代码时, 首先会标识自己已经进入了 SafeRegion, 在此期间虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了。 当线程要离开 SafeRegion 时, 它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的阶段) , 如果完成了, 那线程就当作没事发生过,继续执行; 否则它就必须一直等待,直到收到可以离开安全区域的信号为止。