← 深入理解 Java 虚拟机读书笔记

[6] HotSpot VM 垃圾收集器


HotSpot VM 提供了多种垃圾收集器,每种收集器都有各自的特点,虽然我们要对各个收集器进行比较,但并非为了挑选出一个最好的收集器。我们选择的只是对具体应用最合适的收集器。
查看默认的垃圾收集器:-XX:+PrintCommandLineFlags
查看 GC 详情:-XX:+PrintGCDetails
查看相关垃圾回收器参数:jinfo -flag-flag 为进程ID)。

1. 垃圾收集器概念

1.1 垃圾收集器分类


按 GC 线程数分类
可以分为串行垃圾收集器和并行垃圾收集器:
  • 串行:回收指同一个时间段内,只允许一个 CPU 用于执行 GC 操作,此时工作线程被暂停,直到垃圾回收工作结束。
    • 在单 CPU 或者较小应用内存等硬件平台不是特别优越的场合,串行垃圾收集器的性能表现可以超过并行垃圾收集器和并发垃圾收集器。所以串行垃圾收集器默认被应用在客户端的 Client 模式下的 JVM 中。
    • 在并发能力比较强的 CPU 上,并行垃圾收集器产生的停顿时间要短于串行垃圾收集器。
  • 并行:和串行相反,并行 GC 可以运用在多个 CPU 同时执行垃圾回收,因此提升了应用的吞吐量,不过并行 GC 仍然与串行 GC 一样,采用独占式,使用了 STW 机制。

按工作模式分类
  • 并发式:垃圾收集器与应用程序交替工作,以尽可能减少应用程序的停顿时间。
  • 独占式:一旦运行,就挂起暂停应用程序中所有的用户线程,直到 GC 过程完全结束。

按内存碎片处理方式分类
  • 压缩式
  • 非压缩式

按工作内存区域分类
  • 新生代
  • 老年代

1.2 组合关系

7 款经典垃圾收集器和经典分代之间的关系
垃圾收集器间的组合关系
  • JDK 8 之前,可以用虚线参考关系。
  • CMS 下面的实线,是 CMS GC 失败的后备方案。
  • JDK 8 中弃用了红线的组合,标记为废弃的(如果是在要使用也可以使用)。
  • JDK 9 中将红线组合移除。
  • JDK 14 中弃用了绿线组合。
  • JDK 14 中删除了 CMS。
  • JDK 9 默认 G1。
  • JDK 8 默认 Parallel Scavenge 和 Parallel Old。
  • 新生代用了 Parallel Scavenge 则老年代自动触发用 Parallel Old。
  • Parallel 底层与 ParNew 底层不同,所以不能和 CMS 组合。

1.3 性能指标

吞吐量
吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)
  • 运行用户代码的时间占总运行时间的比例。
  • 总运行时间:程序的运行时间 + GC 的时间。
  • 吞吐量优先:意味着单位时间内,STW 的时间最短。

GC 开销
  • 吞吐量的补数,GC 所占用的时间与总运行时间的比例。

停顿(暂停)时间
执行 GC 时,程序的工作线程被暂停的时间。
停顿时间优先:意味着单次 STW 的时间最短,但是频率可能增加。

GC 频率
相对于应用程序的执行,GC 操作发生的频率。

内存占用
在 Java 堆区所占用的内存大小。

快速
一个对象从诞生到被回收经历的时间。

1.4 不可能三角

抓住两点:吞吐量停顿时间
高吞吐量与低停顿时间,是一对互斥的关系:
  • 如果选择高吞吐量优先,必然需要降低 GC 的执行频率,导致 GC 需要更长的停顿时间来执行内存回收。
  • 如果选择低停顿优先,也只能频繁的执行 GC,引起程序吞吐量的下降。
追求高吞吐量,可以通过减少 GC 执行实际工作的时间,然而,仅仅偶尔运行 GC 意味着每当 GC 运行时将有许多工作要做,因为在此期间积累在堆中的对象数量很高。单个 GC 需要花更多的时间来完成,从而导致更高的停顿时间。而考虑到低停顿时间,最好频繁运行 GC 以便更快速完成,反过来又导致吞吐量下降。
现在的标准,在最大吞吐量优先的情况下,降低停顿时间。

