JVM:垃圾收集器

1,008 阅读44分钟

本章的知识点主要源自《深入理解 JVM 虚拟机》第 3 章。垃圾收集器的设计要考虑到三个核心问题:

  1. 哪些对象需要被回收?
  2. 什么时候回收?
  3. 如何回收?

对于 Java 开发者而言,垃圾收集的工作会由虚拟机来完成。然而,当需要排查内存溢出,内存泄漏等问题时,必须要对这些 "自动化" 的技术进行监控与调节。下面是笔者使用整理出的本章节脑图 ( 放大查看 ):

虚拟机的垃圾回收主要是针对于堆空间进行的,而堆是用于存放各种对象的空间。因此,判断哪些对象是 "死" 的,哪些对象是 "活" 的,是垃圾收集器工作的第一步,其中有两种方式:引用计数方法,可达性分析方法。

1. 引用计数方法

该方法的判断逻辑十分简单:为每个对象添加引用计数器,每当它被引用一次时,计数器数值加一,当释放了一个对它的引用时,计数器数值减一。该方法在某些应用中仍然适用,但至少 JVM 并没有采纳。只需要一个案例就足以说明问题:循环引用的例子。

public class Recycle {
    /**
     * VM args : -XX:+PrintGCDetails
     */
    public static void main(String[] args) {

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

        a.reference = b;
        b.reference = a;

        a = null;
        b = null;

        System.gc();
    }
}

class Obj {

    private static final int _1MB = 1024 * 1024;
    // 用它占据一点空间,以便在 GC 打印的日志中看到内存变化。
    private byte[] box = new byte[_1MB];

    public Obj reference = null;
}

下面是程序的运行结果:

[GC (System.gc()) [PSYoungGen: 5379K->872K(38400K)] 5379K->880K(125952K), 0.0010217 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (System.gc()) [PSYoungGen: 872K->0K(38400K)] [ParOldGen: 8K->656K(87552K)] 880K->656K(125952K), [Metaspace: 3206K->3206K(1056768K)], 0.0048771 secs] [Times: user=0.09 sys=0.00, real=0.00 secs] 

其中,日中显示 PSYoungGen 区从 5379 K 被回收至 872 K,这说明虚拟机没有因为对象 ab "左脚踩右脚" 的操作就放弃回收它们。ab 所引用的对象显然已不可访问,除此之外两者的引用数也不为 0 。这个例子说明,JVM 并非是使用引用计数方法来实现内存回收的。

PS 的全称是 Parallel Scavenge 收集器,YoungGen 表示年轻代区域。

2. 可达性分析

可达性分析是目前主流语言所采用的垃圾回收的方式。它的思路是以 GC Roots 为根并沿着 "引用链" 一路搜索,如果有某个对象不在这条引用链上(或称 GC Roots 不可达),那么说明它是不可能再被引用的。

GC Roots 是一个集合。在正式进行可达性分析之前,虚拟机会率先进行根节点枚举( 后文会提到 ),以此搜寻到可以作为 GC Roots 的对象。这些对象可以被放入到集合中:

  1. 栈帧中本地变量表所引用的对象,比如各个线程执行的方法内的参数,局部变量,临时变量。
  2. 方法区内静态属性引用的对象
  3. 方法区常量池内的对象,譬如字符串常量池 (String Table) 内的引用。
  4. 常驻于 JVM 内部的引用,比如基本数据类型对应的 Class 对象,常驻的异常对象,系统类加载器。
  5. 本地方法栈中的 JNI (Native 方法) 中引用的对象。
  6. 被同步锁持有的对象。
  7. 能够反映 Java 虚拟机内部情况的内容。

3. 引用决定 Die or Live

对象是否被引用,决定了它是否还有存活的价值。在 JDK 1.2 之前,对引用的划分是 "一刀切" 的做法:一个对象,要么被引用,要么被抛弃。然而,这两种状态用来描述 "食之无味,弃之可惜" 的对象过于狭隘了。

以电脑的各种缓存文件做类比:当电脑存储空间足够时,我们可以容忍缓存文件的存在(因为它们一般都可以加快访问速度,所以留下来也没有不妥)。但是当电脑的存储空间紧张时,我们就要将它们清除掉,来为其它更重要的文件腾出空间。

因此,在 JDK 1.2 之后,Java 对引用的概念做了补充,将其分为了四种级别,从上至下依次减弱:

  1. 强引用。普遍存在于各种赋值语句中,比如 Obj a = new Obj(); 。只要强引用存在,这个对象就永远不会被回收。
  2. 软引用。软引用用来描述可用,但非必须的对象(类似于缓存)。在系统将要发生 OOM 异常之前,虚拟机会将所有软引用的对象列入回收清单中并回收。如果这一次仍然没有回收到足够的内存,则抛出 OOM 错误。
  3. 弱引用。与其相关联的对象只能存活在下一次垃圾回收之前,不管内存是否还充裕。
  4. 虚引用。也称幻影引用,它不会影响到关联对象的生命周期,它作用是当对象被回收时,系统得到一个虚引用,并记录下来。

3.1 四种引用的代码实践

强引用是开发中最常见的情况。对于这类引用,虚拟机宁可抛出 OOM 异常,也不会回收掉这部分的内存空间。除此之外,如果想要在某个时刻回收掉这个强引用,则可以将它赋值为 null 。回收器会在下一次工作时会将这个 "泄露" 的对象空间回收掉。

Object a = new Object();
a = null;

对于软引用,我们要使用 SoftReference<T> 泛型类进行实现。在进行实验之前,为了快速得到实验结果,不妨先设置虚拟机参数限定堆空间。实验的思路是:首先创建一个软引用,然后再创建一个强引用以迫使虚拟机进行垃圾回收,并观察之后这个软引用是否还存在。

注意,SoftReference 本身是强引用,它本身不会被回收(除非它本身被赋值为 null 了),它内部的数据(通过 .get() 获得的对象)才是符合软引用定义的,下文的 WeakReference 同理。

