一、为什么要学 JVM
作为 Java 开发者,你可能写过无数业务代码,但遇到这些问题时是否感到无力:
- 线上服务突然 OOM, dump 文件几十 G,不知道从哪下手
- 接口响应越来越慢,CPU 飙高,线程 dump 看不出问题
- 同样的代码,本地跑得好好的,线上就频繁 Full GC
- 面试被问到 JVM 调优经验,只能说"调过堆内存大小"
理解 JVM 是成为高级 Java 工程师的必经之路。 它不只是面试八股文,更是解决线上问题的核心能力。
二、JVM 整体架构
scss
┌─────────────────────────────────────────────────────────┐
│ 类加载器子系统 │
│ Bootstrap → Extension → Application → 自定义类加载器 │
├─────────────────────────────────────────────────────────┤
│ 运行时数据区 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 方法区 │ │ 堆内存 │ │ 虚拟机栈 │ │ 本地方法栈│ │
│ │ (元空间) │ │ (新生代/ │ │ │ │ │ │
│ │ │ │ 老年代) │ │ │ │ │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ ┌──────────┐ │
│ │ 程序计数器│ │
│ └──────────┘ │
├─────────────────────────────────────────────────────────┤
│ 执行引擎 │
│ 解释器 → JIT 编译器 → 垃圾回收器 │
├─────────────────────────────────────────────────────────┤
│ 本地方法接口 │
│ JNI │
└─────────────────────────────────────────────────────────┘
三、运行时数据区详解
3.1 堆内存(Heap)
堆是 JVM 管理的最大一块内存,所有对象实例和数组都在这里分配。
堆内存结构
scss
┌─────────────────────────────────────────┐
│ 老年代 (Old Generation) │ ← 占用 2/3 堆空间
│ (存放长期存活的对象,Full GC 区域) │
├─────────────────────────────────────────┤
│ Eden │ Survivor0 │ Survivor1 │ ← 新生代,占用 1/3 堆空间
│ (8/10) │ (1/10) │ (1/10) │
│ │ From │ To │
└─────────────────────────────────────────┘
新生代(Young Generation)
- Eden 区:新对象首先分配在这里,占新生代 8⁄10
- Survivor 区:两块相同大小的区域(From 和 To),占新生代各 1⁄10
- 对象在 Eden 区经历 Minor GC 后存活,会移到 Survivor 区
老年代(Old Generation)
- 存放长期存活的对象
- 对象在 Survivor 区经历一定次数 GC 后晋升到老年代
- 老年代满了会触发 Full GC(或 Major GC)
对象分配与晋升流程
scss
新对象 → Eden 区
↓
Eden 满了 → Minor GC
↓
存活对象 → Survivor From 区 (年龄 +1)
↓
下次 Minor GC → Survivor To 区 (年龄 +1)
↓
年龄达到阈值 (默认 15) → 老年代
↓
老年代满了 → Full GC
代码示例:观察对象晋升
arduino
public class HeapAllocation {
// -Xms20m -Xmx20m -Xmn10m -XX:+PrintGCDetails -XX:SurvivorRatio=8
// 堆 20M,新生代 10M,Eden 8M,Survivor 各 1M
private static final int _1MB = 1024 * 1024;
public static void main(String[] args) {
byte[] allocation1 = new byte[2 * _1MB]; // Eden 区
byte[] allocation2 = new byte[2 * _1MB]; // Eden 区
byte[] allocation3 = new byte[2 * _1MB]; // Eden 区
// 触发 Minor GC,前面三个对象晋升到老年代
byte[] allocation4 = new byte[4 * _1MB]; // Eden 区放不下,触发 GC
}
}
大对象直接进入老年代
arduino
// -XX:PretenureSizeThreshold=3145728 (3MB)
// 大于 3MB 的对象直接在老年代分配
byte[] bigObject = new byte[4 * 1024 * 1024]; // 4MB,直接进入老年代
为什么要这样设计?
- 避免大对象在 Eden 和 Survivor 之间来回复制,浪费性能
- 大对象生命周期通常较长,直接放老年代更合理
3.2 方法区(Method Area)
存储已被加载的类信息、常量、静态变量、即时编译器编译后的代码等。
JDK 8 之前 vs JDK 8 之后
| 版本 | 实现 | 内存位置 | 垃圾回收 |
|---|---|---|---|
| JDK 7 及之前 | 永久代(PermGen) | JVM 内存 | Full GC 回收 |
| JDK 8 及之后 | 元空间(Metaspace) | 本地内存 | 独立回收 |
元空间(Metaspace)
scss
┌─────────────────────────────────────┐
│ 元空间 (Metaspace) │
│ ┌─────────┐ ┌─────────┐ ┌────────┐ │
│ │ 类元数据 │ │ 方法元数据│ │ 常量池 │ │
│ └─────────┘ └─────────┘ └────────┘ │
│ ┌─────────┐ ┌─────────┐ │
│ │ 字段元数据│ │ 静态变量 │ │
│ └─────────┘ └─────────┘ │
└─────────────────────────────────────┘
元空间大小设置:
ini
# 初始元空间大小
-XX:MetaspaceSize=128m
# 最大元空间大小
-XX:MaxMetaspaceSize=256m
为什么从永久代改为元空间?
- 永久代有固定大小上限 ,容易 OOM(
java.lang.OutOfMemoryError: PermGen space) - 元空间使用本地内存,大小只受限于物理内存
- 元空间有独立的垃圾回收机制,不需要等待 Full GC
运行时常量池
ini
public class ConstantPoolExample {
public static void main(String[] args) {
String s1 = "hello"; // 常量池
String s2 = "hello"; // 常量池,s1 == s2
String s3 = new String("hello"); // 堆中新建对象
System.out.println(s1 == s2); // true
System.out.println(s1 == s3); // false
System.out.println(s1 == s3.intern()); // true,intern() 把堆中字符串放入常量池
}
}
3.3 虚拟机栈(VM Stack)
每个线程私有的内存区域,存储栈帧(Stack Frame)。
栈帧结构
scss
┌─────────────────────────┐
│ 栈帧 (Stack Frame) │ ← 每个方法调用创建一个栈帧
├─────────────────────────┤
│ 局部变量表 (Local Variables) │
│ - 基本数据类型 │
│ - 对象引用 │
│ - returnAddress │
├─────────────────────────┤
│ 操作数栈 (Operand Stack) │
│ - 方法执行的工作区 │
├─────────────────────────┤
│ 动态链接 (Dynamic Linking) │
│ - 指向运行时常量池的方法引用 │
├─────────────────────────┤
│ 方法返回地址 (Return Address)│
│ - 方法执行完返回到哪里 │
└─────────────────────────┘
栈溢出示例
csharp
public class StackOverflowExample {
private int stackDepth = 0;
public void recursiveMethod() {
stackDepth++;
recursiveMethod(); // 无限递归
}
public static void main(String[] args) {
try {
new StackOverflowExample().recursiveMethod();
} catch (StackOverflowError e) {
System.out.println("Stack depth: " + stackDepth);
// 默认栈大小下,大约递归 1万+ 次就会溢出
}
}
}
设置栈大小:
bash
# 设置线程栈大小为 1MB
-Xss1m
3.4 本地方法栈(Native Method Stack)
为虚拟机使用到的 Native 方法服务。
JDK 中有很多 Native 方法,比如:
java
// java.lang.Object 中的 native 方法
public native int hashCode();
public native Object clone();
// java.lang.Thread 中的 native 方法
public static native void yield();
public static native void sleep(long millis);
// java.lang.System 中的 native 方法
public static native void arraycopy(Object src, int srcPos,
Object dest, int destPos, int length);
HotSpot 虚拟机中,本地方法栈和虚拟机栈合二为一。
3.5 程序计数器(Program Counter Register)
当前线程所执行的字节码的行号指示器。
- 线程私有,每个线程独立
- 占用内存很小,不会发生 OOM
- 如果执行的是 Native 方法,计数器值为空(Undefined)
为什么需要程序计数器?
Java 是多线程的,线程切换后需要知道从哪里继续执行。程序计数器记录了当前线程执行的位置。
四、垃圾回收机制
4.1 判断对象是否存活
引用计数法(Python 使用,Java 不用)
css
对象 A 被引用次数 = 2
对象 B 被引用次数 = 1(被 A 引用)
对象 C 被引用次数 = 0 → 可回收
缺点:无法解决循环引用问题
css
A.instance = B;
B.instance = A;
// A 和 B 互相引用,引用计数都不为 0,但应该被回收
可达性分析算法(Java 使用)
vbnet
GC Roots
├── 虚拟机栈中引用的对象
├── 方法区中类静态属性引用的对象
├── 方法区中常量引用的对象
├── 本地方法栈中 JNI 引用的对象
├── 所有被同步锁持有的对象
└── JVM 内部的引用(基本数据类型对应的 Class 对象)
从 GC Roots 出发,沿着引用链搜索,不可达的对象就是垃圾
代码示例:
typescript
public class GcRootsExample {
private static Object staticObject = new Object(); // GC Root:静态变量
public void method() {
Object localObject = new Object(); // GC Root:局部变量
// method 执行完,localObject 不再被引用,可以被回收
// staticObject 一直存在,直到类卸载
}
}
4.2 垃圾回收算法
标记-清除算法(Mark-Sweep)
css
阶段1:标记 - 从 GC Roots 遍历,标记所有可达对象
阶段2:清除 - 回收未被标记的对象
内存状态:
[存活][垃圾][存活][垃圾][垃圾][存活]
↓ 清除后
[存活][空闲][存活][空闲][空闲][存活]
缺点:产生内存碎片
复制算法(Copying)
css
将内存分为两块:From 和 To
阶段1:标记存活对象
阶段2:将存活对象复制到 To 区
阶段3:清空 From 区
阶段4:交换 From 和 To
[存活][垃圾][存活][垃圾] From
[空][空][空][空] To
↓ 复制后
[空][空][空][空] From(下次用)
[存活][存活][空][空] To(下次变成 From)
优点:无内存碎片
缺点:内存利用率只有 50%
新生代使用复制算法,因为新生代对象存活率低,复制开销小。
标记-整理算法(Mark-Compact)
css
阶段1:标记存活对象
阶段2:将存活对象向一端移动
阶段3:清理边界外的内存
[存活][垃圾][存活][垃圾][垃圾][存活]
↓ 整理后
[存活][存活][存活][空闲][空闲][空闲]
优点:无内存碎片,内存利用率高
缺点:移动对象需要更新引用地址,开销较大
老年代使用标记-整理算法,因为老年代对象存活率高,复制算法代价太大。
4.3 垃圾收集器
新生代收集器
Serial(串行)
ruby
单线程收集器,收集时暂停所有工作线程(Stop The World)
适用:单 CPU 环境,客户端模式
参数:-XX:+UseSerialGC
ParNew(并行)
ruby
Serial 的多线程版本,多个线程并行收集
适用:多 CPU 环境,配合 CMS 使用
参数:-XX:+UseParNewGC
Parallel Scavenge(吞吐量优先)
ini
目标是达到可控的吞吐量(用户代码时间 / 总时间)
参数:
-XX:+UseParallelGC
-XX:MaxGCPauseMillis=200 (最大停顿时间)
-XX:GCTimeRatio=99 (吞吐量 99%,即 GC 时间占 1%)
老年代收集器
Serial Old
arduino
Serial 的老年代版本,单线程,标记-整理算法
Parallel Old
ruby
Parallel Scavenge 的老年代版本,多线程,标记-整理算法
参数:-XX:+UseParallelOldGC
CMS(Concurrent Mark Sweep)
markdown
目标:最短停顿时间
算法:标记-清除(会产生碎片)
执行过程:
1. 初始标记(STW,很快)
2. 并发标记(与用户线程并发执行)
3. 重新标记(STW,比初始标记长,但比并发标记短)
4. 并发清除(与用户线程并发执行)
参数:-XX:+UseConcMarkSweepGC
缺点:
- 对 CPU 资源敏感
- 无法处理浮动垃圾(并发清理时产生的新垃圾)
- 产生内存碎片
G1 收集器(Garbage First)
markdown
JDK 9 后的默认收集器,兼顾吞吐量和停顿时间
设计思想:
- 将堆划分为多个 Region(1MB ~ 32MB)
- 每个 Region 可以是 Eden、Survivor 或 Old
- 优先回收垃圾最多的 Region(Garbage First)
执行过程:
1. 初始标记(STW)
2. 并发标记
3. 最终标记(STW)
4. 筛选回收(STW,并行执行)
参数:
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200 (期望最大停顿时间)
优点:
- 可预测的停顿时间
- 无内存碎片(整体看是标记-整理,局部看是复制)
- 适用于大堆内存(6GB 以上)
ZGC 和 Shenandoah(低延迟)
diff
目标:停顿时间不超过 10ms,且与堆大小无关
ZGC 参数:-XX:+UseZGC
Shenandoah 参数:-XX:+UseShenandoahGC
适用场景:
- 超大堆内存(TB 级别)
- 对延迟极度敏感的应用(金融交易、游戏服务器)
JDK 版本要求:
- ZGC:JDK 11+(生产可用),JDK 15+ 正式支持
- Shenandoah:JDK 12+(Red Hat 开发)
4.4 垃圾收集器选择建议
| 场景 | 推荐收集器 | 参数 |
|---|---|---|
| 单核/小内存 | Serial + Serial Old | -XX:+UseSerialGC |
| 吞吐量为先(后台计算) | Parallel Scavenge + Parallel Old | -XX:+UseParallelGC |
| 低延迟为先(Web 应用) | CMS / G1 | -XX:+UseG1GC |
| 大堆内存(6G+) | G1 | -XX:+UseG1GC |
| 超大堆/极致低延迟 | ZGC / Shenandoah | -XX:+UseZGC |
五、JVM 参数与调优
5.1 内存参数
ini
# 堆内存设置
-Xms4g # 初始堆大小 4GB
-Xmx4g # 最大堆大小 4GB(建议 Xms = Xmx,避免动态扩缩容)
-Xmn1g # 新生代大小 1GB
# 元空间设置
-XX:MetaspaceSize=128m
-XX:MaxMetaspaceSize=256m
# 栈大小
-Xss1m # 每个线程栈大小 1MB
# 直接内存(NIO 使用)
-XX:MaxDirectMemorySize=1g
5.2 GC 参数
ruby
# 使用 G1 收集器
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
# 打印 GC 日志
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:/var/log/app/gc.log
# GC 日志文件轮转(JDK 9+)
-XX:+UseGCLogFileRotation
-XX:NumberOfGCLogFiles=10
-XX:GCLogFileSize=100M
# 发生 OOM 时自动生成堆 dump
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/app/heapdump.hprof
5.3 调优案例分析
案例一:频繁 Full GC
现象: 线上服务每隔几分钟就 Full GC,停顿时间 2-3 秒
排查:
scss
# 查看 GC 日志
[Full GC (Allocation Failure) [PSYoungGen: 153600K->0K(179200K)]
[ParOldGen: 409600K->412300K(409600K)] 563200K->412300K(588800K),
[Metaspace: 67890K->67890K(110592K)], 2.3456789 secs]
分析:
- 老年代 409600K → 412300K,回收前后几乎没变化
- 说明老年代对象都是存活的,没有可回收的垃圾
- 老年代满了,新对象晋升不进来,触发 Full GC
原因: 老年代空间太小,或者对象晋升太快
解决:
ini
# 增大老年代空间
-Xms8g -Xmx8g -Xmn3g # 老年代 = 8g - 3g = 5g
# 或者增大晋升阈值,让对象在新生代多待一会儿
-XX:MaxTenuringThreshold=15 # 默认 15,可以适当增大
案例二:Metaspace OOM
现象: 服务运行一段时间后 OOM,错误信息 java.lang.OutOfMemoryError: Metaspace
原因: 动态生成类过多(如 CGLIB 代理、反射、动态脚本),元空间被占满
解决:
ini
# 增大元空间上限
-XX:MaxMetaspaceSize=512m
# 或者检查代码,是否有类加载泄漏
# 常见原因:动态代理类没有正确卸载、OSGi 热部署等
案例三:Young GC 频繁
现象: 每秒都有 Young GC,每次停顿 50ms
排查:
scss
# GC 日志显示
[GC (Allocation Failure) [PSYoungGen: 153600K->2048K(179200K)]
153600K->2048K(588800K), 0.0501234 secs]
分析:
- 新生代 179200K,每次 GC 后从 153600K 降到 2048K
- 说明新生代空间太小,对象很快填满,频繁触发 GC
解决:
ini
# 增大新生代空间
-Xmn2g # 原来是 1g,改为 2g
# 或者调整 Eden 和 Survivor 比例
-XX:SurvivorRatio=6 # Eden : Survivor = 6 : 1 : 1
六、JVM 监控与诊断工具
6.1 命令行工具
jps:查看 Java 进程
ruby
$ jps -lvm
12345 com.example.Application -Xms4g -Xmx4g
12346 sun.tools.jps.Jps -lvm
jstat:查看 GC 统计
yaml
# 每隔 1 秒打印一次 GC 统计,共打印 10 次
$ jstat -gcutil 12345 1000 10
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
0.00 99.99 45.23 67.89 98.12 95.67 1234 12.345 12 34.567 46.912
字段说明:
- S0/S1:Survivor 0/1 区使用率
- E:Eden 区使用率
- O:Old 区使用率
- M:Metaspace 使用率
- YGC/YGCT:Young GC 次数/总耗时
- FGC/FGCT:Full GC 次数/总耗时
jmap:生成堆 dump
perl
# 生成堆 dump 文件
$ jmap -dump:format=b,file=heap.hprof 12345
# 查看堆中对象统计
$ jmap -histo 12345 | head -20
num #instances #bytes class name
----------------------------------------------
1: 1234567 98765360 [B
2: 234567 56245680 java.lang.String
3: 123456 29629440 java.util.HashMap$Node
jstack:查看线程栈
shell
# 打印线程 dump
$ jstack 12345 > thread_dump.txt
# 查看死锁
$ jstack -l 12345 | grep -A 50 "Found one Java-level deadlock"
6.2 可视化工具
VisualVM(JDK 自带)
ruby
$ jvisualvm
功能:
- 监控 CPU、内存、GC
- 生成和分析堆 dump
- 线程分析
- 采样分析热点方法
MAT(Memory Analyzer Tool)
专门分析堆 dump 的工具,功能强大:
bash
# 打开 heap.hprof 文件
# 自动分析内存泄漏嫌疑
# 查看 Dominator Tree、Histogram、Leak Suspects
Arthas(阿里开源)
ruby
# attach 到目标进程
$ java -jar arthas-boot.jar
# 常用命令
top # 查看线程 CPU 占用
heapdump # 生成堆 dump
jad com.example.Service # 反编译类
watch com.example.Service getOrder '{params,returnObj}' # 方法入参和返回值监控
七、总结
理解 JVM 是 Java 开发者的必修课。本文从内存结构、垃圾回收、调优实践三个层面进行了系统讲解:
- 运行时数据区:堆、方法区、栈、程序计数器各司其职
- 垃圾回收:可达性分析、三种算法、多种收集器的选择
- 调优实战:参数设置、案例分析、监控工具的使用
掌握这些知识,面对线上 OOM、GC 问题时,就能做到心中有数、手中有招。