JVM - 垃圾回收

2021/08/24 JVM 共 9072 字,约 26 分钟
Bob.Zhu

经过半个世纪的发展,今天的内存动态分配与内存回收技术已经相当成熟,一切看起来都进入了“自动化” 时代,但当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时, 就必须对这些“自动化”的技术实施必要的监控和调节。

Java 内存运行时区域的各个部分中,程序计数器、虚拟机栈、本地方法栈 3 个区域随线程而生,随线程 而灭,栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。每一个栈帧中分配多少内存 基本上是在类结构确定下来时就已知的(尽管在运行期会由即时编译器进行一些优化,但在基于概念模型的 讨论里,大体上可以认为是编译期可知的),因此这几个区域的内存分配和回收都具备确定性,在这几个区域 内就不需要过多考虑如何回收的问题,当方法结束或者线程结束时,内存自然就跟随着回收了。

而 Java 堆和方法区这两个区域则有着很显著的不确定性:一个接口的多个实现类需要的内存可能会不一样, 一个方法所执行的不同条件分支所需要的内存也可能不一样,只有处于运行期间,我们才能知道程序究竟会 创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。垃圾收集器所关注的正是这部分内存 该如何管理,本文后续讨论中的“内存”分配与回收也仅仅特指这一部分内存。

对象已死

垃圾收集器工作的时候,必须确定,哪些对象是可以被回收的。判断对象是否已死有如下几种方式:

  • 引用计数法
  • 可达性分析

引用计数算法

很多教科书判断对象是否存活的算法是这样的:在对象中添加一个引用计数器,每当有一个地方引用它时, 计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。

客观地说,引用计数算法(Reference Counting)虽然占用了一些额外的内存空间来进行计数,但它的 原理简单,判定效率也很高,在大多数情况下它都是一个不错的算法。但是,在 Java 领域,至少主流的 Java 虚拟机里面都没有选用引用计数算法来管理内存,主要原因是,这个看似简单的算法有很多例外情况 要考虑,必须要配合大量额外处理才能保证正确地工作,譬如单纯的引用计数就很难解决对象之间相互循环 引用的问题。

可达性分析