public class Cycle {
    /**
     * 现在 JVM 的堆空间为 20MB,不允许拓展。
     * VM args : -Xms20m -Xmx20m
     */
    public static void main(String[] args) {

        int _1MB = 1024 * 1024;
	    // 申请 10MB 空间的数组,并使用软引用关联。
        SoftReference<byte[]> softReference = new SoftReference<>(new byte[_1MB * 10]);
        
        // 此软引用存在。
        System.out.println(softReference.get());
        
        // 另创建 10MB 空间,与强引用关联。在不进行 GC 的情形下,会发生 OOM 错误。
        byte[] _10MB = new byte[ _1MB * 10];
        
        // 为了避免 OOM,原先的软引用被回收。
        System.out.println(softReference.get());

    }
}

弱引用的实践比较简单:创建一个弱引用,然后主动进行一次垃圾回收,并观察在此之后它是否还存在。

WeakReference<Object> weakObj = new WeakReference<>(new Object());
System.out.println(weakObj.get());
System.gc();
System.out.println(weakObj.get());

虚引用,和软引用,弱引用有较大的区别。比如说:

// 暂且忽略掉第二个参数,先传入 null 。
PhantomReference<Object> phantomReference = new PhantomReference<>(new Object(), null);
System.out.println(phantomReference.get());

能够观察到,即便在内存充裕,且没有进行垃圾回收的情况下,虚引用内仍然什么都不保留。虚引用实际的作用是:当某个被其它引用方式关联的对象 obj 被回收时,虚引用此刻如同关联对象的 "病危通知书" 被放到一个 "消息队列" 中等待处理,通常是用于记录对象回收状态等工作。

你可以类比观察者模式去理解它:虚引用仅仅充当其它对象的哨兵,而不负责持有对象的引用,因此不会影响对象的生命周期。

因此,上述的代码块中,构造 PhantomReference<T> 所需的第二个参数实际上需要传入一个 ReferenceQueue<T> 队列。当第一个参数中的对象被回收时,有关于它的虚引用会被放到这个队列当中等待程序处理。下面用代码来举例:

public class Recycle {
    /**
     * VM args : -XX:+PrintGCDetails -Xms20m -Xmx20m
     */
    public static void main(String[] args) {

        ReferenceQueue<Obj> notifyQueue = new ReferenceQueue<>();
        WeakReference<Obj> obj = new WeakReference<>(new Obj());
        PhantomReference<Obj> phantomReference = new PhantomReference<>(obj.get(),notifyQueue);
        System.out.println("phantomReference(hashcode=" + phantomReference.hashCode() + ")建立了和弱引用对象 obj 的虚引用关联");
        
        // 弱引用对象在一轮 GC 操作后被回收。
        System.gc();

        // 创建新线程,持续获取 notifyQueue 的信息
        new Thread(() -> {
            while (true) {
                Reference<? extends Obj> poll = notifyQueue.poll();
                if (poll != null) {
                    System.out.println("phantomReference(hashcode=" + poll.hashCode() + ")关联的 obj 被回收了");
                }
            }
        }).start();
    }
}

class Obj {}

4. finalize 方法

finalize() 方法能做到的事情,使用 try - catch 语句完全能够做得更好。我们在这里仅了解该方法对垃圾回收的影响,实际开发中不建议主动使用该方法。

经可达性分析第一轮筛选出的 "不可达" 对象并不会立刻回收,实际上虚拟机还会判断该对象是否需要执行 finalize() 方法。在以下两种情况下,虚拟机会认为没有必要执行它:

  1. 该对象的所属类没有主动覆盖 finalize() 方法。
  2. 虚拟机已经调用过一次该对象的 finalize() 方法了。

对于需要执行 finalize() 方法的对象,虚拟机会将对象置入一个名为 F-Queue 的队列当中,并且通过一个额外的 Finalizer 的低优先级线程执行这些对象的 finalize() 方法。注意,该线程未必会等待每一个 finalize() 执行完毕才回收空间,也不保证按照次序执行每一个对象的 finalize() 方法,因为虚拟机要将资源分配给更有价值的用户线程上。

进入 F-Queue 队列的对象仍然有机会进行自救:如果在执行 finalize() 方法的过程中,这个对象成功地重新和 GC Roots 建立起联系,那么虚拟机会在第二轮筛选 F-Queue 是否有 "逃逸" 的对象时,会将它从 ”死亡“ 队列当中移除。

从某一个对象的视角出发,它从被标记到空间被回收总共经历了这段历程:

下面用代码演示一个例子。

public class Recycle {

    // Obj 可以通过将自身链接到此静态引用的方式进行自救。
    static Obj SAVE_POINT = null;

    public static void main(String[] args) throws InterruptedException {

        Obj obj = new Obj();
        System.out.println("obj 对象的 hashcode = " + obj.hashCode());
        
        obj = null;
        System.gc();

        // 由于 Finalizer 线程的优先级过低,因此让 main 线程休眠 500 秒。
        Thread.sleep(500);

        if (SAVE_POINT != null) {
            System.out.println("SAVE_POINT 引用的 hashcode = " + SAVE_POINT.hashCode());
        }else{
            System.out.println("第一次自救失败了。");
        }

        SAVE_POINT = null;
        System.gc();
        Thread.sleep(500);

        if (SAVE_POINT != null) {
            System.out.println("SAVE_POINT 引用的 hashcode = " + SAVE_POINT.hashCode());
        }else{
            System.out.println("第二次自救失败了。");
        }
    }
}

class Obj {
    @Override
    protected void finalize() {
        System.out.println("finalize 被重写!");
        Recycle.SAVE_POINT = this;
    }
}

由于 Obj 类重写了 finalize() 方法,因此对于该类的实例,虚拟机不会立刻回收,而是将它放到 F-Queue 的 "死缓" 队列里。整个程序的运行结果如下:

obj 对象的 hashcode = 1639705018
finalize 被重写!
SAVE_POINT 引用的 hashcode = 1639705018
第二次自救失败了。

上段代码中有两断重复的代码块,但是对象只在第一个代码块中成功的自救了。原因是前文提到的第二条:虚拟机不会重复执行一个对象的 finalize() 方法,因此一个对象仅有一次机会能通过 finalize() 内的代码块重新 “拯救” 自己。

然而,finalize() 方法不能够等同于 C/C++ 语言的析构函数去理解。它的运行代价高昂且不可控,因此官方并不建议主动重写并调用 finalize() 方法。如果你一定要做一些 "关闭系统资源" 之外的清理工作,那么完全可以通过 try-catch 语句块来完成它。

