垃圾回收算法和垃圾回收器

303 阅读17分钟

参考资料

前言

本文大纲结构如下图所示。

什么是垃圾

如果一个对象不可能再被引用,那么这个对象就是垃圾,应该被回收。

那么,如何判断一个对象垃圾呢?有如下两种算法

  • 第 1 代垃圾回收算法中,采用「引用计数法」
  • 第 2 代垃圾回收算法中,采用「GC Root Tracing 算法(或称为引用追踪算法)」

引用计数法

记录一个对象被引用的次数,当对象被引用时,计数加一;当对象被去除引用时,计数减一。这样就可以通过判断引用计数是否为零,来判断一个对象是否为垃圾。这种方法被称为「引用计数法」。

「引用计数法」存在一个问题,即「循环引用」问题。

  • A 引用了 B,B 引用了 C,C 引用了 A,它们各自的引用计数都为 1。但这三个对象从未被其他对象引用,只有它们自身互相引用。
  • 此时,对象 A、B、C 可认为是垃圾,但根据「引用计数法」,却无法将其判断为垃圾。

GC Root Tracing 算法

目前 Java 虚拟机判断一个对象是不是垃圾,采用的都是 GC Root Tracing 算法,该算法可以解决「引用计数法」中的「循环引用」问题。

GC Root Tracing 算法中,将对象划分为

  1. 可触达
    • 根据引用情况,又分为强引用、软引用、弱引用、虚引用
  2. 可复活
  3. 不可触及

GC Root Tracing 算法中,定义了一个「垃圾收集根元素(GC Root)」,从「垃圾收集根元素(GC Root)」出发,所有可达的对象都是存活的对象,所有不可达的对象都是垃圾。

