为什么要懂 JVM
日常写业务代码时,JVM 似乎是透明的:你 new 出对象、调用方法,剩下的交给它。但一旦出现线上问题——服务在高峰期越来越慢、堆内存被慢慢吃光、某次 Full GC 卡了好几秒——如果不知道内存被谁占着、回收是怎么发生的,排查就只能靠猜。
懂 JVM 带来的是三种能力:知道对象存在哪,从而能判断内存压力来自堆还是栈;知道回收怎么做,从而能解释停顿从哪里来;知道参数怎么配,从而能在容量和延迟之间做出有依据的取舍。它不是面试八股,而是线上问题的诊断语言。
把 JVM 看成一台会自动打扫房间的机器:它替你回收垃圾,但打扫的时机和方式决定了你会不会被「暂停」。理解它的规则,才能安排合适的参数。
- 运行时数据区堆、虚拟机栈、方法区、程序计数器与本地方法栈各司其职。
- 可达性分析从 GC Roots 出发遍历,走不到的对象才是垃圾。
- 分代收集新生代频繁回收、老年代少而慢,对应不同的收集算法。
- 调优方法先看指标再看日志,最后才动参数,避免盲目调参。
运行时数据区:堆、栈与方法区
JVM 在执行 Java 程序时把内存划分成几块用途不同的区域,理解每块区域是否线程共享、存放什么、可能抛什么异常,是排查问题的第一步。下面这张表是最实用的速查:
| 区域 | 线程共享 | 主要存放 | 典型异常 |
|---|---|---|---|
| 堆(Heap) | 共享 | 对象实例、数组 | OutOfMemoryError |
| 虚拟机栈 | 私有 | 栈帧、局部变量、操作数 | StackOverflowError / OOM |
| 方法区(元空间) | 共享 | 类元信息、常量、静态变量 | OutOfMemoryError |
| 程序计数器 | 私有 | 当前字节码行号 | 无 |
| 本地方法栈 | 私有 | 本地方法调用状态 | StackOverflowError |
最常打交道的是堆与虚拟机栈。堆是所有线程共享的对象存储区,也是垃圾回收的主战场;虚拟机栈是每个线程私有的,方法每调用一次就压入一个栈帧,方法返回就弹出。栈帧里保存局部变量表和操作数栈,所以递归太深会撑爆栈,而对象不断累积会撑爆堆。
// 两种典型的「撑爆」:一个爆栈,一个爆堆
public class MemoryDemo {
// 无限递归 -> StackOverflowError
static long depth = 0;
static void recurse() {
depth++;
recurse();
}
// 不断持有对象 -> OutOfMemoryError: Java heap space
static java.util.List<byte[]> holder = new java.util.ArrayList<>();
public static void main(String[] args) {
try {
recurse();
} catch (StackOverflowError e) {
System.out.println("爆栈,调用深度约 " + depth);
}
try {
while (true) {
holder.add(new byte[1024 * 1024]); // 每次 1MB
}
} catch (OutOfMemoryError e) {
System.out.println("堆溢出,已占用 " + holder.size() + " MB");
}
}
}
运行这段代码时加上 -Xss256k 可以更快看到爆栈,加上 -Xmx64m 可以更快看到堆溢出。主动制造这两种异常,是理解内存边界最直接的方式。
对象的一生:创建、分配与引用
一个对象从被 new 出来到被回收,大致经历几个阶段:类加载检查、分配内存、初始化零值、设置对象头、执行构造方法,之后进入使用期,最后在某个回收周期被判定为不可达并释放。理解这条链路,很多现象就能解释。
- 分配位置:绝大多数对象分配在堆的 Eden 区;如果逃逸分析发现对象不会被外部引用,可能被优化成栈上分配或标量替换。
- 指针压缩:开启后对象引用只占 4 字节,能显著减少内存占用,是默认开启的优化。
- 对象头:保存哈希码、GC 分代年龄、锁状态等信息,所以「一个小对象」实际占用的内存比字段之和要多。
- 引用类型:强引用绝不会被回收;软引用在内存不足时才回收;弱引用下次回收就走;虚引用只用于跟踪回收事件。
引用类型直接影响缓存策略。用 SoftReference 做内存敏感缓存、用 WeakHashMap 做伴随键的生命周期管理,都是很常见的做法。反过来,如果一个本该被释放的对象被静态集合一直强引用着,它就会永远活着——这正是内存泄漏的主要来源。
垃圾回收:怎么判断「垃圾」
回收的第一步是找出哪些对象已经没用了。主流虚拟机采用可达性分析:从一组称为 GC Roots 的起点出发,沿着引用链向下遍历,能走到的对象是「存活」,走不到的就是「可回收」。
GC Roots 通常包括:虚拟机栈中局部变量表引用的对象、方法区中静态属性引用的对象、常量引用的对象、本地方法栈中引用的对象,以及活跃线程本身。理解了这一点,就明白为什么「把对象挂到静态集合上」会让它一直存活。
确定存活对象后,回收本身有三种经典思路,各自的取舍不同:
- 标记—清除:直接清理垃圾,实现简单,但会产生大量内存碎片。
- 标记—整理:清除后把存活对象向一端移动,避免碎片,代价是移动开销。
- 复制算法:把存活对象复制到另一块空间再整体清空,效率高但需要额外空间。
正因为没有一种算法全能,虚拟机采用分代收集:新生代对象「朝生夕死」,用复制算法快速回收;老年代对象存活率高,用标记—整理类算法。对象在新生代经历多次回收仍然存活,就会被晋升到老年代,这也是为什么长时间持有的大对象更容易触发老年代压力。
常见垃圾收集器与选型
收集器是回收算法的工程实现,不同收集器在吞吐量和停顿时间上各有侧重。选择哪一个,取决于你的应用更怕「慢」还是更怕「卡」。
| 收集器 | 作用范围 | 特点 | 适用场景 |
|---|---|---|---|
| Serial | 新生代 / 老年代 | 单线程,简单高效 | 客户端、小内存 |
| ParNew / Parallel | 新生代 | 多线程并行回收 | 吞吐优先的后台服务 |
| Parallel Scavenge | 新生代 | 关注吞吐量,可自动调节 | 批处理、计算密集型 |
| CMS | 老年代 | 并发标记清除,低停顿 | 旧版本的响应优先场景 |
| G1 | 整堆 | 分区域回收,可预测停顿 | 大堆、均衡场景(现代默认) |
| ZGC / Shenandoah | 整堆 | 超低停顿,超大堆 | 延迟极其敏感的服务 |
选型的经验顺序是:先确认堆大小与延迟目标,再选收集器。堆在几十 GB 以内、希望兼顾吞吐与停顿,G1 是稳妥的默认值;堆非常大且停顿要求苛刻,才考虑 ZGC 这类低延迟收集器;纯粹的后台批处理任务,吞吐量优先的 Parallel 反而更省资源。需要注意的是,收集器之间并非可以任意组合,具体搭配要参考所使用 JDK 版本的官方说明。
内存溢出与内存泄漏的排查
这两个词经常被混用,但成因完全不同。内存溢出是内存真的不够用,可能是容量配置偏小,也可能是短时间内产生了过多对象;内存泄漏则是对象本该被回收却一直被引用着,导致可用空间越来越少,最终同样表现为溢出。
- 先看异常信息:
Java heap space指向堆,Metaspace指向元空间,unable to create new native thread往往是线程数过多。 - 再看回收日志:老年代占用是否持续走高、Full GC 后是否回落,是区分溢出与泄漏的关键。
- 然后取堆快照:用
jmap -dump导出,再用分析工具查看占用最大的对象及其引用链。 - 最后定位代码:沿着引用链往回找,看是哪一层把它挂在了长生命周期的容器上。
常见的泄漏模式有几种:静态集合只增不减、监听器或回调注册后忘记注销、线程池里的任务持有外部对象、缓存没有设置淘汰策略。它们共同的特征是——引用的生命周期比对象本身长得多。
性能调优:从指标到参数
调优最容易犯的错误是先动参数。正确的顺序是先量化问题,再定位原因,最后才用参数去缓解。如果不知道当前停顿多久、堆增长多快,调参只是碰运气。
需要先看三类指标:吞吐量(单位时间处理多少请求)、延迟(P99 响应时间、单次 GC 停顿)、内存占用(各代使用率与增长速度)。然后按下面的思路逐层收窄:
# 一份常见的启动参数示例(按实际容量调整)
java -Xms4g -Xmx4g \ # 固定堆大小,避免运行期反复扩容
-Xmn2g \ # 新生代大小,视对象存活率调整
-XX:MetaspaceSize=256m \ # 元空间初始值,避免早期频繁扩容
-XX:+UseG1GC \ # 使用 G1 收集器
-XX:MaxGCPauseMillis=200 \ # 期望的最大停顿,G1 会据此调整
-Xlog:gc*:file=gc.log:time,uptime:filecount=5,filesize=20M \ # 回收日志
-XX:+HeapDumpOnOutOfMemoryError \ # 溢出时自动导出堆快照
-XX:HeapDumpPath=/data/dump \ # 快照落盘位置
-jar app.jar
参数只是手段,两个原则值得记住:第一,-Xms 与 -Xmx 设为相同值,避免堆在运行期反复伸缩带来的额外开销;第二,务必打开 GC 日志与溢出时自动导出堆快照,否则问题复现时你将两手空空。
- 停顿过长但堆不大:先怀疑是否有大对象晋升过快,检查新生代与晋升阈值。
- 老年代持续增长且 Full GC 不回落:优先排查引用泄漏,而不是加大堆。
- CPU 高但回收正常:问题可能在业务代码,用采样工具看热点方法。
工具链与继续深入的方向
观察 JVM 不需要复杂环境,JDK 自带一套命令行工具就够用了。它们轻量、无需改代码,线上也能安全执行。
| 工具 | 用途 | 典型命令 |
|---|---|---|
| jps | 列出 Java 进程 | jps -l |
| jstat | 查看各代容量与回收次数 | jstat -gcutil <pid> 1000 |
| jmap | 查看堆概况、导出快照 | jmap -dump:format=b,file=heap.bin <pid> |
| jstack | 导出线程栈,排查死锁与卡顿 | jstack <pid> |
| jcmd | 统一入口,功能更全 | jcmd <pid> GC.heap_info |
把这篇的知识串成一条自学路线:先用 jstat 学会读回收数据,再用 jmap 与堆分析工具看清对象占用,最后结合业务代码理解「谁在持有引用」。当你能独立解释一次停顿是怎么产生的、又靠什么被消除,JVM 这一课就算真正入门了。
调优的终点不是参数表,而是对系统行为的解释力。能说清问题为什么发生,参数自然就知道该怎么写。