5. 方法区的空间回收

在 JDK 7 版本及之前,大部分人认为方法区等同于永久代,因此还有种说法称 “永久代回收”。《Java 虚拟机规范》 中定义,任何虚拟机可以不去实现对方法区的垃圾回收。"永久代回收" 意味某个类本身会被虚拟机直接 "卸载掉" ,然而判断一个类是否已经彻底 “没用” 了,它要满足以下严苛的条件:

  1. 该类以及相关子类的所有实例都已经被回收掉了。
  2. 加载该类的类加载器 ClassLoader (它用来读取外部 .class 文件并转换成 java.lang.Class 类的一个实例)已经被回收掉了。一般的应用场景下,很难通过人为的方式改变这一条件。
  3. 该类对应的 java.lang.Class 没有在任何一处被调用,也没有通过反射调用和该类有关的内容。

然而,即便是满足了这三个条件,虚拟机也仅仅是将这个类标记为 "可回收的",而不会立即执行。在 Java 堆(尤其是新生代)中进行一次 GC 往往可以回收 70% ~ 90% 内存的空间,相比之下,对方法区进行垃圾回收是高投入,低回报的行为。

但是,在大量使用反射,动态代理,cglib 来动态生成大量字节码的应用场景时,则要求虚拟机具备类卸载能力,以节省方法区内的空间。本章提及的垃圾回收都是针对于 Java 堆空间而言的,因此,下文仅涉及 "年轻代" 和 "老年代" 。

6. 垃圾收集理论

从如何判断对象消亡的角度触发,垃圾收集的方式可以分为 "引用计数式垃圾收集" ( Reference Counting GC ) 和 "追踪式垃圾收集" ( Tracing GC ) 。由于虚拟机事实上采用的是后者,因此本文只围绕后者来展开。

当前商业虚拟机的垃圾收集器遵顼以下三条经验法则:

  1. 弱分代假说 ( Weak Generational Hypothesis ):绝大部分对象是 朝生夕灭 的。
  2. 强分代假说 ( Strong Generational Hypothesis ):难回收的对象会更加顽强。
  3. 跨代引用假说 ( Intergenerational Reference Hypothesis ):存在引用关系的两个对象趋向于同生同灭。

前两条经验法则奠定了虚拟机的 “分代回收” 的设计原则:大部分朝生夕灭的对象(新生代)和少部分熬过多轮 GC 的对象(老年代)应该在 Java 堆中分开存储。这样,虚拟机仅需将 GC 工作的重心放在新生代,并将这其中少部分 “顽强” 的对象晋升到老生代中保存(老年代不意味不回收),这样就能大幅度提高垃圾回收的效率。

6.1 GC 回收的粒度

根据新生代(占大部分,GC 回报高),老生代的特性不同(占小部分,GC 回报低),有三种垃圾回收的思想被提出:标记 - 清除,标记 - 复制,标记 - 整理。另外,GC 工作被划分出了更细致的粒度:

  1. 部分收集 ( Partial GC ):对 Java 堆空间内的部分区域进行收集,还可以分为:
    • 新生代收集 ( Minor GC / Young GC ):对新生代的对象进行垃圾回收。
    • 老年代收集 ( Major GC / Old GC ):对老年代的对象进行垃圾回收。
    • 混合收集 ( Mixed GC ):目前 G1 收集器的工作模式,因为它不按照传统方式区分新生代和老年代。
  2. 整堆收集 ( Full GC ):对整个 Java 堆进行收集,相当于 Minor GC + Major GC。

一个新的对象被创建时,它首先会被分配到 Java 堆的年轻代中。此外,年轻代对象的晋升条件有两种:

  1. 熬过多轮年轻代收集。
  2. 该对象占据大量空间,为了节省在 Java 堆内的移动成本,它在 "出生的那一刻" 起就会直接被放入老年代中存储。

6.2不同粒度的 GC 触发时机

除了使用 Region 对 Java 堆划区管理的 G1 收集器涉及到 Mixed GC ,大部分垃圾收集器要么是 Minor GC,要么就是 Full GC。垃圾收集器总是优先进行 Minor GC,因为 Major GC 的效率远低于 Minor GC ( 因此鲜有垃圾收集器会单独进行 Major GC ,但这并不是绝对的,比如 Parallel Scavenge )。如果 Minor GC 回收的空间仍然不够,这时会继续进行 Major GC ( 此时相当于进行完了 Full GC )。

下文提到的 "Eden" , "Survivor" ,"内存分配担保" 等内容可参考标记 - 复制算法当中提到的 "Appel" 式回收过程。

Minor GC

当年轻代中的 Eden 空间没有足够的空间进行分配时,率先对此空间进行 Minor GC。

Full GC

  1. 每一轮 Minor GC 之后,都会有一部分对象从 Survivor 空间晋升到老年代。垃圾收集器实际上会记录这部分对象的大小作为经验值。在下一次 Minor GC 之后,收集器会根据以往的经验值 "判断" 本轮晋升老年代的过程是否有足够的空间进行。如果收集器判断本轮 Minor GC 中,老年代没有足够空间,则它会继续执行一次 Major GC。

  2. 此外,由于大对象也会直接晋升到老年代,若老年代没有足够的连续空间可供分配,此时也会进行一次 Major GC。

如果在 Major GC 之后 ( 即已经 Full GC ) 仍然没有可用空间,此时会出现 OOM 异常并提示堆空间不足。

6.3 跨代引用问题

然而, 将 Java 堆一分为二的做法,面对跨代引用的情况则比较尴尬:比如当进行 Minor GC 时,某个待回收的新生代对象有没有可能正在被一个老年代对象引用呢?如果确实如此,那就不应该将它回收,以免老年代对象那里抛出空指针异常。为了防止事故的发生,垃圾收集器就将不得不再去额外搜索整个老年代区域。反过来进行 Major GC 也会遇到相似的情形。

而第三条经验则指出:跨代引用仅仅是少部分现象。这源于前两个经验的推理得来,比如说:如果一个新生代对象引用了一个老年代对象,那么这个跨代引用会使得该新生代对象同样难以回收,久而久之,该新生代也会晋升到老年代当中,此时的跨代引用也消失了。