当前主流的商用程序语言(Java、C#,上溯至前面提到的古老的 Lisp)的内存管理子系统,都是通过可达 性分析(Reachability Analysis)算法来判定对象是否存活的。这个算法的基本思路就是通过一系列称 为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路 径称为“引用链”(Reference Chain),如果某个对象到 GC Roots 间没有任何引用链相连,或者用图论 的话来说就是 从 GC Roots 到这个对象不可达时,则证明此对象是不可能再被使用的。

如图 3-1 所示,对象 object 5、object 6、object 7 虽然互有关联,但是它们到 GC Roots 是 不可达的,因此它们将会被判定为可回收的对象。

可达性分析

问题:

  1. 哪些对象可以作为根对象呢?
  2. 如果在可达性分析算法中被判定不可达对象,是不是一定会被回收?

可作为 GC Roots 的对象

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 本地方法栈(Native 方法)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 所有被同步锁持有的对象

可作为 GC Roots 的对象主要是关联到 “方法” 的对象:方法栈 和 方法区: 本地方法栈、虚拟机栈、方法区中的静态属性、方法区中的常量 等引用的对象

不可达的对象并非“非死不可”

即使在可达性分析法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑阶段”, 要真正宣告一个对象死亡,至少要经历两次标记过程;可达性分析法中不可达的对象被第一次标记并且进行一次筛选, 筛选的条件是此对象是否有必要执行 finalize 方法。当对象没有覆盖 finalize 方法,或 finalize 方法已经被虚拟机调用过时,虚拟机将这两种情况视为没有必要执行。 被判定为需要执行的对象将会被放在一个队列中进行第二次标记,除非这个对象与引用链上的任何一个对象建立关联, 否则就会被真的回收。

如何判断一个常量是废弃常量

  • JDK1.7 之前运行时常量池逻辑包含字符串常量池存放在方法区, 此时 hotspot 虚拟机对方法区的实现为永久代
  • JDK1.7 字符串常量池被从方法区拿到了堆中, 这里没有提到运行时常量池,也就是说字符串常量池被单独拿到堆,运行时常量池剩下的东西还在方法区, 也就是 hotspot 中的永久代 。
  • JDK1.8 hotspot 移除了永久代用元空间(Metaspace)取而代之, 这时候字符串常量池还在堆, 运行时常量池还在方法区, 只不过方法区的实现从永久代变成了元空间(Metaspace)

假如在字符串常量池中存在字符串 “abc”,如果当前没有任何 String 对象引用该字符串常量的话, 就说明常量 “abc” 就是废弃常量,如果这时发生内存回收的话而且有必要的话,”abc” 就会被系统清理出常量池了。

如何判断一个类是无用的类

方法区主要回收的是无用的类,那么如何判断一个类是无用的类的呢? 判定一个常量是否是“废弃常量”比较简单,而要判定一个类是否是“无用的类”的条件则相对苛刻许多。 类需要同时满足下面 3 个条件才能算是 “无用的类” :

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

垃圾收集算法

垃圾收集算法的实现涉及大量的程序细节,且各个平台的虚拟机操作内存的方法都有差异,在本 节中我们暂不过多讨论算法实现,只重点介绍分代收集理论和几种算法思想及其发展过程。

当前商业虚拟机的垃圾收集器,大多数都遵循了“分代收集”(Generational Collection)的理论 进行设计,分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,它建立在两个 分代假说之上:

  • 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
  • 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。

这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将Java堆划分出不同的 区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。 显而易见,如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在 一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收 到大量的空间;如果剩下的都是难以消亡的对象,那把它们集中放在一块,虚拟机便可以使用较低的频率 来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。

把分代收集理论具体放到现在的商用Java虚拟机里,设计者一般至少会把Java堆划分为新生代(Young Generation)和老年代(Old Generation)两个区域。顾名思义,在新生代中,每次垃圾收集时都 发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。

假如要现在进行一次只局限于新生代区域内的收集(Minor GC),但新生代中的对象是完全有可能被老年代 所引用的,为了找出该区域中的存活对象,不得不在固定的GC Roots之外,再额外遍历整个老年代中所有 对5 象来确保可达性分析结果的正确性,反过来也是一样。为了解决这个问题,就需要对分代收集理论添加第 三条经验法则:

  • 跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。

这其实是可根据前两条假说逻辑推理得出的隐含推论:存在互相引用关系的两个对象,是应该倾向于同时生存 或者同时消亡的。举个例子,如果某个新生代对象存在跨代引用,由于老年代对象难以消亡,该引用会使得 新生代对象在收集时同样得以存活,进而在年龄增长之后晋升到老年代中,这时跨代引用也随即被消除了。

标记-清除算法

最早出现也是最基础的垃圾收集算法是“标记-清除”(M ark-Sweep)算法,在1960年由Lisp之父 John McCarthy所提出。如它的名字一样,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象, 在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。

缺点:

  • 第一个是执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低;
  • 第二个是内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

标记-复制算法

标记-整理算法

分代收集算法

垃圾收集器

如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。 没有万能的垃圾收集器,我们能做的就是根据具体应用场景选择适合自己的垃圾收集器。 试想一下:如果有一种四海之内、任何场景下都适用的完美收集器存在,那么我们的 HotSpot 虚拟机就不会实现那么多不同的垃圾收集器了。

历代JDK默认收集器:

  • jdk1.7 默认垃圾收集器Parallel Scavenge(新生代)+Parallel Old(老年代)
  • jdk1.8 默认垃圾收集器Parallel Scavenge(新生代)+Parallel Old(老年代)
  • jdk1.9 默认垃圾收集器G1

4.1 Serial 收集器

Serial(串行)收集器是最基本、历史最悠久的垃圾收集器了。大家看名字就知道这个收集器是一个单线程收集器了。 它的 “单线程” 的意义不仅仅意味着它只会使用一条垃圾收集线程去完成垃圾收集工作, 更重要的是它在进行垃圾收集工作的时候必须暂停其他所有的工作线程( “Stop The World” ),直到它收集结束。

新生代采用标记-复制算法,老年代采用标记-整理算法。

Serial收集器

但是 Serial 收集器有没有优于其他垃圾收集器的地方呢?当然有,它简单而高效(与其他收集器的单线程相比)。 Serial 收集器由于没有线程交互的开销,自然可以获得很高的单线程收集效率。Serial 收集器对于运行在 Client 模式下的虚拟机来说是个不错的选择。

4.2 ParNew 收集器

ParNew 收集器其实就是 Serial 收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为 (控制参数、收集算法、回收策略等等)和 Serial 收集器完全一样。

新生代采用标记-复制算法,老年代采用标记-整理算法。

ParNew收集器

它是许多运行在 Server 模式下的虚拟机的首要选择,除了 Serial 收集器外,只有它能与 CMS 收集器(真正意义上的并发收集器,后面会介绍到)配合工作。

并行和并发概念补充:

  • 并行(Parallel) :指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。
  • 并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行,可能会交替执行),用户程序在继续运行,而垃圾收集器运行在另一个 CPU 上。

4.3 Parallel Scavenge 收集器

Parallel Scavenge 收集器也是使用标记-复制算法的多线程收集器,它看上去几乎和 ParNew 都一样。 那么它有什么特别之处呢?

