JVM 内存模型与垃圾回收深度调优指南
Java 虚拟机(JVM)的内存管理与垃圾回收机制是每一位后端开发者必须掌握的核心技能。理解 JVM 的内存布局、对象分配策略以及各类垃圾收集器的工作原理,不仅能够帮助我们在日常开发中写出更高效的代码,更能在生产环境出现性能瓶颈或内存泄漏时,快速定位问题并实施有效的调优方案。本文将从内存模型出发,系统性地梳理对象分配、GC 算法、主流收集器、日志分析、参数调优及内存泄漏排查等关键主题,并结合真实的生产案例,为读者提供一份可落地的深度调优指南。
JVM 内存布局
在 HotSpot 虚拟机中,Java 进程启动后占用的内存空间被划分为多个功能不同的数据区域。这些区域的生命周期、作用范围以及管理策略各不相同,理解它们的职责是后续调优工作的基础。
运行时数据区概览
JVM 规范定义的程序运行时数据区域主要包括:程序计数器、虚拟机栈、本地方法栈、堆、方法区以及直接内存。其中堆和方法区是所有线程共享的,而程序计数器、虚拟机栈和本地方法栈则是线程私有的。
// 演示不同内存区域的分配特点
public class MemoryLayoutDemo {
// 类信息、常量池、静态变量存储在方法区(Metaspace)
private static final String CLASS_CONSTANT = "常量";
private static int staticVar = 100;
// 实例字段随对象分配在堆中
private int instanceVar = 200;
public void stackFrameDemo() {
// localVar 存储在虚拟机栈的栈帧中
int localVar = 300;
// objRef 是栈帧中的引用变量,指向堆中的对象
Object objRef = new Object();
System.out.println("局部变量在栈,对象实例在堆");
}
public static void main(String[] args) {
new MemoryLayoutDemo().stackFrameDemo();
}
}
堆内存的细划分
堆内存是垃圾回收管理最集中的区域,在逻辑上划分为年轻代(Young Generation)和老年代(Old Generation)。年轻代又被进一步划分为 Eden 区和两个 Survivor 区(通常记为 S0 和 S1)。
Eden 区:绝大多数新创建的对象首先在这里分配。Eden 区的特点是对象产生速度极快,因此对应的 Minor GC 频率也最高。
Survivor 区:分为 From Survivor 和 To Survivor,两者在任意时刻只有一个被使用。当 Eden 区满时触发 Minor GC,存活的对象被复制到 To Survivor,然后 From 和 To 交换身份。
老年代:存放经过多次 Minor GC 后仍然存活的对象,或者因为体积过大直接在老年代分配的对象。老年代的 GC 称为 Major GC 或 Full GC,通常伴随更长的停顿时间。
# 查看当前应用的堆内存划分情况
jmap -heap <pid>
# 典型输出中可见:
# NewRatio = 2 # 老年代/年轻代 = 2
# SurvivorRatio = 8 # Eden/Survivor = 8
# 即 Eden 占 young 的 8/10, S0 和 S1 各占 1/10
Metaspace 与元数据管理
JDK 8 以前,类的元数据存储在永久代(PermGen)中,受限于固定的内存大小,频繁发生 OutOfMemoryError: PermGen space。JDK 8 彻底移除了永久代,改为使用本地内存实现的 Metaspace。
Metaspace 的大小默认只受限于物理内存,但通过 -XX:MaxMetaspaceSize 仍可设置上限。大量动态生成类(如 CGLIB、反射、OSGi、动态脚本)的场景下,Metaspace 极易被耗尽。
# JVM 参数:设置 Metaspace 大小与监控
java -XX:MetaspaceSize=128m \
-XX:MaxMetaspaceSize=256m \
-XX:+PrintGCDetails \
-XX:+PrintGCDateStamps \
-Xloggc:/var/log/gc.log \
MyApplication
线程栈与本地方法栈
每个线程创建时都会分配一个私有的虚拟机栈,用于存储栈帧。栈帧中保存局部变量表、操作数栈、动态链接和方法返回地址。栈的深度由 -Xss 参数控制。
如果线程请求的栈深度超过虚拟机允许的最大深度,会抛出 StackOverflowError。如果虚拟机栈允许动态扩展但无法申请到足够内存,则抛出 OutOfMemoryError。
// 演示栈溢出
public class StackOverflowDemo {
private int depth = 0;
public void recursiveCall() {
depth++;
recursiveCall(); // 无限递归耗尽栈空间
}
public static void main(String[] args) {
try {
new StackOverflowDemo().recursiveCall();
} catch (StackOverflowError e) {
System.err.println("栈深度超限,触发 StackOverflowError");
}
}
}
直接内存
直接内存(Direct Memory)不属于 JVM 堆的一部分,但可以通过 ByteBuffer.allocateDirect() 或 NIO 库进行操作。直接内存的分配与回收成本更高,但 I/O 性能优于堆内存,因为它避免了在 Java 堆和本地堆之间的数据拷贝。
// 直接内存分配示例
import java.nio.ByteBuffer;
public class DirectMemoryDemo {
public static void main(String[] args) {
// 分配 128MB 直接内存
ByteBuffer directBuffer = ByteBuffer.allocateDirect(128 * 1024 * 1024);
System.out.println("Direct buffer allocated, capacity: " + directBuffer.capacity());
// 使用完毕后手动置空,依赖 Cleaner 释放本地内存
directBuffer = null;
System.gc(); // 建议触发,但不保证立即回收
}
}
下表总结了 JVM 主要内存区域的核心特征对比。
| 内存区域 | 线程共享 | 生命周期 | 存储内容 | 溢出异常 | 调优参数 |
|---|---|---|---|---|---|
| 程序计数器 | 私有 | 随线程 | 字节码行号指示器 | 无 | 无 |
| 虚拟机栈 | 私有 | 随线程 | 栈帧、局部变量、操作数栈 | StackOverflowError / OOM | -Xss |
| 本地方法栈 | 私有 | 随线程 | Native 方法信息 | StackOverflowError / OOM | -Xss |
| 堆(新生代) | 共享 | 随 JVM | Eden、Survivor 对象 | OutOfMemoryError | -Xmn, -XX:SurvivorRatio |
| 堆(老年代) | 共享 | 随 JVM | 长期存活对象 | OutOfMemoryError | -XX:NewRatio |
| Metaspace | 共享 | 随 JVM | 类元数据、常量池 | OutOfMemoryError | -XX:MaxMetaspaceSize |
| 直接内存 | 共享 | 随 JVM | NIO Buffer 数据 | OutOfMemoryError | -XX:MaxDirectMemorySize |
对象分配与晋升
对象在堆中的分配过程并非简单的 new 操作,JVM 在后台实施了一系列优化策略以减少分配开销、降低 GC 压力并提升执行效率。
TLAB 线程本地分配缓冲区
堆是线程共享区域,若多个线程同时在一个位置分配对象,势必需要同步机制保证安全,这会严重影响分配速度。TLAB(Thread Local Allocation Buffer)通过为每个线程预先在 Eden 区分配一小块私有内存来解决这一问题。
# 启用并配置 TLAB
java -XX:+UseTLAB \
-XX:+ResizeTLAB \
-XX:TLABSize=256k \
-XX:+PrintTLAB \
MyApplication
# 日志输出示例:
# TLAB: gc thread: 0x00007f3a3c001800 [id: 1234] desired_size: 256KB
当 TLAB 用尽时,线程会请求新的 TLAB,或者在共享的 Eden 区以同步方式分配。对于大对象,则直接在老年代分配。
逃逸分析与标量替换
JIT 编译器在运行期间通过逃逸分析(Escape Analysis)判断对象的作用域是否仅限于当前方法或线程。如果对象不会逃逸到方法外部,JVM 可以实施一系列激进优化。
栈上分配:将对象分配在栈上而不是堆上,对象随栈帧销毁而自动释放,无需 GC 介入。
标量替换:如果一个聚合量(对象)可以被分解为多个标量值,并且这些标量不会被外部引用,则直接用基本类型变量替代对象字段。
同步消除:如果对象不会逃逸出当前线程,对其施加的锁可以被安全地移除。
// 逃逸分析优化示例:point 对象不会逃逸出方法
public class EscapeAnalysisDemo {
public void compute() {
// 该 Point 对象仅在本方法内使用,可栈上分配或标量替换
Point point = new Point(10, 20);
int sum = point.x + point.y;
System.out.println("sum = " + sum);
}
// 对象返回给调用方,发生逃逸,无法进行栈上分配
public Point createPoint(int x, int y) {
return new Point(x, y);
}
private static class Point {
int x, y;
Point(int x, int y) {
this.x = x;
this.y = y;
}
}
public static void main(String[] args) {
EscapeAnalysisDemo demo = new EscapeAnalysisDemo();
for (int i = 0; i < 10000000; i++) {
demo.compute(); // 热点代码触发 JIT 编译与逃逸分析
}
}
}
# 显式启用逃逸分析(JDK 8 默认开启)
java -XX:+DoEscapeAnalysis -XX:+EliminateAllocations -XX:+EliminateLocks MyApp
对象晋升策略
对象在年轻代的晋升遵循年龄阈值规则。每次经过一次 Minor GC 且存活,对象的年龄就增加一岁。当年龄达到 -XX:MaxTenuringThreshold(默认值通常为 15,G1 中可能为 6)时,对象晋升到老年代。
但 JVM 并非机械地等待年龄达标。如果在 Survivor 空间中相同年龄的所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无需达到 MaxTenuringThreshold。
# 对象晋升相关参数
java -XX:InitialTenuringThreshold=8 \
-XX:MaxTenuringThreshold=15 \
-XX:+PrintTenuringDistribution \
-Xloggc:gc.log \
MyApp
# Tenuring Distribution 日志示例:
# Desired survivor size 1048576 bytes, new threshold 6 (max 15)
# - age 1: 412320 bytes, 412320 total
# - age 2: 389120 bytes, 801440 total
# - age 3: 278400 bytes, 1079840 total
大对象直接进入老年代
大对象是指需要大量连续内存空间的 Java 对象,最典型的就是长字符串和大型数组。大对象对虚拟机的内存分配是一个挑战,因为它们容易导致 Eden 区和 Survivor 区之间发生大量内存复制。
Serial 和 ParNew 收集器提供了 -XX:PretenureSizeThreshold 参数,令大于该值的对象直接在老年代分配。但 Parallel Scavenge 和 G1 收集器不识别此参数。
# 仅对 Serial / ParNew 有效
java -XX:PretenureSizeThreshold=1m MyApp
# G1 中通过 Region 大小间接控制,大于 Region 一半的对象直接进入 Humongous Region
java -XX:+UseG1GC -XX:G1HeapRegionSize=4m MyApp
GC 算法基础
垃圾回收的核心任务是识别并回收那些不再被程序使用的对象所占用的内存。这一领域经过数十年的发展,形成了多种经典算法,理解它们是掌握现代垃圾收集器的前提。
标记-清除算法
标记-清除(Mark-Sweep)是最早出现也是最基础的算法。它分为两个阶段:首先通过可达性分析从 GC Roots 出发标记所有存活对象,然后遍历整个堆清除未被标记的对象。
/**
* 标记-清除算法的核心逻辑示意(伪代码)
* 1. 标记阶段:递归标记所有从 GC Roots 可达的对象
* 2. 清除阶段:遍历堆内存,回收未被标记的对象
*/
public void markSweep() {
// 阶段一:标记
for (Object root : gcRoots) {
mark(root);
}
// 阶段二:清除
for (Object obj : heapObjects) {
if (!obj.isMarked()) {
heap.free(obj);
} else {
obj.clearMark(); // 为下一轮 GC 做准备
}
}
}
private void mark(Object obj) {
if (obj == null || obj.isMarked()) return;
obj.setMarked(true);
for (Object ref : obj.getReferences()) {
mark(ref);
}
}
标记-清除的缺点十分明显:首先,两个阶段的效率都不高,且清除后会产生大量不连续的内存碎片,当后续需要分配大对象时可能因无法找到足够大的连续空间而提前触发一次 Full GC。
复制算法
复制(Copying)算法将可用内存按容量划分为大小相等的两块,每次只使用其中一块。当一块内存用完后,将还存活的对象复制到另一块,然后把已使用的内存空间一次性清理掉。
年轻代的 Minor GC 普遍采用复制算法,Eden 区和 Survivor 区的设计就是这一思路的体现。由于新生代中大部分对象都是朝生夕死的,存活对象很少,因此复制的开销非常低。
# 复制算法在年轻代的应用参数
# Eden : Survivor0 : Survivor1 = 8 : 1 : 1
java -XX:SurvivorRatio=8 -Xmn1g MyApp
# 假设堆为 3GB, 则 Eden=0.8GB, S0=S1=0.1GB
复制算法的代价是将可用内存缩小了一半,不过由于它只使用其中一半,因此不存在内存碎片问题。
标记-整理算法
标记-整理(Mark-Compact)算法在标记阶段与标记-清除相同,但在后续步骤中,它让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
老年代的对象存活率通常较高,不适合使用复制算法(复制成本高),因此标记-整理或标记-清除更适合老年代。Serial Old 和 Parallel Old 使用标记-整理,而 CMS 使用标记-清除(搭配碎片整理)。
# 使用 Serial Old 收集器(标记-整理)
java -XX:+UseSerialGC MyApp
# 使用 Parallel Old 收集器(标记-整理)
java -XX:+UseParallelGC -XX:+UseParallelOldGC MyApp
分代收集理论
当前商业虚拟机的垃圾收集器几乎全部基于分代收集(Generational Collection)理论设计。该理论建立在两个分代假说之上:
- 弱分代假说:绝大多数对象都是朝生夕灭的。
- 强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡。
基于这两个假说,收集器将 Java 堆划分为不同的区域,然后根据各区域对象的存活特点采用最适当的收集算法。新生代使用复制算法,老年代使用标记-清除或标记-整理。
此外,跨代引用(如老年代对象引用新生代对象)的扫描是个难题。JVM 引入了**记忆集(Remembered Set)**来避免扫描整个老年代, drastically 降低了 Minor GC 的根节点枚举范围。
六种垃圾收集器详解
HotSpot 虚拟机发展至今,已经推出了多种各具特色的垃圾收集器。本节将深入剖析 Serial、ParNew、Parallel Scavenge、CMS、G1、ZGC 和 Shenandoah 这六种主流收集器的工作原理、适用场景和配置方法。
| 收集器 | 算法 | 作用区域 | 线程数 | 停顿目标 | 适用场景 |
|---|---|---|---|---|---|
| Serial | 复制 / 标记-整理 | 新生代 / 老年代 | 单线程 | < 1s | 客户端模式 / 单核 |
| ParNew | 复制 | 新生代 | 多线程 | < 1s | 服务端搭配 CMS |
| Parallel Scavenge | 复制 / 标记-整理 | 新生代 / 老年代 | 多线程 | 吞吐量优先 | 后台计算 |
| CMS | 标记-清除 | 老年代 | 多线程 | < 500ms | 互联网低延迟 |
| G1 | 标记-整理 + 复制 | 整堆(Region) | 多线程 | < 200ms | 大堆低延迟 |
| ZGC | 染色指针 / 读屏障 | 整堆 | 多线程 | < 10ms | 超大堆低延迟 |
| Shenandoah | Brooks Pointer / 读屏障 | 整堆 | 多线程 | < 10ms | OpenJDK 低延迟 |
Serial 收集器
Serial 收集器是最古老、最基础的收集器,在新生代使用复制算法,在老年代使用标记-整理算法,且全程单线程执行。在执行垃圾收集时,它会暂停所有工作线程(Stop The World),直到收集结束。
尽管性能不佳,Serial 仍然是客户端模式(Client VM)下的默认收集器。对于内存资源受限的环境(如嵌入式设备)以及早期的单核处理器而言,Serial 避免了线程切换的开销,简洁高效。
# 显式启用 Serial 收集器
java -XX:+UseSerialGC \
-Xms512m -Xmx512m \
MyClientApp
ParNew 收集器
ParNew 是 Serial 的多线程版本,它除了同时使用多条线程进行垃圾收集外,其余行为(控制参数、收集算法、Stop The World 等)与 Serial 完全一致。ParNew 是服务端模式下与 CMS 收集器搭配使用的默认新生代收集器。
# 启用 ParNew + CMS 组合
java -XX:+UseParNewGC \
-XX:+UseConcMarkSweepGC \
-XX:ParallelGCThreads=4 \
-Xms2g -Xmx2g \
MyServerApp
Parallel Scavenge 收集器
Parallel Scavenge 收集器同样采用复制算法,也是多线程并行收集。它的关注点是达到一个可控制的吞吐量,而不是尽可能缩短停顿时间。
吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 运行垃圾收集时间)。高吞吐量意味着 CPU 用于运行用户代码的时间占比更高,适合后台运算而不需要太多交互的任务。
# 启用 Parallel Scavenge(吞吐量优先)
java -XX:+UseParallelGC \
-XX:+UseParallelOldGC \
-XX:GCTimeRatio=19 \ # GC 时间占总时间的 1/(1+19) = 5%
-XX:MaxGCPauseMillis=200 \ # 最大停顿毫秒数(软目标)
-Xms4g -Xmx4g \
BatchProcessingApp
CMS 收集器
CMS(Concurrent Mark Sweep)收集器以获取最短回收停顿时间为目标,是 HotSpot 虚拟机中第一款真正意义上支持并发的收集器。它让垃圾收集线程与用户线程同时工作,从而显著降低了停顿时间。
CMS 基于标记-清除算法,整个过程分为四个阶段:
- 初始标记:标记 GC Roots 能直接关联到的对象,需要 Stop The World,但速度很快。
- 并发标记:从 GC Roots 直接关联对象开始遍历整个对象图,与用户线程并发执行。
- 重新标记:修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要 Stop The World。
- 并发清除:清理删除掉标记阶段判断为已经死亡的对象,与用户线程并发执行。
# 启用 CMS 收集器
java -XX:+UseConcMarkSweepGC \
-XX:+UseParNewGC \
-XX:CMSInitiatingOccupancyFraction=70 \ # 老年代 70% 时触发 CMS
-XX:+UseCMSInitiatingOccupancyOnly \ # 仅按占比触发,禁止 JVM 自动调整
-XX:+CMSClassUnloadingEnabled \ # 回收永久代/Metaspace 类数据
-XX:+CMSScavengeBeforeRemark \ # 重新标记前先执行一次 Minor GC
-Xms4g -Xmx4g \
MyWebApp
CMS 存在三个明显缺陷:一是对 CPU 资源非常敏感;二是无法处理浮动垃圾(Floating Garbage),因此需要预留足够的堆空间,不能等到老年代完全填满再收集;三是标记-清除算法会产生大量内存碎片,可能导致 Full GC。
# CMS 内存碎片相关参数
java -XX:+UseCMSCompactAtFullCollection \ # Full GC 时进行碎片整理
-XX:CMSFullGCsBeforeCompaction=5 \ # 每 5 次 Full GC 后执行一次整理
-XX:+CMSParallelRemarkEnabled \ # 并行重新标记
MyApp
G1 收集器
G1(Garbage First)收集器是 JDK 9 及之后版本的默认收集器,它开创了面向局部收集的设计思路和基于 Region 的内存布局。G1 将堆划分为多个大小相等的独立 Region,每个 Region 根据需要扮演 Eden、Survivor 或 Old 的角色。
G1 的核心目标是在延迟可控的情况下获得尽可能高的吞吐量。它通过 Mixed GC 每次都根据用户设定允许的收集停顿时间,优先回收价值最大的 Region(即垃圾最多的 Region,Garbage First 因此得名)。
# 启用 G1 收集器(JDK 9+ 默认)
java -XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \ # 目标最大停顿 200ms
-XX:G1HeapRegionSize=16m \ # Region 大小(2^n, 1MB~32MB)
-XX:InitiatingHeapOccupancyPercent=35 \ # 整堆使用 35% 时触发并发标记
-XX:+G1UseAdaptiveIHOP \ # 自动调整 IHOP 阈值
-Xms8g -Xmx8g \
MyLatencySensitiveApp
ZGC 收集器
ZGC 是 Oracle 在 JDK 11 中引入的一款低延迟垃圾收集器,JDK 15 正式成为生产可用。ZGC 的设计目标是在任何堆内存大小下都能将停顿时间控制在 10ms 以内。
ZGC 的核心技术包括染色指针(Colored Pointers)和读屏障(Load Barrier)。染色指针将标记信息直接存储在指针的冗余位上,避免了对象头标记带来的内存访问开销。读屏障在对象引用读取时注入额外逻辑,实现并发转移(Relocation)时用户线程对对象位置的自动感知。
# 启用 ZGC(JDK 15+ 生产可用,JDK 17+ 推荐)
java -XX:+UseZGC \
-XX:+ZGenerational \ # JDK 21+ 启用分代 ZGC(推荐)
-XX:MaxGCPauseMillis=5 \ # 目标停顿 5ms
-XX:ZCollectionInterval=5 \ # 强制每 5 秒触发一次 GC(可选)
-Xms16g -Xmx16g \
UltraLowLatencyApp
Shenandoah 收集器
Shenandoah 是由 Red Hat 主导开发的一款低延迟收集器,与 ZGC 类似,目标也是将停顿时间控制在 10ms 以内。它作为 OpenJDK 项目的一部分,在 JDK 12 中首次出现,但 Oracle JDK 直到 JDK 23 才正式包含它。
Shenandoah 的核心创新是并发压缩(Concurrent Compaction)。与 ZGC 使用染色指针不同,Shenandoah 使用 Brooks Pointer(转发布指针),在每个对象头部附加一个转发指针,指向对象的新地址。
# 启用 Shenandoah(OpenJDK 或 JDK 23+)
java -XX:+UseShenandoahGC \
-XX:ShenandoahGCHeuristics=adaptive \ # 自适应启发式策略
-XX:+ShenandoahPacing \ # 内存分配节奏控制
-XX:+ShenandoahAlwaysPreTouch \ # 启动时预分配所有内存页
-Xms8g -Xmx8g \
MyOpenJDKApp
G1 GC 深入
G1 收集器凭借其卓越的可预测停顿时间和在大堆下的出色表现,已经成为现代 Java 应用的首选 GC。理解其 Region 模型、Mixed GC 流程和关键技术细节,是进行 G1 调优的必要条件。
Region 模型与内存布局
G1 将整堆划分为大约 2048 个 Region(Region 个数与堆大小有关),每个 Region 大小可通过 -XX:G1HeapRegionSize 显式指定,否则由 JVM 根据堆大小自动计算。
# Region 大小计算示例
# 若堆为 8GB, G1 默认目标约 2048 个 Region
# 每个 Region 大小 = 8GB / 2048 = 4MB
# 允许的 Region 大小: 1MB, 2MB, 4MB, 8MB, 16MB, 32MB
java -XX:+UseG1GC -XX:G1HeapRegionSize=8m -Xms16g -Xmx16g MyApp
Region 的角色是动态变化的。一个 Region 在某次 GC 前可能是 Eden,GC 后部分数据被复制到 Survivor Region 后它就清空了,下次可以作为 Old Region 使用。
Humongous Region 与大对象
当一个对象的大小超过一个 Region 大小的 50% 时,它会被视为大对象(Humongous Object)。这类对象会被直接分配在一个或多个连续的 Humongous Region 中。
// Humongous 对象分配示例
public class HumongousObjectDemo {
// 假设 Region 大小为 1MB,分配 600KB 的数组就会进入 Humongous
private static final int REGION_SIZE_KB = 1024;
private static final int HUMONGOUS_THRESHOLD = REGION_SIZE_KB / 2;
public static void main(String[] args) {
// 分配一个 512KB 的 byte 数组,若 Region=1MB, 则刚好为 Humongous
byte[] almostHumongous = new byte[HUMONGOUS_THRESHOLD * 1024];
// 分配一个 2MB 的数组,占用 3 个连续的 Humongous Region
byte[] largeArray = new byte[2 * REGION_SIZE_KB * 1024];
System.out.println("大对象已分配至 Humongous Region");
}
}
Humongous Region 永远不会被移动或整理,因此过多的大对象分配会导致堆中产生大量不连续的空洞,影响内存利用率。
Mixed GC 与收集模式
G1 并不存在纯粹的老年代 GC,而是采用 Mixed GC,在一次回收周期中同时回收年轻代的全部 Region 和部分老年代的 Region。
# 观察 G1 的 GC 周期
java -XX:+UseG1GC \
-XX:+PrintGCDetails \
-XX:+PrintGCTimeStamps \
-XX:+PrintAdaptiveSizePolicy \
-Xloggc:g1-gc.log \
-Xms4g -Xmx4g \
MyApp
G1 的收集周期通常包括以下阶段:
Young GC:只回收 Eden 和 Survivor Region,当 Eden 耗尽时触发。
Concurrent Marking Cycle:包含初始标记、根区域扫描、并发标记、重新标记和清理五个子阶段,用于确定老年代中哪些 Region 的垃圾最多。
Mixed GC:在并发标记完成后,G1 会选择所有年轻代 Region 和若干垃圾最多的老年代 Region 进行回收,直到回收掉的垃圾量达到预设阈值。
# G1 Full GC 相关参数及日志识别
# 日志中的 "Full GC" 表示 G1 退化为串行 Full GC,这是性能灾难
# 避免 Full GC 的参数调优
java -XX:+UseG1GC \
-XX:InitiatingHeapOccupancyPercent=35 \
-XX:G1HeapWastePercent=5 \ # 可容忍的垃圾占比,低于此值不回收
-XX:G1MixedGCCountTarget=8 \ # 混合 GC 目标次数
-XX:G1MixedGCLiveThresholdPercent=85 \ # Region 存活对象高于 85% 不回收
-Xms8g -Xmx8g \
MyApp
SATB 与并发标记
G1 使用 SATB(Snapshot-At-The-Beginning) 算法保证并发标记的正确性。在并发标记阶段开始时,G1 会创建一个堆的逻辑快照,此后所有可能改变对象引用关系的写操作都会被记录到 SATB 队列中。
# SATB 相关参数
java -XX:+UseG1GC \
-XX:G1SATBBufferEnqueueingThresholdPercent=60 \
-XX:G1SATBBufferSize=1k \
MyApp
SATB 与 CMS 的增量更新不同,它将所有在快照之后新增的引用关系视为存活,因此可能会产生一些浮动垃圾,但相比于增量更新,SATB 的实现更简单,且不会破坏 Region 的回收价值评估。
G1 调优实战配置
# 生产环境 G1 推荐配置(8GB+ 堆)
java -server \
-XX:+UseG1GC \
-Xms12g -Xmx12g \ # 固定堆大小避免动态扩缩
-XX:MaxGCPauseMillis=200 \ # 目标停顿 200ms
-XX:G1HeapRegionSize=16m \ # 大 Region 减少管理开销
-XX:+UseStringDeduplication \ # 字符串去重(JDK 8u20+)
-XX:+ParallelRefProcEnabled \ # 并行处理引用对象
-XX:InitiatingHeapOccupancyPercent=35 \
-XX:G1ReservePercent=20 \ # 保留 20% 内存作为缓冲
-XX:+AlwaysPreTouch \ # 启动时预分配堆内存
-XX:+DisableExplicitGC \ # 禁止 System.gc() 触发 Full GC
-jar production-app.jar
ZGC/Shenandoah 低延迟 GC
随着现代应用对延迟的要求越来越苛刻,ZGC 和 Shenandoah 这两款致力于将停顿时间控制在毫秒乃至亚毫秒级别的收集器受到了广泛关注。它们在架构上突破了传统 GC 的设计范式,实现了并发标记、并发转移和并发重映射。
ZGC 染色指针与读屏障
ZGC 最引人注目的技术是其染色指针设计。在 64 位系统中,对象的内存地址通常不会用完 64 个比特位。ZGC 利用 x86_64 平台的虚拟地址冗余特性,在指针的高位存储了四种元数据:
- Marked0 / Marked1:用于交替标记对象的存活状态
- Remapped:表示该指针是否已经过重映射(指向对象的新位置)
- Finalizable:标识对象是否仅通过 finalize 方法可达
/* ZGC 染色指针布局示意(64 位,aarch64 / x86_64)
*
* 63-42 | 41 | 40 | 39 | 38-0
* 保留 |Rem|Fina| M1 | 对象地址(41 位)
*
* 通过掩码操作快速提取标记信息,无需访问对象头
*/
读屏障(Load Barrier)是 ZGC 实现并发转移的关键。在用户使用对象引用前,读屏障会检查指针的标记状态。如果指针指向一个尚未重映射的对象,读屏障会将其转发到新地址,并更新引用。
# ZGC 完整启动参数(JDK 21 分代 ZGC 推荐)
java -XX:+UseZGC \
-XX:+ZGenerational \
-XX:MaxGCPauseMillis=1 \ # 目标是亚毫秒级
-XX:SoftMaxHeapSize=24g \ # 软上限,尽量保持在此之下
-XX:+ZUncommit \ # 将未用内存返回给操作系统
-XX:ZUncommitDelay=300 \ # 5 分钟后释放
-XX:ConcGCThreads=4 \ # 并发 GC 线程数
-XX:+AlwaysPreTouch \
-Xms32g -Xmx32g \
UltraLowLatencyService
Shenandoah 并发压缩之路
Shenandoah 的设计哲学与 ZGC 相似,但在工程实现上采用了不同的技术路径。它的核心是在每个对象头部植入一个Brooks Pointer(转发布指针)。
在正常状态下,该指针指向对象自身。当对象被转移时,旧对象头部的指针被更新为新对象的地址。用户线程访问对象时,通过固定的偏移量读取转发布指针完成自动转发。
# Shenandoah 启发式策略选择
java -XX:+UseShenandoahGC \
-XX:ShenandoahGCHeuristics=adaptive \
-XX:+ShenandoahPacing \
-XX:+ShenandoahAlwaysPreTouch \
-XX:+UseLargePages \ # 大页支持降低 TLB miss
-XX:+UseTransparentHugePages \
-Xms16g -Xmx16g \
MyApp
# Heuristics 可选值:
# adaptive - 自适应(推荐)
# compact - 持续压缩,最大化回收
# static - 静态阈值
# passive - 永不主动触发,仅oom时执行
低延迟 GC 对比与选型
ZGC 和 Shenandoah 都实现了极低延迟,但具体选哪个取决于 JDK 发行版和应用负载特征。Oracle JDK 中 ZGC 的支持最为完善;而如果在 OpenJDK 环境或需要避免对指针做 hack 的场景下,Shenandoah 是极佳的选择。
GC 日志分析
GC 日志是排查 JVM 内存问题的第一手资料。现代 JDK(JDK 9+)统一使用 -Xlog 参数配置日志输出,相比传统的 -XX:+PrintGCDetails 更加灵活强大。
开启 GC 日志
# JDK 9+ 统一 GC 日志参数
java -Xlog:gc*:file=/var/log/gc.log:time,uptime,level,tags:filecount=10,filesize=100m \
-XX:+UseG1GC \
-jar MyApp.jar
# 仅记录 GC 停顿信息
java -Xlog:gc+ergo*=trace:stderr:time \
-jar MyApp.jar
经典 GC 日志示例与解读
Parallel GC 日志示例
[2026-09-01T10:23:45.123+0800] [0.356s] GC(0) Pause Young (Allocation Failure) 256M->64M(1024M) 12.345ms
[2026-09-01T10:24:12.456+0800] [27.689s] GC(12) Pause Full (Ergonomics) 768M->256M(1024M) 234.567ms
日志解读:第一条记录了一次 Young GC,由分配失败触发,堆从 256MB 降到 64MB(总容量 1024MB),耗时 12.345ms。第二条是一次 Full GC,由 Ergonomics 机制触发,耗时高达 234ms。
G1 GC 日志示例
[2026-09-01T10:30:00.001+0800] [365.001s][info][gc,start ] GC(50) Pause Young (Normal) (G1 Evacuation Pause)
[2026-09-01T10:30:00.002+0800] [365.002s][info][gc,task ] GC(50) Using 4 workers of 4 for evacuation
[2026-09-01T10:30:00.050+0800] [365.050s][info][gc,heap ] GC(50) Eden regions: 124->0(130)
[2026-09-01T10:30:00.050+0800] [365.050s][info][gc,heap ] GC(50) Survivor regions: 10->15(15)
[2026-09-01T10:30:00.050+0800] [365.050s][info][gc,heap ] GC(50) Old regions: 45->50
[2026-09-01T10:30:00.050+0800] [365.050s][info][gc ] GC(50) Pause Young (Normal) 1024M->520M(2048M) 48.234ms
[2026-09-01T10:30:00.050+0800] [365.050s][info][gc,cpu ] GC(50) User=0.15s Sys=0.02s Real=0.05s
GCViewer 离线分析
GCViewer 是一款开源的 GC 日志可视化分析工具,可以解析和展示停顿时间、内存变化曲线、吞吐量等关键指标。
# 下载并启动 GCViewer
wget https://github.com/chewiebug/GCViewer/releases/download/1.36/gcviewer-1.36.jar
java -jar gcviewer-1.36.jar gc.log
# 通过可视化界面查看:
# - Total Heap 使用曲线
# - Pause 分布直方图
# - Throughput 百分比
# - Avg / Max pause 统计
Visual GC 实时监控
VisualVM 配合 Visual GC 插件可以实时展示 JVM 内存各区域的动态变化,是开发阶段最直观的监控手段。
# 启动 VisualVM(JDK 9 前自带,后续需单独下载)
jvisualvm
# 或通过 jstat 命令行查看各区域使用率
jstat -gcutil <pid> 1000 10
# 输出示例(每秒采样,共 10 次):
# S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
# 0.00 12.50 45.20 30.15 92.30 85.60 120 2.340 3 1.500 3.840
JVM 参数调优
JVM 参数调优不是一成不变的公式,而是需要根据应用特征、负载模型和硬件环境进行反复验证与迭代的过程。
堆内存基础参数
# 基础堆内存设置
java -Xms8g \ # 初始堆大小,建议与最大值一致
-Xmx8g \ # 最大堆大小
-Xmn3g \ # 年轻代大小(G1 中不建议显式设置)
-XX:MetaspaceSize=256m \ # Metaspace 初始大小
-XX:MaxMetaspaceSize=512m # Metaspace 上限
固定堆大小的重要性:将 -Xms 与 -Xmx 设为相同值可以避免 JVM 在运行期间动态调整堆大小带来的性能开销。同时配合 -XX:+AlwaysPreTouch,在 JVM 启动时就将内存页提交并清零,避免运行时的 Page Fault。
G1 核心调优参数
# G1 生产环境调优模板
JAVA_OPTS="
-server
-XX:+UseG1GC
-Xms16g
-Xmx16g
-XX:MaxGCPauseMillis=150
-XX:G1HeapRegionSize=16m
-XX:InitiatingHeapOccupancyPercent=40
-XX:G1ReservePercent=15
-XX:+ParallelRefProcEnabled
-XX:+AlwaysPreTouch
-XX:+DisableExplicitGC
-XX:+UseStringDeduplication
-XX:-OmitStackTraceInFastThrow
"
ZGC 核心调优参数
# ZGC 生产环境配置(JDK 21+)
JAVA_OPTS="
-XX:+UseZGC
-XX:+ZGenerational
-Xms64g
-Xmx64g
-XX:SoftMaxHeapSize=48g
-XX:MaxGCPauseMillis=5
-XX:+AlwaysPreTouch
-XX:+ZUncommit
-XX:ZUncommitDelay=600
-XX:ParallelGCThreads=16
-XX:ConcGCThreads=8
"
线程栈与编译器参数
# 线程栈与 JIT 编译参数
java -Xss512k \ # 线程栈大小(默认 1MB)
-XX:ReservedCodeCacheSize=256m \ # JIT 代码缓存
-XX:+UseCodeCacheFlushing \ # 代码缓存满时刷新旧方法
-XX:+TieredCompilation \ # 分层编译(JDK 8+ 默认开启)
MyApp
# 禁用 JVM 定期执行 Full GC(RMI 分布式 GC 触发)
java -Dsun.rmi.dgc.server.gcInterval=2147483646 \
-Dsun.rmi.dgc.client.gcInterval=2147483646 \
MyApp
# 或彻底禁用显式 GC
java -XX:+DisableExplicitGC MyApp
内存泄漏排查
内存泄漏(Memory Leak)是指程序中已分配的堆内存由于某种原因未能被释放,导致可用内存持续减少,最终引发 OutOfMemoryError 或频繁的 Full GC。
常见内存泄漏场景
- 静态集合类:如将对象放入全局的
static HashMap且永不清理。 - 未关闭的资源:数据库连接、文件流、网络连接未在 finally 块中关闭。
- 监听器与回调:注册了监听器但从未注销。
- 缓存失控:如使用
HashMap做无底洞的本地缓存。 - ThreadLocal 滥用:线程池场景下
ThreadLocal使用完毕后未调用remove()。
// ThreadLocal 内存泄漏风险示例
public class ThreadLocalLeakDemo {
// 使用线程池时,线程被复用,ThreadLocal 不清除会持续累积
private static final ThreadLocal<byte[]> LOCAL_CACHE =
ThreadLocal.withInitial(() -> new byte[1024 * 1024]); // 1MB per thread
public void process() {
byte[] cache = LOCAL_CACHE.get();
// 业务逻辑...
// 危险:此处应调用 LOCAL_CACHE.remove() 但实际未调用
}
public static void main(String[] args) {
ExecutorService pool = Executors.newFixedThreadPool(100);
ThreadLocalLeakDemo demo = new ThreadLocalLeakDemo();
for (int i = 0; i < 10000; i++) {
pool.submit(demo::process);
}
// 100 个线程各持有 1MB ThreadLocal 数据,总共泄漏约 100MB
}
}
MAT(Memory Analyzer Tool)分析步骤
当应用发生 OOM 时,应配置 JVM 在崩溃时自动导出堆转储文件,然后使用 Eclipse MAT 进行分析。
# 配置 OOM 时自动生成堆转储
java -XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/var/log/dumps/ \
-XX:+HeapDumpBeforeFullGC \ # Full GC 前也生成(可选)
-jar MyApp.jar
# 手动触发堆转储
jmap -dump:format=b,file=heap.hprof <pid>
# 使用 MAT 分析堆转储
# 1. 下载 Eclipse MAT: https://www.eclipse.org/mat/
# 2. 打开 .hprof 文件
# 3. 运行 "Leak Suspects Report"
# 4. 查看 Dominator Tree 查找 Retained Heap 最大的对象
MAT 分析步骤详解
第一步:打开堆转储文件。MAT 会自动解析并生成若干报告。如果文件较大(>8GB),建议先通过 jmap 的 live 选项在导出前执行一次 Full GC,只保留存活对象。
# 导出前执行 Full GC,减少文件体积
jmap -dump:live,format=b,file=heap-live.hprof <pid>
第二步:查看 Leak Suspects Report。MAT 会基于支配树算法自动推测可能的泄漏嫌疑点,按嫌疑程度排序展示。
第三步:分析 Dominator Tree。找到 Retained Heap 最大的对象,追溯其 GC Root Path(引用链),确认为何这些对象没有被回收。
第四步:直方图对比(Histogram)。如果有两个不同时刻的堆转储,可以通过 MAT 的 Compare Basket 功能对比类实例数量的增长差异。
// 验证修复后的代码:使用 try-finally 确保 ThreadLocal 清理
public void safeProcess() {
try {
byte[] cache = LOCAL_CACHE.get();
// 业务逻辑...
} finally {
LOCAL_CACHE.remove(); // 关键:使用完毕后显式移除
}
}
生产调优案例
案例一:电商平台大促期间 Full GC 频繁
背景:某电商平台在双 11 大促期间,高峰期订单服务每隔 5-10 分钟发生一次 Full GC,每次停顿 2-5 秒,导致大量请求超时。
环境配置:JDK 8,堆 16GB,使用 Parallel Old GC,老年代 10GB。
排查过程:
- 获取 GC 日志,发现 Full GC 的触发原因均为
Ergonomics,即 JVM 认为需要收缩或扩展堆。 - 查看 GC 前后内存情况,发现每次 Full GC 后老年代仅从 9GB 降到 7GB,回收效率极低。
- 通过 jmap 导出堆转储,MAT 分析发现大量
OrderCreateEvent对象堆积在内存中。 - 检查代码,发现消息队列消费者在处理订单创建事件时,为了幂等性检查将最近 1 小时的事件全部缓存在一个静态
ConcurrentHashMap中,且未设置过期清理机制。
解决方案:
# 1. 立即调整:增大堆并切换至 G1,降低停顿时间
java -XX:+UseG1GC \
-Xms24g -Xmx24g \
-XX:MaxGCPauseMillis=200 \
-XX:InitiatingHeapOccupancyPercent=35 \
-XX:+DisableExplicitGC \
OrderService.jar
# 2. 代码修复:将无界 HashMap 替换为 Caffeine 带过期策略的缓存
// 修复后的缓存定义
import com.github.benmanes.caffeine.cache.Caffeine;
import java.util.concurrent.TimeUnit;
public class OrderEventCache {
private static final Cache<String, OrderCreateEvent> EVENT_CACHE =
Caffeine.newBuilder()
.maximumSize(50000) // 限制最大条目数
.expireAfterWrite(10, TimeUnit.MINUTES) // 10 分钟后自动过期
.recordStats() // 开启统计
.build();
public void cacheEvent(String orderId, OrderCreateEvent event) {
EVENT_CACHE.put(orderId, event);
}
public OrderCreateEvent getEvent(String orderId) {
return EVENT_CACHE.getIfPresent(orderId);
}
}
效果:修复后老年代压力显著下降,G1 的停顿时间稳定在 150ms 以内,Full GC 完全消失。
案例二:金融系统从 CMS 迁移到 ZGC
背景:某证券交易系统使用 JDK 8 与 CMS 收集器,堆大小 32GB。随着业务规模扩大,CMS 并发失败(Concurrent Mode Failure)越来越频繁,导致系统退化为 Serial Old Full GC,单次停顿超过 10 秒。
迁移方案:
- 将 JDK 升级到 JDK 21,获得原生 ZGC 支持。
- 启用分代 ZGC(JDK 21 特性),兼顾低延迟与大堆效率。
- 逐台灰度切换,对比延迟 P99 指标。
# 新版系统启动参数
java -server \
-XX:+UseZGC \
-XX:+ZGenerational \
-Xms32g -Xmx32g \
-XX:SoftMaxHeapSize=28g \
-XX:MaxGCPauseMillis=5 \
-XX:+AlwaysPreTouch \
-XX:+ZUncommit \
-XX:ZUncommitDelay=300 \
-XX:ParallelGCThreads=16 \
-XX:ConcGCThreads=8 \
-XX:+DisableExplicitGC \
TradingEngine.jar
效果:迁移后 GC 停顿从平均 300ms、峰值 10s+ 降低到平均 0.5ms、峰值不超过 3ms。系统可用性从 99.9% 提升到 99.99%。
# ZGC 日志确认极低停顿
[2026-09-01T14:22:10.001+0800] GC(12345) Pause Mark Start 0.012ms
[2026-09-01T14:22:10.005+0800] GC(12345) Pause Mark End 0.008ms
[2026-09-01T14:22:10.120+0800] GC(12345) Pause Relocate Start 0.015ms
[2026-09-01T14:22:10.125+0800] GC(12345) Pause Relocate End 0.010ms
案例三:大数据批处理任务吞吐量优化
背景:某离线数据分析任务使用 Spark 处理 TB 级数据,单次作业运行 4 小时,CPU 利用率仅 40%,GC 时间占比高达 25%。
分析:该任务使用默认 G1,但特点是数据对象生命周期极短且分配速率极高(每秒数 GB),G1 的写屏障和并发标记反而成为瓶颈。
解决方案:针对纯批处理、高吞吐量、不在乎停顿的业务,切换回 Parallel GC,最大化吞吐量。
# Spark Executor JVM 参数优化
spark.executor.extraJavaOptions="
-XX:+UseParallelGC
-XX:+UseParallelOldGC
-XX:GCTimeRatio=19
-XX:MaxGCPauseMillis=1000
-Xms16g
-Xmx16g
-XX:+AlwaysPreTouch
-XX:ParallelGCThreads=16
"
效果:CPU 利用率提升到 85%,单次作业时间从 4 小时缩短到 2.5 小时,GC 时间占比降至 3%。
常见问题(FAQ)
Q1:为什么将 -Xms 和 -Xmx 设为相同值可以提升性能?
A1:当 -Xms 小于 -Xmx 时,JVM 在运行期间会根据负载动态扩展或收缩堆。这一调整过程需要触发 Full GC 来回收和整理内存,从而引入不可预测的停顿。将两者设为相同值并配合 -XX:+AlwaysPreTouch,可以在启动时一次性向操作系统申请全部内存并完成内存页初始化,消除运行期的堆调整开销。
Q2:G1 的 MaxGCPauseMillis 参数设置为 10ms,为什么实际停顿还是 100ms?
A2:-XX:MaxGCPauseMillis 只是 GC 的软目标,而非硬性保证。G1 会尽力通过调整收集的 Region 数量接近该目标,但如果年轻代中有大量存活对象需要复制,或者 Humongous Region 过多导致清理开销激增,实际停顿时间仍可能远超目标值。此外,如果应用程序在 GC 期间产生了异常高的对象分配速率,也会使 G1 来不及响应。
Q3:ZGC 宣称停顿时间不超过 10ms,是否意味着我的系统永远不会出现卡顿?
A3:ZGC 的 10ms 承诺仅指垃圾收集本身的停顿(Root Scanning 等极短阶段)。但系统的卡顿还可能源于操作系统层面的 Page Fault、磁盘 I/O 阻塞、网络延迟、业务代码中的锁竞争等。ZGC 消除了 GC 带来的停顿,但无法解决其他非 GC 因素导致的延迟问题。
Q4:我的应用没有内存泄漏,为什么老年代还在持续增长?
A4:老年代增长未必等同于泄漏。可能的原因包括:对象的生命周期本身就很长(如缓存、会话、连接池);年轻代太小导致对象过早晋升(Premature Promotion);MaxTenuringThreshold 设置过低;或者存在大量大对象直接进入老年代。建议使用 jstat 观察每次 GC 各区域的变化,结合对象的年龄分布信息综合分析。
Q5:CMS 已经废弃,但我的系统还在 JDK 8 且无法升级,有什么应急调优手段?
A5:在必须继续使用 CMS 的场景下,可以尝试以下措施:
- 将
-XX:CMSInitiatingOccupancyFraction从默认的 68% 降低到 50% 甚至更低,尽早启动并发标记。 - 启用
-XX:+ CMSScavengeBeforeRemark,在重新标记前先执行一次 Minor GC,减少重新标记的扫描范围。 - 增大年轻代空间,减少对象过早晋升到老年代的概率。
- 增加 Survivor 空间,允许对象在年轻代中多经历几次 GC 再晋升。
# CMS 应急调优参数
java -XX:+UseConcMarkSweepGC \
-XX:CMSInitiatingOccupancyFraction=50 \
-XX:+UseCMSInitiatingOccupancyOnly \
-XX:+CMSScavengeBeforeRemark \
-XX:+CMSParallelRemarkEnabled \
-Xmn2g -Xms8g -Xmx8g \
LegacyApp
总结
JVM 内存管理与垃圾回收是 Java 性能优化的基石。从堆内存的 Eden、Survivor、Old 到 Metaspace 的职责划分,从 TLAB 分配优化到逃逸分析的栈上分配,从 Serial 单线程收集到 ZGC 的亚毫秒级并发回收,每一个环节都蕴含着值得深究的技术原理。
在实际工作中,没有最好的垃圾收集器,只有最合适的垃圾收集器。对于追求极致吞吐量的离线批处理任务,Parallel GC 依然是可靠的选择;对于延迟敏感且堆容量在 6GB 以上的互联网服务,G1 提供了最均衡的方案;而对于需要毫秒级响应的金融科技系统,ZGC 和 Shenandoah 则是通往低延迟未来之路。
调优的终点不是记住所有参数,而是学会阅读 GC 日志、分析内存转储、定位瓶颈来源。希望本文提供的知识体系、配置模板和生产案例,能够帮助你在面对复杂的 JVM 问题时胸有成竹,从容应对。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。