因此,根据这一条假说,垃圾收集器无需在扫描 GC Roots 的基础上再一次次地扫描整个老年代,只需要通过建立类似索引的方式记录某一块存在跨代引用的内存(即 "记忆集" Remembered Set,后文我们再去讨论它)再后续处理。虽然这增加了额外维护该数据结构的成本,但是相比扫描整个老年代来说仍然是划算的。

7. 垃圾收集算法

下面三种是垃圾收集器广泛采用的垃圾收集算法,它们拥有各自的优势,缺陷:

7.1 标记 - 清除算法

该算法在 1960 年由 Lisp 之父 John McCarthy 提出,是最基础,也是逻辑上最简单的垃圾回收算法。在垃圾收集器工作时,在第一轮中会将目标区域内的可回收对象全部标记,随后在第二轮中将被标记的对象全部回收。这种算法最显著的缺点是:经过一次 GC 之后,堆空间内将出现大量的零碎空间。因此导致大空间对象无法分配内存时,可能又会触发新一轮 GC,并恶性循环。

下面演示了标记清除算法产生大量零碎空间的示意图:

7.2 标记 - 复制算法 ( 适用年轻代 )

在 1969 年,Fenichel 提出了 "用一半,留一半" 思想的半区复制 ( Semispace Copying ) 算法:每次只使用一半的空间,当其中一个半区的空间耗尽时,进行 GC,保留剩下的存活对象移动到另一个崭新的半区中继续使用,而该半区的空间完全清空,等待下一轮使用。

目前,大部分虚拟机基于该算法对新生代空间进行回收( 下文的介绍将默认此算法在新生代空间中执行 ),因为对于新生代而言,只有少部分对象存活了下来,这意味了移动对象的成本降低了。此外,每次 GC 之后都会留下一半 "完全干净" 的内存空间,因此也就不需要像标记 - 清除算法一样担忧产生内存碎片了。

但其缺点也十分明显,这种做法使得可用空间被削减为了原来的 50%。IBM 公司专门进行了一项研究,并对 “朝生夕灭” 做了量化的解释 —— 98 % 的对象都是一次性使用的,或者说它们挺不住第一轮垃圾收集,因此没有必要按照 1 :1 的比例划分出 backup 的空间。

在 1989 年,Andrew Appel 根据这个特性提出了比例更加合理的分区复制分代策略,该方式又称为 "Appel 式回收" :将 新生代 整体再划分出一块 Eden 空间和两块 Survivor 空间,每次仅使用一块 Eden 空间和其中一块 Survivor 空间 (正在使用的 Survivor 空间又称为 "From" 空间,备用的另一个称 "To" 空间)。Survivor 和 Eden 的空间分配比率为 8 :1,换句话说,新生代的空间利用率从 50% 提升至了 90% 。

流程可参考图示来理解:Eden 空间占据大部分空间,用于存储大量 "朝生夕灭" 的对象。在进行一次 GC 之后,Eden 空间内的绝大部分新对象消亡,From 空间内的少部分 "原本有望晋升到老年代" 的对象也消亡。两部分剩余的幸存者被一同移动到了预留的 To 空间中。随后,此 To 空间在下一轮中变为 From 空间,原本的 From 空间随着 Eden 空间一同被清理干净后变成了崭新的 To 空间,这个筛选的过程将周而复始。

被移动到 To 空间意味着这个对象至少在 GC 过程中存活了 1 次。那些在 From / To 空间反复横跳,且在一定轮数 ( HotSpot 虚拟机默认这个值是 15 ) 都没有被回收的对象最终晋升为老年代对象。

当然,任何人都无法保证 Survivor 空间一定能够一次容纳所有幸存的对象。如果在一次 GC 中有占据新生代总区间 10% 以上空间的对象都存活了下来,那么就需要其它内存区域进行担保分配 ( Handle Promotion )。担保分配的意思是:有一个 “担保人” 保证当 Survivor 的空间不够用时,它能够 "代替偿还" 剩下需要的内存空间而不至于发生 OOM。而这个 "担保" 大部分情况是从老年代区域那里 "薅羊毛" 。

此外,前文已经提到过,垃圾收集器为了避免频繁地对对象进行来回移动,因此当某一个对象占据的空间达到一定阈值时,它将会直接晋升为老年代

7.3 标记 - 整理算法 ( 适用老年代 )

在对象存活率较高时,标记 - 复制算法要挪动大量的对象,很显然这不适合用于老年代。在 1984 年 Edward 针对于老年代的特性提出了标记 - 整理算法,它和标记 - 清除算法的区别是:存活的对象会被紧凑排列。或者说,标记 - 清除算法是非移动式的,而标记 - 整理算法是移动式的。而无论选择是否移动对象,都是一种优缺点并存的情况:

如果选择移动对象,那么所有引用这些对象的地方都需要进行更新。进行这种操作必须要暂停所有的用户线程以保存现场 —— 有个更形象的说法是 "Stop the world"。而如果不移动对象而直接清除,针对内存碎片化的问题又需要用更加复杂的内存管理系统来解决,比如说通过 "空闲列表" 来解决内存分配问题。然而,内存分配是用户程序最频繁的操作,没有之一。如果稍有不慎导致在内存分配环节浪费大量时间的话,整个应用程序的吞吐量势必会受到影响。

总而言之,如果选择移动对象,则会让内存回收过程更复杂,如果选择不移动对象,则会让内存分配过程变得更复杂。从垃圾收集器的停顿时间来看,不移动对象的做法几乎不需要停顿时间。但是从程序的吞吐量来考虑,移动对象是一个更有远见的选择:不移动对象的做法虽然减少了垃圾回收的停顿时间,但是它导致后续的分配内存更加的复杂,这个代价远超过前者的收益。

不过有这么一个 “折衷” 的方案:在早期空间足够时,可以采用低延迟的标记 - 清理算法,并容忍一部分的内存碎片。直到内存碎片已经达到影响大对象的内存分配时,再统一使用标记 - 整理算法来解决 —— CMS 收集器就是这样做的。

8. 枚举根节点

枚举根节点,即垃圾收集器寻找所有可作为 GC Roots 节点的过程,是执行可达性分析之前的一步工作。迄今为止,所有的垃圾收集器在执行这一步时都必须要暂停所有的用户线程,即 "Stop the world" 。

