Skip to content
Charles Shao
Go back

JVM 深入(三)· 进阶篇:JIT、内存模型与调优前沿

views

前两篇讲清了 JVM 长什么样、对象怎么生怎么死。这一篇回答两个「灵魂问题」:Java 到底慢不慢? 以及 当它真出性能问题时,怎么科学地治,而不是靠玄学调参? 最后眺望一下 JVM 的今天与未来,并为理论三篇收个束。

TL;DR

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 里)。下次再调用,直接跑机器码,速度飞快。

JIT 即时编译执行流程:字节码先交给解释器逐条执行(启动快),当某方法调用次数超过阈值成为热点代码时,交给 JIT 编译器(先 C1 快编译、再 C2 深优化)编译成本地机器码存入机器码缓存,下次直接执行、飞快 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 出不了这个方法,于是可以:

一处常被讲错的准确表述

很多资料说逃逸分析会「栈上分配对象」。但 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):

先用 C1 快速顶上并观察,再用 C2 基于真实剖面激进优化。但「激进」意味着 投机:JIT 会赌「这个分支几乎不走」「这个虚方法只有一个实现」。万一赌错了怎么办?

分层编译与去优化流程:解释执行(Tier 0)→ C1 编译(Tier 1-3,快、带 profiling)→ C2 编译(Tier 4,慢、激进优化);当投机假设被打破时触发去优化 Deoptimization,退回解释器重新收集数据 分层编译让代码逐级升温;一旦激进优化的投机假设被打破,就去优化退回解释器,保证正确性。

这就是 去优化(Deoptimization):当投机假设被打破(比如那个「只有一个实现」的虚方法突然来了第二个子类),JIT 会把这段已编译的机器码 作废、退回解释器,重新收集数据后再择机编译。还有 OSR(On-Stack Replacement,栈上替换):一个方法只调用了一次、但里面有个跑几百万次的大循环,JVM 能在循环 执行途中 就把它替换成编译后的版本,不必等方法下次被调用。

Java 能逼近 C++ 的根本原因

这种 「运行时优化 + 投机 + 可回退」 的能力是关键 —— C++ 是静态编译,编译期看不到运行时的真实情况;JVM 看得到(哪个分支热、哪个类型实际出现),所以能做更激进的判断,赌错了还能安全退回。这是「动态编译」独有的武器。

四、别忽略的一块:Java 内存模型(JMM)

前面讲的都是「单线程视角」。可一旦多线程共享数据,就冒出一个更隐蔽的问题:一个线程改了变量,另一个线程什么时候、能不能看到? 这由 Java 内存模型(JMM) 规定。它和 GC 说的「堆内存布局」是两码事 —— JMM 管的是 可见性、有序性、原子性

JMM 主内存与工作内存:主内存保存所有线程共享的变量 x,线程 1 和线程 2 各自持有 x 的工作内存副本,通过 read/load 与 store/write 与主内存同步;volatile 保证写立即刷回主内存、读必须从主内存重取,并禁止相关指令重排 JMM:每个线程操作的是共享变量的工作内存副本;volatile 强制写回读取主内存并禁止重排,从而保证可见性。

