类编译加载执行过程
1先将java文件编译为class文件,再由类加载器加载到jvm。类在调用执行过程中,执行引擎会把字节码转为机器码,然后在操作系统中才能执行。在字节码转换为机器码的过程中,虚拟机中还存在着一道编译,那就是即时编译。
最初,虚拟机中的字节码是由解释器( Interpreter )完成编译的,当虚拟机发现某个方法或代码块的运行特别频繁的时候,就会把这些代码认定为“热点代码”。为了提高热点代码的执行效率,在运行时,即时编译器(JIT)会把这些代码编译成与本地平台相关的机器码,并进行各层次的优化,然后保存到内存中
即时编译器类型
我们可以通过参数 “-client”“-server” 强制指定虚拟机的即时编译模式
C1 编译器
C1 编译器是一个简单快速的编译器,主要的关注点在于局部性的优化,适用于执行时间较短或对启动性能有要求的程序,例如,GUI 应用对界面启动速度就有一定要求。(主要针对客户端程序)
C2 编译器
C2 编译器是为长期运行的服务器端应用程序做性能调优的编译器,适用于执行时间较长或对峰值性能有要求的程序。根据各自的适配性,这两种即时编译也被称为 Client Compiler 和 Server Compiler。
分层编译
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。
除了这种默认的混合编译模式,我们还可以使用“-Xint”参数强制虚拟机运行于只有解释器的编译模式下,这时 JIT 完全不介入工作;我们还可以使用参数“-Xcomp”强制虚拟机运行于只有 JIT 的编译模式下
通过 java -version 命令行可以直接查看到当前系统使用的编译模式
热点探测
在 HotSpot 虚拟机中的热点探测是 JIT 优化的条件,热点探测是基于计数器的热点探测,采用这种方法的虚拟机会为每个方法建立计数器统计方法的执行次数,如果执行次数超过一定的阈值就认为它是“热点方法” 。虚拟机为每个方法准备了两类计数器:方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter)。在确定虚拟机运行参数的前提下,这两个计数器都有一个确定的阈值,当计数器超过阈值溢出了,就会触发 JIT 编译。
方法调用计数器
用于统计方法被调用的次数,方法调用计数器的默认阈值在 C1 模式下是 1500 次,在 C2 模式在是 10000 次,可通过 -XX: CompileThreshold 来设定;而在分层编译的情况下,-XX: CompileThreshold 指定的阈值将失效,此时将会根据当前待编译的方法数以及编译线程数来动态调整。当方法计数器和回边计数器之和超过方法计数器阈值时,就会触发 JIT 编译器。
回边计数器
用于统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”(Back Edge),该值用于计算是否触发 C1 编译的阈值,在不开启分层编译的情况下,C1 默认为 13995,C2 默认为 10700,可通过 -XX: OnStackReplacePercentage=N 来设置;而在分层编译的情况下,-XX: OnStackReplacePercentage 指定的阈值同样会失效,此时将根据当前待编译的方法数以及编译线程数来动态调整。
建立回边计数器的主要目的是为了触发 OSR(On StackReplacement)编译,即栈上编译。在一些循环周期比较长的代码段中,当循环达到回边计数器阈值时,JVM 会认为这段是热点代码,JIT 编译器就会将这段代码编译成机器语言并缓存,在该循环时间段内,会直接将执行代码替换,执行缓存的机器语言。
编译优化技术
JIT 编译运用了一些经典的编译优化技术来实现代码的优化,即通过一些例行检查优化,可以智能地编译出运行时的最优性能代码
-XX:+DoEscapeAnalysis开启逃逸分析(jdk1.8默认开启,其它版本未测试) -XX:-DoEscapeAnalysis 关闭逃逸分析 -XX:+EliminateLocks开启锁消除(jdk1.8默认开启,其它版本未测试) -XX:-EliminateLocks 关闭锁消除 -XX:+EliminateAllocations开启标量替换(jdk1.8默认开启,其它版本未测试) -XX:-EliminateAllocations 关闭就可以了
方法内联
调用一个方法通常要经历压栈和出栈。调用方法是将程序执行顺序转移到存储该方法的内存地址,将方法的内容执行完后,再返回到执行该方法前的位置。这种执行操作要求在执行前保护现场并记忆执行的地址,执行后要恢复现场,并按原来保存的地址继续执行。 因此,方法调用会产生一定的时间和空间方面的开销。那么对于那些方法体代码不是很大,又频繁调用的方法来说,这个时间和空间的消耗会很大。方法内联的优化行为就是把目标方法的代码复制到发起调用的方法之中,避免发生真实的方法调用。
具体可以看:《JVM学习-内存模型(一)》
优化前
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; }
优化后
private int add1(int x1, int x2, int x3, int x4) { return x1 + x2+ x3 + x4; }
热点方法不一定会被 JVM 做内联优化,如果这个方法体太大了,JVM 将不执行内联操作。而方法体的大小阈值,我们也可以通过参数设置来优化:
1.经常执行的方法,默认情况下,方法体大小小于 325 字节的都会进行内联,我们可以通过 -XX:MaxFreqInlineSize=N 来设置大小值;
2.不是经常执行的方法,默认情况下,方法大小小于 35 字节才会进行内联,我们也可以通过 -XX:MaxInlineSize=N 来重置大小值。
之后我们就可以通过配置 JVM 参数来查看到方法被内联的情况-XX:+PrintCompilation -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining 之后,运行以下代码:
public static void main(String[] args) { for(int i=0; i<1000000; i++) {//方法调用计数器的默认阈值在C1模式下是1500次,在C2模式在是10000次,我们循环遍历超过需要阈值 add1(1,2,3,4); } }
可看到前面都没有内联优化 到后面使用内联优化
热点方法的优化可以有效提高系统性能,一般我们可以通过以下几种方式来提高方法内联:
1.通过设置 JVM 参数来减小热点阈值或增加方法体阈值,以便更多的方法可以进行内联,但这种方法意味着需要占用更多地内存;
2.在编程中,避免在一个方法中写大量代码,习惯使用小方法体;
3.尽量使用 final、private、static 关键字修饰方法,编码方法因为继承,会需要额外的类型检查。
逃逸分析
-XX:+DoEscapeAnalysis 开启逃逸分析
逃逸分析(Escape Analysis)是判断一个对象是否被外部方法引用或外部线程访问的分析技术,编译器会根据逃逸分析的结果对代码进行优化。
栈上分配
jvm参数配置
-Xmx10m -Xms10m -XX:+PrintGCDetails -XX:+DoEscapeAnalysis
测试代码
public static void main(String[] args) { for(int i=0;i<1000000;i++) { //Test(); } } public static void Test(){ int[] bytes = new int[2]; bytes[0] = 1; bytes[1] = 1; }
gc日志
[GC (Allocation Failure) [PSYoungGen: 2048K->512K(2560K)] 2048K->713K(9728K), 0.0013048 secs] [Times: user=0.01 sys=0.00, real=0.00 secs] Heap PSYoungGen total 2560K, used 955K [0x00000007bfd00000, 0x00000007c0000000, 0x00000007c0000000) eden space 2048K, 21% used [0x00000007bfd00000,0x00000007bfd6edb0,0x00000007bff00000) from space 512K, 100% used [0x00000007bff00000,0x00000007bff80000,0x00000007bff80000) to space 512K, 0% used [0x00000007bff80000,0x00000007bff80000,0x00000007c0000000) ParOldGen total 7168K, used 201K [0x00000007bf600000, 0x00000007bfd00000, 0x00000007bfd00000) object space 7168K, 2% used [0x00000007bf600000,0x00000007bf632610,0x00000007bfd00000) Metaspace used 3192K, capacity 4496K, committed 4864K, reserved 1056768K class space used 349K, capacity 388K, committed 512K, reserved 1048576K
触发了一次 年轻代gc
如果调用Test
[GC (Allocation Failure) [PSYoungGen: 2048K->512K(2560K)] 2048K->666K(9728K), 0.0008924 secs] [Times: user=0.01 sys=0.00, real=0.01 secs] [GC (Allocation Failure) [PSYoungGen: 2560K->512K(2560K)] 2714K->762K(9728K), 0.0006995 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] [GC (Allocation Failure) [PSYoungGen: 2560K->496K(2560K)] 2810K->794K(9728K), 0.0005470 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] Heap PSYoungGen total 2560K, used 1417K [0x00000007bfd00000, 0x00000007c0000000, 0x00000007c0000000) eden space 2048K, 44% used [0x00000007bfd00000,0x00000007bfde64d8,0x00000007bff00000) from space 512K, 96% used [0x00000007bff00000,0x00000007bff7c010,0x00000007bff80000) to space 512K, 0% used [0x00000007bff80000,0x00000007bff80000,0x00000007c0000000) ParOldGen total 7168K, used 298K [0x00000007bf600000, 0x00000007bfd00000, 0x00000007bfd00000) object space 7168K, 4% used [0x00000007bf600000,0x00000007bf64ab48,0x00000007bfd00000) Metaspace used 3212K, capacity 4496K, committed 4864K, reserved 1056768K class space used 355K, capacity 388K, committed 512K, reserved 1048576K
说明还是在堆上分配的 正常因为byte未被外部引用 应该是栈分配的 jdk8测试
锁消除
在没有多线程竞争的情况下,不要使用锁或者线程安全的类,如StringBuffer,因为 append Synchronized 调用的时候会加锁,导致性能下降
在下面这张情况使用StringBuffer和StringBuilder性能是差不多的,应该即时编译器判断没有多线程竞争 会去掉加锁动作
public static String getString(String s1, String s2) { StringBuffer sb = new StringBuffer(); sb.append(s1); sb.append(s2); return sb.toString(); }
标量替换
逃逸分析证明一个对象不会被外部访问,如果这个对象可以被拆分的话,当程序真正执行的时候可能不创建这个对象,而直接创建它的成员变量来代替。将对象拆分后,可以分配对象的成员变量在栈或寄存器上,原本的对象就无需分配内存空间了。这种编译优化就叫做标量替换。
public void foo() { TestInfo info = new TestInfo(); info.id = 1; info.count = 99; ...//to do something }
优化后
public void foo() { id = 1; count = 99; ...//to do something }