本章介绍垃圾收集器的设计思路、算法以及常见实现,同时介绍为对象分配空间时采取的策略。

垃圾收集(Garbage Collection)

GC 的历史比 Java 久远,1960 年诞生于 MIT 的 Lisp 是第一门真正使用内存动态分配和垃圾回收技术的语言。不论是什么语言,在设计 GC 时都必须要解决以下三个问题:

  • 哪些内存需要回收(Who)
  • 什么时候回收(When)
  • 如何回收(How)

也许你要问,虚拟机已经为我们完成了内存回收的工作,目前内存的动态分配与回收技术已经相当成熟,一切都进入了“自动化”时代,我们为什么还要去了解 GC 和内存分配呢?答案很简单,当我们需要排查各种内存溢出、泄漏问题时,当垃圾收集称为系统达到更高并发量的瓶颈时,就必须要对这些“自动化”的技术进行必要的监控和调节。

在上一章的学习中,我们了解到 JVM 的内存可以划分为线程私有部分(虚拟机栈、本地方法栈、程序计数器)和公有部分(堆、方法区),其中线程私有部分所申请的内存,随着线程运行结束而销毁,在这个区域的内存分配和回收具备确定性。公有部分的方法区,其分配多少内存在编译期间也可以确认,因此,需要考虑内存回收的区域主要是 Java 堆,堆中的对象是运行时创建的,它们的创建和回收都是动态的,这是垃圾收集器关注的区域。

对象已死吗?

引用计数器法

引用计数器法(Reference Counting)是一种实现起来很简单的判断对象是否存活的方法。原理是当出现一个对对象的引用时,计数器 +1;当引用失效时,计数器 -1,计数器值为 0 则表示对象已死。它的优点是实现简单,判定效率高,在大部分情况下都是不错的算法,也有很多著名的应用案例:

  • M$ 的 COM(Component Object Model)技术
  • 试用 AS3 的 FlashPlayer
  • Python
  • Squirrel

引用计数器发存在循环引用的问题,因此主流 Java 虚拟机没有选用这种方法来管理内存。

可达性分析法

