vlambda博客
学习文章列表

JVM系列之:你真的了解垃圾回收吗

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 方法中恢复引用 )。只有确定了对象无法恢复引用的时候才会清除对象内存。

那么如何判定对象是否死亡呢?

如何判定对象是否死亡

关于判断对象是否存活有两种方式。引用计数法和可达性分析。

很多教科书判断对象是否存活的算法是这样的:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。上述说法是不正确的,单纯的引用计数很难解决对象之间相互循环引用的问题,如下述案例所示:

img

上述代码的结果显示内存被回收了,意味着虚拟机并没有因为这两个对象互相引用就放弃回收它们,这也从侧面说明了 Java 虚拟机并不是通过引用计数算法来判断对象是否存活的。

当前 Java 通过可达性分析(Reachability Analysis)算法来判定对象是否存活的。通过一系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的。

JVM系列之:你真的了解垃圾回收吗
img

即使在可达性分析算法中判定为不可达的对象,也不是“ 非死不可”的,这时候它们暂时还处于“ 缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:

  1. 如果对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那它将会被第一次标记;
  2. 随后进行一次筛选,筛选的条件是此对象是否有必要执行 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 时, 它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的阶段) , 如果完成了, 那线程就当作没事发生过,继续执行;否则它就必须一直等待,直到收到可以离开安全区域的信号为止。

并发的可达性分析

通过上文可知,JVM 默认使用可达性分析算法来判断对象是否死亡,而可达性分析算法理论上要求全过程都基于一个能保障一致性的快照中才能够进行分析,这意味着必须全程冻结用户线程的运行。虽然 GC Roots 相较于 Java 堆对象来说只是很小的一部分,即使可以通过 OopMap 快速找到 GC Roots,它带来的停顿时间是非常短暂且相对固定的,可以理解为不会随着堆里面的对象的增加而增加。

但是从GC Roots再继续往下遍历对象图, 这一步骤的停顿时间就必定会与 Java 堆容量直接成正比例关系了:堆越大, 存储的对象越多, 对象图结构越复杂, 要标记更多对象而产生的停顿时间自然就更长。

"标记"阶段是所有使用可达性分析算法的垃圾回收器都存在的阶段。如果能够削减"标记"过程这部分的停顿时间,那么收益将是系统性的。

所以并发标记要解决什么问题呢?

就是要消减这一部分的停顿时间。那就是让垃圾回收器和用户线程同时运行,并发工作。也就是我们说的并发标记的阶段。

在介绍并发标记前,首先需要介绍一个新的概念,“三色标记”。

什么是"三色标记"?《深入理解Java虚拟机(第三版)》中是这样描述的:

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

如下图所示:

JVM系列之:你真的了解垃圾回收吗

可以看到,灰色对象是黑色对象与白色对象之间的中间态。当标记过程结束后,只会有黑色和白色的对象,而白色的对象就是需要被回收的对象。

在可达性分析的扫描过程中,如果只有垃圾回收线程在工作,那肯定不会有任何问题。

但是垃圾回收器和用户线程同时运行呢?

垃圾回收器在对象图上面标记颜色,而同时用户线程在修改引用关系,引用关系修改了,那么对象图就变化了,这样就有可能出现两种后果:

  • 一种是把原本消亡的对象错误的标记为存活,这不是好事,但是其实是可以容忍的,只不过产生了一点逃过本次回收的浮动垃圾而已,下次清理就可以。

  • 一种是把原本存活的对象错误地标记为已消亡,这就是非常严重的后果了,一个程序还需要使用的

    对象被回收了,那程序肯定会因此发生错误。

最终我们得知并发标记除了会产生浮动垃圾,还会出现"对象消失"的问题。

浮动垃圾的影响比较小,下次清理即可,关键是如何解决“对象消失”的问题。关于该问题,我们用图片来演示一下。

我们先看一下一次正常的标记过程:

首先是初始状态,很简单,只有 GC Roots 是黑色的。同时需要注意下面的图片的箭头方向,代表的是有向的,比如下图有两条引用链是:根节点->4->5->6 以及 根节点->4->5->7,注意对象2不在根节点的引用链上。

JVM系列之:你真的了解垃圾回收吗

如果是正常扫描,则最后的图像展示如下:

JVM系列之:你真的了解垃圾回收吗

因为灰色对象始终是介于黑色和白色之间的,当扫描完成后只会剩下白色和黑色。黑色对象是存活的对象,白色对象是消亡了,可以回收的对象。

那么来演示一下“对象消失”的情况:

对象5 和对象7之间是有引用关系的,如果在扫描途中,当扫描到对象5时,用户线程删除了这两者之间的引用关系(用虚线来表示取消了引用关系),转而将对象4和对象7关联起来。因为扫描是无法回头的,只能往下走,那么对象7就会被遗忘掉。