一个经典到必考的例子:双重检查锁(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 到底报在哪一块,直接决定你往哪查。

JVM 进程内存版图:堆内(GC 直接管理)只有堆 Heap,对应 OOM: Java heap space;非堆 / 堆外(GC 基本不直接管)包括 Metaspace(OOM: Metaspace)、Code Cache(JIT 机器码)、直接内存 DirectByteBuffer(OOM: Direct buffer memory)、线程栈 ×N(OOM: unable to create new native thread) 一张进程内存版图:堆内只有 Heap,堆外还有 Metaspace / Code Cache / 直接内存 / 线程栈,各自会抛不同的 OOM。

常见 OOM 一张表看全(这也是排障第一步——先看报的是哪种):

OOM 报错出问题的区域常见原因
Java heap space对象太多 / 内存泄漏 / 堆太小
GC overhead limit exceededGC 拼命跑但回收不动,形同泄漏
Metaspace元空间反射 / CGLIB / 热部署生成太多类
Direct buffer memory堆外直接内存NIO / Netty 堆外泄漏、上限设太小
unable to create new native thread线程栈线程开太多,本地内存耗尽
Requested array size exceeds VM limit申请了超大数组

六、调优实战:从「玄学」到「科学」

很多人对 JVM 调优有种神秘感,觉得是黑魔法。其实它一点都不玄学,核心方法论就一句话:

调优第一原则

没有度量,就没有调优。

6.1 调优四步法

  1. 定目标:你到底想优化什么?是降低延迟?提高吞吐?减少 GC 频率?说不清楚的话,别动手;
  2. 看数据:用 jstat、GC 日志、jmap、JFR、Async Profiler 等工具采集真实运行数据;
  3. 找瓶颈:根据数据判断问题在哪 —— 是堆太小?是有内存泄漏?是 Full GC 太多?还是线程死锁?
  4. 改 + 验:调整参数或代码,再次度量,确认有效。

JVM 调优四步循环:① 定目标(延迟 / 吞吐 / GC 频率)→ ② 看数据(jstat · GC 日志 · jmap)→ ③ 找瓶颈(堆太小?泄漏?Full GC?)→ ④ 改 + 验(调参数或代码后再度量),未达标则回到第二步继续迭代 调优是「定目标 → 看数据 → 找瓶颈 → 改 + 验」的闭环,未达标就继续迭代。

6.2 必知的几个参数

参数作用
-Xms / -Xmx堆初始 / 最大大小,生产环境建议设成相等,避免动态扩缩
-Xmn年轻代大小
-XX:SurvivorRatioEden : Survivor 比例,默认 8:1:1
-XX:+UseG1GC / -XX:+UseZGC选择 GC 类型
-XX:MaxGCPauseMillisG1/ZGC 的目标最大停顿时间
-XX:MaxMetaspaceSize元空间上限,防反射 / 热部署撑爆
-XX:MaxDirectMemorySize堆外直接内存上限
-Xss单线程栈大小
-XX:+HeapDumpOnOutOfMemoryErrorOOM 时自动 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->0BEden 从 256M 被清空到 0,新对象都处理完了
Survivors: 32M->48M存活对象复制后 Survivor 涨了一点,正常
Heap: 720M->480M整堆从 720M 降到 480M,本次回收了 240M

看日志先看三件事

  1. 停顿时长(secs)够不够短?
  2. 回收前后堆大小 降了多少(降太少,说明大量对象进了老年代、年轻代回收不掉)?
  3. GC 频率 高不高(短时间内日志刷屏,就是该调优的信号)?

6.5 动手验证:把前两篇的结论亲手跑一遍

调优讲究「眼见为实」。下面几条命令能把整个系列的核心结论 亲手复现出来,比记结论强得多:

想验证什么(对应章节)怎么做
字节码 / 操作数栈(篇一 §2.4)javap -c YourClass 看真实字节码
对象头占 16 字节(篇一 §2.2)引入 org.openjdk.jolClassLayout.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 一直在进化,几个值得关注的方向:

八、回过头看,理论三篇教会了我们什么

三篇走下来,你应该已经有了一张完整的内部构造图:

理论篇脉络回顾

  • 篇一:JVM 是一台跑在操作系统上、逐条执行字节码的「口袋电脑」,内存分成堆 / 栈 / 方法区等区域,对象经「分配 → 赋零值 → 设对象头 → 构造」诞生,类经「加验准解初」加载;
  • 篇二:GC 用可达性分析找垃圾、靠 Safepoint 停顿枚举 Roots,用分代收集 + 三色标记 + 写/读屏障,把回收做到 G1、ZGC 这样又快又不卡;
  • 篇三:JIT 靠热点探测 + 内联 + 逃逸分析 + 投机优化让 Java 逼近 C++,JMM 管住多线程可见性,调优是「度量 → 分析 → 调整 → 验证」的循环。

但 JVM 的价值远不止这些知识点。它真正教给我们的是 两种工程思维

  1. 分层抽象的力量:字节码这一层抽象,让 Java 同时拿到了「跨平台」和「性能优化空间」—— 前者靠静态的指令规范,后者靠动态的 JIT 优化。一静一动,恰好互补。
  2. 针对真实负载做特化:从分代 GC 到 G1 的 Region,再到 ZGC 的染色指针、JIT 的投机优化,JVM 几乎所有重要演进,都源自对真实程序行为的观察。没有「最完美的方案」,只有「最适合当下负载的方案」。

学到家的标志

下次再写下一行 new ArrayList<>() 时,希望你脑子里能闪过这样的画面:这个对象会分配在 Eden 区(除非太大直接进老年代或被标量替换掉根本不进堆)、它的字段值都在堆里、它的类信息在元空间、引用它的局部变量躺在栈帧的局部变量表里、几次 Minor GC 后如果还活着就晋升老年代、JIT 可能正在分析它逃没逃逸……

当这些画面变得自然而然,你就真的把 JVM 学进去了。

地图有了,还要知道自己站在哪一版地形上 —— 从 JDK 8 到 25,LTS 各自带来了哪些语言能力、运行时默认值与升级坑? 这正是下一篇 JDK 版本差异精讲 要回答的。

延伸阅读


views
Share this post on:

Previous Post
JDK 版本差异精讲:从 8 到 25,LTS 演进与关键能力
Next Post
JVM 深入(二)· 核心篇:垃圾回收全解 —— 从可达性分析到 ZGC