上篇我们聊了 JVM 的基础——类加载、内存结构、垃圾回收算法。如果你已经读完了上篇,恭喜你,你已经掌握了 JVM 知识体系的"骨架"。但就像学开车一样,光知道方向盘、油门、刹车在哪还不够,你还得上路跑一圈才能真正学会。
本篇是 JVM 系列的下篇,我们从"是什么"转向"怎么用"——具体到每一个垃圾收集器怎么选、GC 日志怎么看、线上出了问题用什么工具排查、面试时怎么把零散的知识点串联成一个漂亮的回答。如果说上篇帮你"懂 JVM",那这一篇的目标就是帮你"用 JVM"。
第一章:垃圾收集器——HotSpot 的"环卫工"们
上篇我们讲了垃圾回收的算法(标记-清除、标记-复制、标记-整理),但算法只是理论。真正在 JVM 里干活的,是垃圾收集器——它们是算法的具体实现。
1.1 经典收集器全家福
先认识一下各位"环卫工"以及它们的分工搭配:
连线表示经典搭配。上半部分的三个组合是 JDK 8 时代的主流,下半部分的 G1 和 ZGC 是现在和未来的趋势。
1.2 逐个认识它们
Serial / Serial Old——单线程的"老古董"
工作时要暂停所有用户线程(Stop The World,简称 STW),然后单线程慢慢干活。适合单核 CPU 或内存很小的桌面应用。服务器端基本不用,但理解它是理解其他收集器的起点。
ParNew——Serial 的多线程版
新生代用多条 GC 线程并行干活,默认搭配老年代的 CMS。在 JDK 8 时代是很多 Web 应用的选择。
Parallel Scavenge / Parallel Old——追求吞吐量
这两个收集器的重点是 "吞吐量"(运行用户代码的时间占比)。如果你跑后台批处理任务,对偶尔的停顿不敏感,这对搭档是首选。JDK 8 的默认组合就是它们。
CMS(Concurrent Mark Sweep)——追求低延迟
CMS 的设计目标是 "用户线程的停顿时间越短越好"。GC 的某些阶段可以和用户线程并发执行,大幅减少了 STW 时间。但它有两个硬伤:
CMS 已于 JDK 14 被正式废弃。如果你负责的系统还在用 CMS,建议尽快迁移到 G1。
G1(Garbage First)——平衡之王
JDK 9 开始成为默认收集器。核心理念不再是"先分代再收集",而是把堆分成大量大小相等的 Region,每次 GC 优先回收垃圾最多的 Region(所以叫 Garbage First):
G1 最大的优势是停顿时间可预测——通过 -XX:MaxGCPauseMillis=200 设定你期望的最大停顿时间,G1 会尽量把每次 GC 控制在目标范围内。
ZGC——超大堆低延迟的终极方案
JDK 11 引入,JDK 15 正式生产可用。目标是无论堆是 8MB 还是 16TB,STW 时间都控制在 10ms 以内。它用了着色指针和读屏障等高级技术,是目前延迟最低的收集器。
1.3 选型速查表
| JDK 8、堆 2G-4G、后台批处理 | Parallel Scavenge | 高吞吐量,JDK 8 默认 |
| JDK 8、堆 4G-8G、Web 应用 | G1(手动开启) | 低延迟,可预测 |
| JDK 8、堆 8G+、对延迟敏感 | G1(手动开启) | 大堆优势明显 |
| JDK 11+、通用 Web 应用 | G1(默认) | 不用配置,开箱即用 |
| JDK 17+、超大堆、极低延迟 | ZGC | 10ms 以内 STW |
| 小型桌面应用 | Serial | 简单够用 |
实用建议:如果你在用 JDK 8 且堆在 4G 以上,加一行配置就够了:
-XX:+UseG1GC -XX:MaxGCPauseMillis=200
第二章:GC 日志与常用参数——线上排障的瑞士军刀
知道收集器怎么工作还不够。真正到了线上出问题时,你手里最有力的武器就是 GC 日志和 JVM 参数。
2.1 堆内存参数速查
| -Xms | 初始堆大小 | -Xms2g |
| -Xmx | 最大堆大小 | -Xmx4g |
| -XX:NewRatio | 老年代/新生代比例 | -XX:NewRatio=2(老年代:新生代 = 2:1) |
| -XX:SurvivorRatio | Eden 与单个 Survivor 比例 | -XX:SurvivorRatio=8(Eden:Survivor = 8:1) |
| -XX:MaxMetaspaceSize | 元空间最大值 | -XX:MaxMetaspaceSize=256m |
| -XX:MaxDirectMemorySize | 最大堆外内存 | -XX:MaxDirectMemorySize=512m |
注意:-Xms 和 -Xmx 建议设为相同值,避免堆在运行时频繁扩展和收缩。生产环境通常设为物理内存的 50%-75%。
2.2 生产必备参数
# 这几个参数强烈建议所有生产环境都加上
-XX:+HeapDumpOnOutOfMemoryError # OOM 时自动 dump 堆快照
-XX:HeapDumpPath=/data/logs/heap-dump/ # dump 文件存放路径
-XX:ErrorFile=/data/logs/hs_err_pid%p.log # JVM 崩溃时的错误日志
-XX:+PrintCommandLineFlags # 启动时打印所有 JVM 参数(方便确认参数是否生效)
2.3 如何阅读 GC 日志?
JDK 8 开启 GC 日志:
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/data/logs/gc.log
JDK 11+ 开启 GC 日志:
-Xlog:gc*:file=/data/logs/gc.log:time,level,tags
日志解读示例(G1):
[2024-06-01T14:23:45.123+0800] GC(131) Pause Young (Normal) (G1 Evacuation Pause) 812M->726M(2048M) 12.321ms
[2024-06-01T14:23:45.123+0800] GC(131) User=0.04s Sys=0.01s Real=0.01s
逐字段拆解:
| GC(131) | 第 131 次 GC |
| Pause Young (Normal) | 新生代回收,需要 STW |
| 812M->726M(2048M) | GC 前堆占用 812M → GC 后 726M(堆总大小 2048M) |
| 12.321ms | 本次 GC 耗时 |
| User=0.04s | CPU 用户态耗时 |
| Sys=0.01s | 内核态耗时 |
| Real=0.01s | 实际壁钟时间 |
看 GC 日志的口诀:先看频率,再看耗时。Full GC 每小时几十次 → 有问题。每次 GC 耗时超过 1 秒 → 也要查。
第三章:JVM 诊断工具——线上出问题时的应对策略
理论学了不少,但如果线上真的出了问题——CPU 飙升、内存泄漏、死锁——你怎么办?这一章我们全用命令说话。
3.1 jps:一切诊断的起点
jps -l
# 输出示例:
# 12345 com.example.MyApplication
# 12346 jdk.jcmd/sun.tools.jps.Jps
所有其他工具都需要进程 ID,所以 jps 是第一步。你也可以用 ps -ef | grep java,但 jps 更精准。
3.2 排查 CPU 飙升的标准三板斧
# 第一步:找到 CPU 最高的线程
top -Hp 12345
# 假设发现线程 12389 的 CPU 使用率最高
# 第二步:线程 ID 转十六进制
printf "%x\\n" 12389
# 输出:3065
# 第三步:在 jstack 输出中搜索该线程
jstack 12345 | grep -A 20 3065
# 定位到具体代码行
这就是 CPU 排查的标准流程,五分钟内定位到问题代码。
3.3 jstack:线程分析的瑞士军刀
jstack 12345 > thread.txt # 导出线程快照
jstack 能帮你看到:
- 每个线程在干什么(RUNNABLE / BLOCKED / WAITING / TIMED_WAITING)
- 是否有死锁——输出末尾会直接标注 Found one Java-level deadlock:
- 是否有线程长期处于 BLOCKED 状态(可能锁竞争严重)
建议:出问题时连续抓 3-5 次 jstack(间隔 2 秒),比只抓一次更能看出问题。如果某个线程每次都在同一行代码上卡着,基本就是它了。
3.4 jmap + MAT:内存泄漏排查
# 查看堆内存概况
jmap -heap 12345
# 快速统计:哪些类的实例最多?(生产慎用,耗时)
jmap -histo:live 12345 | head -30
# 导出堆 dump 文件(生产慎用,会触发 STW)
jmap -dump:live,format=b,file=heap.hprof 12345
更好的做法:配置 -XX:+HeapDumpOnOutOfMemoryError,让 JVM 在 OOM 时自动 dump,避免手动导出的风险。
导出 dump 后用 Eclipse MAT(免费)分析:
3.5 jstat:实时监控 GC
# 每 1000ms 输出一次 GC 统计,共 10 次
jstat -gc 12345 1000 10
关键输出字段:
| S0C/S1C | Survivor 容量 | — |
| EC | Eden 容量 | — |
| OC | 老年代容量 | — |
| OU | 老年代使用量 | 持续接近 OC,快满了 |
| YGC | 新生代 GC 次数 | — |
| YGCT | 新生代 GC 总耗时 | — |
| FGC | Full GC 次数 | 频繁增长意味着问题 |
| FGCT | Full GC 总耗时 | 占比过高影响吞吐量 |
看到 FGC 在持续增长,但怎么回收也回收不了多少空间 → 大概率是内存泄漏,dump 出来用 MAT 分析。
3.6 Arthas:阿里开源的线上排障神器
当 jstack/jmap 不够用时,Arthas 可以提供更强大的动态诊断能力:
curl -O https://arthas.aliyun.com/arthas-boot.jar
java -jar arthas-boot.jar # 选择要 attach 的 Java 进程
常用命令:
dashboard # 实时面板:线程、内存、GC 一览无余(类似 top)
# 追踪方法调用——比打日志快一万倍
trace com.example.UserService getUser '#cost > 100'
# 输出 getUser 方法中所有耗时 >100ms 的子调用链
# 观察方法入参和返回值
watch com.example.OrderService createOrder '{params, returnObj, throwExp}'
# 热替换 class 文件(紧急修复线上 bug,无需重启)
redefine /tmp/UserService.class
Arthas 的强大之处在于:不需要重启应用,不需要加日志,直接 attach 到运行中的 JVM 进程,实时诊断。
第四章:JIT 编译与性能优化——JVM 为什么越来越快?
你可能听过"Java 比 C++ 慢"的说法。这个说法放在 20 年前是对的,但放在今天已经过时了。HotSpot 之所以叫 HotSpot,核心就在于 JIT(Just-In-Time,即时编译)。
4.1 解释执行 + 编译执行:两手都要抓
第一阶段(启动时):
字节码 → 解释器逐条翻译 → 机器码(慢,但零等待)
第二阶段(运行中,发现热点代码):
字节码 → JIT 编译器深度优化 → 高质量机器码(快,编译有开销)
HotSpot 采用混合模式:启动时用解释器快速响应,运行一段时间后,把执行频繁的**"热点代码"**交给 JIT 做深度编译优化。这样兼顾了启动速度和峰值性能。
4.2 C1 和 C2:两个编译器,各司其职
| C1(Client) | 编译快,优化浅 | 桌面应用,追求启动速度 |
| C2(Server) | 编译慢,优化深 | 服务器应用,追求峰值性能 |
JDK 7 起引入了分层编译——C1 先快速编译让程序热起来,C2 在后面对真正的热点做深度优化。两阶段配合,兼得快速启动和高峰性能。
4.3 JIT 的三个关键优化
(1)方法内联(Method Inlining)
// 原本是两次方法调用
private int add(int a, int b) { return a + b; }
private int calc() { return add(1, 2); }
// JIT 优化后等价于:直接展开,零调用开销
private int calc() { return 1 + 2; }
减少方法调用的压栈出栈开销,是最基础也最有效的优化之一。
(2)逃逸分析(Escape Analysis)
如果一个对象只在一个方法内部使用,不会"逃出"这个方法,JIT 就能做三种激进优化:
// 无逃逸——对象只在方法内部使用
public void test() {
Point p = new Point(1, 2);
// 1. 栈上分配:p 可能根本不去堆里,在栈上就分配了
// 2. 标量替换:p.x 和 p.y 被拆成两个独立变量
// 3. 同步消除:如果 p 被用作锁,这个锁也没必要了
System.out.println(p.x + p.y);
}
// 有逃逸——对象返回出去了,必须在堆上
public Point test() {
return new Point(1, 2); // 逃逸了!
}
(3)锁消除与锁粗化
// 锁消除:StringBuffer 的 append 是同步方法,但如果 sb 是局部变量
// (没有逃逸),JIT 就直接把锁去掉了
public String concat() {
StringBuffer sb = new StringBuffer(); // 局部变量,无逃逸
sb.append("a").append("b"); // 锁被 JIT 消除!
return sb.toString();
}
// 锁粗化:连续对同一个对象加锁,JIT 合并成一次
synchronized (obj) { doA(); }
synchronized (obj) { doB(); }
// JIT 优化后 ≈
synchronized (obj) { doA(); doB(); }
第五章:JVM 内存模型(JMM)——并发编程的基石
先区分清楚:JVM 内存结构(堆、栈、方法区)和 Java 内存模型(JMM)是两个完全不同的概念。前者描述数据存放在 JVM 的哪里,后者描述多线程之间如何通过内存通信。
5.1 为什么需要 JMM?
CPU 速度和内存速度差了两个数量级,所以现代 CPU 加上了多级缓存。但这带来了缓存一致性问题——同一个变量在不同 CPU 核心的缓存里可能值不一样。
JMM 就是一套规范,定义了三件事:
- 可见性:一个线程改了变量,其他线程什么时候能看到?
- 有序性:指令重排的边界在哪里?
- 原子性:哪些操作不可分割?
5.2 happens-before 原则
理解 JMM 的最佳入口是 happens-before 原则:如果操作 A happens-before 操作 B,那么 A 的结果对 B 是可见的。
| 程序次序 | 同一线程内,前面的代码 happens-before 后面的代码 |
| volatile | volatile 变量的写 happens-before 后续对该变量的读 |
| 锁 | unlock happens-before 后续的 lock |
| 传递性 | A hb B 且 B hb C → A hb C |
| 线程启动 | thread.start() hb 线程内的任何操作 |
| 线程终止 | 线程的所有操作 hb thread.join() 返回 |
5.3 volatile 到底干了什么?
volatile 提供两个语义:
最经典的用例是单例模式的双重检查锁定(DCL):
public class Singleton {
private static volatile Singleton instance; // ← 这个 volatile 不能省!
public static Singleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查
instance = new Singleton();
// ⚠️ 这行代码其实分三步:
// (1) 分配内存空间
// (2) 调用构造器初始化对象
// (3) 将 instance 指向分配的内存地址
//
// 如果 (2) 和 (3) 被重排了:
// 线程 A 刚执行完 (3),instance != null,但 (2) 还没做
// 线程 B 此时进入 getInstance(),第一次检查通过
// 直接返回了一个还没初始化的"半成品"对象!
//
// volatile 禁止了这个重排,保住了最后一道防线
}
}
}
return instance;
}
}
面试中如果被问到 volatile 的应用场景,DCL 单例是最佳的例子——既能体现 volatile 禁止指令重排的语义,又和实际开发紧密相关。
第六章:JVM 面试高频题精讲
前面五章的知识已经足够扎实了。这一章我们做一件事:把知识"翻译"成面试回答。
6.1 "说说你对 JVM 的理解"
回答框架(总分总结构):
JVM 是 Java 虚拟机,实现了"一次编写到处运行"。我从三个层面来理解:
类加载层:通过双亲委派模型加载 .class 文件,经历加载、链接、初始化三个阶段。
运行时数据区:堆(存放对象,GC 主战场)、虚拟机栈(方法调用的栈帧)、方法区(类信息和常量)、程序计数器。堆和方法区线程共享,栈和 PC 线程私有。
执行引擎:字节码可由解释器执行或 JIT 编译为机器码。垃圾收集器自动回收不再使用的对象。
实际工作中我最关注堆的内存管理和 GC 调优,这直接影响系统性能。
技巧:用"第一、第二、第三"的结构,显得有逻辑层次。不要一上来就甩内存模型图。
6.2 "Java 怎么判断对象可以回收?"
Java 使用可达性分析。从 GC Roots 出发,沿引用链可达的对象存活,不可达的回收。
GC Roots 包括:栈中局部变量指向的对象、方法区静态变量指向的对象、常量引用的对象、被 synchronized 持有的对象等。
对比引用计数法:Java 不用它,因为解决不了循环引用——两个对象互相引用但外部都不可达时,计数器永远不是 0。
6.3 "你遇到的 OOM 和排查过程"
我遇到过两种:
堆溢出(Java heap space):查询没分页,一次加载几百万条数据。排查:GC 日志中 Full GC 频繁且回收空间很少 → jmap dump 出堆文件 → MAT 分析发现某个 ArrayList 占用大部分堆 → 追溯接口加了分页解决。
元空间溢出(Metaspace):框架在运行时动态生成大量类。特点:堆空间还有很多剩余但报了 OOM。设置 -XX:MaxMetaspaceSize=256m 并修复框架的动态类生成逻辑解决。
6.4 "G1 和 CMS 的区别?"
| 内存布局 | 连续新生代+老年代 | Region 分区,动态划分 |
| 算法 | 标记-清除(会碎片化) | 标记-整理+复制(无碎片) |
| 停顿预测 | 不可预测 | 可设置 MaxGCPauseMillis |
| 大对象 | 直接进老年代 | Humongous Region 存储 |
| Full GC | 单线程,耗时很长 | 多线程,效率高 |
| 当前状态 | JDK 14 已废弃 | JDK 9+ 默认 |
6.5 "为什么 SurvivorRatio 默认是 8?"
因为经验数据显示新生代约 98% 的对象活不过第一次 Minor GC。Eden 和两个 Survivor 的 8:1:1 比例正好匹配:Eden 占 80% 容纳新对象,每个 Survivor 占 10%,确保约 2% 的存活对象能顺利放进 Survivor 区。
6.6 "强引用、软引用、弱引用、虚引用分别是什么?"
- 强引用:最常见,只要引用在就不会被 GC。
- 软引用:内存不足时回收,适合做缓存。图片加载库常用。
- 弱引用:下次 GC 必定回收。ThreadLocalMap 的 Key、WeakHashMap 中用到。
- 虚引用:无法通过它获取对象,唯一用途是对象被回收时收到通知。NIO 的 DirectByteBuffer 用它来管理直接内存的释放。
补充:ThreadLocal 内存泄漏的根因就是 Key 是弱引用(被 GC 后变 null)但 Value 是强引用(仍被 Map 持有),如果不调 remove() 就无法释放。
6.7 "你用过哪些 JDK 版本?有什么区别?"
主要用 JDK 8 和 JDK 11,也关注新的 LTS 版本。
JDK 8 的关键变化:Lambda 和 Stream API、方法区从永久代变为元空间、接口支持默认方法。
JDK 11(LTS):ZGC 实验性引入、新的 HTTP Client API、String 新增 isBlank()/lines() 等方法。
JDK 17(LTS):G1 更成熟,sealed class、pattern matching 预览、ZGC 进一步提升。
JDK 21(最新 LTS):虚拟线程(Virtual Threads)正式发布——高并发场景下可以大幅减少线程开销,这是 Java 并发编程的一个重要转折点。
结语
JVM 不是可以速成的知识点。但好消息是,你不需要掌握全部细节也能做好日常开发。
上篇帮你建立了基础框架(内存区域、类加载、GC 原理),本篇帮你补上了实战技能(收集器选型、参数调优、诊断工具、面试应对)。两篇合在一起,就是一套完整的 JVM 知识体系。
真正让你把 JVM 吃透的,不是看书,而是在你的项目里用一次——给线上服务加上 GC 日志,用 jstat 看一次 GC 频率,用 Arthas trace 一次慢方法,或者定位一个真实的内存泄漏。一次实践,胜过十次阅读。
希望这两篇文章能成为你 JVM 学习路上一个扎实的起点。下次面试被问到 JVM 时,你不再心头一紧,而是微微一笑——因为这些问题你都已经在脑子里转过很多遍了。
【上一篇:JVM 征服手册:从 CRUD 到性能调优的完整指南(一)- 基础入门】



