前两篇讲清了 JVM 长什么样、对象怎么生怎么死。这一篇回答两个「灵魂问题」:Java 到底慢不慢? 以及 当它真出性能问题时,怎么科学地治,而不是靠玄学调参? 最后眺望一下 JVM 的今天与未来,并为理论三篇收个束。
TL;DR
- Java 不慢的秘密 = 解释执行(快速启动)+ JIT 把热点代码编成机器码(长跑飞快),两者互补。
- JIT 三板斧:方法内联、逃逸分析(靠 标量替换 让对象不进堆,并非真「栈上分配」)、锁消除;再叠加 分层编译 Tier 0–4 与投机优化,赌错了用 去优化 / OSR 安全回退。
- JMM 管多线程的可见性与有序性:
volatile保证可见 + 禁重排(但 不保证原子);happens-before是并发正确性的主线 —— 双重检查锁(DCL)单例必须给字段加volatile。 - 内存不只有堆:Metaspace、Code Cache、直接内存、线程栈都在堆外,各自会抛不同类型的 OOM。
- 调优是「定目标 → 看数据 → 找瓶颈 → 改 + 验证」的闭环,没有度量就没有调优。
- 前沿三条线:GraalVM / AOT(启动进入毫秒级)、虚拟线程 Loom(百万级并发)、Valhalla 值类型。
Table of contents
Open Table of contents
一、解释执行 vs 编译执行
很多人有个误解:「Java 是解释执行的,肯定比 C++ 慢」。这个说法在 1996 年成立,但今天早已不成立。先看最基础的对比:
| 方式 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 解释执行 | 字节码一条条翻译成机器指令再执行 | 启动快 | 慢(每次都要重新翻译) |
| 编译执行 | 字节码整体翻译成机器码再跑 | 快 | 启动要先花时间编译 |
JVM 的聪明之处
它两个都用 —— 先解释执行快速启动,再把热点代码编译成机器码长期提速。
二、JIT:热点代码的优化大师
JVM 一开始用解释器执行,跑着跑着会发现:有些方法被反复调用了几千次几万次。这种方法叫 热点代码(Hot Spot) —— HotSpot JVM 这个名字就是这么来的。
对热点代码,JVM 会把它交给 JIT(Just-In-Time)编译器,编译成本地机器码缓存起来(放在上一篇提过的 Code Cache 里)。下次再调用,直接跑机器码,速度飞快。
JVM 先解释执行快速启动,把反复调用的热点代码交给 JIT 编译成机器码缓存,下次直接跑机器码。
形象类比
这就像背英语单词:刚遇到的生词每次都要查字典(解释执行),背熟之后就脱口而出(编译执行)。热点探测靠两个计数器:方法调用计数器 和 循环回边计数器,超过阈值就触发编译。
三、JIT 都做了什么神奇的事
JIT 不仅仅是「翻译成机器码」,它会做大量优化。挑几个最重要的讲。
3.1 方法内联
调用方法是有开销的(要建栈帧、传参数 —— 就是第一篇字节码里那套压栈弹栈)。JIT 发现一个方法体很小、调用又频繁,会直接把方法体 复制到调用点,省掉调用开销。内联还是很多其他优化的前提(内联进来之后才能连着一起优化)。
内联不是无脑展开
它受方法大小限制(如
-XX:MaxInlineSize、-XX:FreqInlineSize),太大的方法不内联。更麻烦的是 虚方法(多态调用):运行时才知道调哪个实现,本来没法内联;JIT 靠 类层次分析(CHA) 判断「当前只有一个实现」时也能大胆内联,一旦后来加载了新子类打破假设,就触发下面要讲的「去优化」。
3.2 逃逸分析
JIT 会分析一个对象的「活动范围」。如果它发现一个对象只在方法内部用、从不会被外部访问到(没「逃逸」),就能做一系列优化。看这个例子,sb 只在方法内部用、没「逃」出去:
public int count() {
StringBuilder sb = new StringBuilder(); // 既没 return,也没赋给外部字段
sb.append("a").append("b");
return sb.length();
}
JIT 一分析:sb 出不了这个方法,于是可以:
- 标量替换:把对象拆成几个基本类型局部变量,直接塞进栈帧,连对象都不创建;
- 锁消除:如果这个对象上有
synchronized,因为不可能被其他线程访问,锁直接去掉。
一处常被讲错的准确表述
很多资料说逃逸分析会「栈上分配对象」。但 HotSpot 实际上并没有实现真正的『在栈上分配一个完整对象』,它是通过 标量替换 把对象拆散成标量塞进栈帧来达到近似效果的。结论不变 —— 你以为「new 对象一定进堆」,在逃逸分析优化后可能根本不进堆 —— 但机制要说清楚:靠的是标量替换,不是真有个「栈上对象」。
眼见为实:给上面 count() 加上 -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining,跑热之后 JIT 真的把 StringBuilder 的一串调用内联进了 count():
@ 5 Demo::count (23 bytes) inline (hot)
@ 1 java.lang.StringBuilder::<init> (7 bytes) inline (hot)
@ 9 java.lang.StringBuilder::append (8 bytes) inline (hot)
@ 14 java.lang.StringBuilder::append (8 bytes) inline (hot)
@ 18 java.lang.StringBuilder::length (6 bytes) inline (hot)
Demo::giant (742 bytes) too large to inline
inline (hot) 就是内联成功;内联进来后 sb 才有机会被逃逸分析判成「没逃逸」并做标量替换。最后一行 too large to inline 正是 3.1 说的「太大的方法不内联」。
3.3 分层编译与去优化:投机与回退
为了平衡「编译速度」和「优化质量」,JIT 分了多层(分层编译,Tiered Compilation):
- Tier 0:解释执行;
- Tier 1–3:C1 编译器,编译快、优化轻,还顺带收集运行数据(profiling);
- Tier 4:C2 编译器,编译慢、优化狠,基于 C1 收集的真实数据做激进优化。
先用 C1 快速顶上并观察,再用 C2 基于真实剖面激进优化。但「激进」意味着 投机:JIT 会赌「这个分支几乎不走」「这个虚方法只有一个实现」。万一赌错了怎么办?
分层编译让代码逐级升温;一旦激进优化的投机假设被打破,就去优化退回解释器,保证正确性。
这就是 去优化(Deoptimization):当投机假设被打破(比如那个「只有一个实现」的虚方法突然来了第二个子类),JIT 会把这段已编译的机器码 作废、退回解释器,重新收集数据后再择机编译。还有 OSR(On-Stack Replacement,栈上替换):一个方法只调用了一次、但里面有个跑几百万次的大循环,JVM 能在循环 执行途中 就把它替换成编译后的版本,不必等方法下次被调用。
Java 能逼近 C++ 的根本原因
这种 「运行时优化 + 投机 + 可回退」 的能力是关键 —— C++ 是静态编译,编译期看不到运行时的真实情况;JVM 看得到(哪个分支热、哪个类型实际出现),所以能做更激进的判断,赌错了还能安全退回。这是「动态编译」独有的武器。
四、别忽略的一块:Java 内存模型(JMM)
前面讲的都是「单线程视角」。可一旦多线程共享数据,就冒出一个更隐蔽的问题:一个线程改了变量,另一个线程什么时候、能不能看到? 这由 Java 内存模型(JMM) 规定。它和 GC 说的「堆内存布局」是两码事 —— JMM 管的是 可见性、有序性、原子性。
JMM:每个线程操作的是共享变量的工作内存副本;volatile 强制写回读取主内存并禁止重排,从而保证可见性。
- 可见性问题从哪来:为了性能,线程会把共享变量缓存在自己的「工作内存」(对应 CPU 寄存器 / 缓存)里。线程 A 改了自己的副本还没刷回主内存,线程 B 读到的就是旧值。
- 有序性问题从哪来:编译器和 CPU 会 指令重排 以提速,单线程内看不出问题,多线程下重排可能让别的线程看到「不该看到的中间状态」。
volatile干两件事:① 写立即刷回主内存、读必须从主内存重取(保证 可见性);② 插入内存屏障 禁止相关重排(保证 有序性)。但它 不保证原子性(i++依然要用synchronized或Atomic类)。happens-before原则:JMM 用它定义「前一个操作的结果一定对后一个操作可见」的若干规则(程序次序、锁的解锁 → 加锁、volatile 写 → 读、线程 start / join 等)。只要两个操作存在 happens-before 关系,就不用担心可见性和重排 —— 这是并发编程真正要背下来的一条主线。
一个经典到必考的例子:双重检查锁(DCL)单例。 很多人写单例会省掉 volatile,结果埋下偶发的诡异 bug:
public class Singleton {
// volatile 绝不能省
private static volatile Singleton instance;
public static Singleton getInstance() {
if (instance == null) { // 第一次检查(无锁,快路径)
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查(持锁)
instance = new Singleton(); // ← 危险点:这一行不是原子的
}
}
}
return instance;
}
}
关键在于 instance = new Singleton() 其实是三步:① 分配内存 → ② 执行构造器 → ③ 把引用赋给 instance。JIT / CPU 允许把它 重排成 ①③②。一旦线程 A 排成「先赋值、后构造」,此时 instance 已经非 null 但对象还没构造完;线程 B 走到第一次检查发现「不为 null」,直接返回并使用 —— 拿到的是一个 半成品对象,字段全是零值。给字段加上 volatile 后,第 ② 步和第 ③ 步之间的重排被内存屏障禁止,问题消失。这就是 happens-before / 有序性在真实代码里活生生的样子。
这里只把 JMM 点到位、和 JVM 内存布局区分开;它本身足够撑起一整篇并发专题(
synchronized锁升级、CAS、AQS…),本系列聚焦虚拟机本身,不再展开。
五、堆外内存与 OOM 全家福
第一篇说过「堆 vs 非堆」,这里把 整个进程的内存版图 补全 —— 因为线上排障时,OOM 到底报在哪一块,直接决定你往哪查。
一张进程内存版图:堆内只有 Heap,堆外还有 Metaspace / Code Cache / 直接内存 / 线程栈,各自会抛不同的 OOM。
- 堆外内存(Direct Memory):
ByteBuffer.allocateDirect()、Netty、NIO 都用它 —— 数据放在 JVM 堆之外 的本地内存,省掉一次「堆内 ↔ 内核」的拷贝,适合高性能 IO。它不受-Xmx限制,由-XX:MaxDirectMemorySize控制,靠 虚引用(第二篇提过)在DirectByteBuffer被回收时释放。用不好就是「堆内存充足、进程却 OOM」的元凶。
常见 OOM 一张表看全(这也是排障第一步——先看报的是哪种):
| OOM 报错 | 出问题的区域 | 常见原因 |
|---|---|---|
Java heap space | 堆 | 对象太多 / 内存泄漏 / 堆太小 |
GC overhead limit exceeded | 堆 | GC 拼命跑但回收不动,形同泄漏 |
Metaspace | 元空间 | 反射 / CGLIB / 热部署生成太多类 |
Direct buffer memory | 堆外直接内存 | NIO / Netty 堆外泄漏、上限设太小 |
unable to create new native thread | 线程栈 | 线程开太多,本地内存耗尽 |
Requested array size exceeds VM limit | 堆 | 申请了超大数组 |
六、调优实战:从「玄学」到「科学」
很多人对 JVM 调优有种神秘感,觉得是黑魔法。其实它一点都不玄学,核心方法论就一句话:
调优第一原则
没有度量,就没有调优。
6.1 调优四步法
- 定目标:你到底想优化什么?是降低延迟?提高吞吐?减少 GC 频率?说不清楚的话,别动手;
- 看数据:用
jstat、GC 日志、jmap、JFR、Async Profiler 等工具采集真实运行数据; - 找瓶颈:根据数据判断问题在哪 —— 是堆太小?是有内存泄漏?是 Full GC 太多?还是线程死锁?
- 改 + 验:调整参数或代码,再次度量,确认有效。
调优是「定目标 → 看数据 → 找瓶颈 → 改 + 验」的闭环,未达标就继续迭代。
6.2 必知的几个参数
| 参数 | 作用 |
|---|---|
-Xms / -Xmx | 堆初始 / 最大大小,生产环境建议设成相等,避免动态扩缩 |
-Xmn | 年轻代大小 |
-XX:SurvivorRatio | Eden : Survivor 比例,默认 8:1:1 |
-XX:+UseG1GC / -XX:+UseZGC | 选择 GC 类型 |
-XX:MaxGCPauseMillis | G1/ZGC 的目标最大停顿时间 |
-XX:MaxMetaspaceSize | 元空间上限,防反射 / 热部署撑爆 |
-XX:MaxDirectMemorySize | 堆外直接内存上限 |
-Xss | 单线程栈大小 |
-XX:+HeapDumpOnOutOfMemoryError | OOM 时自动 dump 堆快照,排查必备,强烈建议线上都加 |
6.3 几个常见问题的定位思路
| 现象 | 大概率原因 | 怎么查 |
|---|---|---|
| 老年代内存只增不减 | 内存泄漏(静态集合一直加东西不清理) | jmap 导 heap dump,用 MAT 看支配树 |
| Full GC 频繁 | 大对象直接进老年代 / Survivor 太小 | 看 GC 日志,加 -XX:+PrintTenuringDistribution |
| 元空间 OOM | 反射 / CGLIB / 热部署生成了太多类 | jstat -gcmetacapacity 看元空间走势 |
| 服务卡死、CPU 不高 | 线程死锁 | jstack 看线程状态,找 BLOCKED 链 |
| CPU 飙到 100% | 某个线程死循环或热点代码 | top -Hp 找出线程 → jstack 看堆栈,或上火焰图 |
6.4 读懂一行 GC 日志
工具采集到的 GC 日志,初看像天书,其实拆开就那么几段。以一行 G1 的 Young GC 为例(JDK 9+ 推荐用统一日志 -Xlog:gc* 打开):
[GC pause (G1 Evacuation Pause) (young), 0.0123456 secs]
[Eden: 256M->0B Survivors: 32M->48M Heap: 720M->480M]
逐段翻译:
| 片段 | 含义 |
|---|---|
GC pause ... (young) | 这是一次年轻代 GC(Minor GC) |
0.0123456 secs | 本次 STW 停顿约 12ms(最该盯的数) |
Eden: 256M->0B | Eden 从 256M 被清空到 0,新对象都处理完了 |
Survivors: 32M->48M | 存活对象复制后 Survivor 涨了一点,正常 |
Heap: 720M->480M | 整堆从 720M 降到 480M,本次回收了 240M |
看日志先看三件事
- 停顿时长(secs)够不够短?
- 回收前后堆大小 降了多少(降太少,说明大量对象进了老年代、年轻代回收不掉)?
- GC 频率 高不高(短时间内日志刷屏,就是该调优的信号)?
6.5 动手验证:把前两篇的结论亲手跑一遍
调优讲究「眼见为实」。下面几条命令能把整个系列的核心结论 亲手复现出来,比记结论强得多:
| 想验证什么(对应章节) | 怎么做 |
|---|---|
| 字节码 / 操作数栈(篇一 §2.4) | javap -c YourClass 看真实字节码 |
| 对象头占 16 字节(篇一 §2.2) | 引入 org.openjdk.jol 用 ClassLayout.parseInstance(obj).toPrintable() 打印对象布局 |
| GC 到底怎么动(篇二) | 加 -Xlog:gc* 打印统一 GC 日志,观察 Eden / Survivor / 晋升 |
| JIT 内联了哪些方法(本篇 §3.1) | 加 -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining |
| 某个参数的默认值到底是多少 | 跑 java -XX:+PrintFlagsFinal -version,再 grep 参数名 |
| 线上进程实时体检 | jcmd <pid> GC.heap_info / Thread.print,或用 JFR 录一段:jcmd <pid> JFR.start |
七、眺望:JVM 的今天与未来
这个系列讲的多是「经典 JVM」。但 JVM 一直在进化,几个值得关注的方向:
- GraalVM / AOT(
native-image):把 Java 提前编译(Ahead-Of-Time) 成原生可执行文件。效果有多夸张?一个 Spring Boot 应用传统 JVM 启动常要 数百毫秒到数秒,AOT 后能压到 几十毫秒甚至个位数毫秒,常驻内存也能降一个数量级 —— 特别适合 Serverless、CLI、云原生短命进程。代价是放弃了运行时 JIT 的动态优化和一部分反射 / 动态能力(要靠配置补齐)。这是对「JIT 启动慢、预热久」这一痛点的正面回应。 - 虚拟线程(Project Loom,JDK 21 正式):由 JVM 调度的 轻量级线程。传统平台线程一个约占 1MB 栈、一台机器开到 几千个 就吃紧;虚拟线程一个只占几百字节起、能轻松开到 百万级。它让「一个请求一个线程」的简单写法重新在高并发下成立,是对传统
-Xss线程栈昂贵、线程数受限的根本性突破。 - 值类型(Project Valhalla):让「值类型 / 内联类」进入 Java,减少对象头开销与指针跳转,对高性能计算意义重大(还在推进中)。
- 工具生态:排障别只停在
jstat/jmap/jstack。JFR(Java Flight Recorder)+ JMC 是几乎零开销的生产级剖析利器;Arthas 能在线上不重启地看方法耗时、热更代码;火焰图 是定位 CPU 热点的通用语言。
八、回过头看,理论三篇教会了我们什么
三篇走下来,你应该已经有了一张完整的内部构造图:
理论篇脉络回顾
- 篇一:JVM 是一台跑在操作系统上、逐条执行字节码的「口袋电脑」,内存分成堆 / 栈 / 方法区等区域,对象经「分配 → 赋零值 → 设对象头 → 构造」诞生,类经「加验准解初」加载;
- 篇二:GC 用可达性分析找垃圾、靠 Safepoint 停顿枚举 Roots,用分代收集 + 三色标记 + 写/读屏障,把回收做到 G1、ZGC 这样又快又不卡;
- 篇三:JIT 靠热点探测 + 内联 + 逃逸分析 + 投机优化让 Java 逼近 C++,JMM 管住多线程可见性,调优是「度量 → 分析 → 调整 → 验证」的循环。
但 JVM 的价值远不止这些知识点。它真正教给我们的是 两种工程思维:
- 分层抽象的力量:字节码这一层抽象,让 Java 同时拿到了「跨平台」和「性能优化空间」—— 前者靠静态的指令规范,后者靠动态的 JIT 优化。一静一动,恰好互补。
- 针对真实负载做特化:从分代 GC 到 G1 的 Region,再到 ZGC 的染色指针、JIT 的投机优化,JVM 几乎所有重要演进,都源自对真实程序行为的观察。没有「最完美的方案」,只有「最适合当下负载的方案」。
学到家的标志
下次再写下一行
new ArrayList<>()时,希望你脑子里能闪过这样的画面:这个对象会分配在 Eden 区(除非太大直接进老年代或被标量替换掉根本不进堆)、它的字段值都在堆里、它的类信息在元空间、引用它的局部变量躺在栈帧的局部变量表里、几次 Minor GC 后如果还活着就晋升老年代、JIT 可能正在分析它逃没逃逸……当这些画面变得自然而然,你就真的把 JVM 学进去了。
地图有了,还要知道自己站在哪一版地形上 —— 从 JDK 8 到 25,LTS 各自带来了哪些语言能力、运行时默认值与升级坑? 这正是下一篇 JDK 版本差异精讲 要回答的。
延伸阅读
- 周志明.《深入理解 Java 虚拟机(第 3 版)》—— 第 11 章(后端编译与优化)、第 12 章(Java 内存模型与线程)对应本篇。
- Scott Oaks. Java Performance (2nd Edition) —— 面向工程实战的 JIT / GC / 调优参考。
- Oracle. The Java® Language Specification — Chapter 17: Threads and Locks —— JMM 与 happens-before 的权威定义。