主流商用程序语言(Java、C#、Lisp)使用可达性分析(Reachability Analysis)方法来判定对象是否存活:

  1. 从一系列 GC Roots 节点开始向下搜索,搜索走过的路径称为引用链(Reference Chain)
  2. 当一个对象到 GC Roots 没有任何引用链相连(不可达)时,证明此对象是不可用的

在 Java 中的 GC Roots 有以下几种

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

可见 GC Roots 节点主要有全局性引用和执行上下文这两个来源。

JDK 1.2 后的几种引用

在 Java 1.2 之前,对“引用”的定义很传统:如果 reference 类型的数据中存储的数值代表的是另一块内存的起始地址,就称为这块内存代表着一个引用。这种定义很纯粹但是太过狭隘,一个对象在这种定义下只有被引用或者没有被引用两种状态。我们希望描述这样一类对象:当内存空间还足够时,则能保存在内存之中;如果内存空间在进行 GC 后还是非常紧张,则可以抛弃这些对象。在 JDK 1.2 之后,Java 对引用的概念进行了扩充,将引用分为“强软弱虚”四种:

  • 强引用(Strong Reference):指在程序代码之中普遍存在的,类似“Object obj = new Object()”这类的引用,只要强引用还存在,垃圾收集器 永远不会 回收掉被引用的对象
  • 软引用(Soft Reference):用来描述一些 还有用但非必需 的对象,对于软引用关联的对象,在系统将要发生内存溢出异常之前,将会把这些对象列入回收范围之中进行第二次回收。如果这次回收还没有足够的内存,则会抛出 内存溢出异常
  • 弱引用(Weak Reference):用来描述 非必需 对象,被弱引用关联的对象只能生存到下一次垃圾回收发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象
  • 虚引用(Phantom Reference):也称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用获取对象实例。为一个对象设置虚引用关联的唯一目的就是能在它被收集器回收时收到一个系统通知

两次标记宣告对象死亡

对于在可达性分析中被判断为“不可达”的对象,有一次进行自救的机会,要真正宣告一个对象死亡,需要经历两次标记过程:

  1. 第一次标记:对象在可达性分析中被标记为“不可达”
  2. 第二次筛选:判断此对象是否有必要执行 finalize() 方法,以下两个条件满足其一时,可认为是“有必要”,a. 对象覆盖了 finalize() 方法;b. 对象的 finalize() 方法未被调用过

对象被判断为“有必要执行 finalize()”时,是其最后一次自救机会,它会被放置在一个名为F-Queue的队列之中,并在稍后由一个虚拟机自动建立的、低优先级的 Finalizer 线程去执行 finalize() 方法,对象进行自救的唯一手段是在 finalize() 方法中建立与 GC Roots 的连接,譬如把自己(this 关键字)赋值给某个类变量或者对象的成员变量,这样会使得对象被移除出“即将回收”集合;换言之,如果这时候对象仍然未能自救,则会迎来死亡的命运。

需要注意的是,任何一个对象的 finalize() 方法只会被系统自动调用一次。在编码中我们应当尽量避免使用finalize()方法,因为它的运行代价高昂,不确定性大,并且无法保证各个对象的调用顺序。

回收方法区

方法区(在 HotSpot 虚拟机中被称为“永久代”)也可以进行垃圾收集(虽然 Java 虚拟机规范中明确表示可以不要求在方法区实现垃圾收集)。不过在方法区中进行收集的“性价比”通常很低,在堆中常规应用进行一次垃圾收集一般可以回收 70%~95% 的空间,而永久代的垃圾收集效率远低于此。

永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类。

废弃常量 的回收与 Java 堆中对象回收十分类似,以字面量回收为例,假如一个字符串“abc”已经进入了常量池中,但是当前系统没有任何一个 String 对象是叫做“abc”的,也没有在其他任何地方引用了这个字面量,如果这时发生内存回收,在必要的情况下,“abc”常量会被清除出常量池。

无用的类 的判断则要稍微复杂一些,需要同时满足以下三个条件,才“可以”被回收,注意不是“一定”而是“可以”

  • 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例
  • 加载该类的 ClassLoader 已经被回收
  • 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法

HotSpot 虚拟机提供了-Xnoclassgc参数进行控制无用的类回收。

垃圾收集算法

标记-清除算法

最基本的收集算法是标记-清除(Mark-Sweep)算法,分为两个阶段:

  1. 标记出所有需要回收的对象
  2. 统一回收所有被标记的对象

标记-清除算法的示意图如下

标记清除
标记清除

标记-清除算法主要不足有两个

  1. 效率问题,标记和清除的效率都不高
  2. 空间问题,清楚之后会产生大量不连续的内存碎片

复制算法

复制(Copying)算法主要目的是解决效率问题,它将可用内存平均分为两个半区,每次只使用其中一个半区,在进行垃圾回收时,将仍存活的对象复制到另半区上,然后清空刚刚使用的半区。复制算法的实现简单运行高效,不足是空间利用率太低,只使用了一半的内存。

复制
复制

现在的商业虚拟机都采用复制算法来回收新生代,IBM 研究表明新生代中的对象多达 98% 是朝生夕死的,所以不需要按照 1:1 的比例来划分内存。而是将内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 空间和其中一块 Survivor 空间(称为 From Survivor),在回收时将存活对象复制到另一块 Survivor 空间(称为 To Survivor),并清理掉 Eden 以及 From Survivor 空间。HotSpot 虚拟机默认对 Eden 和 Survivor 空间分配比例为 8:1。

当 GC 过程中发现 To Survivor 空间不够用时,需要依赖其他内存(老年代)进行分配担保(Handle Promotion)。

标记-整理算法

复制算法对于对象存活率高的场景并不适用,因为需要频繁进行复制操作;并且它还需要额外空间进行分配担保。因此,在老年代中通常不使用复制算法进行内存回收,而是使用的标记-整理(Mark-Compact)算法:

  1. 标记过程与“标记-清除”算法相同,先是标记出仍然存活的对象
  2. 把所有存活对象向一端移动
  3. 清理掉端边界以外的内存
标记整理
标记整理

分代收集算法

当代商业虚拟机采用的都是分代收集算法(Generational Collection),将 Java 堆分为新生代和老年代,根据各个年代的特点采用最合适的收集算法。

  • 新生代:对象存活周期短,每次垃圾收集时有大量对象死去,只有少量存活,使用复制算法
  • 老年代:对象存活周期长,存活率高,没有额外空间对它进行分配担保,采用标记-清除或者标记-整理算法

HotSpot 的算法实现

在虚拟机上实现之前所述的算法时,必需对算法的执行效率有严格考量,才能保证虚拟机高效运行。

枚举根节点

之前讲到作为 GC Roots 的区域主要是全局性引用(常量、类静态属性)和执行上下文(栈帧中的本地变量),很多应用仅仅方法区就有数百兆,如果逐个检查引用,必然会消耗很多时间。

此外,可达性分析对执行时间的敏感还体现在 GC 停顿 上,因为可达性分析必须在一个能确保一致性的快照中进行(分析过程中对象引用关系不可以发生变化),GC 时必须停顿所有的 Java 执行线程(称为 Stop The World)。

目前所有主流虚拟机采用的都是准确式 GC,当执行系统停顿下来时,虚拟机有办法知道,哪些地方存放着对象引用。在 HotSpot 实现中,使用一组称为 OopMap 的数据结构达成这个目的。在类加载完成时,HotSpot 把对象内什么偏移量上是什么类型的数据计算出来,在 JIT 编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。

安全点

在 OopMap 协助下,HotSpot 可以快速且准确地完成 GC Roots 的枚举,然而存在一个很现实的问题:可能导致引用关系变化,或者说 OopMap 内容变化的指令非常多,如果为每一条指令都生成对应的 OopMap,将需要大量的额外空间,导致 GC 的空间成本非常高。

HotSpot 处理的方法是,并没有为每一条指令都生成 OopMap,只是在“特定的位置”记录这些信息,这些位置称为安全点(Safepoint),即程序执行时并非在所有地方都能停顿下来开始 GC,只有在到达安全点时才暂停。安全点的选定基本上是以程序“是否具有让程序长时间执行的特征”为标准进行选定的——因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这个原因而过长时间运行。(这部分不理解

对于 Safepoint 另一个需要考虑的点是如何在 GC 时让所有线程都运行到安全点上停顿下来,这里有两种方案:

  • 抢先式中断(Preemptive Suspension):GC 发生时中断所有线程,如果有线程中断地方不处于安全点,则让该线程运行至安全点。现在几乎没有虚拟机采用抢先式中断来暂停线程从而响应 GC 事件。
  • 主动式中断(Voluntary Suspension):当 GC 需要中断时,设置一个标志位。各个线程执行时主动轮询这个标志,发现中断标志为真时就自己中断挂起。轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。

安全区域

Safepoint 机制保证程序执行时进入 GC 的可行性,但是,当程序不执行的时候(即没有分配 CPU 事件,例如线程处于 Sleep 状态或者 Blocked 状态),线程无法响应 JVM 的中断请求,无法轮询自然无法运行至安全点再中断挂起,安全区域 (Safe Region)就是用来解决这种问题的。

安全区域是指在一段代码片段之中,引用关系不会发生变化。在这个区域中的任意地方开始 GC 都是安全的。可以把 Safe Region 看作是扩展了的 Safepoint。

  • 当线程执行到 Safe Region 中的代码时,首先标识自己已经进入了 Safe Region
  • 当这段时间里 JVM 需要发起 GC 时,对于标识自己为 Safe Region 状态的线程认为它是安全状态
  • 当线程需要离开 Safe Region 时,检查系统是否已经完成了根节点枚举,如果完成则线程继续执行,否则等待直至收到可以安全离开 Safe Region 的信号为止

垃圾收集器

如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。下图展示了 7 种作用于不同分代的垃圾收集器,存在连线的垃圾收集器可以搭配使用。

HotSpot垃圾收集器
HotSpot垃圾收集器

Serial 收集器

  • 最基本、发展历史最悠久的收集器
  • 单线程,进行垃圾收集时必须暂停其他所有工作线程(Stop The World)
  • 是虚拟机运行在 Client 模式下默认的 新生代收集器,在用户的桌面场景应用中,分配给虚拟机的内存一半不会很大,收集几十兆乃至一两百兆的新生代,停顿时间可以控制在 100 毫秒之内,可以接受
  • 对于单个 CPU 的情况,没有线程交互的开销,可以获得最高的单线程收集效率

ParNew 收集器

  • Serial 收集器的多线程版本
  • 许多运行在 Server 模式下的虚拟机首选的新生代收集器
  • 只有它可以与 CMS(Concurrent Mark Sweep)收集器配合工作
  • 在单 CPU 环境中,效率不如 Serial 收集器;默认开启的收集线程数与 CPU 数量相同

Parallel Scavenge 收集器

  • 新生代收集器,采用复制算法
  • 关注点在于 达到可控制的吞吐量(Throughput)吞吐量 = 运行用户代码时间/(运行用户代码时间 + 垃圾收集时间),是一个百分数
  • 停顿时间短的收集器(例如 CMS)适合与用户交互的程序;吞吐量高的收集器(例如 Parallel)适合后台运算而不需要太多交互的程序
  • 自适应调节策略:虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整参数以提供最合适的停顿时间或者最大的吞吐量

Serail Old 收集器

  • Serial 收集器的老年代版本,单线程,“标记-整理”算法
  • 主要意义在于给 Client 模式下的虚拟机使用

Parallel Old 收集器

  • Parallel Scanvenge 收集器的老年代版本,多线程,“标记-整理”算法
  • 吞吐量优先:Parallel Scanvenge + Parallel Old

CMS(Concurrent Mark Sweep)收集器

  • 以获取最短回收停顿时间为目标,应用于网站以及 B/S 系统的服务端,注重服务响应速度,希望停顿时间最短
  • 基于“标记-清除”算法,收集过程分为四步
  1. 初始标记(CMS initial mark),标记 GC Roots 能直接关联到的对象
  2. 并发标记(CMS concurrent mark),进行 GC Roots Tracing
  3. 重新标记(CMS remark),修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录
  4. 并发清除(CMS concurrent sweep)
  • 初始标记、重新标记这两个过程需要 Stop The World
  • 有三个明显缺点
  1. 对 CPU 资源非常敏感,并发阶段会占用一部分线程(或者说 CPU 资源)导致用户程序变慢,吞吐量降低。CMS 默认启动的回收线程数是(CPU数量 + 3)/4,当 CPU 不足 4 个时,对用户程序影响可能变得很大
  2. 无法处理浮动垃圾(Floating Garbage),可能出现“Concurrent Mode Failute”而导致另一次 Full GC 发生。“浮动垃圾”是指并发清理阶段所产生的垃圾,必须为这部分垃圾提前预留空间,以供并发收集时程序运作使用
  3. 因为采用的是“标记-清理”算法,当空间碎片过多时,无法分配大对象,CMS 收集器提供-XX:+UseCMSCompactAtFullCollection参数,用来在 FullGC 时开启压缩

G1(Garbage-First) 收集器

  • JDK 1.7 正式推出(7u4),面向服务端应用
  • 特点:并行与并发,分代收集,空间整合,可预测的停顿
  • 过程:将 Java 堆划分为多个大小相等的独立区域(Region),避免在整个 Java 堆中进行全区域的垃圾收集,而是追踪哥哥 Region 里面的垃圾堆积的价值大小(回收所获得的空间大小及回收所需时间的经验值),在后台维护一个悠闲列表,每次根据允许的收集时间,优先回收价值最大的 Region(也是 Garbage-First 名字由来)
  • 分为 4 个步骤
  1. 初始标记
  2. 并发标记
  3. 最终标记
  4. 筛选回收