2. 新生代垃圾收集器

2.1 Serial 垃圾收集器(单线程)

发布于 1999 年 JDK 1.3.1。
Serial 垃圾收集器采用复制算法、串行回收和 STW 机制的方式执行内存回收。
只开启一条 GC 线程进行垃圾回收,并且在垃圾收集过程中停止一切用户线程(Stop The World,STW)。
一般客户端应用所需内存较小,不会创建太多对象,而且堆内存不大,因此垃圾收集器回收时间短,即使在这段时间停止一切用户线程,也不会感觉明显卡顿。因此 Serial 垃圾收集器适合客户端使用,对于交互性强的应用而言,并不适合采用串行垃圾收集器。
由于 Serial 垃圾收集器只使用一条 GC 线程,避免了线程切换的开销,从而简单高效。
除了新生代,还有用于老年代的 Serial Old 垃圾收集器,同样采取了串行回收,但是用标记-整理算法
HotSpot VM 中,使用 -XX:+UseSerialGC 指定新生代和老年代使用 Serial 垃圾收集器。

2.2 ParNew 垃圾收集器(多线程)

ParNew 是 Serial 的多线程版本,其他方面和 Serial 几乎没有区别。由多条 GC 线程并行地进行垃圾清理。但清理过程依然需要 STW。
ParNew 追求「低停顿时间」,与 Serial 唯一区别就是使用了多线程进行垃圾收集,在多 CPU 环境下性能比 Serial 会有一定程度的提升;但线程切换需要额外的开销,因此在单 CPU 环境中表现不如 Serial。
指定使用 ParNew 垃圾收集器:-XX:UseParNewGC
它表示新生代使用,不影响老年代。
限制 GC 线程数量:-XX:ParallelGCThreads
默认开启和 CPU 相同的线程数。

2.3 Parallel Scavenge 垃圾收集器(多线程,吞吐量优先)

发布于 2002 年 JDK 1.4.2。
Parallel Scavenge 和 ParNew 一样,都是多线程、新生代垃圾收集器。但是两者有巨大的不同点:
  • ParNew:追求降低用户停顿时间,适合交互式应用。
  • Parallel Scavenge:追求 CPU 吞吐量,能够在较短时间内完成指定任务,因此适合没有交互的后台计算,如执行批量处理,订单处理,工资支付,科学计算的应用程序。
  • Parallel Scavenge:目标是达到一个可控的吞吐量。
  • 通过参数 -XX:GCTimeRadio 设置垃圾回收时间占总 CPU 时间的百分比。
  • 通过参数 -XX:MaxGCPauseMillis 设置垃圾处理过程最久停顿时间(单位:毫秒)。
  • 通过命令 -XX:+UseAdaptiveSizePolicy 开启自适应策略。我们只要设置好堆的大小和 -XX:MaxGCPauseMillis-XX:GCTimeRadio,收集器会自动调整新生代的大小、Eden 和 Survivor 的比例、对象进入老年代的年龄,为了达到堆大小、吞吐量、停顿时间之间的平衡点。以最大程度上接近我们设置的 -XX:MaxGCPauseMillis-XX:GCTimeRadio
在手动调优比较困难的场景下,可以直接使用自适应的方式,仅指定 JVM 最大堆、目标吞吐量和停顿时间,让 JVM 自己完成调优工作。
Parallel 垃圾收集器在 JDK 8 之后成为 HotSpot VM 默认垃圾收集器。如果在新生代中使用了 Parallel Scavenge 则老年代自动触发使用用 Parallel Old。

3. 老年代垃圾收集器

3.1 Serial Old 垃圾收集器(单线程)

Serial Old 垃圾收集器是 Serial 的老年代版本,都是单线程收集器,只启用一条 GC 线程,都适合客户端应用。它们唯一的区别就是:Serial Old 工作在老年代,使用标记-整理算法;Serial 工作在新生代,使用复制算法

3.2 Parallel Old 垃圾收集器(多线程,吞吐量优先)

