概览
编译型语言(C++,Fortran等):运行程序前,需要用编译器将代码静态编译成CPU可执行的汇编码。汇编码针对特定的CPU。
优点:只需编译一次,且有足够的程序信息来优化汇编码、执行速度快;
缺点:不支持跨平台。
解释型语言(PHP,Perl等):执行程序时,解释器将代码转换成汇编码。只要有相应的解释器,可在不同的CPU上运行。
优点:支持跨平台;
缺点:执行时会重新翻译代码,解释器一次只能看一行代码,不能像编译器一样做充足的优化,导致速度慢。
Java试图走中间路线,代码会被静态编译成字节码,字节码可以通过Java解释器转换为CPU可执行的汇编码。Java能在代码执行时将其编译成平台特定的二进制码,成为即时编译(JIT)。Java的设计结合了脚本语言的平台独立性和编译型语言的本地性能。
热点编译
Java两种执行方式:编译执行和解释执行。
为什么Java执行代码时,不立即编译代码?
(1)编译代码的成本较高。如果代码只执行一次,解释执行字节码比先编译后再执行速度更快;如果代码被频繁的执行,编译后的执行更快,多次执行节约的时间大于编译字节码的时间。
(2)JVM执行代码的次数越多就会越了解这段代码,有利于编译代码时进行大量优化。
JIT编译器类型
-client
-server
-XX:+TieredCompilation 分层
各自特点:
(1)client编译器开启比server编译器要早,在代码执行的开始阶段,client编译器比server编译器要快;
(2)server编译器生成的代码比client编译器更快(启动较晚,可以获取到更多的支持编译优化的程序信息);
(3)分层编译先由client编译器编译,随着代码变热,再由server编译器重新编译。
如何选择?
(1)当应用的启动时间为首要的性能考量时,首选client编译器。
(2)对于计算量固定的应用,选择实际执行任务最快的编译器。分层编译是批处理任务合理的默认选择。
(3)对于长时间运行的应用,首选server编译器,最好配合分层编译。
Java与JIT编译器版本
编译器的选择取决于JVM是32位还是64位,以及传递给JVM的编译器参数。
编译器中级调优
调优代码缓存
代码缓存:编译后的汇编码存放在代码缓存,如果代码缓存被填满,JVM将不能编译更多的代码。
代码缓存被填满时报错:
CodeCache is full, Compiler has been disabled.
Java7开启分层编译时,代码缓存通常就不够用了,常常需要扩大;使用client编译器的大型程序也需要增加代码缓存的大小。没有好的办法可以计算出程序需要的代码缓存,通常的做法是简单地增加1倍或3倍。
代码缓存初始值:-XX:InitialCodeCacheSize
代码缓存最大值:-XX:ReservedCodeCacheSize
编译阈值
两种计数器:方法调用计数器和方法中的循环回边计数器。
两种编译方式:
标准编译:JVM执行Java某个方法时,会检查该方法的两种计数器总数,根据总数判断该方法是否适合编译。
栈上替换(OSR,On-Stack Replacement):如果方法内的循环很长,JVM会在方法执行完成之前编译循环。每完成一轮循环,回边计数器就会增加,如果超过阈值,那这个循环(非方法)就可以被编译。循环代码编译前或编译中,解释执行;在循环代码编译完成后,JVM会替换还在栈上的代码,在下一轮循环中就会执行更快的编译代码。
参数:-XX:CompileThreshold,阈值等于方法调用计数器和循环回边计数器的总和,触发标准编译, 默认值:client为1500,server为10000。
降低编译阈值通常基于以下两种原因:
(1)减少应用热身的时间,
(2)使一些原本可能不被server编译器编译的方法得以编译。因为计数器会周期性的减少,对于执行不太频繁的代码可能永远达不到编译阈值,即时永远执行的代码(温热)。
检测编译过程
参数:-XX:-PrintCompilation=true 开启编译日志,当一个方法被编译时打印相关信息。
日志格式:timestamp compilation_id attributes (tiered_level) method_name size deopt
timestamp 编译完成时的时间戳(相对于JVM启动时的时间)
compilation_id 编译任务ID
attributes 属性,表示代码编译的状态
%:编译为OSR。
s:方法是同步的。
!:方法有异常处理器。
b:阻塞模式时发生的编译。
n:为封装本地方法发生的编译。
tiered_level 分层编译的级别,非分层编译为空
method_name 被编译的方法名(或是被OSR编译的循环所在的方法)
size 被编译字节码的大小
deopt 表明发生了某种逆优化,通常是made not entrant 或 made zombie
日志可能出现编译错误信息,原因可能有两种
(1)代码缓存满了。需要增大代码缓存
(2)编译的同时加载类。JVM会重新编译
另一种检测编译的方法:jstat -compiler pid 和 jstat -printcompilation pid 1000
编译器高级调优
编译线程
放置在编译队列中的编译任务会被编译线程异步编译。
队列不是按照严格的FIFO,优先级和热点的程度相关。这是输出编译日志中的编译ID为乱序的一个原因。
公共子表达式消除
数组边界检查消除
方法内联(Method Inlining)
编译器所做的最重要的优化方法就是方法内联,特别是对属性封装良好的面向对象的代码来说,如getter、setter。
下面以代码的形式说明方法内联的含义,但实际上方法内联是在字段码编译为机器码时进行的优化。
//原始代码 public static void print(String str) { if(str != null) { System.out.println("print " + str); } } public static void testInline() { String str = null; print(str); } //内联后,事实上testInline是一个无用的代码,可以进行无用代码消除的优化。如果不做内联就无法发现“Dead Code”。 public static void testInline() { String str = null; if(str != null) { System.out.println("print " + str); } }
为什么进行方法内联?
1)去除方法调用的成本(如建立栈桢等);
2)为其他优化建立良好的基础,方法内联膨胀之后可以便于在更大范围上采取后续的优化手段。
什么时候进行方法内联?
方法是否内联取决于方法的热度和方法的大小。
频繁调用内联:如果方法因调用频繁可以内联,只有在方法的字节码小于325字节时(或小于-XX:MaxFreqInlineSize=N所设定的任意值)才会内联;
常规内联:如果方法非调用频繁,只有很小,小于35字节(或小于-XX:MaxInlineSize=N所设定的任意值)时才会内联。
JVM怎么进行方法内联?
Java语言的默认的实例方法是虚方法,虚方法需要在运行时进行方法接受者的多态选择(详细知识见《深入理解JVM》第8章),所以在编译期做内联的时候根本无法确定应该使用哪个方法版本。为解决这个问题,JVM引入了“类型继承关系分析”(Class Hierarchy Analysis,CHA)技术。CHA技术用于确定一个接口是否有多于一种的实现,一个类是否有子类等信息,可以判断一个方法是否有多个版本。
方法内联时的判断逻辑如下:
1)如果是非虚方法,则直接内联即可;
2)如果是虚方法,则通过CHA查询此方法在当前程序下是否有多个目标版本;
3)如果只有一个版本,也可以进行内联,但属于激进优化,被称为守护内联(Guarded Inlining)。后续JVM一直没有加载会导致此方法的接收者的继承关系发生变化的类,那么守护条件成立。如果守护条件成立,那么内联优化后的代码一直可以使用;否则,就要抛弃已经编译的代码,通过解释执行或重新编译,这种情况被称为“逃生门”。
4)如果有多个版本,则尝试通过内联缓存(Inline Cache)完成方法内联。大致原理:在未发生方法调用前,内联缓存是空的,当发生第一次调用时,缓存记录下方法接收者的版本信息,当以后再次调用该方法时,会比较版本信息,如果版本一致则可以继续使用这个内联,如果版本不一致则取消当前内联,重新进行方法分派。当程序实际使用了虚方法的多态特性时,才不能使用内联,而不是在虚方法拥有多个接收者版本时就不能使用内联。
方法内联的优化建议:
几乎不用调整内联参数,提倡通过调整内联参数以提高性能的建议往往忽略调常规内联和频繁调用内联之间的关系。例如:通过增加MaxInlineSize以便内联更多的方法,更多的方法在第一次调用时就会被内联,但是,方法只有经常被调用时才值得内联。MaxInlineSize调优的最终结果就是减少了热身测试所需要的时间,但不太可能对长期运行的程序产生重大影响。
逃逸分析(Escape Analysis)
逃逸分析是编译器做的最复杂的优化。逃逸分析并不是直接优化代码的手段,而是为其他优化手段提供分析技术。
方法逃逸:当一个对象在方法中被定义,可能被其他外部方法引用,例如作为调用参数传递到其他方法中去;
线程逃逸:甚至可能被外部线程访问到,比如赋值给类变量或可以在其他线程中访问到的实例变量。
如果能证明一个对象不会发生方法逃逸或线程逃逸,可以为这个变量进行一些高效的优化:
(1)栈上分配(Stack Allocation)
JVM正常情况下,对象在堆上分配,在堆上进行垃圾回收耗资源。如果确定对象不会发生方法逃逸,直接在栈上分配是个不错的选择,对象随着栈帧弹出而销毁,减少垃圾回收的压力。
HotSpot JVM由于实现栈上分配比较复杂,暂时还没做这项优化。
(2)消除同步锁(Synchronization Elimination)
如果确定对象不会逃逸出线程,只有一个线程访问对象,可以实施锁消除,减少锁的耗时。
(3)标量替换(Scalar Replacement)
标量是指一个变量无法再分解成更小的变量所表示,例如:Java中的基本类型和引用类型;相反,一个变量还可以继续分解就称为聚合量,例如:Java中的对象。如果逃逸分析认为一个变量不会被外部访问并且是聚合量,那么在实际执行中可能就不新建这个对象,而是直接创建在这个方法中使用到的成员变量来代替。这样可以让对象的成员在栈上分配和读写,还可以为后续进一步的优化手段创造条件。
最后,由于很难保证逃逸分析的性能收益一定大于它的消耗,所以要谨慎开启逃逸分析。默认情况下1.6Update23之后是开启逃逸分析的,如果确人对程序运行有益,也可以通过参数手动开启。
-XX:+DoEscapeAnalysis
-XX:+PrintEscapeAnalysis
-XX:+EliminateAllocations
-XX:+EliminateLocks
-XX:+PrintEliminateAllocations
逆优化
逆优化是指编译器不得不撤销之前的某些编译
两种情况:
made not entrant(代码被丢弃)
1、可能和类与接口是实现方式有关(一个接口有不同的实现类)
2、可能与分层编译实现的细节有关(先由client编译,再由server编译,替换client编译的代码)
made zombie(产生僵尸代码)
分层编译级别
分层编译可以在2种编译器和5种级别之间进行。
0:解释代码
1:简单C1编译代码
2:受限的C1编译代码
3:完全C1编译代码
4:C2编译代码
Java与C++编译器对比