Java 从入门到精通(十六):JVM 垃圾回收与线上调优——GC 收集器、参数与 OOM 排查

本文默认读者已掌握 Java 基础与多线程,JVM 以 HotSpot 为主,JDK 横跨 8 / 11 / 17 / 21,会明确标注版本差异——尤其是 GC 日志格式(JDK 9 前后完全两套)与默认收集器的变更。建议边读边跑第五节的日志实验与第七节的 CPU 飙高实验:调优从来不是背参数,而是”看到数据—提出假设—小步验证”的闭环。

一、GC 到底要解决什么问题

1.1 为什么不是引用计数

判断对象”还活着没有”,最朴素的想法是引用计数(Reference Counting):每个对象维护计数器,有人引用 +1,失效 -1,归零立刻回收。Python、PHP、Objective-C ARC 都用这套方案,实现简单、回收及时。但它有致命缺陷——无法处理循环引用,两个对象互相持有对方时计数永远不为 0;且每次赋值都要改计数器,多线程下还要保证原子性。HotSpot 因此放弃了它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* 演示循环引用:在引用计数方案下这两个对象永远不会被回收。
* HotSpot 使用可达性分析,因此 GC 后它们会被正常回收。
*/
public class ReferenceCountingProblem {
public Object instance; // 互相引用形成的环

public static void main(String[] args) {
ReferenceCountingProblem a = new ReferenceCountingProblem();
ReferenceCountingProblem b = new ReferenceCountingProblem();
a.instance = b;
b.instance = a;

// 断开外部引用,此时 a、b 已经不可达,但它们的引用计数仍为 1
a = null;
b = null;

// HotSpot 下这里能正常回收;若是纯引用计数实现,内存就此泄漏
System.gc();
}
}

1.2 可达性分析与六类 GC Roots

HotSpot 采用可达性分析(Reachability Analysis):从一批被称为 GC Roots 的根对象出发,沿引用链(Reference Chain)向下搜索,能被触及的对象存活,其余全部判定为可回收。GC Roots 固定包含六类:

类别 具体说明 典型例子
虚拟机栈中的局部变量 栈帧的局部变量表引用的对象,方法没结束就还活着 正在执行方法里的 User user = new User()
方法区中的静态属性 类的 static 字段引用的对象,类不被卸载就一直存活 private static Config cfg
方法区中的常量 运行时常量池里的引用,String.intern() 的结果 static final String S = "abc"
本地方法栈的 JNI 引用 Native 方法通过 JNI 持有的局部/全局引用 Netty 的堆外内存、JNI 调用的局部引用
同步锁持有者 被 synchronized 持有的 monitor 对象 synchronized(lock) 中的 lock
JVM 内部引用 虚拟机自身需要的对象,如系统类加载器、异常对象、JMX Bean Class 对象、常驻异常类

注意后两类常被忽略,而一个典型坑是静态集合:static Map 是 GC Roots 的直接下级,往里塞对象等于给它们续命,这是最常见的内存泄漏源头。

1.3 对象的”死缓”:finalize

判定不可达后对象并不会立刻回收,而是进入”缓刑”:真正死亡至少经过两次标记。第一次标记后筛选是否有必要执行 finalize()(没重写或已执行过则直接进回收队列);有必要执行的对象放入 F-Queue,由低优先级的 Finalizer 线程执行,这是对象唯一一次自我拯救机会——只要在 finalize() 里重新挂上引用链,第二次标记就会被移出回收队列。

1
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
/**
* finalize 的自我拯救:第一次标记后重新建立引用即可逃过回收。
* 注意:finalize 自 JDK 9 起已被 @Deprecated,JDK 18 起默认禁用,
* 任何资源清理都应该改用 try-with-resources 或 Cleaner。
*/
public class FinalizeEscapeGC {
public static FinalizeEscapeGC SAVE_HOOK = null;

@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize 被执行,对象自我拯救");
FinalizeEscapeGC.SAVE_HOOK = this; // 重新挂到静态引用上
}

public static void main(String[] args) throws InterruptedException {
SAVE_HOOK = new FinalizeEscapeGC();

SAVE_HOOK = null;
System.gc(); // 第一次:finalize 执行,对象被救回
Thread.sleep(500);
System.out.println(SAVE_HOOK == null ? "已回收" : "仍存活");

SAVE_HOOK = null;
System.gc(); // 第二次:finalize 不会再执行,彻底回收
Thread.sleep(500);
System.out.println(SAVE_HOOK == null ? "已回收" : "仍存活");
}
}

必须强调:finalize() 已被时代淘汰——执行时机不确定、不保证执行完、性能极差,还会意外延长对象生命周期。资源释放请用 try-with-resources,堆外内存清理请用 java.lang.ref.Cleaner。

1.4 方法区也能被回收

很多人以为方法区(JDK 8 之后的元空间 Metaspace)没有垃圾回收,这是误解。它回收两类东西:废弃常量(常量池中已无对象引用的字面量)与不再使用的类。判定一个类型”不再使用”需同时满足三个条件,缺一不可:

  1. 该类所有实例都已被回收,堆中不存在该类及其任何子类的实例;
  2. 加载该类的 ClassLoader 已被回收;
  3. 该类对应的 java.lang.Class 对象没有任何地方被引用,无法通过反射访问它的方法。

