前言
在很多年以前,做C或者C++的程序员经常说Java语言的运行速度不如C或C++,Java运行速度慢主要是因为它是解释执行的,而C或C++是编译执行的,解释执行需要通过JVM虚拟机将字节码实时翻译成机器码(边翻译边执行),才能运行在操作系统上,这个过程会比编译执行慢。
但现在再说这个结论就不太对了,随着JIT即时编译技术的发展,性能差距正在逐步缩小,甚至在某些情况下,执行速度是优于C或C++的。
一.为什么出现JIT
1.JVM代码执行流程
我们编写的Java程序是由以.java结尾的源文件,通过javac指令编译为由.class结尾的字节码文件,而字节码文件被加载进入JVM后,是通过JVM的解释器逐条读取字节码,并将其翻译成对应平台的机器指令执行。虽然解释执行具有跨平台性好、启动速度快的特点,但其执行效率相对较低。因为每次执行字节码时,都需要经过解释器的翻译过程,这增加了额外的开销。特别是在执行循环、递归等热点代码时,性能瓶颈尤为明显。
2.JIT技术的引入
为了解决这一性能瓶颈,JVM引入了JIT即时编译器技术。JIT技术能够在程序运行时动态地将字节码编译成本地机器码,并且根据程序的实际运行情况对机器码进行优化,从而提高程序的执行效率。
当某些方法或代码块(它们都对应特定的字节码)被频繁调用时,这部分代码就被视为热点代码。JVM虚拟机会针对性的对这部分’热点代码进行优化编译,将它们从字节码转换为本地机器码,然后将优化后的本地机器码缓存起来,后续再执行时可以直接从缓存中获取并运行,无需再次编译
。JVM提供了一个参数“-XX:ReservedCodeCacheSize”,用来限制 CodeCache 的大小。也就是说,JIT 编译后的代码都会放在 CodeCache 里。
而热点代码由热点探测进行发现,热点探测基于计数器,JVM虚拟机会为每个方法建立对应的计数器,统计方法的执行次数、方法内的循环次数等,如果计数器超过指定阈值,则标识其为热点代码。
二.认识JIT即时编译器
1.C1和C2编译器
主流的HotSpot虚拟机内置了两个JIT编译器:C1(Client Compiler)编译器和C2(Server Compiler)编译器,C1和C2编译器在优化方面有不同的侧重点:C1侧重编译速度,C2侧重深度优化
- Client Compiler(C1):针对客户端应用程序,优化启动时间,以较少的编译优化来实现更快的编译速度。
- Server Compiler(C2):C2编译器侧重于深度优化,与C1正好相反,C2编译器的编译时间较长,但优化的程度较高。C2的优化策略比较深度,会进行更高级的优化,比如逃逸分析等,C2编译器编译的代码的执行速度通常比C1编译器快。
C2编译器由于深度优化代码过于复杂,已经很难维护了,从JDK 10开始,Graal编译器已经代替了C2编译器,与C1编译器协同工作
2.JIT优化技术-热点探测
JIT(Just-In-Time)优化技术是一种在程序运行时动态地将部分代码编译成机器代码,以提高程序执行效率和性能的技术。这种技术广泛应用于动态语言、虚拟机和一些解释型语言的执行环境中。JIT优化技术主要包括:热点探测,编译优化,内联优化,挑分析等。
热点检测:热点检测是指在程序运行时,通过监测代码的执行情况,识别出被频繁执行的代码块或方法,即热点代码。通过计数器记录代码块或方法的执行次数,当某个代码块的执行次数超过一定阈值时,认为它是热点代码。
虚拟机为每个方法准备了两类计数器:方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter)。在确定虚拟机运行参数的前提下,这两个计数器都有一个确定的阈值,当计数器超过阈值溢出了,就会触发 JIT 编译。
方法调用计数器
用于统计方法被调用的次数,方法调用计数器的默认阈值在客户端模式下是 1500 次,在服务端模式下是 10000 次(我们用的都是服务端,java –version查询),可通过 -XX: CompileThreshold 来设定
回边计数器
用于统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”(Back Edge),该值用于计算是否触发 C1 编译的阈值,在不开启分层编译的情况下,在服务端模式下是10700。
回边计数器阈值 =方法调用计数器阈值(CompileThreshold)×(OSR比率(OnStackReplacePercentage)-解释器监控比率(InterpreterProfilePercentage)/100 , 通过 java -XX:+PrintFlagsFinal –version查询相关参数:
其中OnStackReplacePercentage默认值为140,InterpreterProfilePercentage默认值为33,如果都取默认值,那Server模式虚拟机回边计数器的阈值为10700. 回边计数器阈值 =10000×(140-33)=10700
3.方法内联
将函数调用处的代码直接插入到调用点,减少函数调用的开销。
// 方法内联
public int xx() {int num1 = 111;int num2 = 222;// 等价于 -> sum = num1 + num2int sum = add(num1, num2);return sum;
}public int add(int num1, int num2) {return num1 + num1;
}
在代码中,方法内联会将其中的add(num1, num2)方法转换为实际的num1 + num1,直接进行计算操作,避免了方法调用。
4.锁消除技术
如果在线程安全的情况下使用了一个线程安全的容器那么会导致性能降低,比如StringBuffer这样的类的append方法是有Synchronized同步锁使的性能底下
public void xx(){SpringBuffer s = new StringBuffer();s.append(...)
}
但实际上,在以上代码测试中,StringBuffer 和 StringBuilder 的性能基本没什么区别。这是因为在局部方法中创建的对象只能被当前线程访问,无法被其它线程访问,这个变量的读写肯定不会有竞争,这个时候 JIT 编译会对这个对象的方法锁进行锁消除。
使用StringBuffer和StringBuilder,我们把锁消除关闭—测试发现性能差别有点大
- -XX:+EliminateLocks开启锁消除(jdk1.8默认开启,其它版本未测试)
- -XX:-EliminateLocks 关闭锁消除
锁粗化
for( ... ){Synchronized(this){ ... }
}
锁粗化的作用:如果检测到同一个对象执行了连续的加锁和解锁的操作,则会将这一系列操作合并成一个更大的锁,从而提升程序的执行效率。
5.逃逸分析技术
大家常理解的对象分配是在堆中分配的,对象的引用变量通常在栈中,当方法结束栈销毁后,堆中对象失去引用后等待垃圾回收器回收。在某种情况下对象是可以在栈中分配的,也就是说当栈被销毁对象也会被销毁,这样的话大大减少了GC的回收成本。这种对象分配就是栈上分配,是否能在栈上分配需要使用逃逸分析
算法进行计算。
逃逸分析的原理:分析对象动态作用域,当一个对象在方法中定义后,它不会被外部方法所引用(无法逃逸),那么这样的对象会被在栈中分配,因为该对象只是在当前方法中使用,如下:
public void jjjj(){for(... : 50000){xxx();}
}
public void xxx() {User user = new User(); //栈上分配user.name = "zhangsan";user.age = 18;//to do something
}
当然逃逸分析技术属于JIT的优化技术,所以必须要符合热点代码,JIT才会优化,另外对象如果要分配到栈上,需要将对象拆分(大对象放不下需要拆解),这种编译优化就叫做标量替换技术。
也就是说:要满足栈中分配需要满足2个条件,一是热点代码 ,而是标量替换。
- -XX:+DoEscapeAnalysis开启逃逸分析(jdk1.8默认开启)
- -XX:-DoEscapeAnalysis 关闭逃逸分析
- -XX:+EliminateAllocations开启标量替换(jdk1.8默认开启)
- -XX:-EliminateAllocations 关闭标量替换
三.总结
本篇文章介绍了JVM的JIT即时编译器,它解决了解释器在逐行解释性能差的问题,它通过对热点代码的探测,将热点代码编译后进行缓存,从而提高程序的执行性能。
而JIT的优化技术除了热点代码编译缓存外,还提供了方法内联,锁消除,逃逸分析等手段来提高程序性能。
文章结束喜欢的话请给个好评!!!