JVM 调优:内存模型、GC 参数与故障排查
引言:我们为什么需要JVM调优
Java 程序运行在 Java 虚拟机(JVM)之上,JVM 负责内存管理、线程调度和字节码执行。很多应用在测试环境表现正常,一到生产环境就出现响应变慢、频繁停顿甚至内存溢出(OOM),根源往往在于 JVM 参数配置不当。
本教程将从内存模型、垃圾回收(GC)参数和典型故障排查三个核心维度出发,帮助你建立一套实用的 JVM 调优体系。无论你是后端开发、运维,还是正在准备面试,掌握这些知识都能让你更从容地面对性能问题。
一、JVM 内存模型:你的对象住在哪里
调优的第一步是理解内存是如何划分的。以最常用的 HotSpot 虚拟机为例,其运行时数据区主要分为以下几个部分:
1.1 堆(Heap)
堆是 JVM 中最大的一块内存区域,几乎所有对象实例都在这里分配。堆进一步分为:
- 新生代(Young Generation):新创建的对象首先分配在这里。又细分为一个 Eden 区和两个 Survivor 区(S0、S1)。
- 老年代(Old Generation):经历多次 Minor GC 后仍然存活的对象会被晋升到老年代。
核心调优点:控制新生代和老年代的大小比例,能直接影响 Minor GC 的频率和 Full GC 的停顿时间。
1.2 方法区 / 元空间
- JDK 7 及之前:称为永久代(PermGen),存储类的元数据、静态变量、常量池等,大小通过
-XX:PermSize-XX:MaxPermSize设置,容易发生 OOM。 - JDK 8 及之后:永久代被彻底移除,改用元空间(Metaspace),使用本地内存。由
-XX:MetaspaceSize和-XX:MaxMetaspaceSize控制。
注意:大量动态生成类的场景(如 CGLIB、动态代理、Groovy)如果未限制元空间大小,可能耗尽系统内存。
1.3 虚拟机栈与本地方法栈
每个线程拥有独立的虚拟机栈,每个方法执行时创建一个栈帧存储局部变量、操作数栈等。栈深度过深或局部变量表过大都会导致 StackOverflowError 或 OOM(如果能动态扩展)。通常无需主动调优,但排查内存问题时不能忽略其对整体内存的占用。
1.4 直接内存
NIO 中的 DirectByteBuffer 直接在堆外分配内存,不受 JVM 堆大小限制,但受系统总物理内存限制。与元空间一样,过大的直接内存使用可能引起 OOM。
二、垃圾回收(GC)核心原理
JVM 通过垃圾回收自动释放不再使用的对象内存。了解 GC 如何工作,才能有针对性地调整参数。
2.1 对象何时被回收?
目前主流的 JVM 使用可达性分析算法:以一系列 GC Roots(如栈帧中的引用、静态变量等)为起点,通过引用链向下搜索,凡是没有可达路径的对象就被判定为“可回收”。
2.2 分代收集与 GC 类型
几乎所有的商业 GC 收集器都基于分代假说设计:
- Minor GC / Young GC:只回收新生代,发生频繁,停顿时间短。
- Major GC / Old GC:只回收老年代,通常伴随至少一次 Minor GC(不同的收集器划分略有差异)。
- Full GC:回收整个堆和方法区/元空间,停顿时间长,是调优中要极力减少甚至避免的。
2.3 常见 GC 收集器速览
| 收集器 | 区域 | 算法 | 特点 | 适用场景 |
|---|---|---|---|---|
| Serial | 新生代 | 复制 | 单线程,停顿明显 | 客户端应用 |
| ParNew | 新生代 | 复制 | Serial 的多线程版本 | 配合 CMS 使用 |
| Parallel Scavenge | 新生代 | 复制 | 关注吞吐量 | 后台计算任务 |
| Serial Old | 老年代 | 标记‑整理 | 单线程 | 客户端或 CMS 后备 |
| Parallel Old | 老年代 | 标记‑整理 | 多线程 | 配合 Parallel Scavenge |
| CMS | 老年代 | 标记‑清除 | 低停顿,并发收集 | JDK 8 及之前 Web 应用 |
| G1 | 整个堆 | 分区标记‑复制 | 可控停顿,兼顾吞吐 | JDK 9+ 默认,大堆(>4G) |
| ZGC/Shenandoah | 整个堆 | 分区并发 | 亚毫秒级停顿 | 超低延迟场景(JDK 11+) |
三、核心 GC 参数配置指南
GC 调优的 80% 工作在于选择合适的收集器和分配合理的内存比例。
3.1 基础堆大小参数
-Xms4g # 初始堆大小,建议与 -Xmx 设置相同,避免内存抖动
-Xmx4g # 最大堆大小
-Xmn2g # 新生代大小(包含 Eden+S0+S1)。也可用 -XX:NewRatio 控制
-XX:SurvivorRatio=8 # Eden 与单个 Survivor 的比例,默认 8,即 Eden:S0:S1=8:1:1
经验法则:
- Web 应用常见新生代占比 1/3 到 1/2 堆大小,避免短期对象过早进入老年代。
- 将
-Xms和-Xmx设置为相同值,防止堆动态调整耗费资源。
3.2 选择收集器与关键开关
# 使用 G1 收集器(JDK 8 需显式指定,JDK 9+ 默认)
-XX:+UseG1GC
# 期望的最大 GC 停顿时间(G1 尽力达成,非绝对保证)
-XX:MaxGCPauseMillis=200
# 使用 CMS 收集器(JDK 8 可用)
-XX:+UseConcMarkSweepGC
-XX:CMSInitiatingOccupancyFraction=70 # 老年代使用率达 70% 时触发 CMS GC
-XX:+UseCMSInitiatingOccupancyOnly # 只根据设定阈值触发,不自动调整
# 使用并行收集器(追求吞吐量)
-XX:+UseParallelGC
-XX:ParallelGCThreads=4 # 并行收集线程数
3.3 打印 GC 日志(必备)
无论测试还是生产,都强烈建议开启详细的 GC 日志,这是故障复盘最直接的依据。
# JDK 8 及之前
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/path/to/gc.log
# JDK 9+ 统一日志格式
-Xlog:gc*:file=/path/to/gc.log:time,level,tags
日志中关注 Full GC 次数、停顿时间、新生代/老年代回收前后大小,特别警惕 [Full GC (Allocation Failure) …] 反复出现的情况。
3.4 内存溢出自动转储
-XX:+HeapDumpOnOutOfMemoryError # OOM 时自动生成堆转储文件
-XX:HeapDumpPath=/dump/hprof # 指定转储路径
四、故障排查:从现象到根因
当线上应用出现异常,你需要快速定位问题。以下是几种高频问题的排查思路。
4.1 CPU 飙升或线程死锁
使用 top 找到 Java 进程 PID,然后:
# 查看 CPU 占用最高的线程
top -Hp <pid>
# 将线程 ID 转为 16 进制,打印线程栈
printf "%x\n" <线程id>
jstack <pid> | grep -A 20 <十六进制线程id>
重点关注 BLOCKED 或 RUNNABLE 但反复出现在同一个方法栈的线程。
4.2 Full GC 频繁,响应变慢
工具组合:
# 每 1000ms 统计一次 GC 状态,共 10 次
jstat -gc <pid> 1000 10