编译
- 前端编译:即常见的**.java文件被编译成.class文件**的过程
- 运行时编译:机器无法直接运行Java生成的字节码,在运行时,JIT或者解释器会将字节码转换为机器码
- 类文件在运行时被进一步编译,可以变成高度优化的机器代码
- C/C++编译器的所有优化都是在编译期完成的,运行期的性能监控仅作为基础的优化措施是无法进行的
- JIT编译器是JVM中运行时编译最重要的部分之一
编译 / 加载 / 执行
类编译
- javac:将.java文件编译成.class文件
- javap:反编译.class文件,重点关注常量池和方法表集合
- 常量池主要记录的是类文件中出现的字面量和符号引用
- 字面量:字符串常量、基本类型的常量
- 符号引用:类和接口的全限定名、类引用、方法引用、成员变量引用
- 方法表集合
- 方法的字节码、方法访问权限、方法名索引、描述符索引、JVM执行指令、属性集合等
类加载
- 当一个类被创建实例或者被其他对象引用时,JVM如果没有加载过该类,会通过类加载器将**.class文件加载到内存**中
- 不同的实现类由不同的类加载器加载
- JDK中的本地方法类一般由根加载器(Bootstrap Loader)加载
- JDK中内部实现的扩展类一般由扩展加载器(ExtClassLoader)加载
- 程序中的类文件则由系统加载器(AppClassLoader)加载
- 在类加载后,.class类文件中的常量池信息以及其他数据会被保存到JVM内存的方法区中
类连接
- 类在加载进内存后,会进行连接、初始化,最后才能被使用,连接又包括验证、准备、解析三部分
- 验证
- 验证类符合JVM规范,在保证符合规范的前提下,避免危害虚拟机安全
- 准备
- 为类的静态变量分配内存,初始化为系统的初始值
- 对于
final static
修饰的变量,直接赋值为用户的定义值
private final static int value = 123
,会在准备阶段分配内存,并初始化值为123
private static int value = 123
,会在准备阶段分配内存,并初始化值为0
- 解析
- 将符号引用转为直接引用
- 在编译时,Java类并不知道所引用的类的实际地址,因此只能使用符号引用来代替
- .class文件的常量池中存储了符号引用,实际使用时,需要将它们转化为JVM能直接获取的内存地址或指针
类初始化
- 类初始化是类加载过程的最后阶段,首先执行构造器**
<clinit>
**方法
- 前端编译时(javac),收集所有的静态变量赋值语句、s静态代码块、静态方法,成为
<clinit>
**方法
- 初始化类的静态变量和静态代码块均为用户自定义的值,初始化顺序和Java源码从上到下的顺序一致
- 子类初始化时,会先调用父类的**
<clinit>
方法,再调用子类的<clinit>
**方法
- JVM会保证**
<clinit>
方法的线程安全**,保证同一时间只有一个线程执行
- JVM在实例化新对象时,会调用**
<init>
方法对实例变量**进行初始化,并执行对应的构造方法内的代码
即时编译
- 初始化完成后,类在调用执行过程中,执行引擎需要把字节码转换为机器码,然后才能在操作系统中执行
- 在字节码转换为机器码的过程中,虚拟机还存在着一道编译,那就是即时编译
- 起初,虚拟机中的字节码是由解释器(Interpreter)完成编译的
- 当虚拟机发现某个方法或者代码块运行得特别频繁的时候,就会把这些代码认定为热点代码
- 为了提高热点代码的执行效率,JIT会把热点代码编译成与本地平台相关的高度优化的机器码,然后保存在内存中
即时编译类型
- 在HotSpot VM中,内置了两个JIT,分别是C1编译器(Client Compiler)和C2编译器(Server Compiler)
- C1编译器是一个简单快速的编译器,主要的关注点在于局部性的优化,适用于执行时间较短或对启动速度有要求的程序
- C2编译器是为长期运行的服务器端应用程序做性能调优的编译器,适用于执行时间较长或对峰值性能有要求的程序
- 在Java 7之前,需要根据应用的特性来选择对应的JIT,虚拟机默认采用解释器和其中一个即时编译器配合工作
- Java 7引入了分层编译,综合了C1的启动性能优势和C2的峰值性能优势
- 也可以通过参数
-client
、-server
强制指定虚拟机的即时编译模式
- 分层编译是将JVM的执行状态分了5个层次
- 第0层
- 程序解释执行,默认开启Profiling(如果不开启,可触发第2层编译)
- 第1层
- 称为C1编译,将字节码编译为本地代码,进行简单可靠的优化,不开启Profiling
- 第2层
- 称为C1编译,开启Profiling,仅执行带方法调用次数和循环回边执行次数Profiling的C1编译
- 第3层
- 称为C1编译,开启Profiling,执行所有带Profiling的C1编译
- 第4层
- 称为C2编译,将字节码编译为本地代码
- 但会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化
- Java 8默认开启分层编译,参数
-client
、-server
已失效
- 如果只想开启C2,可以关闭分层编译
-XX:-TieredCompilation
- 如果只想开启C1,可以打开分层编译,并且使用参数
-XX:TieredStopAtLevel=1
- 单一编译模式(上面的都是混合编译模式)
-Xint
- 强制虚拟机运行于只有解释器的编译模式下,JIT完全不介入工作
-Xcomp
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| $ java -version openjdk version "1.8.0_222" OpenJDK Runtime Environment (Zulu 8.40.0.25-CA-macosx) (build 1.8.0_222-b10) OpenJDK 64-Bit Server VM (Zulu 8.40.0.25-CA-macosx) (build 25.222-b10, mixed mode)
$ java -Xint -version openjdk version "1.8.0_222" OpenJDK Runtime Environment (Zulu 8.40.0.25-CA-macosx) (build 1.8.0_222-b10) OpenJDK 64-Bit Server VM (Zulu 8.40.0.25-CA-macosx) (build 25.222-b10, interpreted mode)
$ java -Xcomp -version openjdk version "1.8.0_222" OpenJDK Runtime Environment (Zulu 8.40.0.25-CA-macosx) (build 1.8.0_222-b10) OpenJDK 64-Bit Server VM (Zulu 8.40.0.25-CA-macosx) (build 25.222-b10, compiled mode)
|
热点探测
- HotSpot VM的热点探测是JIT优化的条件,热点探测是基于计数器的热点探测
- 采用这种方法的虚拟机会为每个方法建立计数器统计方法的执行次数
- 如果执行次数超过一定的阈值就认为是热点方法
- 虚拟机为每个方法准备了两类计数器:方法调用计数器(Invocation Counter)、回边计数器(Back Edge Counter)
- 在确定虚拟机运行参数的前提下,这两个计数器都有一个明确的阈值,当计数器超过阈值,就会触发JIT编译
- 方法调用计数器
- C1模式下是1500次,C2模式下是10000次,可通过参数
-XX:CompileThreshold
来设置
- 在分层编译的情况下,参数
-XX:CompileThreshold
将失效
- 将会根据当前待编译的方法数以及编译线程数来动态调整
- 当方法计数器和回边计数器之和超过方法计数器阈值时,触发JIT编译
- 回边计数器
- 用于统计一个方法中循环体代码执行的次数(回边:字节码中遇到控制流向后跳转的指令)
- 不开启分层编译时,C1默认为13995,C2默认为10700,可通过参数
-XX:OnStackReplacePercentage
来设置
- 在分层编译的情况下,参数
-XX:OnStackReplacePercentage
同样会失效
- 将会根据当前待编译的方法数以及编译线程数来动态调整
- 建立回边计数器的主要目的是为了触发OSR(On Stack Replacement)编译,即_栈上编译_
- 在一些循环周期比较长的代码段中,当循环达到回边计数器阈值时,JVM会认为这段代码是热点代码
- JIT编译器会将这段代码编译成机器语言并缓存,在该循环时间内,直接替换执行代码,执行缓存的机器语言
编译优化技术
方法内联
- 调用一个方法通常要经历压栈和出栈
- 调用方法是将程序执行顺序转移到存储该方法的内存地址,将方法的内容执行完后,再返回到执行该方法前的位置
- 方法调用会产生一定的时间开销和空间开销,对于方法体代码不是很大,又频繁调用的方法来说,这个开销会很大
- 方法内联:_把目标方法的代码复制到发起调用的方法中,避免发生真实的方法调用_
- JVM会自动识别热点方法,并对它们使用方法内联进行优化,可以通过
-XX:CompileThreshold
来设置热点方法的阈值
- 热点方法不一定会被JVM做内联优化,如果方法体太大,JVM将不执行内联操作
- 经常执行的方法,默认情况下,方法体大小小于325字节的都会进行内联,参数**
-XX:MaxFreqInlineSize
**
- 不经常执行的方法,默认情况下,方法体大小小于35字节的才会进行内联,参数**
-XX:MaxInlineSize
**
- 提高方法内联的方式
- 减少
-XX:CompileThreshold
,增加-XX:MaxFreqInlineSize
或-XX:MaxInlineSize
- 避免在一个方法中写大量代码,习惯使用小方法体
- 尽量使用private、static、final关键字修饰方法,编码方法因为继承,会需要_额外的类型检查_
1 2 3
| -XX:+UnlockDiagnosticVMOptions // 解锁对 JVM 进行诊断的选项参数。默认是关闭的,开启后支持一些特定参数对 JVM 进行诊断 -XX:+PrintCompilation // 在控制台打印编译过程信息 -XX:+PrintInlining // 将内联方法打印出来
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| private static int add1(int x1, int x2, int x3, int x4) { return add2(x1, x2) + add2(x3, x4); }
private static int add2(int x1, int x2) { return x1 + x2; }
public static void main(String[] args) { for (int i = 0; i < 1_000_000; i++) { add1(1, 2, 3, 4); } }
|
1 2 3 4 5 6 7 8
| 377 21 4 Test::add1 (12 bytes) @ 2 Test::add2 (4 bytes) inline (hot) @ 7 Test::add2 (4 bytes) inline (hot) ... 384 24 % 4 Test::main @ 2 (23 bytes) @ 12 Test::add1 (12 bytes) inline (hot) @ 2 Test::add2 (4 bytes) inline (hot) @ 7 Test::add2 (4 bytes) inline (hot)
|
逃逸分析
逃逸分析是判断一个对象是否被外部方法引用或外部线程访问的分析技术,编译器会根据逃逸分析的结果对代码进行优化
栈上分配
- 在Java中默认创建一个对象是在堆上分配内存的,当堆内存中的对象不再使用时,会被垃圾回收
- 这个过程相对分配在栈上的对象的创建和销毁来说,更耗时间和性能
- 逃逸分析如果发现一个对象只在方法中使用,就会将对象分配在栈上
- HotSpot VM暂时没有实现这项优化
锁消除
- 在局部方法中创建的对象只能被当前线程访问,无法被其他线程访问,JIT会对该对象的方法锁进行锁消除
标量替换
- 逃逸分析证明一个对象不会被外部访问
- 如果该对象可以被拆分的话,当程序真正执行的时候,可能不会创建这个对象,而是直接创建它的成员变量来代替
- 将对象拆分后,可以分配对象的成员变量在栈或寄存器上,原本的对象就无需分配内存空间了,称为标量替换
1 2 3 4 5
| public void foo() { TestInfo info = new TestInfo(); info.id = 1; info.count = 99; }
|
逃逸分析后,代码被优化
1 2 3 4
| public void foo() { id = 1; count = 99; }
|
JVM参数
1 2 3
| -XX:+DoEscapeAnalysis 开启逃逸分析(JDK 1.8默认开启) -XX:+EliminateLocks 开启锁消除(JDK 1.8默认开启) -XX:+EliminateAllocations 开启标量替换(JDK 1.8默认开启)
|
参考资料
Java性能调优实战