固定可作为 GC Roots 的节点主要是栈帧中本地变量表内的引用,或者是方法区内全局性的引用。对于一个庞大的 Java 应用而言,光是方法区的内容就达到了上千兆,更不要提临时扫描每个线程的方法栈,栈内的每个栈帧,栈帧内的每个局部变量表了。

对于 Hotspot 这类追求 "准确式垃圾收集" 的虚拟机而言,当一个类被加载时,它就会分析一些关键点并记录下它的实例对象在哪个偏移量中会有哪个引用,它的生效区域是哪里,然后封装到名为 OopMap ( ordinary object pointer map 的缩写 ) 的数据结构中保存。对于即时编译的过程,虚拟机也会在关键点提前记录下栈和寄存器内哪些位置是引用。总的来说,这种做法一定程度上避免了虚拟机对栈帧全局扫描,提高了枚举效率。

比如笔者通过 hsdis 反汇编工具将一个 .class 字节码文件转换成了本地代码,并截取了部分结果:

  [Constants]
  # {method} {0x0000000017472b48} 'newObj' '()V' in 'com/jitT/TestMainClass'
  #           [sp+0x20]  (sp of caller)
  0x0000000002ba72a0: mov    %eax,-0x6000(%rsp)
  ...
  0x0000000002ba72f5: mov    %rdx,%rbp
  0x0000000002ba72f8: data16 xchg %ax,%ax
  0x0000000002ba72fb: callq  0x0000000002ae61a0  ; OopMap{rbp=Oop off=96}
                                                ;*invokespecial <init>
                                                ; - com.jitT.TestMainClass::newObj@4 (line 12)
                                                ;   {optimized virtual_call}
  0x0000000002ba7300: add    $0x10,%rsp
  0x0000000002ba7304: pop    %rbp

0x0000000002ba72fb 处的 callq 指令中,我们观察到了一个 Oop{rbp=Oop off=96} 标记,它指明了 RBP 栈基址寄存器存在一个普通对象指针,有效区域从 callq 指令开始直到指令流起始位置 0x0000000002ba72a0 ( hex ) + 96 ( dec ) = 0x0000000002ba7300,即到下一条 add 指令为止,因为 RBP 寄存器的对象在第 0x0000000002ba7304 条指令被弹出了 ( hsdis 和 jitwatch 工具可参考此知乎链接。此百度云盘链接( 提取码:deh4 ) 提供现成的 64 位 Windows 端 hsdis-amd64.all 依赖库 )。

因为引用关系随着线程的执行随时可能发生变化,从理论上讲,每一条改变引用的指令都应该在后面跟上一条 OopMap。可如果将事无巨细地将所有 OopMap 全记录下来,这又会带来存储上的麻烦。

8.1 安全点

因此,实际上虚拟机只在笔者反复强调的关键点位置上才会记录 OopMap ,这些关键点其实被称作是安全点假定目前的用户线程都处于 run 状态,当开始枚举根节点时,所有的用户线程不会立刻暂停,而是运行到这个安全点才停下来,以保证程序实际的引用状态和该安全点的 OopMap 所提供的引用信息保持一致。

安全点的选取标准是 "是否可能要陷入长时间等待" 为标准的。直到所有的用户线程都安全暂停之后,枚举根节点才开始,因此不能容忍少部分耗时的线程 "缓慢驶入" 安全点而耽误整体的工作进度。顺序执行的指令流一般不会太耗时间,而涉及到指令复用的地方:方法调用,循环跳转,异常跳转等往往耗时较长,这些地方才会出现安全点。

具体到如何协调所有用户线程到安全点,原本有两个选择,但是事实上主流虚拟机都选择了后者:

抢先式中断 ( Preemptive Suspension ) :系统首先会将所有用户线程强制暂停,然后将 CPU 分配给那些没有到达安全点的线程等待它们执行完毕。

主动式中断 ( Voluntary Suspension ):各个线程在执行过程中不断轮询同一个标志位。当此标志位变化时,线程就各自运行到最近的安全点之后主动挂起。

8.2 安全区域

然而,安全点的使用存在一个假设:所有的用户线程目前都是 run 状态。对于因系统调用或者 CPU 使用时间片到而导致被挂起的线程是无法响应标志位的变化的。对于虚拟机而言,它也显然没有耐心等待这些线程重新分配到 CPU 之后再移动到安全点。

对此,应对的措施是将安全点 "延展" 成一片安全区域,它指某一片区域内引用关系都不会发生变化。这样的话,当虚拟机进行根节点枚举时,即便线程处于挂起状态,并且没有停靠在指定的安全点,只要它所处的区域没有对 OopMap 造成影响,那么垃圾回收仍然可以正常进行。

对于线程而言,当它进入到某个安全区域的代码时,会用标志位对外表示自己已经处于 "安全的状态"。而当它要离开安全区域时,则会先检查目前是否已经完成了根节点枚举。若已完成,则线程照常执行。若没有完成,则线程一直等待直到收到允许离开安全区域的信号为止。

9. 记忆集与卡表

首先,记忆集 ( Remember ) 是为了标识出 "跨代引用" ,方便回收器对这些 "个例" 单独处理。实际上,即便是类似 G1,ZGC 等涉及到 Partial GC 的垃圾收集器都会面临相似的问题。它的思路是化大为小 ( 笔者将其理解成建立索引 ):将整个 Java 堆逻辑上分解成特定大小的区域。如果有办法记录下哪个区域中发生了跨代引用,那么回收器只需要针对这个区域搜索就可以了,从而避免了对整个 Java 堆区间的扫描。

至于这个区域的粒度多大,下面是有一些可供参考的选项,但实际上选择的是第三种:

  1. 字长精度:一条记录映射一个机器字长(如 32 位或 64 位),该字内有字段包含了对象指针。
  2. 对象精度:一条记录映射到一个对象,该对象内包含对象指针。
  3. 卡精度:每一个记录精确到一块内存区域,该区域内包含对象指针。

对于卡精度,被映射的每一块内存区域又可称之为 卡页 (Card Page) 。而存储着 (记录 -> 内存) 的集合又称之为 卡表 ( Card Table )。对于 Hotspot 虚拟机而言,一个卡页是 2^9 个字节,即 512 字节,一个卡页内可能存储着多个对象。因此它的卡表标记逻辑是:

CARD_TABLE [this address >> 9] = 0; 