三条全满足才允许卸载。这解释了为何大量使用反射、动态代理、Groovy 脚本与自定义 ClassLoader 的服务会看到 Metaspace 持续上涨甚至 OOM——每生成一个代理类就是一个新类型,ClassLoader 不死,这些类型就永远无法卸载。

二、三种基础算法与分代假说

2.1 标记-清除:最简单的方案,但会碎

标记-清除(Mark-Sweep) 分两步:先标记存活对象,再统一清除未标记的对象,是最基础的算法。它有两个缺点:一是执行效率不稳定,标记与清除的开销随对象数量线性增长;二是内存碎片,清除后产生大量不连续空间,明明总空闲内存足够却无法为大对象找到连续区间,从而提前触发下一次 GC。

2.2 标记-复制:用空间换时间

标记-复制(Copying) 把内存划为两块,每次只用一块,GC 时把存活对象复制到另一块再整块清空。它无碎片,回收后内存天然连续,分配时用”指针碰撞”(bump-the-pointer)即可,极其高效;代价是可用内存只剩一半。

由于新生代对象”朝生夕死”,存活率通常低于 10%,商业 JVM 采用更聪明的划分:一块较大的 Eden 加两块较小的 Survivor,默认 8:1:1,每次只用 Eden 与其中一块 Survivor,回收时复制到另一块,只”浪费”10% 作缓冲区,这便是 Appel 式回收。当 Survivor 放不下本次存活对象时,要靠分配担保(Handle Promotion) 把多出来的对象直接送进老年代。

2.3 标记-整理:移动的成本

标记-整理(Mark-Compact) 标记之后不让存活对象原地不动,而是让它们向内存一端滑动,再清理边界以外的空间。它同样无碎片且不浪费空间,但移动对象必须更新所有指向它们的引用,且必须全程 STW,成本最高。”移动”与”不移动”各有利弊:不移动停顿短但分配慢、吞吐低;移动停顿长但分配快、吞吐高。所以吞吐量优先的 Parallel Scavenge 选整理,延迟优先的 CMS 选清除(只在碎片严重时整理一次)。

2.4 分代收集假说

一切分代设计都建立在两条经验假说的基石上:

  • 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕死的,98% 的对象活不过一次 GC。
  • 强分代假说(Strong Generational Hypothesis):熬过越多次 GC 的对象,越难以消亡。

由此 JVM 把堆划为新生代与老年代:新生代存活少、复制便宜,用复制算法且回收频繁;老年代存活多、复制不划算,用清除或整理且回收稀疏。跨越两代的引用叫跨代引用(Inter-generational Reference),它带来一个麻烦——只扫新生代不安全,全扫老年代又不划算,这正是记忆集与卡表要解决的问题。

分代并非唯一思路。G1 逻辑上仍分代,物理上却把堆切成等大 Region;ZGC 早期干脆全堆并发、不分代。这背后的思路叫分区收集:把堆切成小块,每次只回收收益最高的若干块(G1 的 CSet),把一次大停顿拆成多次小停顿。

2.5 算法横向对比

算法 核心过程 优点 缺点 适用区域
标记-清除 标记存活,原地清除 实现简单,不需要移动对象,停顿短 产生内存碎片,分配效率下降 CMS 老年代
标记-复制 复制到另一半区域 无碎片,分配用指针碰撞极快 浪费一半空间,存活率高时成本大 新生代(Eden + Survivor)
标记-整理 存活对象向一端滑动 无碎片,不浪费空间 移动引用更新成本大,必须 STW Parallel Old 老年代、G1 的 Evacuation
增量/分区收集 每次只回收部分区域 把大停顿拆成小停顿,可预测 维护 RSet 等结构有额外开销 G1、Shenandoah、ZGC

三、并发标记的难点:三色标记与屏障技术

3.1 三色标记法

要让用户线程与 GC 线程并发工作,必须有一种描述”扫描进度”的模型,这就是 三色标记(Tri-color Marking):

颜色 含义 状态解读
白色 White 尚未被标记 分析开始时全是白;结束时仍是白即代表不可达,将被回收
灰色 Gray 已被标记,但引用链未扫描完 还需要继续向下遍历,是”待办队列”
黑色 Black 已被标记,且所有引用都扫描过 确认存活,不会再被重新扫描

标记过程就是白色逐步变灰、再变黑的过程,最后剩下的白色即垃圾。

3.2 并发标记会出两类错

一旦用户线程与标记线程同时运行,对象图不断被修改,于是出现两种错误:一是浮动垃圾(Floating Garbage),对象扫描过后才被切断引用,本轮已标记为黑而”多活一轮”,可以容忍下一轮再收,但 CMS 与 G1 必须预留空间;二是对象消失(Object Disappearance),活对象被当成白色回收掉,会引发诡异空指针甚至崩溃,绝对不可接受。

Wilson 证明,”对象消失”必须同时满足两个必要条件:赋值器插入了从黑色对象到白色对象的新引用,且删除了全部从灰色对象到该白色对象的直接或间接引用。因为黑色不再被扫描、白色不会被遍历,这个被黑色引用着的白对象永远得不到标记机会。

