JVM 解释器和即时编译器的工作机制
JVM(Java Virtual Machine)作为 Java 程序运行的核心,其执行引擎主要包括 解释器(Interpreter) 和 JIT(Just-In-Time)即时编译器。两者协同工作,兼顾 启动速度 与 运行性能,是 Java “一次编写,到处运行” 与 “高性能” 并存的关键。
JVM 解释器
解释器负责 逐条读取字节码指令,并将其 翻译成对应平台的机器码执行。它不进行任何优化,直接“边解释边执行”。
JVM 加载
.class文件后,得到字节码(bytecode)。解释器从方法的字节码开始,一条一条地解释执行。
每条字节码指令(如
iload,invokevirtual,iadd)都会被映射为本地机器指令。
核心职责
-
读取字节码:从
.class文件加载的字节码指令流中逐条读取。 -
解释执行:将每条字节码指令翻译成对应平台的本地机器操作。
-
维护运行时状态:管理 Java 栈帧、局部变量表、操作数栈、程序计数器等。
在Java的编译体系中,一个Java的源代码文件变成计算机可执行的机器指令的过程中,需要经过两段编译
-
第一段:.java文件转换成.class文件(Jvm编译器javac)
-
第二段:.class转换成机器指令的过程(jvm解释器)
在第二编译阶段,JVM 通过解释字节码将其翻译成对应的机器指令,逐条读入、解释翻译。
动态编译(dynamic compilation)指的是“在运行时进行编译”,与之相对的是事前编译,简称AOT,也叫静态编译。
JIT即时编译(just-in-time compilation) 当某段代码即将第一次被执行时进行编译,因而叫“即时编译”。
自适应动态编译(adaptive dynamic compilation)也是一种动态编译,但它通常执行的时机比JIT编译迟,先让程序“以某种式”先运行起来,收集一些信息之后再做动态编译。这样的编译可以更加优化。
工作原理
HotSpot JVM 的解释器主要由 模板解释器(Template Interpreter) 实现
为每条字节码指令(如
iload,invokevirtual,iadd)预先生成一段本地机器码模板。运行时,解释器通过查表(dispatch table)跳转到对应模板执行。相比传统的“switch-case 解释器”,性能更高(减少分支预测失败)。
执行流程示例:
1 | public int add(int a, int b) { |
对应的字节码:
1 | iload_1 // 将局部变量1(a)压入操作数栈 |
解释器执行过程:
- PC(程序计数器)指向
iload_1- 查找
iload_1对应的机器码模板,执行(将 a 压栈)- PC 增加,指向下一条
iload_2,重复…- 直到
ireturn,方法返回。
整个过程无编译,纯解释执行。
JIT 即时编译器
JIT 编译器在程序 运行时 将 热点代码(Hot Spot) 编译为 本地机器码,并缓存起来,后续直接执行机器码,大幅提升性能。
监控执行频率:JVM 内置计数器,统计方法或代码块的调用次数。
识别热点代码:当某段代码执行次数超过阈值(如 10,000 次),被标记为“热点”。
触发 JIT 编译:JIT 编译器将字节码编译为高度优化的本地机器码。
替换执行入口:后续调用直接跳转到编译后的机器码,不再经过解释器。
1 | -XX:+PrintCompilation # 打印 JIT 编译日志 |
JIT 的核心优化技术:
方法内联(Method Inlining):消除方法调用开销。
逃逸分析(Escape Analysis):判断对象是否逃逸出方法,决定是否栈上分配。
锁消除(Lock Elimination):对不会被多线程访问的对象,去除同步。
循环展开(Loop Unrolling):减少循环控制开销。
死代码消除(Dead Code Elimination):移除无用代码。
分支预测优化:基于运行时数据优化 if/else 路径。
| 优化类别 | 典型技术 | 目标 |
|---|---|---|
| 调用优化 | 方法内联、去虚拟化 | 消除调用开销 |
| 内存优化 | 逃逸分析、栈分配、标量替换 | 减少 GC、提升缓存 |
| 同步优化 | 锁消除 | 提升并发性能 |
| 控制流优化 | 分支预测、死代码消除 | 精简执行路径 |
| 计算优化 | 常量折叠、循环展开 | 减少运行时计算 |
| 数据流优化 | 循环外提、寄存器分配 | 提升 CPU 利用率 |
热点检测
热点检测(Hot Spot Detection) 是 JIT编译器决定“哪些代码值得优化”的核心机制。JVM 不会盲目编译所有代码,而是通过运行时监控,识别出执行频率高或占用 CPU 时间长的“热点代码”,然后交由 JIT 编译器进行深度优化。
避免过度编译:只优化真正影响性能的代码。
平衡启动速度与运行性能:冷代码用解释器快速执行,热代码用 JIT 提升效率。
动态适应程序行为:根据实际运行负载调整优化策略。
目前主要的热点代码识别方式是热点探测(Hot Spot Detection),有以下两种:
-
基于采样方式探测
周期性检测各个线程的栈顶,发现某个方法经常出在栈顶,就认为是热点方法。
好处是实现简单,缺点是无法精确确认一个方法的热度。容易受线程阻塞或别的原因干扰热点探测。
-
基于计数器热点探测
采用这种方法的虚拟机会为每个方法、代码块建立计数器,统计方法的执行次数,某个方法超过阀值就认为是热点方法,触发JIT编译。
在HotSpot虚拟机中使用的是—基于计数器的热点探测方法,因此它为每个方法准备了两个计数器:
方法调用计数器:就是记录一个方法被调用次数的计数器,当方法调用次数超过阈值,触发 JIT 编译。
该计数器具有 热度衰减(Counter Decay) 机制: 如果方法长时间未被调用,计数器会周期性衰减(防止“历史热点”长期占用编译资源)。
回边计数器:是记录方法中的for或者while的运行次数的计数器。识别循环密集型的热点代码。
回边计数超过阈值,触发 OSR
方法内联
消除方法调用开销(栈帧创建、参数传递、返回值处理),并为后续优化(如常量传播、死代码消除)创造条件。
将被调用方法的字节码直接“复制”到调用处。
1 | int add(int a, int b) { return a + b; } |
-
小方法(如 getter/setter)几乎总是内联。
-
虚方法(virtual)若运行时只有一种实现(monomorphic),也可内联。
-
支持多态内联(bimorphic/inline caching):对 2 种类型做分支预测。
逃逸分析
分析对象的作用域,判断其是否“逃逸”出当前方法或线程,从而决定:
是否可以在栈上分配(而非堆)
是否可以标量替换(拆解对象为局部变量)
是否可以消除同步锁
优化的目的就是减少内存堆分配压力,可避免 GC 压力,提升性能。所以对象和数组并不是都在堆上分配内存的。
逃逸分析是Java虚拟机中比较前沿的优化技术。一种可以有效减少程序中同步负载和堆内存分配压力的跨函数全局数据流分析算法。
通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。
| 逃逸级别 | 含义 | 是否优化 |
|---|---|---|
| NoEscape | 对象仅在方法内使用,未被外部引用 | 栈分配、标量替换 |
| ArgEscape | 作为参数传递给其他方法,但未被长期持有 | 可能部分优化 |
| GlobalEscape | 被全局变量引用或返回 | 无法优化,必须堆分配 |
逃逸分析的基本行为就是分析对象动态作用域:
-
当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中,称为方法逃逸。
-
赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸。
1
2
3
4
5
6
7public static StringBuffer craeteStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb; // 直接返回对象,有可能被其他方法所改变,作用域就不只是在该方法内部了(方法逃逸)
return sb.toString(); // 不直接返回 StringBuffer对象,那么将不会逃逸出方法。
}
通过JVM参数可指定是否开启逃逸分析,
1 | -XX:+DoEscapeAnalysis # 表示开启逃逸分析 |
使用逃逸分析,编译器可以对代码做如下优化:
同步省略。如果一个对象被发现只能被一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的。
分离对象或标量替换。有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。
锁消除
动态编译同步块的时候,JIT编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问。
结合逃逸分析:如果一个对象不会被多线程访问(如局部对象),则其上的
synchronized锁可安全移除。移除不必要的同步锁,提升并发性能。
1 | public void f() { |
所以,在使用synchronized的时候,如果JIT经过逃逸分析之后发现并无线程安全问题的话,就会做锁消除。
循环优化
-
循环展开
将循环体复制多次,减少循环控制(如 i++、条件判断)开销。
例如:
for (int i=0; i<4; i++)→ 展开为 4 次独立语句。 -
循环不变量外提
将循环体内不随循环变化的计算移到循环外。
1
2
3
4for (int i = 0; i < list.size(); i++) { ... }
// 优化为:
int len = list.size();
for (int i = 0; i < len; i++) { ... } -
死循环检测与消除
若循环无副作用且结果未被使用,可能被整个删除。
死代码消除
移除永远不会执行或结果未被使用的代码。
1 | if (false) { |
常量折叠后出现不可达分支
变量未被读取(无副作用)
常量传播与折叠
在编译期计算常量表达式,减少运行时计算。
1 | int a = 2; |
原本是变量的值变成常量。
分支预测与条件优化
基于运行时数据,优化 if/else 或 switch 的执行路径。
记录分支跳转频率(如 99% 走 if 分支)。
将高概率路径放在代码前面(CPU 分支预测更准)。
对低概率路径(如异常处理)做“冷代码”处理,甚至不编译。
栈上分配
在某个方法中定义一个对象,但是并没有在方法外部引用他。这个对象并不会逃逸到方法外部。经过JIT的逃逸分析之后,就可以对其内存分配进行优化,在栈上分配。
1 | for (int i = 0; i < 1000000; i++) { |
堆内存中分配的对象减少
GC次数减少
标量替换
将对象“打散”为若干基本类型变量,避免对象分配。
标量是指一个无法再分解成更小的数据的数据。Java中的原始数据类型就是标量。相对的,那些还可以分解的数据叫做聚合量(Aggregate),Java中的对象就是聚合量,因为他可以分解成其他聚合量和标量。
在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。这个过程就是标量替换。
1 | // point对象并没有逃逸出alloc方法,并且point对象是可以拆解成标量的。 |
减少堆分配
提升缓存局部性
便于寄存器分配