每个卡页对应一个 Byte 为单位的值。这个值若是 0 ,则表示它是 干净 的 ( 没有发生跨代引用的情况 ),值若是 1 ,则表示它是 的(内部发生了跨代引用)。

9.1 写屏障

下一个问题是:当跨代引用出现时,应该什么时候修改对应的卡表变脏?显然,这个动作应该在赋值开始的那一刻就执行。

对于解释执行的字节码,虚拟机在加载字节码指令时有充分的介入空间,在一些编译执行的场合,虚拟机加载的已经是编译好的字节码文件了。对此,虚拟机就要通过 写屏障 ( Write Barrier ) 技术来实现程序在引用改变前后插入额外的动作,以此维护卡表的状态。过程可以类比于 AOP 思想的环绕通知,写屏障也可以分为 写前屏障 ( Pre-write Barrier) 和 写后屏障 ( Post-write Barrier ) 。

虚拟机在应用无条件的写屏障之后,会在所有引用赋值的代码中插入卡表的维护操作 ( 无论是否属于跨代引用 ),它增加了额外开销,但是和扫描整个 GC Roots 相比不值一提。在高并发情况下,卡表可能要面临伪共享问题。

展开编译执行和解释执行的通俗解释。
编译执行相当于我们提前编写 .java 文件并经过 javac 编译成可随时供 JVM 加载的 .class 文件。解释执行则类似于我们通过 REPL 终端和 JVM 进行 "一问一答" 式交互,明显的区别是后者不另生成 .class 文件 。
    

9.2 伪共享问题

现代中央处理器缓存是以一个 缓存行 ( Cache Line ) 为单位的。假设现在一个缓存行内为 64 B,卡表中的一个值占 1 Byte 空间,则缓存行内存了 64 个卡表项,它一共映射了 32 KB 内存的空间。

假使在并发情况下,有两个线程在这 32 KB 的空间内同时修改了引用关系,最终这两处修改会同时影响到一个缓存行。很明显,这两个线程会相互影响(写回,无效化,或者同步)而导致性能降低。

虽然这些数据共享一个缓存行,但是只要有任意一处更改,整个缓存行的数据就要重新刷新,这就是 “伪共享" 问题。对写屏障来说,优化的办法就是 ”尽量不要去修改卡表的状态“:当卡表没有被标记过时才会将其标记为脏。

10. 并发的可达性分析

可达性分析要将 GC Roots 的所有结点为起点,对 "引用图" 进行 BFS ,因此消耗的时间也要更多。首先讨论用户线程全部被冻结的(串行的)情况,即垃圾收集器工作时,其引用关系不会改变。这里引入 三色标记法 ( Tri-color Marking ) 来说明。给定以下条件:

  1. 黑色:这个对象内的引用关系已经全部扫描过了。
  2. 灰色:这个对象正在被扫描。
  3. 白色:这个对象还没有被扫描到。

可达性分析的过程是白色块(未被扫描)至灰色块(正在扫描)再到黑色块(扫描完毕)的过程,但是值得注意一点的是,以被标记为黑色块的对象现在不会重新扫描。

笔者利用 PPT 动画演示出了一个垃圾收集器在正常情况下 ( 引用关系不会动态改变 ) 的推进过程:

但实际上,垃圾收集器的该标记过程是和用户线程并发的。这意味着会出现两类预料外的结果:第一种是本该被回收的对象被标记为 "存活" 的,这类垃圾称之为 浮动垃圾 。第二种是应当存活的对象却被回收掉了(下文简称 "对象消失" 问题),显然这要比前者严重得多。

但是垃圾收集器对这种现象也表示很 "委屈":这个道理好比当妈妈为你打扫屋子时,你总是被要求放下一切手头的工作,乖乖的坐在椅子上不要乱动。如果她一边打扫,你一边乱扔纸屑,那这个屋子就永远都打扫不利索。

10.1 对象消失问题

当同时满足以下两个条件时,会产生对象消失的问题:

  1. 新增了一个或多个黑色对象 A 对象到白色对象 B 的引用。
  2. 删掉了所有的灰色对象 C 可到白色对象 B 的直接或者间接引用。

下面是黑色对象 A 重新引用了白色对象 B,但是灰色对象 C 却删除了对白色对象 B 的直接引用所导致的 "对象消失" :

下面是黑色对象 A 重新引用了白色对象 B,但是灰色对象 C 却删除了对白色对象 B 的间接引用所导致的 "对象消失":

若要防止 "对象消失" 的现象,只需要破坏掉这两个条件的任意一条即可,因此诞生了两种解决方案:

增量更新 ( Incremental Update ): 黑色对象新增了引用,则它重新变成灰色对象。在并发结束之后重新对这些灰色对象进行扫描。该方法破坏了第一条件。

原始快照 ( Snapshot At The Beginning,SATB ):灰色对象一旦取消某些引用,就将这些灰色对象记录下来。在并发结束后重新对这些灰色对象进行扫描,该方法破坏了第二条件。

对于 Hotspot 虚拟机而言,两种方式都有被使用。CMS 是基于增量更新,而 G1 则是基于原始快照的。

11. 经典垃圾收集器

在实际应用中,虚拟机往往会使用两种垃圾收集器协同工作,并根据不同收集器的特性让它们专门负责 Minor GC,或者是 Major GC。

衡量一个垃圾收集器的指标有三个:内存占用 ( Footprint ),吞吐量 ( Throughput ),和低延迟 ( Latency ) 。根据 "天下没有免费的午餐定理",几乎没有在三个方面都性能卓越的垃圾收集器,因为垃圾收集器在某一方面的性能优势势必要牺牲其它方面做代价。因此,又称这三个指标为 “不可能三角”

因此,在追求不同指标的应用场景中,应当选择不同的垃圾回收器 (组合) 。

11.1 Serial / Serial Old ( 新生代 / 老年代 )

Serial 是最基础的垃圾收集器,在 JDK 1.3 版本之前是 Hotspot 虚拟机的唯一选择。Serial 收集器还衍生出了专门用于老年代收集的 Serial Old 收集器,它基于标记 - 整理算法实现。它们都是 “单线程” 的收集器,在这里除了表示该垃圾收集器仅使用单独的 CPU 完成工作之外,还表示当此收集器工作时,其它的用户线程会被全部暂停,即 " Stop the world " 现象:

