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

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 | /** |
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 | /** |
必须强调:finalize() 已被时代淘汰——执行时机不确定、不保证执行完、性能极差,还会意外延长对象生命周期。资源释放请用 try-with-resources,堆外内存清理请用 java.lang.ref.Cleaner。
1.4 方法区也能被回收
很多人以为方法区(JDK 8 之后的元空间 Metaspace)没有垃圾回收,这是误解。它回收两类东西:废弃常量(常量池中已无对象引用的字面量)与不再使用的类。判定一个类型”不再使用”需同时满足三个条件,缺一不可:
- 该类所有实例都已被回收,堆中不存在该类及其任何子类的实例;
- 加载该类的 ClassLoader 已被回收;
- 该类对应的
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 | // 复现对象消失的抽象模型(伪代码) |
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 | // 写屏障的伪代码示意(HotSpot 内部是 C++ 与汇编实现) |
写屏障不是免费的:每次引用赋值都多几条指令,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 | # JDK 8 经典写法:打印详细 GC 并输出到文件,带时间戳 |
5.3 读懂一条 Young GC 日志
下面是 JDK 11 + G1 的一条典型日志(为便于说明做了折行):
1 | [2026-10-24T09:12:33.481+0800][12345.678s][info][gc] GC(1024) Pause Young (G1 Evacuation Pause) 2046M->812M(4096M) 32.451ms |
字段拆解: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”
接到告警后按下面的顺序推理,每一步用数据排除一种可能:
- 看老年代曲线。每次 Full GC 后老年代回落很深(如 8GB → 500MB),说明对象并非真常驻,属于”过早晋升”或”一次性大对象”,是参数问题;若回落后迅速涨满且底部阶梯不断抬高,高度怀疑内存泄漏。
- 看 Metaspace。JDK 8 后元空间在本地内存且默认无上限,动态代理/反射/热部署频繁时其扩容触发的 Full GC 很常见,日志会出现
Metadata GC Threshold,应设-XX:MaxMetaspaceSize并排查 ClassLoader 泄漏。 - 看 Humongous 分配。日志里
humongous频繁出现说明有大量超过 Region 一半的对象,应调大-XX:G1HeapRegionSize或治理大对象(大 byte[]、大 List、一次性 ResultSet)。 - 看 System.gc()。RMI、NIO 直接内存与部分框架会显式调用它,加上
-XX:+DisableExplicitGC或-XX:+ExplicitGCInvokesConcurrent。 - 最后才怀疑参数:
-XX:InitiatingHeapOccupancyPercent过高会让并发标记来不及完成,Mixed GC 跟不上分配,最终退化为 Full GC。
1 | # 快速抓取 10 分钟内的 GC 统计,确认是 Young 还是 Full 在增长 |
六、调优方法论
6.1 不可能三角
调优本质上是在三个目标之间做取舍,它们构成一组不可能三角:
- 吞吐量 Throughput —— 单位时间内处理的有效工作量;
- 停顿时间 Latency —— 单次 GC 造成的 STW;
- 内存占用 Footprint —— 堆大小与额外元数据开销。
三者最多同时优化两个:堆大则吞吐高但单次停顿可能更长;堆小则停顿短但 GC 频繁、吞吐下降;追求低停顿要引入并发 GC,就得牺牲部分吞吐(额外 GC 线程)与内存(RSet、染色指针开销)。调优的第一步永远是明确当前业务牺牲哪一项。
6.2 三步法
- 明确指标。不要说”有点卡”,要说”P99 < 200ms,每分钟 GC 总停顿 < 1s,吞吐 > 98%”,指标必须可度量。
- 基准压测。用生产级流量回放,固定并发与数据集,跑出基线(GC 日志 + 监控 + APM),每次只改一个变量。
- 小步验证。改完重新压测对比,无效就回滚。切忌一次改五个参数——即使有效,你也不知道是哪个起了作用。
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 | # 每 1 秒输出一次,共 10 次;重点关注 O 列是否持续上涨 |
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 | # 存活对象的直方图,按占用排序,前 20 行通常就能看到"元凶" |
7.4 CPU 100% 的完整排查链路
现象:某实例 CPU 持续 100%,接口超时。排查链路如下:
1 | # 1)找到 CPU 最高的进程 |
1 | /** |
在 jstack 输出中该线程会处于 RUNNABLE,栈顶就是 CpuBurnDemo.burn(CpuBurnDemo.java:12)。连续三次抓栈都停在同一行,基本可断定是死循环或热点方法;栈顶若是 java.util.regex.Pattern 则是正则灾难性回溯;若大量线程停在 BLOCKED ... waiting to lock 则是锁竞争。
7.5 内存泄漏定位:从代码到 MAT
泄漏最常见于两类代码:静态容器与未清理的 ThreadLocal。
1 | /** |
堆转储拿到之后用 MAT(Memory Analyzer Tool) 打开,按三个视图依次分析:
| 视图 | 作用 | 使用要点 |
|---|---|---|
| Leak Suspects | 自动生成泄漏嫌疑报告 | 看最大对象的引用链,”谁在持有它”一目了然 |
| Dominator Tree | 按”支配”关系展示对象占用 | 快速定位占堆最大的那一棵对象树 |
| Histogram | 按类统计实例数与浅堆 | 排序后找异常类,右键 “Merge Shortest Paths to GC Roots” 查引用来源 |
7.6 Arthas:线上不重启的瑞士军刀
很多线上环境不允许随便 dump(STW 风险),此时用阿里开源的 Arthas,靠 attach 机制工作,无需重启:
1 | # 启动并 attach 到目标进程 |
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 崩溃瞬间自动留下证据。
八、高频面试题
- 主流 JVM 为什么不用引用计数? 无法解决循环引用,且每次赋值都要维护计数器,多线程下还要保证原子操作。
- GC Roots 有哪些? 虚拟机栈局部变量、方法区静态属性与常量、本地方法栈 JNI 引用、同步锁持有者、JVM 内部引用(Class 对象、常驻异常等)。
- 什么是三色标记?并发标记会出哪两类错? 白(未标记)、灰(已标记未扫完)、黑(扫完)。错一是浮动垃圾(可容忍),错二是对象消失(活对象被误回收,不可接受)。
- 对象消失的两个必要条件与解法? 黑色插入指向白色的新引用,且灰色删除到该白色的全部引用。CMS 用增量更新破坏条件一,G1 用 SATB 破坏条件二,都靠写屏障记录。
- 卡表与记忆集是什么关系? 记忆集是”记录跨区域引用”的抽象结构,卡表是其字节数组实现:按 512 字节切卡页,dirty 卡表示有跨代引用,GC 只扫脏卡。
- CMS 的四个阶段与三个缺点? 初始标记(STW)、并发标记、重新标记(STW)、并发清除。缺点:对 CPU 敏感、可能触发 Concurrent Mode Failure 退化成 Serial Old、清除算法产生碎片。
- G1 为何叫 Garbage First?Region 与 CSet 是什么? 堆切成等大 Region,可动态扮演 Eden/Survivor/Old;G1 优先把垃圾最多的 Region 放进回收集合 CSet,用 CSet 大小控制停顿。
-XX:MaxGCPauseMillis是硬性保证吗? 不是,是软目标。G1 据此调节新生代与 CSet 规模”尽力”达成;设得过小会导致每次只回收极少 Region,垃圾堆积最终触发 Full GC。- ZGC 为什么能亚毫秒停顿? 染色指针把状态写进指针高位,省去 RSet 与对象头访问;读屏障在应用读引用时自愈转发,实现并发标记/重分配/重映射,STW 只剩极短根扫描。
- Safepoint 与 STW 的关系? STW 需等所有线程跑到安全点,故停顿 = 最慢线程到达安全点的时间 + GC 时间;可数循环被 JIT 优化掉轮询会拉长 TTSP,可用
-XX:+UseCountedLoopSafepoints缓解。 - Full GC 频繁的典型原因? 内存泄漏、老年代不足、元空间扩容(
Metadata GC Threshold)、显式System.gc()、大对象直入老年代、并发模式失败、晋升速率过高。 - 分配速率与晋升速率为什么重要? 分配速率决定 Young GC 频率(反推新生代该多大),晋升速率决定老年代填满速度(反推要不要管大对象与长生命周期集合)。
- 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 一次次”打破”的。














