从零开始理解JVM的JIT编译机制
# 写在文章开头
在现代软件开发中,Java 语言因其跨平台性和强大的生态系统而广受欢迎。然而,性能一直是开发者关注的重点之一。为了提升 Java 应用的运行效率,Java 虚拟机(JVM)引入了多种优化技术,其中最引人注目的莫过于即时编译(Just-In-Time Compilation,简称 JIT)。本文将深入探讨 JVM 中的 JIT 编译技术,揭示其背后的原理和工作机制,并介绍如何通过配置和调优来最大化应用性能。
你好,我是 SharkChili ,禅与计算机程序设计艺术布道者,希望我的理念对您有所启发。
📝 我的公众号:写代码的SharkChili
在这里,我会分享技术干货、编程思考与开源项目实践。
🚀 我的开源项目:mini-redis
一个用于教学理解的 Redis 精简实现,欢迎 Star & Contribute:
https://github.com/shark-ctrl/mini-redis (opens new window)
👥 欢迎加入读者群
关注公众号,回复 【加群】 即可获取联系方式,期待与你交流技术、共同成长!
# 详解JIT编译技术
# 即时编译的执行点
在初始化阶段完成后,执行引擎不断将调用到的字节码翻译成机器码交由计算机执行,Java字节码转为机器码之间还有一步转换,我们称之为既时编译:

最初Java字节码文件是直接通过解释器( Interpreter )解释为机器码直接运行的。对于某些执行频率比较频繁的代码,我们可以称之为热点代码,JIT就会针对这些热点代码进行相应的优化并缓存,以提升程序的运行效率:

# 即时编译器类型有哪些?
我们以HotSpot 虚拟机为例,该虚拟机内置了两个JIT编译器,分别为:
C1编译器:主要关注点在于局部性优化,常用于那些执行时间短,或者要求快速启动的应用程序,例如GUI应用程序。C2编译器:常用于长期运行且对峰值性能有高要求的服务器。
所以我们也称C1编译器和C2编译器为 Client Compiler或者Server Compiler。
在 Java7 之前,需要根据程序的特性来选择对应的 JIT,虚拟机默认采用解释器和其中一个编译器配合工作。
Java7 引入了分层编译,这种方式综合了 C1 的启动性能优势和 C2 的峰值性能优势,我们也可以通过参数 “-client”“-server” 强制指定虚拟机的即时编译模式。分层编译将 JVM 的执行状态分为了 5 个层次:
第 0 层:程序解释执行,默认开启性能监控功能(Profiling),如果不开启,可触发第二层编译; 第 1 层:可称为 C1 编译,将字节码编译为本地代码,进行简单、可靠的优化,不开启 Profiling; 第 2 层:也称为 C1 编译,开启 Profiling,仅执行带方法调用次数和循环回边执行次数 profiling 的 C1 编译; 第 3 层:也称为 C1 编译,执行所有带 Profiling 的 C1 编译; 第 4 层:可称为 C2 编译,也是将字节码编译为本地代码,但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化。
在 Java8 中,默认开启分层编译,-client 和 -server 的设置已经是无效的了。如果只想开启
C2,可以关闭分层编译(-XX:-TieredCompilation),如果只想用
C1,可以在打开分层编译的同时,使用参数:-XX:TieredStopAtLevel=1。
我们可以使用java -version查看当前编译的编译模式,可以看到笔者服务器的JVM使用的就是混合编译模式:
java version "1.8.0_202"
Java(TM) SE Runtime Environment (build 1.8.0_202-b08)
Java HotSpot(TM) 64-Bit Server VM (build 25.202-b08, mixed mode)
2
3
4
当然,如果我们想将编译器模式改为解释器模式,就可以键入下面这条命令:
java -Xint -version
如果我们想强制运行JIT编译模式,也可以使用
java -Xcomp -version
# JIT的热点探测
# 什么是JIT热点探测
HotSpot 虚拟机判定热点代码是基于两种计数器进行的,分别是方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter),只有执行代码符合他们的标准且达到他的设置的阈值时才会进行JIT编译优化。
# 方法调用计数器
方法调用器会针对方法的执行频率进行相应的优化,当某个方法执行次数超过阈值时,就会触发JIT编译优化,这个阈值我们可以通过jinfo查看:
jinfo -flag CompileThreshold pid
以笔者某个java进程为例,可以看到JVM设置的方法调用计数器判定是否是热点代码的条件为调用次数达到10000次:
-XX:CompileThreshold=10000
2
这也就意味着当方法调用在一段时间(而非永久叠加)次数达到10000次的时候,就会提交一个编译请求,后续执行时都直接用缓存中的编译后的机器码直接运行:

# 回边计数器
在字节码遇到控制流后跳转的操作我们称之为回边,回边计数器判定代码为热点代码的条件是一个代码在循环体内达到回边计数器要求的阈值,而这个阈值我们也可以通过jinfo查看
jinfo -flag OnStackReplacePercentage pid
以笔者的进程为例可以看到当回边次数达到140时也会执行相应的JIT优化,即当这段代码被判定为热点代码时,JVM就会进行一种栈上编译的优化操作,它会将这段代码编译为最优逻辑保存到本地内存,在执行循环体的期间,直接使用缓存中的机器码:
-XX:OnStackReplacePercentage=140
注意:与方法计数器不同,回边计数器没有计数热度衰减的过程,因此这个计数器统计的就是该方法循环执行的绝对次数。
# JIT编译优化技术
# 方法内联
我们都知道方法调用会经历一个压栈和出栈的操作,执行调用方法时会将地址转移到存储该方法的起始地址上,待调用结束后,在返回原来的位置。 这就意味着一个方法调用另一个方法时,就需要保存当前方法执行位置,栈上压入被调用方法,执行完成后,恢复现场继续执行之前执行的方法。因此方法调用期间是有一定的时间和空间的开销的。
所以JIT会对那些方法调用方法非常频繁的代码执行方法内联:
private int add1(int x1, int x2, int x3, int x4) {
return add2(x1, x2) + add2(x3, x4);
}
private int add2(int x1, int x2) {
return x1 + x2;
}
2
3
4
5
6
最终会被优化为如下,由此减少方法调用时压栈和出栈的开销:
private int add1(int x1, int x2, int x3, int x4) {
return x1 + x2 + x3 + x4;
}
2
3
4
但是方法内敛优化也是有条件的,除了必须是热点代码(达到XX:CompileThreshold的阈值)以外,还要达到以下要求:
- 对于经常执行的方法,方法体要小于
325字节,这个字节数可以通过-XX:MaxFreqInlineSize=N来调整。 - 对于不经常执行的方法,方法体要小于
35字节,这个字节数可以由-XX:MaxInlineSize=N来调整。
我们不妨看一段代码,可以看到add1执行了1000000次
public class JVMJit {
public static void main(String[] args) {
for (int i = 0; i < 1000000; i++) {
add1(1, 2, 3, 4);
}
}
private static int add1(int i, int i1, int i2, int i3) {
return i + i1 + i2 + i3;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
我们可以对这段程序添加这样一段参数:
-XX:+PrintCompilation -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining
他们的含义分别是:
-XX:+PrintCompilation // 在控制台打印编译过程信息
-XX:+UnlockDiagnosticVMOptions // 解锁对 JVM 进行诊断的选项参数。默认是关闭的,开启后支持一些特定参数对 JVM 进行诊断
-XX:+PrintInlining // 将内联方法打印出来
2
3
可以看到这段代码被判定为热点代码,说明他已经被JVM优化了:

所以这就要求我们平时写代码时:
- 方法体尽可能小。
- 尽可能使用
private、final、static修饰,避免一些没必要的类是否继承等相关检查。
# 栈上分配
在将栈上分配前,我们需要先了解一个叫逃逸分析(Escape Analysis)的技术。
逃逸分析就是判断当前操作的对象是否有被外部方法引用或外部线程访问的一种技术,对应逃逸分析分为如下三种情况:
- 无逃逸:对象并没有被外部其他对象或者线程引用
- 方法逃逸:对象被外部方法引用,例如对象作为入参传递到其他方法
- 线程逃逸:创建的对象被外部线程访问到
逃逸分析则是基于上述几种逃逸情况针对性进行优化,比如笔者现在要说的栈上分配。
我们都知道创建一个对象,都是在堆上分配的,假如这个对象使用完毕,垃圾回收器就会将其回收,因为创建对象涉及堆内存的申请、分配完成后的回收整理等一系列步骤,需要耗费大量资源,而栈则不一样,它使用的引用或者各种变量随着调用的结束就消亡,无论是分配创建还是销毁执行性能表现都优于堆上创建对象。
而栈上分配就是抓住这一特点,当他经过逃逸分析技术发现这个对象并没有被线程引用即无逃逸或方法逃逸的情况下,那么它就会将该对象分配在栈上。如下面这样一段代码,笔者在createObj批量创建了TestObj对象,可以看到这个对象没有发生逃逸,所以最终结果应该是栈上分配分配该对象:
public static void main(String[] args) {
//创建对象
createObj();
ThreadUtil.sleep(Integer.MAX_VALUE);
}
public static void createObj() {
long start = System.currentTimeMillis();
for (int i = 0; i < Integer.MAX_VALUE; i++) {
// 创建对象
TestObj obj = new TestObj();
// 传入obj执行 空操作,避免jit因为空创建而优化掉该操作
nothingTodo(obj);
}
long end = System.currentTimeMillis();
System.out.println("耗时:" + (end - start) + "ms");
}
public static void nothingTodo(TestObj obj) {
if (obj == null) {
obj = new TestObj();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
对此笔者添加打印参数-XX:+PrintGC并将程序启动,可以发现批量创建TestObj耗时在2ms且并没有发生gc,由此可印证在无逃逸或方法逃逸的情况下,JIT将会采用栈上分配的方式创建对象。

与之对应笔者给出关闭逃逸分析后-XX:-DoEscapeAnalysis -XX:+PrintGC的结果,可以看到频繁的gc且对象创建总耗时为5697ms由此可以进一步印证上述理论。

同理再证,当对象被外部线程访问即触发线程逃逸后不会触发栈上分配,对此笔者将obj赋值给外部线程且开启逃逸分析和gc打印:
-XX:+DoEscapeAnalysis -XX:+PrintGC
对应代码如下:
public static void createObj() {
TestObj obj = null;
long start = System.currentTimeMillis();
for (int i = 0; i < Integer.MAX_VALUE; i++) {
// 创建对象
obj = new TestObj();
}
long end = System.currentTimeMillis();
// 创建外部线程访问对象,触发线程逃逸
TestObj finalObj = obj;
new Thread(() -> {
if (finalObj == null) {
System.out.println(finalObj);
}
}).start();
System.out.println("耗时:" + (end - start) + "ms");
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
输出结果如下,可以看到耗时变长触发gc,很明显线程逃逸使得栈上分配失败:

# 锁消除
同样在逃逸分析某些没有被外部方法或者其他线程引用的情况下,会将某些锁消除。例如下面这段代码,实际上你在运行时可以发现StringBuffer 和StringBuilder 性能上没有什么区别,这正是因为锁消除为我们做的优化工作。
public static void main(String[] args) {
appendStr(1000);
}
public static void appendStr(int count) {
StringBuffer sb = new StringBuffer();
for (int i = 0; i < count; i++) {
sb.append("no: " + i + " ");
}
}
2
3
4
5
6
7
8
9
10
从编译后的字节码可以看出,因为对象没有发生逃逸,中间字符串拼接操作都是通过StringBuilder完成操作的,在StringBuilder完成字符串拼接之后再追加到StringBuffer上:

# 标量替换
当一个代码的对象在方法上可以拆分,并且代码仅仅是对这个对象的变量进行各种操作的话,编译器可能会执行标量替换,如下所示
public void foo() {
TestInfo info = new TestInfo();
info.id = 1;
info.count = 99;
...//to do something
}
2
3
4
5
6
由于上述代码仅仅是创建一个对象后操作对象的变量,实际上这个工作似乎和对象没有任何关联,编译器识别到这点之后就不去创建没必要的对象,进而使用标量替换的方式将对象的成员变量放到栈上,避免没必要的对象创建和销毁。
public void foo() {
id = 1;
count = 99;
...//to do something
}
2
3
4
5
6
我们可以通过设置 JVM 参数来开关逃逸分析,还可以单独开关同步消除和标量替换,在 JDK1.8 中 JVM 是默认开启这些操作的。
-XX:+DoEscapeAnalysis 开启逃逸分析(jdk1.8 默认开启,其它版本未测试)
-XX:-DoEscapeAnalysis 关闭逃逸分析
-XX:+EliminateLocks 开启锁消除(jdk1.8 默认开启,其它版本未测试)
-XX:-EliminateLocks 关闭锁消除
-XX:+EliminateAllocations 开启标量替换(jdk1.8 默认开启,其它版本未测试)
-XX:-EliminateAllocations 关闭就可以了
2
3
4
5
6
7
8
# 常见面试题
# 能不能给我简单介绍一下JIT优化技术
JVM内置的解释器会将编译后的字节码翻译为机器码,然后再执行,考虑到代码边解释边执行的效率非常低,于是hotspot就引入JIT技术(Just-In-Time),它会针对那些执行比较频繁的热点代码(hot spot code)进行优化并缓存,由此提升程序执行的效率:

# Java中的对象一定在堆内存上分配内存吗?为什么?
不一定,如果对象经过逃逸分析的判定发现对象没有发生逃逸,hotspot虚拟机的JIT优化机制则直接在栈上完成对象分配,对此我们不妨做一个实验,以下面这代码为例,我们通过JVM参数-XX:-DoEscapeAnalysis关闭逃逸分析:
public static void main(String[] args) {
long start = System.currentTimeMillis();
for (int i = 0; i < Integer.MAX_VALUE; i++) {
new TestObj();
}
long end = System.currentTimeMillis();
System.out.println(end - start);
ThreadUtil.sleep(Integer.MAX_VALUE);
}
static class TestObj {
}
2
3
4
5
6
7
8
9
10
11
12
13
14
从抽样器重可以知晓,关闭逃逸分析之后所有的对象都会创建在堆上,且分配在堆就会因为堆内存已满导致GC,所以关闭逃逸分析之后这段代码的执行耗时为7754ms:

与之的对应的是eden区的GC次数也达到了34次:

与之相反一旦我们开启逃逸分析-XX:+DoEscapeAnalysis,可以看到因为JIT优化的缘故,本次堆区创建的对象就少了很多
同时新生代gc也直接变为0:

# 小结
自此我们对逃逸分析是什么以及工作极致和几种常见的优化手段进行了详尽的介绍,希望对你有帮助。
你好,我是 SharkChili ,禅与计算机程序设计艺术布道者,希望我的理念对您有所启发。
📝 我的公众号:写代码的SharkChili
在这里,我会分享技术干货、编程思考与开源项目实践。
🚀 我的开源项目:mini-redis
一个用于教学理解的 Redis 精简实现,欢迎 Star & Contribute:
https://github.com/shark-ctrl/mini-redis (opens new window)
👥 欢迎加入读者群
关注公众号,回复 【加群】 即可获取联系方式,期待与你交流技术、共同成长!
# 参考
Java 性能调优实战:https://time.geekbang.org/column/intro/185 (opens new window)
JIT—逃逸分析:https://blog.csdn.net/xioayu96/article/details/107061664 (opens new window)
深入理解Java内存与运行时机制:逃逸分析、栈上分配与标量替换:https://cloud.tencent.com/developer/article/2560474 (opens new window)
《深入理解java虚拟机》