1
2
3
4
5
6
7
// 复现对象消失的抽象模型(伪代码)
// 初始:root -> B(黑), B -> C(灰), C -> D(白)
// 步骤 1(满足条件一):黑色对象 B 新增指向白色对象 D 的引用
B.field = D;
// 步骤 2(满足条件二):灰色对象 C 删除指向 D 的引用
C.field = null;
// 结果:D 仍然是白色,却被 B(黑) 引用着 —— 若此刻回收,D 被错误清除

3.3 两种解法:增量更新与原始快照

既然必须同时满足两个条件才出错,只要破坏其中一个即可,两种流派由此诞生:

增量更新(Incremental Update):破坏条件一。黑色对象插入指向白色的引用时,用写屏障记录这条新引用,并发标记结束后以这些黑色对象为根重新扫描——黑色重新变回灰色,这就是”增量”的含义。CMS 采用此方案,代价是重新标记(Remark)必须 STW,并发期间新引用越多 Remark 越长。

原始快照 SATB(Snapshot-At-The-Beginning):破坏条件二。灰色对象要删除指向白色的引用时,用写屏障记下这条即将消失的旧引用,并发结束后以标记开始那一刻的快照为准重扫:无论后来删没删,都按”开始时是活的”处理。G1、Shenandoah 采用此方案。

维度 增量更新(CMS) 原始快照 SATB(G1)
破坏的条件 条件一:黑色插入新引用 条件二:灰色删除旧引用
屏障时机 写后屏障(post-write barrier) 写前屏障(pre-write barrier)
记录内容 新插入的引用 即将被删除的引用
语义 新引用必须被扫描 按开始时的快照,旧引用一律算存活
浮动垃圾 较少 相对更多
最终处理 Remark 阶段 STW 重新扫描 Final Marking 阶段处理 SATB 队列

3.4 记忆集、卡表与写屏障

解决办法是记忆集(Remembered Set):记录”从非收集区域指向收集区域的引用”的抽象结构。HotSpot 最常见的实现是卡表(Card Table)——把老年代按 512 字节切成卡页,用字节数组记录每张卡状态,卡内只要有一个跨代引用就标记 dirty,GC 只扫脏卡,”扫整个老年代”降为”扫少量脏卡”。

维护卡表靠写屏障(Write Barrier):在引用字段赋值动作周围插入一小段代码(与并发编程的内存屏障不同,本质是虚拟机层面的 AOP 切面)。

1
2
3
4
5
6
7
8
// 写屏障的伪代码示意(HotSpot 内部是 C++ 与汇编实现)
void oop_field_store(oop* field, oop new_value) {
// 写前屏障:SATB 记录即将被覆盖的旧引用
pre_write_barrier(*field);
*field = new_value; // 真正的赋值
// 写后屏障:把这张卡标记为 dirty,供跨代引用扫描
post_write_barrier(field, new_value);
}

写屏障不是免费的:每次引用赋值都多几条指令,HotSpot 会先判断卡是否已是 dirty(是就不重复写)来降低开销。G1 的成本更重——每个 Region 都有一份 RSet,写屏障还要做跨 Region 判断,这也是 G1 相比 CMS 吞吐略有损失的原因之一。

3.5 Safepoint 与安全区域

GC 需要”整个世界停止”(Stop The World,STW)。线程不能在任意位置停下,必须停在 JVM 能准确知道哪些寄存器与栈槽存着引用的位置,这就是安全点(Safepoint),HotSpot 只在方法调用、循环跳转、异常跳转等少数位置放置。STW 的实现是 GC 置标志位、线程跑到安全点时主动轮询并自我挂起,故 STW 耗时 = 最后一个线程走到安全点的时间 + GC 时间。

一种经典故障是可数循环导致的 safepoint 延迟:JIT 可能把 for (int i = 0; i < 1000000; i++) 这类整型计数循环的轮询优化掉,线程迟迟无法暂停,TTSP 高达数秒,JDK 10 起默认开启 -XX:+UseCountedLoopSafepoints 缓解。而 sleep() 或 Blocked 的线程无法响应轮询,于是引入安全区域(Safe Region):区域内引用关系不变,线程离开前必须检查 GC 是否已结束。

四、垃圾收集器全家族

4.1 新生代三杰:Serial / ParNew / Parallel Scavenge

Serial 是最古老的收集器:单线程执行复制算法,全程 STW,没有线程交互开销,在单核或几百 MB 堆的场景仍是合理选择。ParNew 是它的多线程并行版本,历史意义在于它是唯一能与 CMS 配合的新生代收集器。

Parallel Scavenge 的目标与 CMS/G1 完全不同:追求可控的吞吐量(Throughput),即用户代码时间 /(用户代码时间 + GC 时间)。它提供 -XX:MaxGCPauseMillis 与 -XX:GCTimeRatio,并支持 -XX:+UseAdaptiveSizePolicy 自适应调节——开启后 JVM 自动调整新生代大小、Eden/Survivor 比例与晋升年龄,把调优负担交给虚拟机。它是 JDK 8 服务端的默认新生代收集器。

4.2 老年代:Serial Old / Parallel Old / CMS

Serial Old 是 Serial 的老年代版本,单线程标记-整理,主要作为 CMS 失败时的兜底方案。

Parallel Old 是 Parallel Scavenge 的老年代版本,多线程标记-整理,与 Parallel Scavenge 搭档构成”吞吐量优先”组合。