Parallel Old 垃圾收集器是 Parallel Scavenge 的老年代版本,追求 CPU 吞吐量。采用标记-整理算法,同样基于并行回收和 STW 机制。
手动指定 Parallel Old 为老年代垃圾收集器:-XX:+UseParallelOldGC
在 JDK 8 中这个选项是默认开启的,与新生代的 Parallel 垃圾收集器关联,开启一个,默认开启另一个。

3.3 CMS 垃圾收集器(低停顿优先)

发布于 2004 年 JDK 5。
CMS(Concurrent Mark Sweep,并发标记清除)垃圾收集器是以获取最短回收停顿时间为目标的收集器(追求低停顿),它在垃圾收集时使得用户线程和 GC 线程并发执行,因此在垃圾收集过程中用户也不会感到明显的卡顿。
  • 初始标记:STW,使用一条初始标记线程对所有仅与 GC Roots 直接关联的对象进行标记。一旦标记完成后就会恢复之前被暂停的所有应用线程,由于直接关联对象比较小,所以这里速度非常快。
  • 并发标记:使用多条标记线程,与用户线程并发执行。从 GC Roots 的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长。但是不需要暂停用户线程,可以与标记线程一起并发运行,进行可达性分析,标记出所有废弃对象。
  • 重新标记:STW,为了修正并发标记期间,因用户程序继续运作导致标记产生变动的那一部分对象进行标记记录。使用多条标记线程并发执行,将刚才并发标记过程中新出现的废弃对象标记出来(增量更新)。
  • 并发清除:只使用一条 GC 线程,由于清除时不需要移动存活对象,可以与用户线程并发执行,清除刚才标记的对象。这个过程非常耗时。
并发标记与并发清除过程耗时最长,且可以与用户线程一起工作,因此,总体上说,CMS 垃圾收集器的内存回收过程是与用户线程一起并发执行的。
由于在 GC 阶段用户线程没有中断,所以在 GC 过程中,还应该确保应用程序用户线程有足够的内存可用。因此 CMS 不能像其他垃圾收集器那样等到老年代几乎填满再进行回收,而是当堆内存使用率达到某一阈值时,便开始进行回收。
要是在执行 CMS GC 的过程中同时有对象要放入老年代,而此时老年代预留的空间无法满足程序需要,就会出现一次 concurrent mode failure 失败,这时 JVM 启用备用方案,临时启用 Serial Old 收集器来重新进行老年代的垃圾收集,这样停顿时间就更长了。
CMS 的优点:
  • 并发收集
  • 低停顿
CMS 的缺点:
  • 吞吐量低
  • 使用标记-清除算法产生碎片空间
  • 无法处理浮动垃圾(并发标记、清理阶段产生的垃圾),导致频繁 Full GC
CMS 垃圾收集器采用标记-清除算法,会产生内存碎片,只能够选择空闲列表策略执行内存分配。
📔
为什么不采用标记-整理算法?因为并发清除时,如果压缩整理内存,原来的用户线程使用的内存就无法使用了。标记-整理算法更适合 STW 场景下使用。
  • 对于产生内存碎片空间的问题,可以通过开启 -XX:+UseCMSCompactAtFullCollection,在每次 Full GC 完成后都会进行一次内存压缩整理,将零散在各处的对象整理到一块。设置参数 -XX:CMSFullGCsBeforeCompaction 告诉 CMS,经过了 N 次 Full GC 之后再进行一次内存压缩整理。不过内存压缩整理无法并发执行,会带来停顿时间更长的问题
  • 通过参数 -XX:+UseConcMarkSweepGC 指定使用 CMS 为老年代垃圾收集器。开启后会自动将 -XX:UseParNewGC 打开,即 ParNew + CMS + Serial Old 的组合。
    • Parallel 底层与 ParNew 底层不同,不能和 CMS 组合。
  • 通过参数 -XX:CMSlnitiatingOccupanyFraction 设置堆内存使用率的阈值,一旦达到该阈值,则开始进行 GC。JDK 5 及之前默认阀值为 68,即老年代的空间使用率达到 68 % 时会执行一次 GC;JDK 6 及以后默认阀值为 92。如果内存增长缓慢,可以设置一个稍大的值,有效降低 GC 的触发频率,减少老年代回收的次数。如果应用程序内存使用率增长很快,则应该降低这个阈值,以避免频繁触发 Serial Old 串行垃圾收集器。
  • 通过参数 -XX:ParallelCMSThreads 设置 CMS 的并发线程数量,默认启动的线程数是 (处理器核心数量 + 3) / 4。ParallelGCThreads 是新生代并行垃圾收集器的并发线程数。