但即便是今天,Serial 垃圾收集器仍然可作为 Hotspot 虚拟机在客户端模式下的默认新生代收集器。它的优势是简单,高效。尤其是对于单核处理器或者处理器数量较少的机器来说,Serial 垃圾收集器没有线程交互的开销,因此它可以专心于垃圾收集并最大化单线程运作的效率。

11.2 ParNew ( 年轻代 )

ParNew 相当于 Serial 垃圾收集器的并发版本。它除了使用多条 GC 线程协同工作之外,其余的特征,启动参数和 Serial 完全一致。笔者仍然介绍它的原因是:在 JDK 7 及之前的版本中,它是首选的新生代垃圾收集器,因为唯独它能够和下文介绍的 CMS 垃圾收集器协同工作。

ParNew 收集器在单线程工作环境中绝对不会有比 Serial 更好的工作效果。甚至由于线程交互的开销,该收集器的 超线程技术 ( Hyper-Threading ) 实现的伪双核效果还不及 Serial 收集器。但是,服务器的处理器数量越多,则 ParNew 的优势越明显。

11.3 Parallel Scavenge ( 年轻代 )

Parallel Scavenge 也是一款新生代垃圾收集器,同样基于标记 - 复制算法,同样支持多 GC 线程协同工作。而它的特别之处在于:达到一个可控制的吞吐量。吞吐量的计算方式为:用户线程执行时间 / ( 用户线程执行时间 + 垃圾收集器执行时间) 。比如在 100 分钟的工作时间内,虚拟机有 99 分钟在执行用户线程,此时的吞吐量即为 99%。

相比追求低停顿时间的垃圾收集器而言,这类收集器更适合用在不需要太多和用户进行交互的分析任务,它们又称 "吞吐量优先收集器"。对立的存在是 "低延时垃圾收集器",典型的代表是 Shenandoah ,ZGC 收集器等。

Parallel Scavenge 收集器提供两种虚拟机启动参数来控制吞吐量:第一种是严格控制每次 GC 的最大停顿时间的 -XX:MaxGCPauseMillis,第二种是直接设置吞吐量比率的 -XX:GCTimeRatio 。但是,停顿时间设置得并不是越低越好。等量的回收空间,停顿时间的减少必然导致回收次数的增加。这种类似的取舍在建立了 "时间停顿模型" 的 G1 收集器中同样会发生。

如果用户很难以手动方式对这些参数进行调优,还可以使用 Parallel Scavenge 提供的 -XX:UseAdaptiveSizePolicy:即使用 **自适应调节策略 **,垃圾收集器会根据实际运行情况来调整合适的 Eden / Survivor 空间占比,允许直接晋升到老年代的对象阈值等内容。这也是和 ParNew 收集器的一个最大特征。

不过,有趣的一点是,在 JDK 6 版本之前,专注于年轻代收集的 Parallel Scavenge 收集器却找不到一个合适的老年代收集器搭档,包括当时已经诞生的 CMS 垃圾收集器。因此在当时一旦选择它作为年轻代收集器,则除了性能相对低下 Serial Old 收集器之外似乎别无选择。

11.4 Parallel Old ( 老年代 )

当这款老年代收集器在 JDK 6 版本出现时,”吞吐量优先收集器“ 总算有了实力相当的搭配组合。它相当于是 Parallel Scavenge 的老年代版本,支持并发收集,基于标记 - 整理算法。在注重吞吐量或者处理器资源稀缺的场合,可以考虑 Parallel Scavenge 和 Parallel Old 的年轻代,老年代收集器组合。

11.5 CMS ( 老年代 )

CMS,全称为 Concurrent Mark Sweep,是一种追求最短回收停顿时间的垃圾收集器,非常适用于基于浏览器的 B/S 系统的服务器上,因为这类应用非常注重对用户请求的响应速度。从名字上可知 CMS 总体来说是基于标记 - 清除算法的垃圾回收器,前文提到过,CMS 只有在内存碎片过多时才会适量地启用耗时的标记 - 整理算法整理 Java 堆空间。

它是一个老年代收集器,常和 ParNew 配合垃圾收集工作。工作流程分为 4 个步骤:

初始标记 ( CMS initial mark),标记 GC Roots 直接关联的对象,需要暂停所有用户线程,但是耗时很短。

并发标记 ( CMS concurrent mark ),基于初始标记深入到对象图种进行搜索,耗时较长,但是不需要暂停所有用户线程,可以与之并发。

重新标记 ( CMS remark ),为了避免对象消失问题,修正在并发标记期间引用关系改变的对象标记,见前文的增量更新。需要暂停所有用户线程,并使用多条 GC 线程完成。

并发清除 ( CMS concurrent sweep),清除所有被判断为死亡的对象。

下面的流程图描述了 CMS 的工作流程,这里仅以四核环境为例:

总体来说,CMS 是一个优秀的垃圾回收器,因为它是 Hotspot 虚拟机追求低停顿的第一次成功的尝试。然而,目前并没有一个用在认可场景下 "性价比" 都保持最高的垃圾回收器。以 CMS 为例,它仍有以下缺点:

一,实际上,面向并发设计的程序都对处理器资源十分敏感。虽然它在工作期间并没有在每一个阶段都要求 "Stop the world",但是并发阶段仍然会使得应用程序变慢一些 ( 根据没有免费的午餐定理,又不需要停顿又不会影响原应用程序运行效率的垃圾收集器是不存在的) 。CMS 默认启动的工作线程数( CPU 使用数) = ( 机器 CPU 个数 + 3) / 4 。对于四核以上的机器而言,CMS 占用了不超过 25 % 的 CPU 资源。但对于四核以下的机器而言,CMS 会很大程度影响用户线程。

二,由于 CMS 的清除过程是和其它用户线程并发的,在此期间新产生的 "浮动垃圾" 要等着下一次 GC 当中收集。这意味着垃圾回收器总是要 “提前工作” 来预留出空间(至少也要能装得下并发清除期间用户线程产生的 "浮动垃圾"),而不能等待老年代几乎也要被填满的时候才开始垃圾收集。

