JVM(Java Virtual Machine)即Java虚拟机,Java代码都是在JVM上运行的,所以了解JVM是成为Java高手的毕竟之路。
本系列内容将对JVM的知识进行介绍,是从头学习JVM知识的笔记。
本系列内容根据自己的学习和理解的基础上,并参考《深入理解Java虚拟机》一书介绍的知识所写。如果有写的不对的地方,请各位多多提点。
在JVM中,Java程序最初是通过解释器(Interpreter)进行解释执行的,当虚拟机发现某个方法或代码的运行特别频繁时,就会把它们认定为“热点代码”(Hot Spot Code)。
为了提高热点代码的执行效率,在运行时会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器称为即时编译器(Just In Time Compiler,JIT)。
主流的Java虚拟机都有解释器与编译器,他们各有优势:
HotSpot中内置了两个即时编译器,分别为Client Compiler(简称C1)和 Server Compiler(简称C2)。默认采用一个解释器和一个编译器配合的方式工作,叫做混合模式(Mixed Mode),用户可以使用“-client”或“-server”参数去强制指定编译器。
只使用解释器的方式叫“解释模式”(Interpreted Mode),只使用编译器的方式叫“编译模式”(Compiled Mode)。可以通过虚拟机的 -version
命令查看相应信息。
同时,解释器还可以作为一个“逃生门”。当编译器进行激进优化不成立时,如加载了新类型后集成结构出现变化、出现了“罕见陷阱”(Uncommon Trap)时,可以通过逆优化(Deoptimization)退回到解释状态继续执行,部分没有解释器的虚拟机(JRockit)也会使用没有激进优化的C1担任逃生门。
由于即时编译需要耗费时间,或者需要解释器替编译器收集性能监控信息等情况,即时编译采用分层编译(Tiered Compilation),即根据编译器编译、优化的规模和耗时,划分出不同的层次:
实施分层编译后,C1和C2可以同时工作,许多代码可能会被多次编译。用C1来获取更高的编译速度,用C2来获取更好地编译质量。
在运行过程中会被即时编译器编译的“热点代码”有两类:
第一种情况,编译器会以整个方法作为编译对象,这也是JVM中的标准JIT编译方式。
第二种情况,依然会以整个方法作为编译对象,但是编译发生在方法执行的过程中(循环方法体内),因此形象的称为栈上替换(On Stack Replacement,OSR编译,即方法栈帧还在栈上,但是方法被替换了)。
次数是判断为热点代码的判定条件,判断一段代码是不是热点代码的行为就叫“热点探测”(Hot Spot Detection),目前主要的热点探测方式有两种:基于采样的热点探测,基于计数器的热点探测。
基于采样的热点探测(Sample Based Hot Spot Detection):采用这种方式虚拟机会周期性地检查各个线程的栈顶,如果某个方法经常出现在栈顶,即为“热点方法”。
这种方式的好处是实现简单、高效,容易获取方法调用关系。缺点是很难精确确认热度,也可能是因为线程阻塞等外界因素导致堆积在栈顶。
基于计数器的热点探测(Counter Based Hot Spot Detection):采用这种方式的虚拟机会为每个方法(甚至代码块)建立计数器,统计执行次数,超过一定的次数即为“热点方法”。HotSpt使用的是这种方法。
这种方法实现起来比较麻烦一些,需要为每个方法建立并维护计数器,且不能直接获取方法的调用关系,但是它的统计结果更加精确和严谨。
基于计数器的热点探测方式中有两种计数器:
方法调用计数器调用次数的默认值在Client模式下是1500次,在Server模式下是10 000次,可以通过虚拟机参数 -XX:CompileThreshold来设置。
如果不做任何设置,方法代用计数器统计的并不是方法被调用的绝对次数,而是一个相对频率(即一段时间内被调用的次数)。当超过一定时间限度,如果方法的热度不足以提交给即时编译器,那么它的方法调用计数器的值就会减半,这个过程叫做热的的衰减(Counter Decay),这段时间被称为半衰周期(Counter Half Life Time)。
可以使用虚拟机参数 -XX:-UseCounterDecay来关闭热度衰减;可以用参数 -XX:CounterHalfLifeTime设置半衰周期的时间,单位为秒。
回边计数器记录的是该方法循环的绝对次数。可以使用虚拟机参数 -XX:OnStackReplacePercentage 来间接调整回边计数器的阈值。
方法调用计数器阈值(CompileThreshold) x OSR比率(OnStackReplacePercentage)/ 100
OnStackReplacePercentage默认值为933,都取默认值则Client模式下阈值默认为13995。
方法调用计数器阈值(CompileThreshold) x (OSR比率(OnStackReplacePercentage)- 解释器监控比率(InterpreterProfilingPercentage))/ 100
其中OnStackReplacePercentage默认值为140,InterpreterProfilingPercentage默认值为33,则在Server模式下默认阈值为10700。
无论是方法的即时编译,还是OSR即时编译,都是需要时间去完成的,在代码编译未完成之前,都仍然按照解释器方式继续执行,编译则在后台的编译线程中进行。可以通过参数 -XX:-BackgroundCompilation来禁止后台编译。
在后台编译过程中C1和C2编译器编译过程是不一样的。C1(Client Compiler)是一个简单快速的三段式编译器,主要关注点在于局部性的优化。C2(Server Compiler)则是耗时的全局化优化。
Server Compiler则是专门面向服务端的典型应用并未服务端的性能配置特别调整过的编译器,也是一个充分优化过的高级编译器。它会执行所有经典优化动作,如无用代码消除、循环展开、循环表达式外提、等等。
语言无关的最经典优化技术之一:公共子表达式消除。
语言相关的最经典优化技术之一:数组范围检查消除。
最重要的优化技术之一:方法内联。
最前沿的优化技术之一:逃逸分析。
公共子表达式消除的含义是:若某个表达式E已经计算过,且它的运算从之前到现在的过程中变量的值一直没有变化,那么这次E就成了公共子表达式。
若这种优化仅局限于程序的基本快内就称为局部公共子表达式消除。如果优化的范围涵盖了多个基本快,就称为全局公共子表达式消除。
举个简单例子来说明:
//加入存在如下代码定义
int d = (c * b) * 12 + a + (a + b *c);
//编译器检测到b*c 与 c*b 是一样的表达式,若b和c的值时不变的,
//那么公共子表达式消除优化后如下
int d = E * 12 + a + (a + E);
//还可以进行代数简化 这种优化方式,优化后如下
int d = E * 13 + a * 2;
Java是一门动态安全的语言,因此对数组访问时会自动进行上下界的检查,否则抛出运行时异常:java.lang.ArrayIndexOutOfBoundsException。虽然这种检查是好事,但是每一次都进行检查则是负担。于是虚拟机将检查放在编译的时候进行检查,如果编译器检查通过了,之后的多次运行,甚至是循环都不会在进行判断了。
除了尽可能把运行期的检查提到编译器检查的方式外,还有一种方式是——隐式异常处理。即虚拟机将可能抛出的异常注册为一个Segmeng Fault信号的异常,使用异常处理的方式,当检查出现情况则把对应异常抛出。Java中空指针检查和算术运算中除数为零检查都用了这种方式。下面举个例子说明:
//隐式异常处理优化前
//存在数组 foo[i]
if(foo != null){
return foo.value;
}else{
throw new NullPointException();
}
//优化后
try{
return foo.value;
}catch(Segmeng Fault){
throw new NullPointException();
}
方法内联,是指JVM在运行时将指定的函数体插入并取代每一处调用该函数的地方(上下文),从而消除调用成本,并为接下来进一步的代码性能优化提供基础,是JVM的一个重要优化手段之一。
简单通俗的讲就是把方法内部调用的其它方法的逻辑,嵌入到自身的方法中去,变成自身的一部分,之后不再调用该方法,从而节省调用函数带来的额外开支。
举个内联的可能形式:
//原代码
public int add(int a, int b , int c, int d){
return add(a, b) + add(c, d);
}
public int add(int a, int b){
return a + b;
}
//内联后
public int add(int a, int b , int c, int d){
return a + b + c + d;
}
逃逸分析是目前Java虚拟机中最比较前沿的优化技术,它并不是直接优化代码的手段,而是为其他优化手段提供依据的分析技术。
逃逸分析获取对象的引用,分析对象动态作用域,分析其会不会逃逸到方法或者线程之外。
逃逸分析使用以下方式提高效率::
逃逸分析的论文早在1996年就已经发布,但直到JDK1.6才实现,虽然目前该技术尚未足够成熟,依然是JIT优化技术的一个重要方向。