4. 区域化垃圾收集器

4.1 G1 垃圾收集器(区域化)

发布于 2012 年 JDK 7u4,于 2017 年 JDK 9 中成为 HotSpot VM 默认垃圾收集器,以替代 CMS。又于 2018 年 JDK 10 中实现并行性,改善了最坏情况下的 Full GC 停顿时间。
全功能收集
G1(Garbage First)是一款面向服务端应用的全功能通用垃圾收集器,它没有新生代和老年代的概念,而是将堆划分为一块块独立的 Region,以不同的 Region 来表示 Eden、From、to、老年代等。
当要进行垃圾收集时,首先估计每个 Region 中垃圾的数量,每次都从垃圾回收价值最大的 Region 开始回收,因此可以获得最大的回收效率。在停顿时间可控的情况下,获得尽可能高的吞吐量,才能担当起全功能收集器的重任和期望。

空间整合度
从整体上看, G1 是基于标记-整理算法实现的垃圾收集器,从局部(两个 Region 之间)上看是基于复制算法实现的,这意味着运行期间不会产生内存空间碎片。两种算法都避免产生内存碎片,有利于程序长时间运行,分配大对象不会因为无法找到连续空间提前触发下一次 GC,尤其当 Java 堆非常大的时候,G1 优势更加明显。

可预测的停顿时间模型
G1 能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不能超过 N 毫秒。

G1 缺点
  • 相较于 CMS,G1 并不具备全方位、压倒性的优势。比如用户程序运行中,G1 无论是为了垃圾收集产生的内存占用,还是程序运行时额外的执行负载都比 CMS 要高。
  • 从经验上说,对于小内存的应用 CMS 表现大概率优于 G1,在大内存上 G1 优势发挥更多,平衡点在 6 ~ 8 GB 之间。

适用场景
  • 面向服务器端的应用,针对具有大内存、多处理器的机器。
  • 最主要的是应用需要 GC 时的低停顿时间。
  • 如:在 Java 堆大小约为 6 GB 或更大时,可预测的停顿时间可以低于 0.5 秒,G1 每次只清理一部分 Region 来保证每次 GC 停顿时间不会过长。
  • 用来替换 JDK 5 中的 CMS:
    • 超过 50 % 的 Java 堆被活动数据占用。
    • 对象分配频率或年代提升频率变化很大。
    • GC 停顿时间过长,长于 0.5 ~ 1 秒。

Region
📔
问题:一个对象和它内部所引用的对象可能不在同一个 Region 中,那么当进行 GC 时,是否需要扫描整个堆内存才能完整地进行一次可达性分析?
并不是,对于每个 Region 都有一个记忆集(Remembered Set,RSet),用于记录本区域中所有对象引用的对象所在的区域,进行可达性分析时,只要在 GC Roots 中再加上记忆集即可防止对整个堆内存进行遍历。
G1 中记忆集的应用其实要复杂很多,每个 Region 都维护有自己的记忆集,这些记忆集还会记录下别的 Region 指向自己的指针,并标记这些指针分别在哪些卡页的范围内。G1 的记忆集本质上是一种哈希表,Key 是别的 Region 的起始地址,Value 是一个集合,里面存储的是卡表的索引号。这种「双向」的卡表结构(卡表是「我指向谁」,这种结构还记录了「谁指向我」)比原来的卡表实现起来要更复杂,G1 至少要耗费大约相当于 Java 堆容量 10 % 至 20 % 的额外内存来维持收集器工作。
每个 Region 的大小都相同,且在一个 JVM 生命周期内不会改变。一个 Region 有可能是属于 Eden、Survivor 或老年代等内存区域。但是一个 Region 只可能属于一个角色。例如,图中的 E 表示该 Region 属于 Eden 内存区域,S 表示属于 Survivor 内存区域,O 表示属于老年代内存区域,空白则表示未使用的内存区域。
G1 还增加了一种新的内存区域:Humongous,如图中的 H 块。主要用于存储大对象,如果超过 1.5 个 Region,就会被放到 Humongous 区域中。那些超过了整个 Region 容量的超级大对象,将会被存放在 N 个连续的 Humongous 区域中,G1 中的大多数行为都把 Humongous 区域作为老年代的一部分来看待。
虽然 G1 仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的了,它们都是一系列 Region(不需要连续)的动态集合。
G1 之所以能建立可预测的停顿时间模型,是因为它将 Region 作为单次回收的最小单元,每次收集到的内存空间都是 Region 大小的整数倍。并且让 G1 去跟踪各个 Region 里面的垃圾「价值」大小(回收所获得的空间大小及回收所需时间),然后在后台维护一个优先级列表,每次根据参数设定允许的收集停顿时间(-XX:MaxGCPauseMillis,默认 200 毫秒),优先处理回收收益最大的那些 Region,这也就是「Garbage First」名字的由来。