在 JDK 5 版本中默认老年代使用 68% 空间之后就要启动 CMS。但如果老年代空间的使用速度不那么快(即大部分对象都是临时的),则可以通过 VM 启动参数 -XX:CMSInitiatingOccupancyFraction 提高 CMS 的触发百分比,以此将 CPU 资源更多地分配给用户线程。

假使预留的空间无法满足分配新对象的需要,此时虚拟机则不得不再次 "Stop the world",并且临时使用 Serial Old 垃圾收集器专门对老年代进行一次收集,此时停顿的时间就比较长了,因此 CMS 的触发百分比并非越高越好。

三,因为 CMS 基于清除 - 标记算法,因此也继承了该算法遗留下来的缺陷,比如内存碎片化而导致大对象无法分配空间,而不得不启动一次 Full GC 的情况。

11.6 G1 ( 混合收集 )

G1,即 Garbage First 收集器,笔者选择介绍它是因为 G1 代表了垃圾收集技术的又一个里程碑式成果。也是从 G1 开始,最先进的垃圾收集器的设计导向从 "一次清干净" 变为 "能够应付应用程序的内存分配速率"。早在 JDK 6 版本时 G1 就作为 "实验性质" 的垃圾收集器供开发人员使用,并最终在 JDK 8 Update 40 版本后,G1 已经可以提供并发的类卸载功能,至此完成了当初的全部计划功能。

G1 是一款面向服务器应用的垃圾收集器。在 JDK 9 发布之日,G1 就宣布取代了 Parallel Scavenge 和 Parallel Old 组合,CMS 垃圾收集器也坐上了 "冷板凳"。它的设计者希望 G1 是一款能够达到可建立起 "停顿时间模型" 的回收器:用户指定 M 秒的时间片内,回收器的工作时间大概率不超过 N 秒的目标(也可以理解成回收器使用资源的时间占比不超过 N/M)。

为了实现这个目标,G1 首先有别于传统垃圾收集器的一点是:它无区别地将 Java 整个堆区间划分成一块块的 Region 空间,任何一个 Region 空间都可以根据实际需要充当 Eden 空间,Survivor 空间或者是老年代空间。每次仅将需要回收的 Region 空间组合成一个 回收集( Collection Set,简称 CSet )。因此 G1 的 Partial GC 过程还可称之为 Mixed GC。

至于回收时,衡量的标准也不再是单纯的根据年轻代 / 老年代,而是通过评价回收每个 Region 空间的 "性价比",并罗列出一个基于回收价值的优先列表。同时,用户还可以通过虚拟机参数 -XX:MaxGCPauseMillis( 默认情况下 200 毫秒 ) ,G1 收集器将基于两者来判断 "最应该清理的 Region 空间"。

同时,Region 空间内还会有特殊的 Humongous 区域,用于存放 "大" 对象:G1 认为如果某一个对象占据了 Region 区域一半以上空间,则该对象可以被认为是 "大" 对象。如果这个对象是超过一个 Region 空间的 "超大" 对象,则它会被放在 N 个连续的 Humongous 空间当中。

CMS 采取增量更新的方式来避免并发标记期间引用关系改变导致的标记结果错误,而 G1 主要通过原始快照 ( SATB ) 方式来实现。此外,G1 为每一个 Region 空间设计了两个 Top at Mark Start ( 简称 TAMS ) 指针,并且为它们预留了一些空间:在并发回收的过程中产生的新对象都会被分配在这两个指针位置以上,G1 默认这些对象是存活的,不会把它们回收。

G1 收集器的工作过程也可分为四个步骤:

初始标记 ( Initial Marking ):仅标记 GC Roots 能够直达的对象,并修改 TAMS 指针的值,保证下一次用户线程运行时能够正确地在可用 Region 空间内分配新的对象,这个阶段需要短暂的一段 "Stop the world"。

并发标记 ( Concurrent Marking ):从 GC Roots 开始做完整的可达性分析,并处理 SATB 过程中引用变动的对象。这一过程耗时较长,但是可以和用户线程并发。

最终标记 ( Final Marking ):再一次对用户线程做短暂的停顿,处理并发标记阶段结束后仍然存留的少量 SATB 记录。

筛选回收 ( Live Data Counting and Evacuation ):统计并列出准备回收的 Region 空间,它们组成一个 CSet 。此外,这些 Region 空间内的存活对象会被集中 移动 到另外的空闲 Region 空间。由于涉及到了对象移动,因此这个过程必须要暂停所有的用户线程,并需要多条 GC 线程并发完成。

和 CMS 收集器相比,G1 并非是完全追求低延时,或者说它试图 在低延时和高吞吐量之间寻找一个平衡点。也正因如此,它将停顿时间的期望值交给了用户选择,以此来应对更关注低停顿,抑或是更关注高吞吐的两种不同情况。一般来说,默认 200 毫秒的设置已足够用。若过分追求低停顿时间 ( 比如设置为 20 毫秒 ),则 G1 回收器每一次只能 ”仓促“ 的回收一小部分 Region 空间,甚至回收速度赶不上用户线程的使用速度导致触发 Full GC,这反而降低了性能。

11.7 常见 GC 回收器组合及启动参数

在 JDK 7/8 版本,垃圾回收器组合是 Parallel Scavenge + Parallel Old 组合。直到 JDK 9 版本,年轻代、老年代的垃圾收集统一交付给了支持 Mixed GC 的 G1 垃圾收集器。

orderyoungold启动参数年轻代Eden年轻代Survivor老年代
0serialSerial Old-XX:+UseSerialGC“Eden Space”“Survivor Space”“Tenured Gen”
1serialCMS-XX:-UseParNewGC-XX:+UseConcMarkSweepGC“Eden Space”“Survivor Space”“CMS Old Gen”
2ParNewCMS-XX:+UseConcMarkSweepGC“Par Eden Space”“Par Survivor Space”“CMS Old Gen”
3ParNewSerial Old-XX:+UseParNewGC“Par Eden Space”“Par Survivor Space”“Tenured Gen”
4Parallel ScavengeSerial Old-XX:+UseParNewGC“PS Eden Space”“PS Survivor Space”“Tenured Gen”
5Parallel ScavengeParallel Old-XX:+UseParallelOldGC“PS Eden Space”“PS Survivor Space”“PS Old Gen”
6G1G1-XX:+UseG1GC“G1 Eden Space”“G1 Survivor Space”“G1 Old Gen”

12. 参考资料