JVM - 垃圾回收器
# 1. GC 分类与核心性能指标
Java 虚拟机 (JVM) 规范并未对垃圾收集器 (Garbage Collector, GC) 的具体实现做过多硬性规定,允许不同的 JVM 厂商和版本提供各自的实现。随着 Java 的高速发展,涌现了多种 GC 实现。我们可以从不同维度对它们进行分类。
# 1.1 垃圾收集器分类
1. 按线程数划分:
- 串行垃圾回收器 (Serial GC): 在执行垃圾回收时,只使用单个 CPU 核心和单个回收线程。此时,所有的应用程序线程 (用户线程) 都必须暂停 (Stop-The-World, STW),直到回收完成。
- 优点:实现简单,线程切换开销小,在单核 CPU 或小内存环境下可能效率较高。
- 缺点:STW 时间可能较长,影响用户体验。
- 适用:Client 模式下的 JVM、硬件资源有限的环境。
- 并行垃圾回收器 (Parallel GC): 使用多个 CPU 核心和多条回收线程同时进行垃圾回收操作,以缩短回收的总时间。但回收期间仍然会暂停所有用户线程 (STW)。
- 优点:能充分利用多核 CPU 资源,提高 GC 效率,增加系统吞吐量。
- 缺点:仍然存在 STW,线程切换和管理有一定开销。
- 适用:注重吞吐量、后台计算型任务、服务器端应用。
(上图:串行,下图:并行。注意两者都暂停了用户线程)
2. 按工作模式划分:
- 独占式垃圾回收器 (Stop-The-World GC): GC 一旦运行,就会暂停所有用户线程,直到 GC 过程完全结束。Serial GC 和 Parallel GC 都属于此类。
- 并发式垃圾回收器 (Concurrent GC): 垃圾回收线程可以与用户线程交替或同时运行(部分或全部 GC 阶段)。目标是尽可能减少应用程序的停顿时间。
- 优点:低延迟,更好的用户体验。
- 缺点:实现复杂,可能占用 CPU 资源影响用户线程性能,通常会带来一些吞吐量损失。
- 适用:对响应时间要求高的应用,如 Web 服务器、交互式应用。
(上图:独占式,下图:并发式)
3. 按碎片处理方式划分:
- 压缩式垃圾回收器 (Compacting GC): 在回收完成后,会对存活的对象进行整理 (Compact),将它们向内存一端移动,从而消除内存碎片。使用如“标记-压缩 (Mark-Compact)”算法。
- 优点:内存使用率高,后续分配速度快(可用指针碰撞)。
- 缺点:整理过程相对耗时,可能增加 STW 时间。
- 非压缩式垃圾回收器 (Non-Compacting GC): 回收后不移动存活对象,只是清理出空闲空间。使用如“标记-清除 (Mark-Sweep)”算法。
- 优点:清除阶段较快。
- 缺点:会产生内存碎片,可能导致后续大对象分配困难,需要维护空闲列表。
4. 按工作的内存区间划分:
- 年轻代垃圾回收器 (Young GC / Minor GC Collector): 只负责回收年轻代 (Eden, Survivor 区) 的垃圾。
- 老年代垃圾回收器 (Old GC / Major GC Collector): 只负责回收老年代的垃圾。(注意:Major GC 有时也指 Full GC)。
- 整堆垃圾回收器 (Full GC Collector): 回收整个 Java 堆(包括年轻代和老年代),有时还包括方法区/元空间。例如 G1 在特定模式下可以回收整个堆。
# 1.2 评估 GC 的核心性能指标
评估一款 GC 的性能,通常关注以下几个核心指标:
- 吞吐量 (Throughput): 指 CPU 用于运行用户代码的时间占总运行时间(用户代码运行时间 + GC 时间)的比例。
- 公式:
吞吐量 = 用户代码运行时间 / (用户代码运行时间 + GC 时间)
- 高吞吐量意味着 GC 占用的 CPU 时间少,应用程序的“有效工作”比例高。
- 公式:
- 暂停时间 (Pause Time): 指执行垃圾收集时,应用程序线程被暂停 (STW) 的时长。单次暂停时间的长短直接影响用户体验。
- 低延迟 (Low Latency) 通常指的就是追求尽可能短的单次暂停时间。
- 垃圾收集开销 (GC Overhead): 吞吐量的补数,即 GC 时间占总运行时间的比例。
GC 开销 = GC 时间 / (用户代码运行时间 + GC 时间)
。 - 收集频率 (Collection Frequency): GC 操作发生的频率。通常,为了降低暂停时间,可能需要增加收集频率。
- 内存占用 (Footprint): GC 自身运行时需要占用的内存大小(例如 GC 元数据、卡表、记忆集等)。
- 快速 (Rapidity): 指一个对象从创建到最终被回收所经历的时间。通常不是主要关注点。
核心权衡:吞吐量 vs. 暂停时间
- 吞吐量优先: 目标是在单位时间内处理更多的用户请求或完成更多的计算任务。这通常意味着容忍较长的单次 GC 暂停时间,以换取较低的 GC 频率和较小的 GC 总开销。适合后台计算、批处理等对实时响应要求不高的场景。
(图中两次较长的 STW,但总 GC 时间可能更短)
- 暂停时间优先 (低延迟): 目标是尽可能缩短单次 GC 暂停时间,保证应用的快速响应。这可能需要更频繁地执行 GC(每次处理更少的工作量),从而可能牺牲一部分吞吐量(因为 GC 总开销可能增加)。适合 Web 应用、交易系统、GUI 应用等交互性强的场景。
(图中多次较短的 STW,但总 GC 时间可能更长)
不可能三角: 吞吐量、暂停时间、内存占用这三者往往难以同时达到最优。一款优秀的 GC 通常最多只能优化其中的两项。随着硬件发展,内存占用变得相对次要,吞吐量和暂停时间成为主要的权衡焦点。
现代目标: 许多现代 GC 的设计目标是在保证可控的低暂停时间的前提下,尽可能提高吞吐量。
# 2. HotSpot VM 中的垃圾回收器概述
# 2.1 垃圾回收器发展简史 (HotSpot)
- JDK 1.3.1 (1999): 引入第一款 GC——Serial GC (串行)。
- JDK 1.4.2 (2002): 引入 Parallel GC (并行,吞吐量优先) 和 CMS GC (Concurrent Mark Sweep,并发,低延迟优先)。ParNew (Serial 的并行版) 也随之出现。
- JDK 6: Parallel GC 成为 HotSpot Server 模式下的默认 GC。
- JDK 7u4 (2012): G1 GC (Garbage-First) 登场,标志着区域化、可预测停顿的 GC 时代的开始。
- JDK 9 (2017): G1 GC 成为默认 GC,取代 CMS。CMS 被标记为废弃 (Deprecated)。
- JDK 10 (2018): G1 实现并行的 Full GC,改善最坏情况延迟。
- JDK 11 (2018): 引入 Epsilon GC (无操作 GC) 和 ZGC (可伸缩低延迟 GC,实验性)。
- JDK 12 (2019): 引入 Shenandoah GC (低停顿 GC,实验性,主要由 Red Hat 开发)。G1 增强,可自动返还未使用内存给 OS。
- JDK 13 (2019): ZGC 增强,可自动返还未使用内存给 OS。
- JDK 14 (2020): 删除 CMS GC。ZGC 支持 macOS 和 Windows。
# 2.2 7 款经典的垃圾收集器
HotSpot VM 在发展过程中(截止 JDK 8 左右)形成了 7 款经典的垃圾收集器:
- 新生代收集器:
Serial
: 串行,复制算法。ParNew
: 并行 (Serial 的多线程版),复制算法。Parallel Scavenge
: 并行,复制算法,关注吞吐量,有自适应调节策略。
- 老年代收集器:
Serial Old
: 串行 (Serial 的老年代版),标记-压缩算法。Parallel Old
: 并行 (Parallel Scavenge 的老年代版),标记-压缩算法。CMS
(Concurrent Mark Sweep): 并发,标记-清除算法,关注低延迟。
- 整堆收集器:
G1
(Garbage-First): 并发+并行,基于 Region,逻辑分代,整体看作标记-压缩,兼顾吞吐量和可预测的低延迟。
# 2.3 经典收集器与分代关系
- 新生代专用: Serial, ParNew, Parallel Scavenge
- 老年代专用: Serial Old, Parallel Old, CMS
- 整堆可用 (逻辑分代): G1
# 2.4 垃圾收集器的组合关系 (HotSpot JDK 7/8)
不同的新生代和老年代收集器可以搭配使用,但并非任意组合都行。
常见组合:
Serial
+Serial Old
: Client 模式默认。ParNew
+Serial Old
: 不再推荐。ParNew
+CMS
: 低延迟组合,ParNew 是唯一能配合 CMS 的新生代并行 GC。Serial Old 作为 CMS 失败时的后备。Parallel Scavenge
+Serial Old
: 不再推荐。Parallel Scavenge
+Parallel Old
: 吞吐量优先组合,JDK 8 Server 模式默认。G1
: 不需要搭配,自身同时处理新生代和老年代。
重要变更:
- JDK 8: 将
Serial + CMS
和ParNew + Serial Old
标记为废弃。 - JDK 9: 完全移除
Serial + CMS
和ParNew + Serial Old
组合。G1 成为默认 GC。 - JDK 14: 弃用
Parallel Scavenge + Serial Old
组合。彻底删除 CMS GC。
为何需要多种收集器? Java 应用场景多样(桌面、Web 服务器、大数据处理、实时系统等),对 GC 的需求(吞吐量、延迟、内存占用)也不同。没有万能的收集器,需要针对具体应用选择最合适的。
# 2.5 如何查看默认垃圾收集器
通过 JVM 参数:
- 添加
-XX:+PrintCommandLineFlags
参数运行 Java 程序。输出的信息中会包含-XX:+UseXXXGC
这样的参数,指明了使用的 GC。 - 例如,JDK 8 Server 模式下,通常会看到
-XX:+UseParallelGC
,表示使用了 Parallel Scavenge (新生代) + Parallel Old (老年代) 组合。 - JDK 9 及以后,默认会看到
-XX:+UseG1GC
。
- 添加
通过命令行工具 (jinfo):
首先找到 Java 进程的
PID
(可以使用jps
命令)。然后使用
jinfo -flag <GC相关参数名> <pid>
来查看具体的 GC 参数是否启用。例如:jps # 找到目标进程的 PID jinfo -flag UseParallelGC <pid> # 查看是否使用 Parallel GC jinfo -flag UseParallelOldGC <pid> # 查看是否使用 Parallel Old GC jinfo -flag UseG1GC <pid> # 查看是否使用 G1 GC jinfo -flag UseConcMarkSweepGC <pid> # 查看是否使用 CMS GC (JDK14 前)
1
2
3
4
5
# 3. Serial 回收器 (串行回收)
# 3.1 概述
- 历史最悠久: JDK 1.3.1 引入的第一款 GC。
- 工作方式:
- 新生代 (Serial GC): 采用复制 (Copying) 算法,单线程执行,STW。
- 老年代 (Serial Old GC): 采用标记-压缩 (Mark-Compact) 算法,单线程执行,STW。
- 默认场景: HotSpot VM 在 Client 模式下的默认新生代和老年代收集器。
- Server 模式用途:
- 配合 Parallel Scavenge 使用 (JDK 14 已废弃此组合)。
- 作为 CMS 收集器并发失败后的后备方案 (Fallback)。
(单 GC 线程工作,用户线程暂停)
# 3.2 优缺点
- 优点:
- 简单高效 (单线程下): 没有线程交互的开销,在单核 CPU 或小内存环境下,可以获得最高的单线程收集效率。
- 内存占用小: GC 自身需要的数据结构最少。
- 缺点:
- STW 时间长: 对于稍大内存的应用,单线程执行 GC 会导致较长的暂停时间,影响用户体验。
- 不适合多核: 无法利用多核 CPU 的优势。
# 3.3 适用场景
- 用户桌面应用 (Client 模式),内存通常不大 (几十到几百 MB),短暂停顿可以接受。
- 单核 CPU 环境。
- 对暂停时间要求不高的后台任务。
- 作为 CMS 的后备。
- Serverless 等内存占用和启动时间敏感的新场景。
# 3.4 参数配置
-XX:+UseSerialGC
: 显式指定新生代和老年代都使用串行收集器 (Serial + Serial Old)。
总结: Serial GC 因其简单和低开销,在特定场景下仍有价值,但对于主流的服务器端应用,其 STW 停顿通常是不可接受的。
# 4. ParNew 回收器 (并行回收)
# 4.1 概述
- 定位: Serial GC 的多线程并行版本,主要用于新生代。
- 工作方式:
- 采用复制 (Copying) 算法。
- 使用多条线程并行执行垃圾回收。
- 执行期间需要 STW。
- 与 Serial 的区别: 核心区别在于使用了多线程进行回收,其他机制(算法、STW)基本一致。
- 重要角色: 在 CMS 收集器出现后,ParNew 是唯一能与之配合工作的新生代并行收集器。因此,在 G1 出现之前,
ParNew + CMS
是追求低延迟应用常用的组合。
(多个 GC 线程并行工作,用户线程暂停)
# 4.2 性能比较
- 多核环境: ParNew 能充分利用多核优势,回收速度通常比 Serial 快,可以提升吞吐量。
- 单核环境: 由于存在线程切换和同步开销,ParNew 效率不一定比 Serial 高,甚至可能更差。
# 4.3 适用场景
- 配合 CMS 收集器使用 (在 JDK 9 之前是常见选择)。
- 运行在多核 CPU 服务器上,对新生代进行回收。
# 4.4 参数配置
-XX:+UseParNewGC
: 手动指定使用 ParNew 作为新生代收集器。注意: 这不会自动启用老年代的并行收集器,通常需要配合-XX:+UseConcMarkSweepGC
(启用 CMS) 使用。如果单独使用-XX:+UseParNewGC
而不指定老年代 GC,可能会默认搭配 Serial Old。-XX:ParallelGCThreads=<N>
: 限制 ParNew (以及 Parallel Scavenge/Parallel Old) 的并行 GC 线程数。默认情况下,线程数与 CPU 核心数相关(具体计算方式见后文 Parallel Scavenge)。
# 5. Parallel Scavenge 回收器 (吞吐量优先)
# 5.1 概述
- 定位: 与 ParNew 类似,也是一款新生代、并行、复制算法的收集器,也需要 STW。
- 核心目标: 与 ParNew/CMS 不同,Parallel Scavenge 的目标是达到一个可控制的吞吐量 (Throughput)。它被称为“吞吐量优先”的垃圾收集器。
- 自适应调节策略: 这是 Parallel Scavenge 与 ParNew 的一个重要区别。如果开启自适应调节 (
-XX:+UseAdaptiveSizePolicy
,默认开启),JVM 会根据当前系统的运行情况(如 CPU 负载、期望的吞吐量和暂停时间目标),动态调整新生代大小 (-Xmn
)、Eden 与 Survivor 区的比例 (-XX:SurvivorRatio
)、晋升老年代的对象年龄 (-XX:MaxTenuringThreshold
) 等参数,以尽可能达到用户设定的目标。 - 老年代搭档: Parallel Scavenge 通常与 Parallel Old 收集器配合使用 (
Parallel Scavenge + Parallel Old
),这也是 JDK 8 Server 模式下的默认 GC 组合。
# 5.2 适用场景
- 后台计算和数据处理: 对暂停时间不敏感,但希望 CPU 能高效利用,尽快完成运算任务的场景(如批处理、订单处理、科学计算)。
- 不需要太多用户交互的应用。
- 当 吞吐量 是首要性能指标时。
# 5.3 Parallel Old 收集器
- 定位: Parallel Scavenge 的老年代版本。
- 工作方式:
- 采用标记-压缩 (Mark-Compact) 算法。
- 使用多线程并行执行。
- 执行期间需要 STW。
- 出现原因: 在 Parallel Old 出现之前 (JDK 1.6),Parallel Scavenge 只能搭配 Serial Old。但 Serial Old 是单线程的,无法充分发挥多核服务器的性能,限制了 Parallel Scavenge 的吞吐量优势。"Parallel Scavenge + Parallel Old" 组合实现了新生代和老年代的全并行处理,是真正的“吞吐量优先”组合。
# 5.4 参数配置
-XX:+UseParallelGC
: 显式指定新生代使用 Parallel Scavenge。会自动激活-XX:+UseParallelOldGC
。-XX:+UseParallelOldGC
: 显式指定老年代使用 Parallel Old。会自动激活-XX:+UseParallelGC
。 (在 JDK 8 中两者默认都开启)。-XX:ParallelGCThreads=<N>
: 设置并行 GC 的线程数。- 默认值: CPU 核心数 <= 8 时,N = CPU 核心数;CPU 核心数 > 8 时,N = 3 + [5 * CPU 核心数 / 8]。
- 建议设置与 CPU 核心数相等。
-XX:MaxGCPauseMillis=<ms>
: 设置期望的最大 GC 暂停时间(单位毫秒)。这是一个“软目标”,JVM 会尽力达成,但不保证。JVM 为了达到这个目标可能会牺牲吞吐量,例如减小新生代的大小,导致更频繁的 Minor GC。需要谨慎设置。-XX:GCTimeRatio=<N>
: 设置吞吐量目标。值为 0 到 100 之间的整数。计算公式是GC时间 <= 总时间 / (N + 1)
。- 默认值是 99,意味着 GC 时间占比不超过 1 / (99 + 1) = 1%。即用户代码执行时间占 99%。
- 调大 N 会提高吞吐量目标(允许更少的 GC 时间),调小 N 则降低吞吐量目标(允许更多的 GC 时间)。
- 该参数与
-XX:MaxGCPauseMillis
存在冲突。追求低暂停时间可能需要增加 GC 频率,从而降低吞吐量。
-XX:+UseAdaptiveSizePolicy
: 开启自适应调节策略 (默认开启)。如果开启,JVM 会自动调整堆的各部分大小、晋升阈值等以尝试满足-XX:MaxGCPauseMillis
和-XX:GCTimeRatio
设定的目标。对于不熟悉手动调优的用户,这是一个方便的选择。如果需要手动精细控制,可以考虑关闭它 (-XX:-UseAdaptiveSizePolicy
)。
# 6. CMS 回收器 (Concurrent Mark Sweep - 低延迟)
# 6.1 概述
- 定位: HotSpot VM 中第一款真正意义上的并发 (Concurrent) 收集器,主要用于老年代。
- 核心目标: 尽可能缩短垃圾收集时用户线程的暂停时间 (STW),追求低延迟。
- 工作方式: 基于 标记-清除 (Mark-Sweep) 算法。其大部分工作(如并发标记、并发清除)可以与用户线程并发执行。
- 重要性: 在 G1 成熟之前,CMS 是对响应时间有较高要求的应用(如 Web 服务器)的主流选择。
# 6.2 工作流程 (四个主要阶段)
CMS 的工作过程比之前的收集器复杂,主要分为:
- 初始标记 (Initial Mark):
- 需要 STW。
- 任务:仅仅标记出 GC Roots 能直接关联到的对象。
- 速度非常快,因为只处理根节点直接引用的对象。
- 完成后恢复用户线程。
- 并发标记 (Concurrent Mark):
- 与用户线程并发执行。
- 任务:从初始标记找到的对象出发,遍历整个对象引用图,标记所有可达对象。
- 耗时较长,但由于是并发执行,不影响用户程序的运行(或影响较小)。
- 期间用户线程可能修改引用关系,CMS 会记录这些变化(通过写屏障和 "Dirty Card" 机制),为后续的重新标记做准备。
- 重新标记 (Remark):
- 需要 STW。
- 任务:修正在并发标记期间因用户线程运行而产生变动的对象的标记记录(处理 "Dirty Card")。
- 停顿时间通常比初始标记稍长,但远短于并发标记的时间。
- 并发清除 (Concurrent Sweep):
- 与用户线程并发执行。
- 任务:清除在标记阶段被判定为死亡的对象,回收它们占用的内存空间。
- 由于不移动存活对象,此阶段可以并发。
关键点: CMS 通过将最耗时的标记和清除阶段设计为并发执行,显著降低了 STW 的总时长,实现了低延迟。但 STW 并未完全消除(初始标记和重新标记仍需 STW)。
# 6.3 CMS 的问题与挑战
尽管 CMS 在降低延迟方面表现出色,但也存在一些固有缺点:
- 对 CPU 资源敏感: 并发阶段虽然不暂停用户线程,但 GC 线程仍然需要占用 CPU 资源。在 CPU 核心数不足的情况下,GC 线程可能会抢占用户线程的 CPU 时间,导致应用程序整体吞吐量下降。
- 无法处理“浮动垃圾” (Floating Garbage): 在并发清除阶段,用户线程仍然在运行并可能产生新的垃圾。这部分新产生的垃圾在本次 GC 中无法被处理(因为标记阶段已经结束),只能等到下一次 GC 时才能清理。这些未被及时清理的垃圾就是“浮动垃圾”。
- 需要预留空间: 由于并发清除阶段用户线程还在运行,CMS 必须预留一部分内存空间供用户线程在 GC 期间分配新对象。因此,CMS 不能等到老年代几乎完全满了才开始 GC,而是需要在一个较低的内存使用率阈值时就启动(通过
-XX:CMSInitiatingOccupancyFraction
控制)。如果预留空间不足以满足用户线程的需求,就会发生 "Concurrent Mode Failure"。 - 并发失败 (Concurrent Mode Failure): 当 CMS 预留的内存无法满足程序分配新对象的需求时,JVM 不得不暂停所有线程 (STW),并临时启用后备的 Serial Old 收集器来执行一次Full GC。这次 Full GC 会使用单线程、标记-压缩算法,停顿时间会非常长,严重影响性能。
- 内存碎片: CMS 基于标记-清除算法,回收后会产生大量不连续的内存碎片。这使得后续分配大对象时可能找不到足够的连续空间,即使总空闲空间还很多,也可能提前触发 Full GC(特别是 Concurrent Mode Failure 后的 Serial Old Full GC 会进行压缩)。
为何 CMS 不用标记-整理? 因为标记-整理算法需要移动存活对象,这会改变对象的内存地址。如果在并发阶段进行整理,用户线程正在访问的对象地址可能会突然失效,导致程序错误。移动对象通常需要在 STW 状态下进行,这与 CMS 追求并发、低延迟的目标相悖。
# 6.4 优点与缺点总结
优点:
- 并发收集: 大部分 GC 工作与用户线程并发。
- 低延迟: STW 时间相对较短(主要在初始标记和重新标记)。
缺点:
- CPU 敏感: 影响吞吐量。
- 浮动垃圾: 无法处理当次 GC 中新产生的垃圾。
- 内存碎片: 标记-清除算法导致。
- 并发失败风险: 可能触发长时间的 Full GC。
# 6.5 相关参数配置
-XX:+UseConcMarkSweepGC
: 手动启用 CMS 作为老年代收集器。会自动启用 ParNew 作为新生代收集器 (-XX:+UseParNewGC
)。形成ParNew + CMS + Serial Old (Fallback)
组合。-XX:CMSInitiatingOccupancyFraction=<percent>
: 设置老年代内存使用率达到多少百分比时触发 CMS 回收。JDK 5 及之前默认 68,JDK 6 及之后默认 92。需要根据应用内存增长速度调整,设置过高可能导致 Concurrent Mode Failure,设置过低则增加 GC 频率。-XX:+UseCMSCompactAtFullCollection
: (默认开启) 指定在执行 Full GC (特指由 CMS 触发或 Concurrent Mode Failure 后的 Serial Old GC) 时,是否对老年代进行碎片整理。开启可以解决碎片问题,但会增加 Full GC 的停顿时间。-XX:CMSFullGCsBeforeCompaction=<N>
: (默认 0) 设置执行 N 次不进行碎片整理的 Full GC 后,下一次进入 Full GC 时才进行碎片整理。用于在 Full GC 停顿时间和碎片积累之间做权衡。-XX:ParallelCMSThreads=<N>
: 设置 CMS 并发阶段的线程数。默认约为ParallelGCThreads
(年轻代并行线程数) 的 1/4。
# 6.6 CMS 的谢幕
由于上述缺点以及 G1 等更先进收集器的出现,CMS 在:
- JDK 9: 被标记为 Deprecated。
- JDK 14: 被彻底移除。
口诀总结 (Serial, Parallel, CMS):
- 最小内存和开销: 选
Serial GC
。 - 最大吞吐量: 选
Parallel GC
(Parallel Scavenge + Parallel Old
)。 - 最小暂停时间 (低延迟): 选
CMS GC
(JDK 14 前)。
# 7. G1 回收器 (Garbage-First - 区域化分代式)
# 7.1 G1 的诞生背景与目标
为何需要 G1? 随着业务越来越复杂,内存需求越来越大(GB 甚至 TB 级别),处理器核心数越来越多,传统的 GC(如 CMS 或 Parallel)面临挑战:
- CMS 的碎片和 Concurrent Mode Failure 问题在大堆下更突出。
- Parallel GC 虽然吞吐量高,但 STW 时间会随着堆增大而显著增加,难以满足低延迟需求。 需要一款能够适应大内存、多核 CPU,兼顾高吞吐量,同时提供可预测的、较短暂停时间的收集器。
G1 (Garbage-First) 应运而生,在 JDK 7u4 正式引入,并在 JDK 9 成为默认 GC。
G1 的核心目标: 在延迟可控的情况下,获得尽可能高的吞吐量,成为一款“全功能收集器”。
# 7.2 G1 的名称由来
G1 不再像传统 GC 那样基于固定的、连续的新生代和老年代进行回收。它将整个 Java 堆划分为多个大小相等的独立区域 (Region)。
G1 会跟踪每个 Region 中垃圾的价值(回收能释放多少空间以及预估的回收时间)。在每次进行垃圾回收时(特别是 Mixed GC),G1 会根据用户设定的最大暂停时间目标 (-XX:MaxGCPauseMillis
),优先选择那些回收价值最高(即垃圾最多、回收最划算)的 Region 进行回收。
这就是 "Garbage-First" (垃圾优先) 名称的由来。
# 7.3 G1 的主要特点与优势
- 并行与并发 (Parallel & Concurrent):
- 并行: 在 STW 阶段(如 Young GC 的 Evacuation、Remark 阶段的部分工作),G1 使用多个 GC 线程并行执行,充分利用多核 CPU。
- 并发: 在大部分标记阶段(如 Concurrent Marking),G1 的 GC 线程可以与用户线程并发执行。
- 分代收集 (Generational Collection):
- G1 仍然保留了逻辑上的分代概念(年轻代 Young Gen, 老年代 Old Gen)。
- 但它不是物理上连续划分的。年轻代的 Eden 区、Survivor 区和老年代都是由一组不要求连续的 Region 动态组成的。
- 这使得 G1 可以同时管理新生代和老年代,而不需要两种不同的 GC 算法来分别处理。
- 空间整合 (Region & Compaction):
- G1 将堆划分为 Region。回收的基本单位是 Region。
- 在 Region 内部或跨 Region 进行对象移动时,采用的是复制 (Copying) 算法。
- 从整体效果来看,每次回收都伴随着存活对象的移动和整理,因此 G1 可以看作是一款基于标记-压缩 (Mark-Compact) 思想的收集器(虽然具体实现是复制)。
- 有效避免了内存碎片,有利于程序长时间稳定运行。
- 可预测的停顿时间模型 (Soft Real-Time):
- 这是 G1 相对于 CMS 的核心优势。
- G1 允许用户设定一个期望的最大 GC 暂停时间 (
-XX:MaxGCPauseMillis
)。 - G1 会根据这个目标,智能地选择本次回收的 Region 集合 (Collection Set, CSet) 的大小,尽量在不超过目标时间的前提下,完成最有价值的回收。
- 这使得 G1 的停顿时间相对可控,特别是在大堆内存下,避免了传统 GC 可能出现的、与堆大小成正比的超长 STW。
- 虽然 G1 不保证 100% 满足目标(是 "soft" real-time),但在绝大多数情况下能很好地控制停顿。
# 7.4 G1 的缺点与挑战
- 内存占用 (Footprint): G1 为了维护 Region 之间的引用关系 (Remembered Sets) 以及支持并发标记等,需要额外的内存开销。通常比 CMS 或 Parallel GC 需要更多的内存。
- 额外执行负载 (Overhead): G1 的写屏障 (Write Barrier) 比 CMS 更复杂,用于维护 Remembered Sets,这会给用户程序带来更高的运行时负载。
- 小内存场景: 在堆内存较小 (例如 < 6-8 GB) 的情况下,G1 的额外开销可能使其性能不如 CMS 或 Parallel GC。G1 更适合大内存应用。
# 7.5 G1 的分区 Region 详解
- Region 大小: G1 将堆划分为约 2048 个(目标数量)大小相同的 Region。Region 大小通过
-XX:G1HeapRegionSize
设置,范围是 1MB 到 32MB,且必须是 2 的幂。如果未指定,JVM 会根据初始堆大小自动计算。Region 大小在 JVM 生命周期内不变。 - Region 角色: 每个 Region 在某个时刻只能扮演一种角色:Eden, Survivor, Old (老年代)。Region 的角色是动态变化的。
- Humongous Region (H): G1 引入了一种特殊的 Region 类型——Humongous。用于存储大对象 (大小超过 Region 容量 50% 的对象)。
- 目的: 避免大对象直接进入老年代(如果是短生命周期大对象会影响 Old 区 GC),也避免它们在年轻代反复复制。
- 分配: 如果一个对象太大,一个 Humongous Region 放不下,G1 会寻找连续的多个 Humongous Region 来存储它。
- 回收: Humongous Region 被视为老年代的一部分。其中的对象如果在 GC 中被确定为垃圾,整个 Region 会被直接回收。查找连续 Humongous Region 可能触发 Full GC。
- Region 内分配: 在每个 Region 内部,对象分配通常使用指针碰撞 (Bump The Pointer) 技术(因为 Region 内对象移动后是连续的)。
# 7.6 G1 的核心概念:记忆集 (Remembered Set, RSet)
问题: G1 以 Region 为单位回收,如何处理跨 Region 引用?例如,回收某个 Young Region 时,如何知道是否有 Old Region 中的对象引用了它里面的对象,而无需扫描整个老年代?
解决方案: 记忆集 (Remembered Set)。
- 每个 Region 都关联着一个记忆集 (RSet)。
- RSet 记录了其他 Region 中的对象指向当前 Region 中对象的引用信息 (通常记录的是引用来源对象的 Card)。
- 维护机制: 通过写屏障 (Write Barrier) 实现。当程序执行引用类型字段赋值操作 (
obj.field = ref
) 时,写屏障会被触发。它会检查ref
指向的对象与obj
是否在不同的 Region。如果在不同 Region,就会通过一个延迟队列 (Dirty Card Queue) 机制,最终将这个引用信息更新到ref
所在 Region 的 RSet 中。 - GC 时应用: 在进行 GC (特别是 Young GC 或 Mixed GC) 时,扫描某个 Region 的存活对象时,除了从 GC Roots 出发,还需要将该 Region 的 RSet 中记录的外部引用也作为扫描的入口点,以确保不会漏掉被其他 Region 引用的存活对象。
(Region 2 的 RSet 记录了来自 Region 1 和 Region 3 的引用)
为何使用 Dirty Card Queue? 直接在写屏障中更新 RSet 开销较大(可能需要同步)。使用队列可以将更新操作异步化、批量化处理,提高性能。在 GC 开始前的 RSet 更新阶段 (Update RSet),会处理队列中的卡片。
# 7.7 G1 的垃圾回收过程
G1 的回收过程主要涉及三种模式:
- 年轻代 GC (Young GC):
- 触发时机: 当 Eden 区满时触发。
- 性质: 并行、独占式 (STW)。
- 回收范围 (Collection Set, CSet): 所有年轻代 Region (Eden + Survivor)。
- 过程:
- 扫描根 (Scan Roots): 查找 GC Roots 直接引用的对象。
- 更新 RSet (Update RSet): 处理 Dirty Card Queue,确保 RSet 准确。
- 处理 RSet (Process RSet): 根据 RSet 识别被老年代引用的年轻代对象。
- 对象复制 (Object Copy):
- 将 Eden 和 From-Survivor 区的存活对象复制到 To-Survivor 区(如果 To 区空间足够且对象年龄未到阈值)。
- 或者复制到空的 Old Region(如果对象年龄达到阈值
-XX:MaxTenuringThreshold
,或者 To-Survivor 区空间不足)。 - 复制过程中完成内存整理。
- 处理引用 (Process References): 处理软、弱、虚、Final、JNI Weak 引用。
- 结果: 原 Eden 和 From-Survivor 区变为空闲,To-Survivor 区(现在成为新的 From-Survivor)包含存活对象。
并发标记周期 (Concurrent Marking Cycle):
- 触发时机: 当整个堆内存使用率达到阈值
-XX:InitiatingHeapOccupancyPercent
(IHOP,默认 45%) 时触发。目的是为后续的 Mixed GC 识别老年代中哪些 Region 的垃圾最多。 - 过程 (主要阶段):
- 初始标记 (Initial Mark):
- 需要 STW。
- 标记 GC Roots 直接可达的对象。
- 通常伴随一次 Young GC 一起进行( piggybacked)。
- 根区域扫描 (Root Region Scanning):
- 扫描在初始标记的 STW 期间被 Young GC 移到 Survivor 区的对象,找出它们对老年代的引用。
- 并发执行(在 Young GC 之后,下次 Young GC 开始前完成)。
- 并发标记 (Concurrent Marking):
- 与用户线程并发执行。
- 遍历整个堆的对象图,查找存活对象。
- 使用 SATB (Snapshot-At-The-Beginning) 算法记录并发期间引用关系的变化。
- 如果发现某个 Region 完全是垃圾,会提前回收 (Concurrent Cleanup)。
- 计算每个 Region 的存活度 (Liveness)。
- 最终标记 (Remark / Final Mark):
- 需要 STW。
- 处理 SATB 日志,最终确定所有存活对象。
- 停顿时间比 CMS 的 Remark 短(因为 SATB 的处理相对简单)。
- 独占清理 (Cleanup):
- 需要 STW。
- 统计各个 Region 的存活对象和垃圾比例。
- 识别出可以完全回收的空闲 Region (内部全是垃圾)。
- 对 RSet 进行清理 (Scrubbing)。
- 排序老年代 Region,找出回收价值最高的那些,为 Mixed GC 做准备。
- 注意: 这个阶段不执行对象移动和垃圾回收。
- 初始标记 (Initial Mark):
- 触发时机: 当整个堆内存使用率达到阈值
混合回收 (Mixed GC):
- 触发时机: 在并发标记周期完成之后触发。不是一次性的,通常会执行多次 Mixed GC。
- 性质: 并行、独占式 (STW)。
- 回收范围 (CSet): 所有年轻代 Region + 部分回收价值高的老年代 Region (由 Cleanup 阶段选出)。
- 选择策略: G1 根据用户设定的最大暂停时间目标 (
-XX:MaxGCPauseMillis
),选择一部分老年代 Region 加入 CSet,使得本次回收能在目标时间内完成。优先选择垃圾比例最高的 Region (-XX:G1MixedGCLiveThresholdPercent
控制可选 Region 的最低垃圾比例,默认 85% 存活度即 15% 垃圾,注意原文档 65% 可能是笔误或旧版本值)。 - 过程: 与 Young GC 的过程非常类似(扫描根、更新处理 RSet、对象复制、处理引用),只是 CSet 中包含了选定的老年代 Region。存活的老年代对象会被复制到新的空闲 Region(这些 Region 之后也成为老年代的一部分)。
- 执行次数: Mixed GC 会执行多次,直到回收了足够多的老年代内存(或者剩余有价值回收的老年代 Region 比例低于某个阈值
-XX:G1HeapWastePercent
,默认 10%),或者达到了配置的 Mixed GC 最大次数 (-XX:G1MixedGCCountTarget
,默认 8)。
- Full GC (必要时的后备):
- G1 的设计目标是避免 Full GC。但在某些极端情况下,如果 Mixed GC 或 Young GC 过程中发生空间分配失败(例如,复制对象时找不到足够的空闲 Region,或 Humongous 对象分配找不到连续空间),G1 会退化 (Fallback) 到一次完全的 STW Full GC。
- 早期的 G1 Full GC 是单线程的标记-压缩,性能很差。
- JDK 10 之后: G1 的 Full GC 改为多线程并行执行,性能得到显著改善。
- 应对: 如果频繁发生 Full GC,通常需要调整 G1 相关参数(如增大堆内存、调整 IHOP 阈值、增加预留内存比例
-XX:G1ReservePercent
等)或优化应用程序的内存分配行为。
# 7.8 G1 优化建议
- 年轻代大小: 避免使用
-Xmn
或-XX:NewRatio
固定年轻代大小。让 G1 根据暂停时间目标自动调整年轻代 Region 数量是最佳实践。 - 暂停时间目标:
-XX:MaxGCPauseMillis
不要设置得过于严苛(例如低于 100ms)。过低的目标可能导致 G1 为了满足时间而频繁进行小的 GC,牺牲吞吐量,甚至可能因为回收效率低而更容易触发 Full GC。通常建议设置为 100-500ms 之间,根据应用需求调整。 - IHOP 阈值:
-XX:InitiatingHeapOccupancyPercent
(默认 45) 是触发并发标记周期的关键参数。如果应用内存使用增长快,可能需要适当降低此阈值,让并发标记更早开始,避免老年代空间耗尽。如果内存增长慢且 Full GC 频繁(可能是并发失败),可以尝试适当提高此阈值。
# 8. 垃圾回收器总结与选择
# 8.1 主流垃圾回收器特点回顾
回收器 | 类型 | 算法 | 目标 | 优点 | 缺点 | 适用场景/备注 |
---|---|---|---|---|---|---|
Serial | 串行, STW | 复制 (Y)/压 (O) | 简单/低开销 | 单核高效, 内存占用小 | STW 长, 不适合多核 | Client 模式, 单核, 小内存, Serverless |
ParNew | 并行, STW | 复制 (Y) | 配合 CMS | 多核下比 Serial 快 | 单核下可能不如 Serial, STW | 主要配合 CMS (JDK9 前) |
Parallel Scav. | 并行, STW | 复制 (Y) | 高吞吐量 | 吞吐量高, 自适应调节 | STW 时间可能较长 | 后台计算, 批处理, JDK 8 Server 默认 (配合 Old) |
Serial Old | 串行, STW | 标记-压缩 (O) | 简单/低开销 | 同 Serial | 同 Serial | Client 模式, CMS/Parallel Scavenge 后备 (旧) |
Parallel Old | 并行, STW | 标记-压缩 (O) | 高吞吐量 | 吞吐量高 | STW 时间可能较长 | 配合 Parallel Scavenge, JDK 8 Server 默认 |
CMS | 并发, STW | 标记-清除 (O) | 低延迟 | STW 短 (并发标记/清除) | CPU 敏感, 碎片, 浮动垃圾, 并发失败风险 | 对响应时间敏感应用 (JDK 14 已移除) |
G1 | 并发+并行, STW | 复制/标记-压缩 | 平衡 | 可预测停顿, 空间整合, 兼顾吞吐量/延迟 | 内存占用高, 额外负载, 小堆性能一般 | 大内存服务器 (>=6-8GB), JDK 9+ 默认 |
ZGC | 并发, STW | 标记-压缩 | 极低延迟 | <10ms 停顿 (目标), 支持 TB 级堆 | 吞吐量有损耗 (目标 <15%), 较新 (实验性) | 超大内存, 极低延迟要求, JDK 11+ |
Shenandoah | 并发, STW | 标记-压缩 | 极低延迟 | 停顿与堆大小无关 (目标), 支持大堆 | 吞吐量损耗可能较大, OpenJDK 特有 (非 Oracle JDK) | 大内存, 极低延迟要求 (OpenJDK 12+) |
# 8.2 如何选择垃圾回收器?
选择 GC 是一个重要的 JVM 调优步骤。一般原则如下:
- 优先调整堆大小: 在选择 GC 前,首先确保
-Xms
和-Xmx
设置合理,很多性能问题仅仅是堆大小不合适导致的。让 JVM 在合理的堆大小下自适应通常是第一步。 - 根据应用需求选择:
- 内存限制: 如果内存非常小(例如 < 100MB),或者运行在严格限制资源的单核环境 (如某些嵌入式或 Serverless 场景),
Serial GC
可能是最佳选择。 - 吞吐量优先: 如果应用是后台计算密集型,对停顿时间不敏感,且运行在多核服务器上,
Parallel GC
(Parallel Scavenge + Parallel Old
) 是一个好选择(尤其在 JDK 8 环境)。 - 低延迟优先: 如果应用是交互式或对响应时间要求高(例如,要求停顿 < 1 秒),且运行在多核、内存较大的服务器上:
- JDK 9+: G1 GC 是默认且通常是最佳选择。它在延迟和吞吐量之间取得了很好的平衡。
- JDK 8: 如果 G1 表现不佳(例如内存较小或特定负载下),可以考虑
ParNew + CMS
(但需注意 CMS 的缺点和已被移除的趋势)。
- 超大内存与极低延迟: 如果堆内存非常大 (几十 GB 到 TB 级别),并且对暂停时间有极其严格的要求 (例如 < 10-50ms),可以考虑尝试 ZGC (JDK 11+) 或 Shenandoah (OpenJDK 12+)。但它们相对较新,需要充分测试,并可能牺牲一些吞吐量。
- 内存限制: 如果内存非常小(例如 < 100MB),或者运行在严格限制资源的单核环境 (如某些嵌入式或 Serverless 场景),
核心观点:
- 没有“最好”的收集器,只有“最合适”的。
- 调优总是针对特定场景和需求。
- 对于现代 Java 应用(尤其 JDK 9+),G1 是官方推荐的、适用范围广、性能均衡的默认选择。很多情况下不需要更换。
- 关注和理解常用 GC 的日志和参数是进行调优的基础。
# 9. GC 日志分析
通过分析 GC 日志,可以深入了解 JVM 的内存分配、回收行为,是诊断 GC 问题和进行性能调优的关键手段。
# 9.1 开启 GC 日志的常用参数
-verbose:gc
或-XX:+PrintGC
: 输出简要 GC 日志信息。-XX:+PrintGCDetails
: 输出详细 GC 日志信息(推荐使用)。-XX:+PrintGCTimestamps
: 在每条 GC 日志前添加相对于 JVM 启动时间的时间戳 (秒)。-XX:+PrintGCDatestamps
: 在每条 GC 日志前添加绝对日期时间戳。-XX:+PrintHeapAtGC
: 在每次 GC 前后打印堆的详细信息(各分代、分区的使用情况)。有助于分析内存变化。-Xloggc:<file-path>
: 将 GC 日志重定向到指定文件。例如-Xloggc:./logs/gc.log
。- JDK 9+ 统一日志框架: 使用
-Xlog:gc*:<file-path>:...
进行更精细的控制。例如-Xlog:gc*:file=gc.log:time,level,tags:filecount=5,filesize=10m
。
# 9.2 解读 GC 日志示例
示例 1: -verbose:gc
(简要日志)
示例 2: -XX:+PrintGCDetails
(详细日志)
关键信息解读:
- GC 类型:
GC
通常指 Young GC (Minor GC),Full GC
指整堆回收 (Major GC / Full GC)。Full GC 通常伴随较长的 STW。 - 收集器名称:
[DefNew
/[Serial
: 使用 Serial GC (新生代)。[ParNew
: 使用 ParNew GC (新生代)。[PSYoungGen
: 使用 Parallel Scavenge (新生代)。[Tenured
/[SerialOld
: 使用 Serial Old (老年代)。[ParOldGen
: 使用 Parallel Old (老年代)。[CMS
: CMS GC 相关日志。[G1 pause (young)
,[G1 pause (mixed)
,[G1 pause (full)
: G1 GC 的不同阶段。
- 内存变化:
X->Y(Z)
格式表示:回收前大小 -> 回收后大小 (总大小)。单位通常是 K (KB) 或 M (MB)。- 方括号
[...]
内通常表示特定区域(如新生代PSYoungGen
或老年代ParOldGen
)的变化。 - 方括号外表示整个堆内存的变化。
- 方括号
- 耗时:
... secs]
或[Times: user=X sys=Y, real=Z secs]
user
: 用户态 CPU 耗时。sys
: 内核态 CPU 耗时。real
: 实际经过时间 (Wall Clock Time),即 STW 时间。- 在多核并行 GC 时,
user + sys
可能大于real
时间。
- 触发原因:
(Allocation Failure)
是最常见的原因,表示分配新对象时空间不足,触发了 GC。其他可能的原因包括(System.gc())
,(CMS Initial Mark)
,(G1 Evacuation Pause)
等。
示例分析图解:
- Young GC 示例:
- Full GC 示例:
# 9.3 GC 回收过程示例代码与日志
// 文件名: GCLogTest1.java
/**
* 演示对象分配和 GC 日志
* VM 参数 (示例): -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:+UseSerialGC
* (堆总大小 20M,新生代 10M,老年代 10M,Eden:Survivor = 8:1:1)
*/
public class GCLogTest1 {
private static final int _1MB = 1024 * 1024;
public static void testAllocation() {
System.out.println("开始分配...");
byte[] allocation1, allocation2, allocation3, allocation4;
// 分配 3 个 2MB 对象到 Eden 区 (Eden 共 8MB)
allocation1 = new byte[2 * _1MB];
System.out.println("分配 allocation1 (2MB)");
allocation2 = new byte[2 * _1MB];
System.out.println("分配 allocation2 (2MB)");
allocation3 = new byte[2 * _1MB];
System.out.println("分配 allocation3 (2MB)"); // Eden 已用 6MB
// 尝试分配 4MB 对象,此时 Eden 剩余 2MB,空间不足,触发 Young GC
System.out.println("准备分配 allocation4 (4MB)...");
allocation4 = new byte[4 * _1MB];
System.out.println("分配 allocation4 (4MB) 完成");
}
public static void main(String[] agrs) {
testAllocation();
System.out.println("方法结束");
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
预期 GC 行为 (使用 Serial GC, -Xmn10M
, -XX:SurvivorRatio=8
):
- Eden = 8MB, Survivor = 1MB * 2, Old = 10MB.
allocation1
,allocation2
,allocation3
(共 6MB) 成功分配到 Eden。- 分配
allocation4
(4MB) 时,Eden 剩余 2MB 不足。触发 Young GC。 - Young GC 时,
allocation1, 2, 3
(共 6MB) 是存活的。 - 尝试将它们复制到 Survivor 区 (1MB),空间不足。
- 根据空间分配担保机制,这 6MB 对象会直接晋升到老年代 (Old Gen)。(假设老年代空间足够,为 10MB)。
- Young GC 后,Eden 区被清空。
allocation4
(4MB) 现在可以成功分配到 Eden 区。
JDK 8 运行日志示例 (与预期略有不同,可能受具体 VM 实现影响或参数未完全按预期生效,例如大对象直接晋升策略):
开始分配...
分配 allocation1 (2MB)
分配 allocation2 (2MB)
分配 allocation3 (2MB)
准备分配 allocation4 (4MB)...
[GC (Allocation Failure) [DefNew: 6322K->668K(9216K), 0.0034812 secs] 6322K->4764K(19456K), 0.0035169 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
分配 allocation4 (4MB) 完成
方法结束
Heap
def new generation total 9216K, used 7050K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
eden space 8192K, 77% used [0x00000000fec00000, 0x00000000ff23b668, 0x00000000ff400000)
from space 1024K, 65% used [0x00000000ff500000, 0x00000000ff5a71d8, 0x00000000ff600000)
to space 1024K, 0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
tenured generation total 10240K, used 4096K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
the space 10240K, 40% used [0x00000000ff600000, 0x00000000ffa00020, 0x00000000ffa00200, 0x0000000100000000)
Metaspace used 3469K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 381K, capacity 388K, committed 512K, reserved 1048576K
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
日志分析:
[DefNew: 6322K->668K(9216K)]
: Young GC 发生。新生代从 6322K 降到 668K (总容量 9216K)。这表明大部分(约 5.6MB)被回收或晋升了。6322K->4764K(19456K)
: 整个堆从 6322K 降到 4764K (总容量 19456K)。堆内存减少了约 1.5MB。tenured generation ... used 4096K
: 老年代最终使用了 4096K (4MB)。eden space ... 77% used
: Eden 区最终使用了 77% (约 6.3MB 中的一部分,可能 allocation4 最终分配在了这里?日志显示 Eden 约 7MB 已用,from 约 0.6MB 已用,这与简单预期有出入,可能涉及对象对齐、TLAB 或其他 VM 内部机制)。- 与预期对比: 日志显示老年代最终用了 4MB,似乎是
allocation4
直接晋升到了老年代(可能被视为大对象?),而allocation1, 2, 3
在 Young GC 后有部分存活在了 Survivor 区 (from space ... 65% used
)。这说明实际 GC 行为可能比理论模型更复杂。
(这些图是理论示意,与实际日志可能不完全吻合)
# 9.4 常用日志分析工具
手动分析 GC 日志比较繁琐,可以使用专门的工具:
- GCViewer: 开源工具,可绘制 GC 活动图表,计算关键性能指标。
- GCEasy: 在线 GC 日志分析服务 (gceasy.io),上传日志文件即可获得详细的分析报告和图表。
- 其他: GCHisto, GCLogViewer, Hpjmeter, garbagecat 等。
# 10. 垃圾回收器的新发展
GC 技术仍在持续演进,特别是针对低延迟和超大堆内存场景。
# 10.1 G1 的持续改进
G1 作为当前的默认 GC,仍在不断优化。例如:
- 并行 Full GC (JDK 10+): 解决了早期 G1 Full GC 性能差的问题。
- 并发 Card Table 扫描优化等。
# 10.2 Serial GC 的新应用
虽然古老,但 Serial GC 的简单和低开销使其在资源受限或对启动时间敏感的新兴场景(如 Serverless、微服务实例)中找到了用武之地。
# 10.3 CMS 的退场
由于算法理论缺陷和 G1 等更优选择的出现,CMS 已在 JDK 14 中被移除。
# 10.4 Epsilon GC (JDK 11+)
- "No-Op" (无操作) GC: 它不执行任何实际的垃圾回收。
- 用途:
- 性能测试: 用于衡量应用本身的性能基准,排除 GC 干扰。
- 内存压力测试: 测试应用在内存耗尽前的行为。
- 超短生命周期应用: 如果应用运行时间极短,可能在内存耗尽前就结束了,不需要 GC。
- 需要完全手动内存管理的应用 (通过 Unsafe 等)。
# 10.5 Shenandoah GC (OpenJDK 12+)
- 主要贡献者: Red Hat。
- 核心目标: 极低的暂停时间,且暂停时间与堆大小无关 (目标 99.9% < 10ms)。
- 技术特点: 基于 Region,使用转发指针 (Forwarding Pointers) 和 Brooks Pointers 技术实现并发的对象移动和整理。这意味着它可以在用户线程运行时并发地复制和整理对象,从而大大减少 STW 时间。
- 权衡: 为了实现极低延迟,可能需要牺牲一定的吞吐量 (运行时开销更大)。
- 兼容性: 主要在 OpenJDK 中提供,Oracle JDK 默认不包含。
(注意:此图显示了低延迟优势和吞吐量劣势)
# 10.6 ZGC (Z Garbage Collector) (JDK 11+, 生产可用 JDK 15+)
- 主要贡献者: Oracle。
- 核心目标: 与 Shenandoah 类似,追求极低的暂停时间 (< 10ms 目标),同时支持超大堆内存 (TB 级别),并尽可能减小对吞吐量的影响 (< 15% 目标)。
- 技术特点:
- 基于 Region 内存布局 (称为 ZPage)。
- 不设分代 (目前)。
- 使用染色指针 (Colored Pointers) 和内存多重映射 (Multi-Mapping) 技术实现并发的对象标记、移动和重定位。
- 染色指针: 将对象的引用状态(如是否标记、是否重定位)直接编码在指针本身(利用 64 位地址空间的高位),读写对象时无需额外访问对象头。
- 多重映射: 将同一块物理内存映射到三个不同的虚拟地址空间,通过切换访问的虚拟地址来高效地处理并发重定位过程中的对象访问。
- 几乎所有 GC 工作(包括对象移动)都是并发执行的,STW 时间极短(仅在初始标记、再标记等少数几个点)。
- 适用场景: 需要在大内存 (几十 GB 到 TB) 下实现极低延迟 (< 10ms) 的应用。
- 平台支持: JDK 14 开始支持 Windows 和 macOS。
ZGC 参数启用 (JDK 11-14 需解锁实验性参数):
# JDK 11-14
-XX:+UnlockExperimentalVMOptions -XX:+UseZGC
# JDK 15+
-XX:+UseZGC
2
3
4
# 10.7 AliGC (阿里巴巴)
- 基于 G1 改进,主要面向阿里巴巴内部的超大堆内存应用场景,进行了特定优化。
- 非 OpenJDK 标准 GC。
总结: GC 技术仍在高速发展,低延迟是当前最重要的趋势之一。G1 是成熟、均衡的选择,而 ZGC 和 Shenandoah 代表了追求极致低延迟的前沿方向。选择哪款 GC 取决于具体的应用需求、硬件环境和 JDK 版本。