混合收集
G1 提供了两种 GC 模式,Minor GC 和 Mixed GC,两种都是完全 STW 的。
  • Minor GC:选定所有属于新生代的 Young Region。通过控制年轻代的 Region 个数,即年轻代内存大小,来控制 Minor GC 的时间开销。
  • Mixed GC:选定所有属于新生代的 Young Region,外加根据并发标记阶段统计得出收集收益高的若干老年代 Old Region。在用户指定的开销目标范围内尽可能选择收益高的老年代 Old Region。
G1 不再局限于分代收集:衡量标准不再是属于哪个分代,而是哪块 Region 回收收益最大,所以 Mixed GC 是伴随 Minor GC 而发生的。
如果不计算使用写屏障维护记忆集的操作,G1 垃圾收集器的工作过程分为以下几个步骤:
  • 初始标记:STW,仅使用一条初始标记线程对所有与 GC Roots 直接关联的对象进行标记。而且是借用进行 Minor GC 的时候同步完成的,所以 G1 收集器在这个阶段实际并没有额外的停顿。
  • 并发标记:使用一条标记线程与用户线程并发执行。此过程进行可达性分析,递归扫描整个堆里的对象图,这阶段耗时较长。当对象图扫描完成以后,还要重新处理 SATB 记录下的在并发时有引用变动的对象。
  • 最终标记:STW,使用多条标记线程并发执行。用于处理并发阶段结束后仍遗留下来的最后那少量的 SATB 记录。
  • 筛选回收:STW,使用多条回收线程并发执行。更新 Region 的统计数据,对各个 Region 的回收价值和成本进行排序,根据参数设置的允许停顿时间来制定回收计划,可以自由选择任意多个 Region 构成回收集,然后把决定回收的那一部分 Region 的存活对象复制到空的 Region 中,再清理掉整个旧 Region 的全部空间。
并发标记结束后,老年代为垃圾的 Region 被回收了,部分为垃圾的 Region 被计算出来了,默认情况下,这些老年代的 Region 会被分 8 次回收(可通过 -XX:G1MixedGCCountTarget 设置)。
由于 Old Region 默认分8次回收,G1 会优先回收价值高的 Region,并且有一个阈值会决定内存分段是否被回收:-XX:G1MixedGCLiveThresholdPercent(默认为 65)。意思是垃圾占比达到 65 % 才会被回收。如果垃圾占比比较低,意味存活对象比较高,复制的时候就需要花更多的时间。
有一个参数:-XX:G1HeapWastePercent(默认为 10),意思是允许整个堆内存中有 10% 的空间被浪费,意味着如果发现可以回收的垃圾占堆内存比例低于10%,则不再进行混合回收,因为 GC 花费更多的时间,但是回收到的内存却很少。
G1 初衷就是要避免 Full GC,如果不能正常工作,G1 会 STW。使用单线程的 Serial Old 进行垃圾回收,性能非常差,应用程序停顿时间长。导致 Full GC 原因可能有两个:
  • 回收阶段的时候没有足够的空间存放晋升的对象。
  • 并发处理过程完成之前空间耗尽了。