CMS(Concurrent Mark Sweep) 是第一款真正意义上追求低停顿的收集器,整个过程分四步:

阶段 是否 STW 工作内容
初始标记 Initial Mark 是,极短 只标记 GC Roots 能直接关联到的对象
并发标记 Concurrent Mark 否 沿引用链遍历,与用户线程并发;用增量更新处理漏标
重新标记 Remark 是,较长 修正并发期间变动的部分,处理增量更新的记录
并发清除 Concurrent Sweep 否 清理死亡对象,与用户线程并发

CMS 有三个著名缺陷:

  • 对 CPU 敏感:并发阶段占用线程(默认 (CPU + 3) / 4),CPU 少时严重挤压业务吞吐;
  • 浮动垃圾与 Concurrent Mode Failure:并发期间用户线程仍在分配,必须预留空间,一旦不足就触发”并发失败”,JVM 只能冻结所有线程改用 Serial Old 做一次全堆整理,停顿反而暴增;
  • 内存碎片:基于标记-清除,碎片累积后只能靠 -XX:+UseCMSCompactAtFullCollection(JDK 9 起废弃)整理。

CMS 在 JDK 9 被标记废弃,JDK 14 正式移除。

4.3 G1:Region 化与可预测停顿模型

G1(Garbage First) 是收集器技术史上的里程碑,也是 JDK 9 之后的默认收集器。它彻底改变了堆的组织方式:

  • Region 划分:整堆切成约 2048 个等大 Region(1MB~32MB,由 -XX:G1HeapRegionSize 指定或自动计算),每个 Region 可动态扮演 Eden / Survivor / Old,是单次回收的最小单位。
  • Humongous 对象:超过 Region 一半大小的对象直接进专门的 Humongous Region,避免反复复制,按老年代对待。
  • CSet(Collection Set):本次要回收的 Region 集合。G1 优先收垃圾最多的 Region,这就是 “Garbage First” 的由来,也是可预测停顿模型的核心——用 CSet 大小控制停顿。
  • RSet(Remembered Set):每个 Region 维护一份记忆集,记录”谁引用了我”,避免全堆扫描。
  • SATB:并发标记用原始快照解决漏标。

G1 有三种回收模式:Young GC(Eden 满时触发,存活对象 Evacuate 到 Survivor 或 Old,STW 但多线程并行);Mixed GC(并发标记完成后同时回收新生代与若干垃圾占比高的老年代 Region,是主力动作);Full GC(Mixed GC 跟不上分配速度时退化为单线程串行整理,必须避免)。

-XX:MaxGCPauseMillis(默认 200ms)的语义必须准确理解:它是 G1 的软目标,G1 据此动态调整新生代与 CSet 中老年代 Region 的数量;设得太小(如 10ms)会让每次只回收极少量 Region,垃圾堆积最终触发 Full GC。

4.4 ZGC:染色指针与读屏障

ZGC 的目标极为激进:在 TB 级堆上把停顿压到亚毫秒级,且不随堆大小增长。它靠两项核心技术做到:

染色指针(Colored Pointer):借用 64 位指针中未使用的高位(x86-64 实际只用 46 位左右)作元数据标记位,表示对象的 Marked0 / Marked1 / Remapped / Finalizable 状态。存活信息直接写在指针里,GC 看指针即知对象状态,无需访问对象头,也不必维护 RSet;代价是地址空间受限(最大堆 16TB,不支持 32 位与压缩指针)。

读屏障(Load Barrier):对象被并发搬移后指向它的指针还没更新,ZGC 不急着遍历修正,而是在应用线程读取引用时用读屏障检查指针颜色,发现已搬移就”自愈(self-healing)”地修正到新地址——这正是 ZGC 没有传统 Remark 阶段的原因。

ZGC 的主要阶段(并发标记 → 并发预备重分配 → 并发重分配 → 并发重映射)全部并发,只有极短的根扫描需要 STW。JDK 21 引入分代 ZGC(JEP 439),JDK 23 起分代模式成为默认(JEP 474)。Shenandoah 是思路相近的另一款低停顿收集器,由 Red Hat 主导,用 Region + Brooks 转发指针 + 读屏障实现并发整理,JDK 12 起进入 OpenJDK。

4.5 收集器能力总览与选型

收集器 分代 算法 并行度 停顿目标 适用堆 备注
Serial 是 复制 + 整理 单线程 不追求 < 100MB 客户端、小容器
ParNew 新生代 复制 并行 — 中小 仅配合 CMS
Parallel Scavenge + Old 是 复制 + 整理 并行 吞吐量优先 任意 JDK 8 默认
CMS 是 清除(标记-整理兜底) 并发 低停顿 4~16GB JDK 14 移除
G1 是(逻辑分代) 复制 + 整理 并发 可预测,默认 200ms 4~64GB JDK 9+ 默认
ZGC 可选分代 复制(并发整理) 并发 亚毫秒 8GB~16TB JDK 21 分代
Shenandoah 可选分代 复制(并发整理) 并发 亚毫秒~数十毫秒 任意 Red Hat 系

默认收集器的演进:JDK 8 用 Parallel Scavenge + Parallel Old;JDK 9 起改为 G1(JEP 248);JDK 11 引入实验性 ZGC,JDK 15 转正;JDK 21 默认仍是 G1 但提供分代 ZGC;JDK 23 起 ZGC 默认分代模式。