Parallel Scavenge 收集器关注点是吞吐量(高效率的利用 CPU)。CMS 等垃圾收集器的关注点更多的是用户线程的停顿时间 (提高用户体验)。所谓吞吐量就是 CPU 中用于运行用户代码的时间与 CPU 总消耗时间的比值。

-XX:+UseAdaptiveSizePolicy: 一个开关参数,当这个参数打开之后,就不需要手工指定新生代的大小(-Xmn)、 Eden与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象年龄(-XX:PretenureSizeThreshold) 等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量, 这种调节方式称为GC自适应的调节策略(GC Ergonomics)。

Parallel Scavenge 收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量,如果对于收集器运作不太了解, 手工优化存在困难的时候,使用 Parallel Scavenge 收集器配合自适应调节策略,把内存管理优化交给虚拟机去完成也是一个不错的选择。

Parallel Scavenge收集器

JDK 1.7 和 1.8 默认使用 UseParallelGC 垃圾回收器,该垃圾回收器默认启动了 AdaptiveSizePolicy。 可以使用 java -XX:+PrintCommandLineFlags -version 命令查看:

java -XX:+PrintCommandLineFlags -version
-XX:InitialHeapSize=128146880 -XX:MaxHeapSize=2050350080 -XX:+PrintCommandLineFlags -XX:+UseCompressedOops -XX:+UseParallelGC
java version "1.7.0_76"
Java(TM) SE Runtime Environment (build 1.7.0_76-b13)
Java HotSpot(TM) 64-Bit Server VM (build 24.76-b04, mixed mode)

4.4.Serial Old 收集器

Serial 收集器的老年代版本,它同样是一个单线程收集器。它主要有两大用途:一种用途是在 JDK1.5 以及以前的版本中与 Parallel Scavenge 收集器搭配使用,另一种用途是作为 CMS 收集器的后备方案。

4.5 Parallel Old 收集器

Parallel Scavenge 收集器的老年代版本。使用多线程和“标记-整理”算法。在注重吞吐量以及 CPU 资源的场合,都可以优先考虑 Parallel Scavenge 收集器和 Parallel Old 收集器。

4.6 CMS 收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。 它非常符合在注重用户体验的应用上使用。

CMS(Concurrent Mark Sweep)收集器是 HotSpot 虚拟机第一款真正意义上的并发收集器, 它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。

从名字中的Mark Sweep这两个词可以看出,CMS 收集器是一种 “标记-清除”算法实现的, 它的运作过程相比于前面几种垃圾收集器来说更加复杂一些。整个过程分为四个步骤:

  • 初始标记: 暂停所有的其他线程,并记录下直接与 root 相连的对象,速度很快;
  • 并发标记: 同时开启 GC 和用户线程,用一个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以 GC 线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。
  • 增量标记: 重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短
  • 并发清除: 开启用户线程,同时 GC 线程开始对未标记的区域做清扫。

CMS收集器

从它的名字就可以看出它是一款优秀的垃圾收集器,主要优点:并发收集、低停顿。 但是不管是哪个JDK版本, CMS从来都不是默认的垃圾收集器,它有以下几个明显的缺点:

  • 对 CPU 资源敏感:CMS默认开启的GC线程数是:(CPU核心数+3)/4,当CPU核心数超过4个时,GC线程会占用不到25%的CPU资源,如果CPU数不足4个,GC线程对程序的影响就会非常大,导致程序的性能大幅降低。
  • 浮动垃圾:并发清理阶段,用户线程仍在制造垃圾,只能留给下次GC时回收。
  • 内存碎片:它使用的回收算法-“标记-清除”算法会导致收集结束时会有大量空间碎片产生,导致的后果就是:堆空间明明还有很多,但就是找不到一块连续的内存区域为大对象分配内存,而不得不触发一次Full GC,这样GC的停顿时间又会变得更长。
  • 并发失败:触发 Serial Old 执行 Full GC,反而导致停顿时间变得更长。由于浮动垃圾的存在,因此CMS必须预留一部分空间来装载这些新产生的垃圾。如果CMS预留的内存无法容纳浮动垃圾,那么就会导致「并发失败」,这时JVM不得不触发预备方案,启用Serial Old收集器来回收Old区,这时停顿时间就变得更长了。

4.7 G1 收集器

G1 (Garbage-First) 是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征.

被视为 JDK1.7 中 HotSpot 虚拟机的一个重要进化特征。它具备一下特点:

  • 并行与并发:G1 能充分利用 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短 Stop-The-World 停顿时间。部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 java 程序继续执行。
  • 分代收集:虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但是还是保留了分代的概念。
  • 空间整合:与 CMS 的“标记-清理”算法不同,G1 从整体来看是基于“标记-整理”算法实现的收集器;从局部上来看是基于“标记-复制”算法实现的。
  • 可预测的停顿:这是 G1 相对于 CMS 的另一个大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内。