参数设置
  • -XX:+UseG1GC:采用 G1 垃圾收集器。
  • -XX:G1HeapRegionSize:设置每个 Region 大小,值是 2 的 N 次幂,范围是 1 MB ~ 32 MB 之间,目标是根据最小的 Java 堆划分出约 2048 个 Region,默认是堆内存的 1/2000。
  • -XX:MaxGCPauseMillis:设置期望达到的最大 GC 停顿时间指标,JVM 尽力但不保证,默认是 200 ms。
  • -XX:ParallelGCThread:设置 STW 工作线程数,最多设置 8 条线程。
  • -XX:ConcGCThreads:设置并发标记的线程数,将 N 设置为并行垃圾回收线程数(parallelGCThreads)的 1/4 左右。
  • -XX:InitiatingHeapOccupancyPercent:设置触发并发 GC 周期的 Java 堆占用率阈值,超过此值就触发 GC,默认是 45。
优化建议:
  • 避免使用 -Xmn-XX:NewRatio 等相关选项显式设置新生代大小。
  • 固定的新生代大小会覆盖停顿时间的目标。
  • 停顿时间的目标不要太苛刻,太苛刻会影响吞吐量。

4.2 Shenandoah 垃圾收集器(OpenJDK)

仅限非 OracleJDK,低停顿时间,高运行负载下的吞吐量下降。

4.3 ZGC 垃圾收集器(新发展)

发布于 JDK 11(实验特性),在 JDK 15 中成为正式特性。
在尽可能对吞吐量影响不大的前提下,实现在任意堆内存大小下都可以把垃圾回收的停顿时间限制在 10 毫秒以内的低延迟。
  • 并发标记
  • 并发预备重分配
  • 并发重分配
  • 并发重映射
除了初始标记需要 STW,其他地方几乎都是并发执行的。

5. 垃圾收集器总结

截止到 JDK 8,HotSpot VM 一共有 7 款垃圾收集器。每一款垃圾收集器都有不同的特点,在具体使用的时候,需要根据具体的情况具体分析,选用合适的垃圾收集器。

5.1 GC 日志分析

通过阅读 GC 日志,可以了解 JVM 内存分配与回收策略。
打开 GC 日志:-verbose:gc(只会显示总的 GC 堆的变化):
8.003: [GC (Allocation Failure) [PSYoungGen: 33280K->2696K(38400K)] 33280K->2704K(125952K), 0.0026782 secs] [Times: user=0.02 sys=0.00, real=0.02 secs] 
8.661: [GC (Allocation Failure) [PSYoungGen: 35976K->4181K(38400K)] 35984K->4197K(125952K), 0.0038111 secs] [Times: user=0.03 sys=0.03, real=0.02 secs] 
9.509: [GC (Allocation Failure) [PSYoungGen: 37461K->5109K(38400K)] 37477K->5557K(125952K), 0.0038902 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
  • GCFull GC:GC 的类型,GC:只在新生代上进行,Full GC 包括整个 Java 堆(也说明发生了 STW)。
  • Allocation Failure:GC 发生的原因。
  • 33280K->2704K:堆在 GC 前的大小和 GC 后的大小。
  • 125952K:现在的堆大小。
  • 0.0026782 secs:GC 持续的时间。
  • PSYoungGen:使用 Parallel Scavenge 垃圾收集器在新生代的名字。
  • ParNew:使用 ParNew 垃圾收集器在新生代的名字。
  • DefNew:使用 Serial 垃圾收集器在新生代的名字。
GC 日志参数列表:
  • -XX:+PrintGC,输出 GC 日志,类似:-verbose:gc
  • -XX:+PrintGCDetails,输出 GC 的详细日志。
  • -XX:+PrintGCTimeStamps,输出 GC 的时间戳(以基准时间的形式)。
  • -XX:+PrintGCDateStamps,输出 GC 的时间戳(以日期的形式,如 2013-05-04T21:53:59.234+0800)。
  • -XX:+PrintHeapAtGC,在进行 GC 的前后打印出 Java 堆的信息。
  • -Xloggc:../logs/gc.log,日志文件的输出路径。

Nobelium is built with ♥ and ⚛ Next.js. Proudly deployed on ▲Vercel.

© Ashinch 2021 桂ICP备18011166号-1