选型建议:堆 < 4GB 且追求吞吐,Parallel 组合够用;4~32GB 的通用在线服务,G1 是最稳的选择,通常只需设定 -Xms/-Xmx 与 -XX:MaxGCPauseMillis;8GB 以上且对延迟极敏感(网关、撮合、实时风控),直接上 ZGC;超大堆 + 大对象缓存,优先分代 ZGC 或 Shenandoah。

迁移建议:从 JDK 8 升级时不要急着换收集器,先迁到 JDK 11/17 的 G1 跑一轮压测拿到基线,再用同一套脚本对比 ZGC。切到 ZGC 后重点观察分配速率与 CPU 占用——并发 GC 线程会吃掉额外核数,2~4 核的紧张容器反而不适合它。

五、GC 日志与参数实战

5.1 常用参数速查

参数 作用 使用提示
-XX:NewRatio 老年代与新生代的比例,默认 2(新生代占 1/3) 只在未显式设置 -Xmn 时生效
-XX:SurvivorRatio Eden 与单个 Survivor 的比例,默认 8 设为 8 即 Eden:S0:S1 = 8:1:1
-XX:MaxTenuringThreshold 晋升老年代的年龄阈值,默认 15 G1 下会自动调整,不建议手动调大
-XX:PretenureSizeThreshold 超过该大小的对象直接进老年代 仅 Serial / ParNew 生效,单位为字节
-XX:+UseAdaptiveSizePolicy 自适应调节新生代与 Survivor 大小 Parallel 默认开启;用 CMS 时建议关闭
-XX:ParallelGCThreads STW 阶段的并行 GC 线程数 默认约等于 CPU 核数,核多时可限制
-XX:ConcGCThreads 并发标记阶段的线程数 默认 ParallelGCThreads / 4,调大会抢业务 CPU
-XX:InitiatingHeapOccupancyPercent G1 触发并发标记的整堆占用比例,默认 45 调小可提前启动标记,避免 Full GC

5.2 两套日志格式

JDK 9 之前使用 -XX:+PrintGCDetails 系列零散开关;JDK 9 起统一为 -Xlog(JEP 158 Unified JVM Logging),语法是 -Xlog:[标签][:输出方式][:修饰符]。

1
2
3
4
5
6
7
8
9
10
11
12
13
# JDK 8 经典写法:打印详细 GC 并输出到文件,带时间戳
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintTenuringDistribution \
-Xloggc:/data/logs/gc.log -XX:+UseGCLogFileRotation \
-XX:NumberOfGCLogFiles=5 -XX:GCLogFileSize=20M

# JDK 9+ 统一日志:gc 相关所有标签、输出到文件、带时间与标签
-Xlog:gc*:file=/data/logs/gc.log:time,uptime,level,tags:filecount=5,filesize=20m

# JDK 11 常用模板:额外记录安全点停顿,便于分析 TTSP
-Xlog:gc*,gc+heap=debug,safepoint:file=/data/logs/gc.log:time,uptime,level,tags:filecount=10,filesize=50m

# 启动时打印堆摘要,快速确认参数是否生效
-Xlog:gc+heap=info

5.3 读懂一条 Young GC 日志

下面是 JDK 11 + G1 的一条典型日志(为便于说明做了折行):

1
2
3
4
5
[2026-10-24T09:12:33.481+0800][12345.678s][info][gc] GC(1024) Pause Young (G1 Evacuation Pause) 2046M->812M(4096M) 32.451ms
[2026-10-24T09:12:33.481+0800][info][gc,task] GC(1024) Using 8 workers of 8 for evacuation
[2026-10-24T09:12:33.490+0800][info][gc,heap] GC(1024) Eden regions: 512->0(508)
[2026-10-24T09:12:33.490+0800][info][gc,heap] GC(1024) Survivors: 24->28
[2026-10-24T09:12:33.490+0800][info][gc,heap] GC(1024) Old regions: 1180->1184

字段拆解:12345.678s 是 JVM 运行时长,用它算 GC 频率最直接;GC(1024) 表示第 1024 次 GC,编号差除以时间差即分配频率;2046M->812M(4096M) 是回收前 → 回收后的堆占用(括号内为总堆);32.451ms 是本次 STW 停顿;Eden regions: 512->0(508) 表示 Eden 清空并为下轮备好 508 个;Survivors: 24->28 说明有对象在晋升途中堆积;Old regions: 1180->1184 即本次实际晋升量。

真正要盯的是派生指标:分配速率 =(GC 前 Eden 占用)/(两次 GC 间隔);晋升速率 =(Old 增量)/(两次 GC 间隔);以及 GC 频率与平均/最大停顿。

5.4 用 GCEasy / GCViewer 分析

单条日志看局部,整份日志看趋势。把 gc.log 上传 GCEasy(或用 GCViewer 本地打开),重点看四组指标:

指标 健康参考 异常时的含义与动作
吞吐量 Throughput > 95%,越高越好 低于 90% 说明 GC 吃掉大量 CPU,先查是否频繁 Full GC
最大停顿 Max Pause 小于 SLA(如 200ms) 尖刺来自 Full GC、并发失败或 Remark 过长
分配速率 Allocation Rate 稳定、无持续爬升 持续爬升通常伴随泄漏或流量放大
晋升速率 Promotion Rate 远小于分配速率 接近分配速率说明大量对象被过早晋升,需扩大新生代