「垃圾收集根元素(GC Root)」是一组活跃引用的集合,包括

  1. 局部变量(Local variables
  2. 活动线程(Active threads
  3. 静态域(Static fields
  4. JNI 引用(JNI references
  5. 其他对象

内存泄漏和内存溢出

  • 内存溢出(OOM)是指可用内存不足。
  • 内存泄漏(Memory Leak)是指本来无用的对象却继续占用内存,没有再恰当的时机释放占用的内存。
  • 两者关系如下
    1. 如果存在严重的内存泄漏问题,随着时间的推移,则必然会引起内存溢出。
    2. 内存泄漏一般是资源管理问题和程序 Bug,内存溢出则是内存空间不足和内存泄漏的最终结果。

垃圾回收算法

垃圾回收算法,可分为 3 种

  1. 标记清除算法(Mark and Sweep)
  2. 标记复制算法,可简称为复制算法(Mark-Sweep-Copy)
  3. 标记压缩算法(Mark-Sweep-Compact)

3 种垃圾回收算法的对比如下表。

垃圾回收算法优点缺点
标记清除算法不需要移动太多对象会产生空间碎片问题
标记复制算法解决了空间碎片问题将内存空间折半,且需要移动存活对象
标记压缩算法解决了空间碎片问题需要移动存活对象

标记清除算法

「标记清除算法」分为两个阶段

  1. 标记阶段
    • 标记所有由 GC Root 触发的可达对象,所有未被标记的对象就是垃圾对象
  2. 清除阶段
    • 清除所有未被标记的对象

「标记清除算法」会产生「空间碎片问题」。如果空间碎片过多,则会导致内存空间的不连续。虽然对大多数对象来说,也可以分配在不连续的空间中,但效率要低于连续的内存空间。

标记复制算法

「标记复制算法」的核心思想是将原有的内存空间分为两块,每次只使用一块,在垃圾回收时,将正在使用的内存中的存活对象复制到未使用的内存块中。之后清除正在使用的内存块中的所有对象,「标记复制算法」最后交换两个内存块的角色,完成垃圾回收。

「标记复制算法」的缺点是要将内存空间折半,极大地浪费了内存空间。

标记压缩算法

「标记压缩算法」是「标记清除算法」的优化版,该算法分为两个阶段

  1. 标记阶段
    • 标记所有由 GC Root 触发的可达对象,所有未被标记的对象就是垃圾对象
  2. 压缩阶段
    • 将所有存活的对象压缩在内存的一边,之后清理边界外的所有空间

垃圾回收的设计思想

上文中介绍了 3 种垃圾回收算法,每种算法均有各自的优点和缺点。若单独采用任何一种算法,最终垃圾回收的效率都不会太好。所以,在设计「垃圾回收机制」时,采用了下面两种思想。

  1. 分代思想
  2. 分区思想

分代思想

「分代思想」中,对 JVM 内存的不同区域,采用不同的垃圾回收算法。

  1. 对年轻代这种存活对象较少的区域,适合采用标记复制算法。这样只需要复制少量对象,便可完成垃圾回收,并且还不会有内存碎片。
  2. 对老年代这种存活对象较多的区域,适合采用标记压缩算法或标记清除算法。这样不需要移动太多的内存对象。

下面,对「年轻代中采用标记复制算法」做必要的说明。

标记复制算法的一种最简单实现便是折半内存使用,另一半备用。但实际上,年轻代(Young generation)并没有被等分为两部分,而是被划分了为 3 个部分

  1. 新生代(Eden space)
  2. 存活区 S0 (From Survivor 0 区)
  3. 存活区 S1 (To Survivor 1 区)

默认的虚拟机配置中,Eden : from : to = 8:1:1。这个比例是 IBM 公司根据大量统计得出的结果。根据 IBM 公司对对象存活时间的统计,他们发现 80% 的对象存活时间都很短,是朝生夕死的,于是他们将新生代(Eden space)设置为年轻代的 80%,这样可以减少内存空间的浪费,提高内存空间利用率。在我们可以观察到的任何时刻,S0 和 S1 总有一个是空的。两个存活区一般较小,并不浪费多少空间。

分区思想

「分区思想」中,将整个堆空间划分成连续的不同小区间。每一个小区间都独立使用,独立回收。这样做的好处是,可以控制一次回收多少个区间,可以较好地控制 GC 时间。

垃圾回收的类型

垃圾回收有如下几种类型

  1. Minor GCYoung GC
  2. Major GCOld GC
  3. Full GC

下面将对这 3 种垃圾回收类型进行介绍,最后对 Stop-The-World 进行简要介绍。

Minor GC

从年轻代空间回收内存被称为 Minor GC,有时候也称之为 Young GC

  • 当 JVM 无法为一个新的对象分配空间时会触发 Minor GC,比如当 Eden 区满了。所以 Eden 区越小,越频繁执行 Minor GC
  • 当年轻代中的 Eden 区分配满的时候,年轻代中的部分对象会晋升到老年代,所以 Minor GC 后老年代的占用量通常会有所升高。
  • 所有的 Minor GC 都会触发 Stop-The-World,停止应用程序的线程。

Major GC

从老年代空间回收内存被称为 Major GC,有时候也称之为 Old GC

  • 许多 Major GC 是由 Minor GC 触发的,所以很多情况下将这两种 GC 分离是不太可能的。
  • 分配对象内存时发现内存不够,会触发 Minor GCMinor GC 会将对象移到老年代中,如果此时老年代空间不够,就会触发 Major GC

Full GC

Full GC 是清理整个堆空间,包括年轻代、老年代和永久代(如果有的话)。因此, Full GC 可以说是 Minor GCMajor GC 的结合。

当准备要触发一次 Minor GC 时,如果发现年轻代的剩余空间比以往晋升的空间小,则不会触发 Minor GC 而是转为触发 Full GC 因为 JVM 此时认为,之前这么大空间的时候已经发生对象晋升了,那现在剩余空间更小了,那么很大概率上也会发生对象晋升。既然如此,那就直接来一次 Full GC,整理一下老年代和年轻代的空间。

另外,在永久代分配空间时,若已经没有足够空间时,也会触发 Full GC

Stop-The-World

Stop-The-World(全世界暂停),指的是在进行垃圾回收时因为标记或清理的需要,必须让所有执行任务的线程停止执行任务,从而让垃圾回收线程回收垃圾的时间间隔。

Stop-The-World 这段时间里,所有非垃圾回收线程都无法工作,都暂停下来。只有等到垃圾回收线程工作完成才可以继续工作。可以看出,Stop-The-World 时间的长短将关系到应用程序的响应时间。因此在 GC 过程中,Stop-The-World 的时间是一个非常重要的指标。

垃圾回收器

Java 虚拟机的垃圾回收器,可以分为 4 大类

  1. 串行回收器
  2. 并行回收器
  3. CMS 回收器
  4. G1 回收器

从串行回收器,到并行回收器、CMS回收器,再到 G1 回收器,垃圾回收器不断改进,使得垃圾回收效率不断提升。特别是「分区」思想诞生后,对于垃圾回收停顿时间的控制更加细腻,可以让应用有更完美的延时控制,保证更好的用户体验。

可使用下面参数指定使用的垃圾回收器。

-XX:+UseSerialGC
-XX:+UseParallelGC
-XX:+UseParallelGC -XX:+UseParallelOldGC
-XX:+UseConcMarkSweepGC
-XX:+UseConcMarkSweepGC -XX:+UseParNewGC
-XX:+UseG1GC

串行回收器

串行回收器是指使用单线程进行垃圾回收的回收器,每次回收时只有一个线程。

串行回收器进行垃圾回收时,会触发 Stop-The-World 现象,即其他线程都需要暂停,等待垃圾回收完成。

串行回收器可以在新生代和老年代使用。根据作用的堆空间,可分为

  1. 新生代串行回收器
    • 使用标记复制算法
  2. 老年代串行回收器
    • 使用标记压缩算法

并行回收器

并行回收器中,使用多线程进行垃圾回收,可有效缩短垃圾回收使用的时间。

根据作用内存区域的不同,并行回收器可划分为

  1. 新生代 ParNew 回收器
    • 只是简单地将串行回收器多线程化,其回收策略、算法以及参数和新生代串行回收器都一样。
    • 使用标记复制算法。
  2. 新生代 ParallelGC 回收器
    • 和新生代 ParNew 回收器非常类似,同样使用标记复制算法,都是多线程、独占式的收集器,也会导致 Stop-The-World
    • 和新生代 ParNew 回收器不同的是,新生代 ParallelGC 回收器有一个自适应 GC 调节策略,可以保证系统的吞吐量。
    • 自适应 GC 调节策略中,可通过 -XX:MaxGCPauseMillis 设置垃圾回收的最大停顿时间;通过 -XX:GCTimeRatio 设置吞吐量的大小(0~100 的值)。若 GCTimeRatio 的值为 n,那么系统将花费不超过 1/(1+n) 的时间用于垃圾回收。
  3. 老年代 ParallelGC 回收器
    • 使用标记压缩算法

CMS 回收器

CMS 回收器(Mostly Concurrent Mark and Sweep Garbage Collector),即最大并发—标记—清除—垃圾收集器,非常关注系统的停顿时间。

  • 对年轻代采用并发标记复制算法
  • 对老年代采用并发标记清除算法

CMS 回收器的设计目标是避免在老年代垃圾收集时出现长时间的卡顿,非常关注系统的停顿时间。CMS 回收器主要通过两种手段来达成此目标

  1. 不对老年代进行整理(这也造成了老年代内存碎片问题),而是使用空闲列表(free-lists)来管理内存空间的回收。
  2. mark-and-sweep(标记—清除)阶段的大部分工作,和应用线程一起并发执行。

也就是说,在标记—清除阶段并没有明显的应用线程暂停。但值得注意的是,它仍然和应用线程争抢 CPU 时间(CMS 回收器整个过程中将产生两次 Stop-The-World)。默认情况下,CMS 使用的并发线程数等于 CPU 核心数的 1/4。

CMS 回收器的工作步骤包括

  1. 初始标记(第一次 Stop-The-World
  2. 并发标记
  3. 并发预清理
  4. 可取消的并发预清理
  5. 最终标记(第二次 Stop-The-World
  6. 并发清除
  7. 并发重置

G1 回收器

G1 回收器(Garbage-First,即垃圾优先)是 JDK 1.7 中使用的全新垃圾回收器,从长期目标来看,其是为了取代 CMS 回收器。

G1 回收器拥有独特的垃圾回收策略,这跟之前所有垃圾回收器采用的垃圾回收策略不同。从「分代」看,G1 依然属于分代垃圾回收器。但它使用了「分区」算法,从而使得新生代(Eden 区)、存活区 S0、存活区 S1 和老年代等各块内存不必连续。

串行回收器、并行回收器、CMS 回收器中,内存分配都是连续的一块内存,如下图所示。

G1 回收器中,使用了「分区」算法,将一大块的内存分为许多细小的区块,不要求内存是连续的。

如上图所示,G1 回收器中,将一大块内存划分为许多细小的区块(Region)。

  • 每个区块(Region)被标记为 E、S、O 和 H。HHumongous,表示该 Region 存储的是巨型对象(humongous object,H-obj)。
  • G1 回收器中,当新建对象大小超过 Region 大小一半时,会直接在新的一个或多个连续 Region 中分配,并标记为 H

G1 收集器的工作阶段分为 4 个阶段

  1. 新生代 GC
  2. 并发标记周期
  3. 混合收集
  4. 如果需要,可能进行 FullGC

垃圾回收器的选择

综合来看,G1 回收器 是 JDK 11 之前 HotSpot JVM 中最先进的准产品级垃圾收集器。重要的是,HotSpot 工程师的主要精力都放在不断改进 G1 上面。在更新的 JDK 版本中,将会带来更多强大的功能和优化。G1 作为 CMS 的代替者出现,解决了 CMS 中的各种疑难问题,包括暂停时间的可预测性,并终结了堆内存的碎片化。

如果 CPU 资源不受限制,那么 G1 可以说是 HotSpot 中最好的选择,特别是在最新版本的 JVM 中。当然这种降低延迟的优化也不是没有代价的,由于额外的写屏障和守护线程,G1 的开销会更大。 如果系统属于吞吐量优先型的,又或者 CPU 持续占用 100%,而又不在乎单次 GC 的暂停时间,那么 CMS 是更好的选择。

总之,G1 适合大内存,需要较低延迟的场景。 对于内存大小的考量

  1. 若内存在 4G 以上,算是比较大,用 G1 的性价比较高。
  2. 若内存超过 8G,如 16G、64G 内存,非常推荐使用 G1 GC。

选择正确的 GC 算法和垃圾回收器,唯一可行的方式就是去尝试,并找出不合理的地方。一般性的指导原则如下

  1. 如果系统考虑吞吐优先,CPU 资源都用来最大程度处理业务,用 Parallel GC。
  2. 如果系统考虑低延迟有限,每次 GC 时间尽量短,用 CMS GC
  3. 如果系统内存堆较大,同时希望整体来看平均 GC 时间可控,使用 G1 GC。

Java11、12中的垃圾回收器

JDK 8 的默认 GC 策略是并行 GC。G1 成为 JDK9 以后版本的默认 GC 策略,同时,ParNew + SerialOld 这种组合不被支持。

后续 JDK 版本,不断优化 GC,吸收了「Pauseless GC」设计思想,并引入了新的垃圾回收器。

  1. Java 11中引入了 ZGC(Zero GC)
  2. Java 12中引入了 Shenandoah GC

Pauseless GC

早在 2005 年,Azul Systems 公司的三位工程师就给出了非常棒的解决方案,在论文《无停顿 GC 算法(The Pauseless GC Algorithm)》中提出了「Pauseless GC」设计。他们发现,低延迟的秘诀主要在于两点

  1. 使用读屏障
  2. 使用增量并发垃圾回收

Pauseless GC 算法主要分为 3 个阶段

  1. 标记(Mark)
  2. 重定位(Relocate)
  3. 重映射(Remap)

每个阶段都是完全并行的,而且每个阶段都是和业务线程并发执行的。

JDK 11 中引入的 ZGC 垃圾收集器,JDK 12 中引入的 Shenandoah GC 都借鉴并采用了 Pauseless GC 的设计思想。

Java 11中的ZGC

ZGCZ Garbage Collector,这是一款低停顿、高并发,基于小堆块(region)、不分代的增量压缩式垃圾收集器,平均 GC 耗时不到 2 毫秒,最坏情况下的暂停时间也不超过 10 毫秒。

ZGC 的主要特点如下

  • GC 最大停顿时间不超过 10ms
  • 堆内存支持范围广,小至几百 MB 的堆空间,大至 4TB 的超大堆内存(JDK 13 升至 16TB)
  • 与 G1 相比,应用吞吐量下降不超过 15%
  • 当前只支持 Linux/x64 位平台,预期 JDK14 后支持 macOS 和 Windows 系统
  • ZGC 使用了两项关键技术,着色指针和读屏障

Java 12中的Shenandoah GC

Java 12 中引入了 Shenandoah GC 垃圾回收器(音译 “谢南多厄”,美国一个地名)。Shenandoah 是一款超低延迟垃圾收集器(Ultra-Low-Pause-Time Garbage Collector),其设计目标是管理大型的多核服务器上,超大型的堆内存。GC 线程与应用线程并发执行,使得虚拟机的停顿时间非常短暂。

Java 13对ZGC的改进

Java 13 对 ZGC 的改进,主要体现在下面几点

  • 可以释放不使用的内存给操作系统
  • 最大堆内存支持从 4TB 增加到 16TB
  • 添加参数 -XX:SoftMaxHeapSize 来软限制堆大小

GC 线程数设置参考准则

  1. 若 CPU 核数 N 小于等于 8,推荐设置 GC 线程数 ParallelGCThreads 等于 CPU 核数 N
  2. 若 CPU 核数 N 大于 8,推荐设置 GC 线程数 ParallelGCThreads 为于 CPU 核数 N5/8
-XX:ParallelGCThreads= N*(5/8)

GC日志

GC日志相关参数

  1. -verbose:gc
    • 和其他 GC 参数组合使用,在 GC 日志中输出详细的 GC 信息,包括每次 GC 前后各个内存池的大小,堆内存的大小,提升到老年代的大小,以及消耗的时间。
    • 此参数支持在运行过程中动态开关。
  2. -XX:+PrintGCDetails-XX:+PrintGCTimeStamps
    • 打印 GC 细节与发生时间。
  3. -Xloggc:file
    • -verbose:gc功能类似,只是将每次 GC 事件的相关情况记录到一个文件中。文件位置最好在本地,以避免网络的潜在问题。
    • 若与 verbose:gc 命令同时出现在命令行中,则以 -Xloggc 为准。

GC日志解读工具

可以借助第三方可视化工具来分析 GC 日志(当然,也可以直接阅读 GC 日志)。

  • GCEasy 工具提供了下面 3 种产品
    1. GCEasy 是一款在线的 GC 日志分析工具,支持各种版本的 GC 日志格式
    2. FastThread 是一款 Java 线程分析工具(Java Thread Dump Analyzer
    3. HeapHero 是一款 Heap Dump 分析工具(Heap Dump Analyzer
  • GCViwer