G1 收集器的运作大致分为以下几个步骤:

  • 初始标记
  • 并发标记
  • 最终标记
  • 筛选回收

G1 收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 Region(这也就是它的名字 Garbage-First 的由来) 。这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限时间内可以尽可能高的收集效率(把内存化整为零)。

4.8 ZGC 收集器

ZGC(The Z Garbage Collector)是JDK 11中推出的一款低延迟垃圾回收器,它的设计目标包括:

  • 停顿时间不超过10ms;
  • 停顿时间不会随着堆的大小,或者活跃对象的大小而增加;
  • 支持8MB~4TB级别的堆(未来支持16TB)。

三色标记算法

CMS在可达性分析的时候,采用了一种称为”三色标记”的算法,来满足GC线程可以和用户线程一起工作的需求。

三色划分

  • 黑色
    • 对象本身及其所有引用都被扫描过
    • 黑色不能直接指向白色
    • 不能被回收
  • 灰色
    • 对象本身被扫描过
    • 还存在至少一个引用没被扫描
  • 白色
    • 没有被GC扫描过
    • 扫描结束后白色可以被回收

标记过程

  1. 刚开始,所有的对象都是白色,没有被访问。
  2. 将GC Roots直接关联的对象置为灰色。
  3. 遍历灰色对象的所有引用,灰色对象本身置为黑色,引用置为灰色。
  4. 重复步骤3,直到没有灰色对象为止。
  5. 结束时,黑色对象存活,白色对象回收。

三色标记看似完美的解决了并发标记的问题,但是存在漏标的问题。 原因就在于,在标记算法执行的过程中,用户线程会改变对象的引用关系。 比如如果堆中对象的三色状态如下所示:

三色原始状态

漏标

在并发标记的过程中,线程1已经扫描完成了,此时业务线程执行了A.c=C B.c=null 那么就变成了如下图:此时线程1和线程2完成了所有标记,对象C会被错误的回收掉。 这就是漏标的问题,主要发生在并发标记和业务线程同时执行,业务线程改变了对象的指向, 而线程已经完成了所有标记,那么此时无法在进行标记。

三色状态更改

CMS解决方案

增量更新算法(Incremental Update):

当一个白色对象被一个黑色对象引用,将黑色对象重新标记为灰色,让垃圾回收器重新扫描 (注意这里是从根节点开始扫描而不是从A)。

CMS1漏标解决方案

将A变成灰色,则垃圾回收器会从根节点重新扫描。然后A就会变成黑色;C就变成灰色,再变成黑色。

G1解决方案

原始快照算法(SATB:snapshot-at-the-beginning): 刚开始做一个快照,当B和C消失的时候要把这个引用推到GC的堆栈,保证C还能被GC扫描到, 最重要的是要把这个引用推到GC的堆栈,是灰色对象指向白色的引用,如果一旦某一引用消失掉, 会把它放到栈(GC方法运行时数据也是来自栈中),其实还是能找到它,下回直接扫描就可以了。 对应着G1垃圾回收过程的最终标记,对用户线程做一个短暂的暂停,用于处理并发阶段后, 扔遗留下来的少量的SATB记录(漏标对象)。 例如:刚开始GC的时候,会做一个快照,可以理解为拍了一张照片,取名叫做S。

这时候业务线程执行操作A.c=C B.c=null,B和C的连线消失了,就会把上述的快照S, 推到GC堆栈中去。那么就意味着之前的关系/引用还是存在的,SATB关注引用的删除。 两张快照信息对比发现了不一致,专门针对变化的地方进行处理就可以了,就会从A开始再扫描一次。

G1漏标解决方案

CMS和G1的两种解决方案的对比:

  • 原始快照算法关注引用的删除 (b->c的引用),从A开始扫描。
  • 增量更新算法关注引用的增加 (A->C的引用),从根重新扫描。
  • G1在处理并发标记的过程比CMS效率更高,主要就是解决漏标算法决定的。

错标

假设GC线程已经遍历到B了,此时用户线程执行了以下操作:

B.D = null;  // B到D的引用被切断
A.xx = D;    // A到D的引用被建立

B到D的引用被切断,且A到D的引用被建立。 此时GC线程继续工作,由于B不再引用D了,尽管A又引用了D,但是因为A已经标记为黑色, GC不会再遍历A了,所以D会被标记为白色,最后被当做垃圾回收。 可以看到错标的结果比漏表严重的多,浮动垃圾可以下次GC清理,而把不该回收的对象回收掉, 将会造成程序运行错误。

错标只有在满足下面两种情况下才会发生:

  • 灰色指向白色的引用全部断开
  • 黑色指向白色的引用被建立

参考资料

文档信息

Search

    Table of Contents