5.5 案例:”每 10 分钟一次 Full GC”

接到告警后按下面的顺序推理,每一步用数据排除一种可能:

  1. 看老年代曲线。每次 Full GC 后老年代回落很深(如 8GB → 500MB),说明对象并非真常驻,属于”过早晋升”或”一次性大对象”,是参数问题;若回落后迅速涨满且底部阶梯不断抬高,高度怀疑内存泄漏。
  2. 看 Metaspace。JDK 8 后元空间在本地内存且默认无上限,动态代理/反射/热部署频繁时其扩容触发的 Full GC 很常见,日志会出现 Metadata GC Threshold,应设 -XX:MaxMetaspaceSize 并排查 ClassLoader 泄漏。
  3. 看 Humongous 分配。日志里 humongous 频繁出现说明有大量超过 Region 一半的对象,应调大 -XX:G1HeapRegionSize 或治理大对象(大 byte[]、大 List、一次性 ResultSet)。
  4. 看 System.gc()。RMI、NIO 直接内存与部分框架会显式调用它,加上 -XX:+DisableExplicitGC 或 -XX:+ExplicitGCInvokesConcurrent。
  5. 最后才怀疑参数:-XX:InitiatingHeapOccupancyPercent 过高会让并发标记来不及完成,Mixed GC 跟不上分配,最终退化为 Full GC。
1
2
3
4
5
6
7
8
# 快速抓取 10 分钟内的 GC 统计,确认是 Young 还是 Full 在增长
jstat -gcutil <pid> 1000 600 > /tmp/gcutil.txt

# 观察 Humongous 与 metaspace 相关日志
grep -E "Metadata|humongous|Full GC|To-space" /data/logs/gc.log | tail -50

# 确认是否有显式 System.gc()
grep -c "System.gc()" /data/logs/gc.log

六、调优方法论

6.1 不可能三角

调优本质上是在三个目标之间做取舍,它们构成一组不可能三角:

  • 吞吐量 Throughput —— 单位时间内处理的有效工作量;
  • 停顿时间 Latency —— 单次 GC 造成的 STW;
  • 内存占用 Footprint —— 堆大小与额外元数据开销。

三者最多同时优化两个:堆大则吞吐高但单次停顿可能更长;堆小则停顿短但 GC 频繁、吞吐下降;追求低停顿要引入并发 GC,就得牺牲部分吞吐(额外 GC 线程)与内存(RSet、染色指针开销)。调优的第一步永远是明确当前业务牺牲哪一项。

6.2 三步法

  1. 明确指标。不要说”有点卡”,要说”P99 < 200ms,每分钟 GC 总停顿 < 1s,吞吐 > 98%”,指标必须可度量。
  2. 基准压测。用生产级流量回放,固定并发与数据集,跑出基线(GC 日志 + 监控 + APM),每次只改一个变量。
  3. 小步验证。改完重新压测对比,无效就回滚。切忌一次改五个参数——即使有效,你也不知道是哪个起了作用。

6.3 不同业务形态的策略

业务形态 首要目标 推荐配置
在线接口服务(HTTP/RPC) 延迟 P99 G1 + -XX:MaxGCPauseMillis=100;延迟极敏感改 ZGC
离线批处理 / 数据同步 吞吐量 Parallel Scavenge + Old,堆尽量大,允许长停顿
本地缓存 / 大对象常驻 内存与停顿平衡 G1 调大 Region,或分代 ZGC;治理 Humongous
小内存容器(< 2GB) 内存占用 Serial 或 Parallel,避免并发 GC 抢核
网关 / 实时风控 极低延迟 分代 ZGC,预留 1~2 核给 GC 线程

6.4 容量规划

调堆大小不该拍脑袋,而要从分配速率反推:假设分配速率 500MB/s,希望 Young GC 间隔约 2 秒,则 Eden 约需 1GB,按 8:1:1 推算新生代约 1.25GB。若晋升速率 10MB/s,希望 Full GC 间隔不低于 1 小时,老年代至少预留 36GB 再乘 1.5 倍冗余——这往往说明”老年代其实不需要那么大”,真正该管的是降低晋升速率:减少大对象、缩短长生命周期集合、治理 ThreadLocal 泄漏。

6.5 常见误区

几个必须避开的坑:① 盲目调大堆——堆越大单次 Full GC 越长,可能把 500ms 变成 5 秒;② 随便设 -Xmn——固定新生代会关闭 G1 的自适应调节,让 MaxGCPauseMillis 失效;③ 无脑调大 MaxTenuringThreshold——对象在 Survivor 里反复复制徒增成本,G1 本就会自适应;④ 不看日志就调参——九成 GC 问题根源在代码(泄漏、大对象、不合理缓存),参数只能缓解;⑤ -Xms 与 -Xmx 不等——堆伸缩本身会触发 GC,生产环境一律设成相等。

七、生产问题排查工具箱

7.1 命令行工具速查