最终得到如下图示:

JVM系列之:你真的了解垃圾回收吗

和之前分析的正常扫描结束的对象图对比,就能直观地看到,对象7会被当成垃圾回收。这样就出现了对象消失的情况。

怎么解决"对象消失"问题呢?

有一个大佬叫 Wilson,他在1994年在理论上证明了,当且仅当以下两个条件同时满足时,会产生"对象消失"的问题,原来应该是黑色的对象被误标为了白色:

  • 条件一:赋值器插入了一条或者多条从黑色对象到白色对象的新引用。
  • 条件二:赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。

注意:条件二中说的 该白色对象 指的就是条件一里面的白色对象。

所以,我们有理由相信:条件一和条件二是有先后顺序的,即必须是赋值器插入了一条或者多条从黑色对象到白色对象的新引用,然后赋值器又删除了全部从灰色对象到该白色对象的直接或间接引用。在这样的情况下,才会出现“对象消失”的情况。

目前有两种方案:增量更新(Incremental Update)和原始快照(Snapshot At The Beginning,SATB)。

在HotSpot虚拟机中,CMS是基于增量更新来做并发标记的,G1则采用的是原始快照的方式。

什么是增量更新呢?

增量更新要破坏的是第一个条件(赋值器插入了一条或者多条从黑色对象到白色对象的新引用),当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。

可以简化的理解为:黑色对象一旦插入了指向白色对象的引用之后,它就变回了灰色对象。

什么是原始快照呢?

原始快照要破坏的是第二个条件(赋值器删除了全部从灰色对象到该白色对象的直接或间接引用),当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。

这个可以简化理解为:无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照开进行搜索。

需要注意的是,上面的介绍中无论是对引用关系记录的插入还是删除,虚拟机的记录操作都是通过写屏障实现的。后文会详细介绍写屏障。

增量更新用的是写后屏障(Post-Write Barrier),记录了所有新增的引用关系。

原始快照用的是写前屏障(Pre-Write Barrier),将所有即将被删除的引用关系的旧引用记录下来。

垃圾收集算法

标记-清除算法

算法分为“标记”和“清除”阶段:在标记阶段,首先通过根节点,标记所有从根节点开始的可达对象。因此,未被标记的对象就是未被引用的垃圾对象。然后在清除阶段,清除所有未被标记的对象。它是最基础的收集算法,后续的算法都是对其不足进行改进得到。这种垃圾收集算法会带来两个明显的问题:

  • 效率问题。回收的空间是不连续的,在对象的堆空间分配过程中,尤其是大对象的内存分配,不连续内存空间的工作效率要低于连续的空间。
  • 空间碎片(标记清除后会产生大量不连续的碎片)
JVM系列之:你真的了解垃圾回收吗

标记-复制算法

为了解决效率问题,“复制”收集算法出现了。它可以将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。

如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,复制算法需要复制的存活对象数量就会相对少。虽然该算法不会导致空间碎片的问题,但是它的代价却是将系统内存折半。

在后面介绍到的垃圾收集器,使用了复制算法的思想,新生代分为 eden 空间、from survivor、to survivor,其中 from 和 to 空间视为用于复制的两块大小相同、地位相等、且可进行角色互换的空间块。

标记-整理算法

标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。更关键的是,如果不想浪费50%的空间, 就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。

根据老年代的特点提出的一种标记-整理算法,在标记-清除算法的基础上做了一些优化。和标记-清除算法一样,标记-整理算法也首先需要从根节点开始,对所有可达对象做一次标记,但之后,它并不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。这种方法既避免了碎片的产生,由不需要两块相同的内存空间,因此性价比较高。

标记-整理算法的最终效果等同于标记-清除算法执行完成后,再进行一次内存碎片整理。

分代算法

当前虚拟机的垃圾收集都采用分代收集算法,这种算法没有什么新的思想,只是根据对象存活周期的不同将内存分为几块。一般将 Java 堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。

比如在新生代中,每次收集都会有大量对象死去,所以可以选择标记-复制算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集。

总结

上文详细介绍了关于垃圾回收的基础知识,主要包括:

  • 为什么要垃圾回收?

  • 何时进行垃圾回收?

  • 回收什么东西?

  • 回收死亡的对象,那么如何判断对象已死亡?

    • 可达性分析算法介绍,并发环境下存在的问题以及解决方案。
    • 安全点与安全区域的介绍
    • Stop-the-world
  • 梳理现有的垃圾收集算法

下一讲会继续介绍垃圾收集器、JVM内存分配等内容,敬请期待。

参考文献

面试官:你说你熟悉jvm?那你讲一下并发的可达性分析

《深入理解Java虚拟机》

JVM-如何判断一段数据是真正的数据,还是对象的引用

找出栈上的指针/引用