命令 主要用途 典型用法
jps 列出本机 Java 进程 PID jps -lv
jstat 实时查看 GC 与类加载统计 jstat -gcutil <pid> 1000
jinfo 查看/动态修改虚拟机参数 jinfo -flag MaxHeapSize <pid>
jmap 堆直方图与堆转储 jmap -histo <pid> / jmap -dump:format=b,file=heap.hprof <pid>
jstack 抓取线程栈,排查死锁与 CPU 飙高 jstack -l <pid> > /tmp/stack.txt
jcmd 官方推荐的”全能工具”,替代以上多数命令 jcmd <pid> VM.flags / jcmd <pid> Thread.print

7.2 jstat:先看趋势再动手

1
2
3
4
5
6
# 每 1 秒输出一次,共 10 次;重点关注 O 列是否持续上涨
jstat -gcutil 12345 1000 10

# 输出示例:
# S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
# 0.00 45.23 78.11 62.45 94.12 91.03 1024 12.345 3 5.678 18.023
  • S0/S1 —— 两块 Survivor 使用率,长期同时为 0 或长期接近 100% 都异常;
  • E —— Eden 使用率,其上涨速度即分配速率;
  • O —— 老年代使用率,只看这一个就能判断是否需要立即介入;
  • M —— 元空间使用率,涨到 100% 会触发 Full GC;
  • YGC/YGCT —— Young GC 次数与总耗时,YGCT/YGC 即平均单次耗时;
  • FGC/FGCT —— Full GC 次数与总耗时,FGC 持续增长是红色信号;
  • GCT —— GC 总耗时,GCT / 运行时长 反推吞吐量。

7.3 jmap 与 jstack

1
2
3
4
5
6
7
8
9
10
11
# 存活对象的直方图,按占用排序,前 20 行通常就能看到"元凶"
jmap -histo:live 12345 | head -20

# 导出堆转储(会触发一次 Full GC 并 STW,务必先摘流量!)
jmap -dump:live,format=b,file=/tmp/heap.hprof 12345

# 更推荐用 jcmd,语义更清晰,同样注意 STW 风险
jcmd 12345 GC.heap_dump /tmp/heap.hprof

# 抓线程栈,连续抓 3 次便于对比"卡在哪一行"
jstack -l 12345 > /tmp/stack1.txt; sleep 3; jstack -l 12345 > /tmp/stack2.txt

7.4 CPU 100% 的完整排查链路

现象:某实例 CPU 持续 100%,接口超时。排查链路如下:

1
2
3
4
5
6
7
8
# 1)找到 CPU 最高的进程
top -c
# 2)找到该进程内 CPU 最高的线程(记住线程号,如 12399)
top -Hp 12345
# 3)把线程号转成 16 进制,因为 jstack 里的 nid 是 16 进制
printf '%x\n' 12399 # 输出 306f
# 4)在线程栈中定位该线程
jstack -l 12345 | grep -A 20 "nid=0x306f"
1
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
/**
* 导致 CPU 100% 的典型代码:看似无害的死循环。
* 常见于:状态判断写错、while 里没有 sleep、正则回溯、HashMap 并发成环(JDK 7 前)。
*/
public class CpuBurnDemo {
private volatile boolean running = true;

public void burn() {
int i = 0;
while (running) {
// 空转,没有让出 CPU;线程一直处于 RUNNABLE
i++;
}
}

public void fixed() {
while (running) {
doWork();
// 修复:让出 CPU,或使用阻塞队列 take() 等待任务
LockSupport.parkNanos(1_000_000L); // 休眠 1ms
}
}

private void doWork() { /* 业务处理 */ }

public void stop() { running = false; }
}

在 jstack 输出中该线程会处于 RUNNABLE,栈顶就是 CpuBurnDemo.burn(CpuBurnDemo.java:12)。连续三次抓栈都停在同一行,基本可断定是死循环或热点方法;栈顶若是 java.util.regex.Pattern 则是正则灾难性回溯;若大量线程停在 BLOCKED ... waiting to lock 则是锁竞争。

7.5 内存泄漏定位:从代码到 MAT

泄漏最常见于两类代码:静态容器与未清理的 ThreadLocal。

1
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
32
33
34
35
36
/**
* 典型泄漏一:静态 Map 作为缓存但没有淘汰策略,key 永不失效。
* 因为 static 引用属于 GC Roots,放入的对象永远无法被回收。
*/
public class StaticCacheLeak {
private static final Map<String, byte[]> CACHE = new HashMap<>();

public void put(String userId, byte[] payload) {
CACHE.put(userId, payload); // 只进不出,堆会持续上涨
}

// 修复方案:改用 Caffeine / Guava Cache,设置最大容量与过期时间
private static final Cache<String, byte[]> SAFE_CACHE = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(10))
.build();
}

/**
* 典型泄漏二:线程池 + ThreadLocal。
* 线程复用导致 ThreadLocalMap 一直挂在线程上,value 不 remove 就永远可达。
*/
class ThreadLocalLeak {
private static final ThreadLocal<byte[]> BUFFER = new ThreadLocal<>();

public void handle() {
BUFFER.set(new byte[1024 * 1024]); // 1MB
try {
doSomething();
} finally {
BUFFER.remove(); // 必须 remove,否则线程复用时内存持续增长
}
}

private void doSomething() { /* ... */ }
}

堆转储拿到之后用 MAT(Memory Analyzer Tool) 打开,按三个视图依次分析:

视图 作用 使用要点
Leak Suspects 自动生成泄漏嫌疑报告 看最大对象的引用链,”谁在持有它”一目了然
Dominator Tree 按”支配”关系展示对象占用 快速定位占堆最大的那一棵对象树
Histogram 按类统计实例数与浅堆 排序后找异常类,右键 “Merge Shortest Paths to GC Roots” 查引用来源

7.6 Arthas:线上不重启的瑞士军刀

很多线上环境不允许随便 dump(STW 风险),此时用阿里开源的 Arthas,靠 attach 机制工作,无需重启:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 启动并 attach 到目标进程
java -jar arthas-boot.jar

# 实时面板:线程、内存、GC、运行时信息一屏看完
dashboard -i 2000 -n 5

# 找出 CPU 占用最高的 5 个线程,并打印栈(等价于 top -Hp + jstack)
thread -n 5

# 导出堆转储(等价于 jmap -dump)
heapdump --live /tmp/heap.hprof

# 查看某个静态字段的当前值,确认缓存是否失控
ognl '@com.example.StaticCacheLeak@CACHE.size()'

# 观察方法出入参与返回值,定位是哪些参数导致大对象
watch com.example.OrderService query '{params, returnObj}' -x 3 -n 5

# 追踪方法内部调用耗时,定位慢在哪一层
trace com.example.OrderService query '#cost > 50' -n 5

7.7 线上应急原则

排查讲方法,应急讲顺序:先止血,再定位。用户正在报错时不要执着于抓现场——先扩容、重启(记得先 jmap -dump 保留现场)、降级或限流,把故障时长压到最短,事后再做根因分析。

另两条经验:其一,监控要前置,用 Prometheus + JMX Exporter 采集 jvm_gc_pause_seconds、jvm_memory_used_bytes;其二,OOM 要自动化,加上 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dump/ 与 -XX:+ExitOnOutOfMemoryError,让 JVM 崩溃瞬间自动留下证据。

八、高频面试题

  1. 主流 JVM 为什么不用引用计数? 无法解决循环引用,且每次赋值都要维护计数器,多线程下还要保证原子操作。
  2. GC Roots 有哪些? 虚拟机栈局部变量、方法区静态属性与常量、本地方法栈 JNI 引用、同步锁持有者、JVM 内部引用(Class 对象、常驻异常等)。
  3. 什么是三色标记?并发标记会出哪两类错? 白(未标记)、灰(已标记未扫完)、黑(扫完)。错一是浮动垃圾(可容忍),错二是对象消失(活对象被误回收,不可接受)。
  4. 对象消失的两个必要条件与解法? 黑色插入指向白色的新引用,且灰色删除到该白色的全部引用。CMS 用增量更新破坏条件一,G1 用 SATB 破坏条件二,都靠写屏障记录。
  5. 卡表与记忆集是什么关系? 记忆集是”记录跨区域引用”的抽象结构,卡表是其字节数组实现:按 512 字节切卡页,dirty 卡表示有跨代引用,GC 只扫脏卡。
  6. CMS 的四个阶段与三个缺点? 初始标记(STW)、并发标记、重新标记(STW)、并发清除。缺点:对 CPU 敏感、可能触发 Concurrent Mode Failure 退化成 Serial Old、清除算法产生碎片。
  7. G1 为何叫 Garbage First?Region 与 CSet 是什么? 堆切成等大 Region,可动态扮演 Eden/Survivor/Old;G1 优先把垃圾最多的 Region 放进回收集合 CSet,用 CSet 大小控制停顿。
  8. -XX:MaxGCPauseMillis 是硬性保证吗? 不是,是软目标。G1 据此调节新生代与 CSet 规模”尽力”达成;设得过小会导致每次只回收极少 Region,垃圾堆积最终触发 Full GC。
  9. ZGC 为什么能亚毫秒停顿? 染色指针把状态写进指针高位,省去 RSet 与对象头访问;读屏障在应用读引用时自愈转发,实现并发标记/重分配/重映射,STW 只剩极短根扫描。
  10. Safepoint 与 STW 的关系? STW 需等所有线程跑到安全点,故停顿 = 最慢线程到达安全点的时间 + GC 时间;可数循环被 JIT 优化掉轮询会拉长 TTSP,可用 -XX:+UseCountedLoopSafepoints 缓解。
  11. Full GC 频繁的典型原因? 内存泄漏、老年代不足、元空间扩容(Metadata GC Threshold)、显式 System.gc()、大对象直入老年代、并发模式失败、晋升速率过高。
  12. 分配速率与晋升速率为什么重要? 分配速率决定 Young GC 频率(反推新生代该多大),晋升速率决定老年代填满速度(反推要不要管大对象与长生命周期集合)。
  13. JDK 各版本默认收集器? JDK 8 是 Parallel Scavenge + Parallel Old;JDK 9 起改为 G1;JDK 11 引入实验性 ZGC,JDK 15 转正;JDK 21 提供分代 ZGC,JDK 23 起默认分代。

JVM 内存与垃圾回收的主线到此走完:从”对象怎么死”到”谁来收”,再到”怎么调、怎么查”。下一篇进入字节码与类加载机制,看看 .class 文件里藏着什么,以及 ClassLoader 的双亲委派是如何被 Tomcat、SPI 一次次”打破”的。