3.1.3 类执行机制
在完成将class文件信息加载到JVM并产生Class对象后,就可执行Class对象的静态方法或实例化对象进行调用了。在源码编译阶段将源码 编译为JVM字节码,JVM字节码是一种中间代码的方式,要由JVM在运行期对其进行解释并执行,这种方式称为字节码解释执行方式。
字节码解释执行
由于采用的为中间码的方式,JVM有一套自己的指令,对于面向对象的语言而言,最重要的是执行方法的指令,JVM采用了invokestatic、 invokevirtual、invokeinterface和invokespecial四个指令来执行不同的方法调用。invokestatic对应 的是调用static方法,invokevirtual对应的是调用对象实例的方法,invokeinterface对应的是调用接口的方 法,invokespecial对应的是调用private方法和编译源码后生成的<init>方法,此方法为对象实例化时的初始化方法,例 如下面一段代码:
public class Demo{
public void execute(){
A.execute();
A a = new A();
a.bar();
IFoo b = new B();
b.bar();
}
}
class A{
public static int execute(){
return 1+2;
}
public int bar(){
return 1+2;
}
}
class B implements IFoo{
public int bar(){
return 1+2;
}
}
public interface IFoo{
public int bar();
}
通过javac编译上面的代码后,使用javap -c Demo查看其execute方法的字节码:
public void execute();
Code:
0: invokestatic #2; //Method A.execute:()I
3: pop
4: new #3; //class A
7: dup
8: invokespecial #4; //Method A." < init > ":()V
11: astore_1
12: aload_1
13: invokevirtual #5; //Method A.bar:()I
16: pop
17: new #6; //class B
20: dup
21: invokespecial #7; //Method B." < init > ":()V
24: astore_2
25: aload_2
26: invokeinterface #8, 1; //InterfaceMethod IFoo.bar:()I
31: pop
32: return
从以上例子可看到invokestatic、invokespecial、invokevirtual及invokeinterface四种指令对应调用方法的情况。
Sun JDK基于栈的体系结构来执行字节码,基于栈方式的好处为代码紧凑,体积小。
线程在创建后,都会产生程序计数器(PC)(或称为PC registers)和栈(Stack);PC存放了下一条要执行的指令在方法内的偏移量;栈中存放了栈帧(StackFrame),每个方法每次调用都 会产生栈帧。栈帧主要分为局部变量区和操作数栈两部分,局部变量区用于存放方法中的局部变量和参数,操作数栈中用于存放方法执行过程中产生的中间结果,栈 帧中还会有一些杂用空间,例如指向方法已解析的常量池的引用、其他一些VM内部实现需要的数据等,结构如图3.5所示。
图3.5 Sun JDK 基于栈的体系结构 |
下面来看一个方法执行时过程的例子,代码如下:
public class Demo(){
public static void foo(){
int a = 1 ;
int b = 2 ;
int c =(a+b) * 5;
}
}
编译以上代码后,foo方法对应的字节码为以及相应的解释如下:
public static void foo();
Code:
0: iconst_1 //将类型为int、值为1的常量放入操作数栈;
1: istore_0 //将操作数栈中栈顶的值弹出放入局部变量区;
2: iconst_2 //将类型为int、值为2的常量放入操作数栈;
3: istore_1 //将操作数栈中栈顶的值弹出放入局部变量区;
4: iload_0 //装载局部变量区中的第一个值到操作数栈;
5: iload_1 //装载局部变量区中的第二个值到操作数栈;
6: iadd //执行int类型的add指令,并将计算出的结果放入操作数栈;
7: iconst_5 //将类型为int、值为5的常量放入操作数栈;
8: imul //执行int类型的mul指令,并将计算出的结果放入操作数栈;
9: istore_2 //将操作数栈中栈顶的值弹出放入局部变量区;
10: return // 返回
1. 指令解释执行
对于方法的指令解释执行,执行方式为经典冯·诺依曼体系中的FDX循环方式,即获取下一条指令,解码并分派,然后执行。在实现FDX循环时有 switch-threading、token- threading、direct-threading、subroutine-threading、inline-threading等多种方式 。
switch-threading是一种最简方式的实现,代码大致如下:
while(true){
int code = fetchNextCode ();
switch(code){
case IADD:
// do add
case …:
// do sth.
}
}
以上方式很简单地实现了FDX的循环方式,但这种方式的问题是每次执行完都得重新回到循环开始点,然后重新获取下一条指令,并继续switch,这导致了大部分时间都花费在了跳转和获取下一条指令上,而真正业务逻辑的代码非常短。
token-threading在上面的基础上稍做了改进,改进后的代码大致如下:
IADD:{
// do add;
fetchNextCode();
dispatch();
}
ICONST_0:{
push(0);
fetchNextCode();
dispatch();
}
这种方式较之switch-threading方式而言,冗余了fetch和dispatch,消耗的内存量会大一些,但由于去除了switch,因此性能会稍好一些。
其他direct-threading、inline-threading等做了更多的优化,Sun JDK的重点为编译成机器码,并没有在解释器上做太复杂的处理,因此采用了token-threading方式。为了让解释执行能更加高效,Sun JDK还做了一些其他的优化,主要有:栈顶缓存(top-of-stack caching)和部分栈帧共享。
2. 栈顶缓存
在方法的执行过程中,可看到有很多操作要将值放入操作数栈,这导致了寄存器和内存要不断地交换数据,Sun JDK采用了一个栈顶缓存,即将本来位于操作数栈顶的值直接缓存在寄存器上,这对于大部分只需要一个值的操作而言,无须将数据放入操作数栈,可直接在寄存 器计算,然后放回操作数栈。
3. 部分栈帧共享
当一个方法调用另外一个方法时,通常传入另一方法的参数为已存放在操作数栈的数据。Sun JDK在此做了一个优化,就是当调用方法时,后一方法可将前一方法的操作数栈作为当前方法的局部变量,从而节省数据copy带来的消耗。
另外,在解释执行时,对于一些特殊的情况会直接执行机器指令,例如Math.sin、Unsafe. compareAndSwapInt等。
编译执行
解释执行的效率较低,为提升代码的执行性能,Sun JDK提供将字节码编译为机器码的支持,编译在运行时进行,通常称为JIT编译器。Sun JDK在执行过程中对执行频率高的代码进行编译,对执行不频繁的代码则继续采用解释的方式,因此Sun JDK又称为Hotspot VM,在编译上Sun JDK提供了两种模式:client compiler(-client)和server compiler(-server)。
client compiler又称为C1 ,较为轻量级,只做少量性能开销比高的优化,它占用内存较少,适合于桌面交互式应用。在寄存器分配策略上,JDK 6以后采用的为线性扫描寄存器分配算法 ,在其他方面的优化主要有:方法内联、去虚拟化、冗余削除等。
1. 方法内联
对于Java类面向对象的语言,通常要调用多个方法来完成功能。执行时,要经历多次参数传递、返回值传递及跳转等,于是C1 采取了方法内联的方式,即把调用到的方法的指令直接植入当前方法中。
例如一段这样的代码:
public void bar(){
…
bar2();
…
}
private void bar2(){
// bar2
}
当编译时,如bar2代码编译后的字节数小于等于35字节 ,那么,会演变成类似这样的结构 :
public void bar(){
…
// bar2
…
}
可在debug版本的JDK的启动参数加上-XX:+PrintInlining来查看方法内联的信息。
方法内联除带来以上好处外,还能够辅助进行以下冗余削除等优化。
2. 去虚拟化
去虚拟化是指在装载class文件后,进行类层次的分析,如发现类中的方法只提供一个实现类,那么对于调用了此方法的代码,也可进行方法内联,从而提升执行的性能。
例如一段这样的代码:
public interface IFoo{
public void bar();
}
public class Foo implements IFoo{
public void bar(){
// Foo bar method
}
}
public class Demo{
public void execute(IFoo foo){
foo.bar();
}
}
当整个JVM中只有Foo实现了IFoo接口,Demo execute方法被编译时,就演变成类似这样的结构:
public void execute(){
// Foo bar method
}
3. 冗余削除
冗余削除是指在编译时,根据运行时状况进行代码折叠或削除。
例如一段这样的代码:
private static final Log log = LogFactory .getLog("BLUEDAVY");
private static final boolean isDebug = log .isDebugEnabled();
public void execute(){
if(isDebug){
log.debug("enter this method: execute");
}
// do something
}
如log.isDebugEnabled返回的为false,在执行C1编译后,这段代码就演变成类似下面的结构:
public void execute(){
// do something
}
这是为什么会在有些代码编写规则上写不要直接调用log.debug,而要先判断的原因。
Server compiler又称为C2 ,较为重量级,C2采用了大量的传统编译优化技巧来进行优化,占用内存相对会多一些,适合于服务器端的应用。和C1不同的主要是寄存器分配策略及优化的范 围,寄存器分配策略上C2采用的为传统的图着色寄存器分配算法 ;由于C2会收集程序的运行信息,因此其优化的范围更多在于全局的优化,而不仅仅是一个方法块的优化。收集的信息主要有:分支的跳转/不跳转的频率、某条 指令上出现过的类型、是否出现过空值、是否出现过异常。
逃逸分析 是C2进行很多优化的基础,逃逸分析是指根据运行状况来判断方法中的变量是否会被外部读取。如不会则认为此变量是逃逸的,基于逃逸分析C2在编译时会做标量替换、栈上分配和同步削除等优化。
1. 标量替换
标量替换的意思简单来说就是用标量替换聚合量。
例如有这么一段代码:
Point point = new Point(1,2);
System.out.println(" point.x = "+point.x+" ; point.y ="+point.y);
当point对象在后面的执行过程中未用到时,经过编译后,代码会变成类似下面的结构:
int x = 1 ;
int y = 2 ;
System.out.println(" point.x = "+x+" ; point.y ="+y);
之后基于此可以继续做冗余削除。
这种方式能带来的好处是,如果创建的对象并未用到其中的全部变量,则可以节省一定的内存。而对于代码执行而言,由于无须去找对象的引用,也会更快一些。
2. 栈上分配
在上面的例子中,如果p没有逃逸,那么C2会选择在栈上直接创建Point对象实例,而不是在JVM堆上。在栈上分配的好处一方面是更加快速,另一方面是回收时随着方法的结束,对象也被回收了,这也是栈上分配的概念。
3. 同步削除
同步削除是指如果发现同步的对象未逃逸,那也没有同步的必要了,在C2编译时会直接去掉同步。
例如有这么一段代码:
Point point = new Point(1,2);
synchronized(point){
// do something
}
经过分析如果发现point未逃逸,在编译后,代码就会变成下面的结构:
Point point = new Point(1,2);
// do something
除了基于逃逸分析的这些外,C2还会基于其拥有的运行信息来做其他的优化,例如编译分支频率执行高的代码等。
运行后C1、C2编译出来的机器码如果不再符合优化条件,则会进行逆优化(deoptimization),也就是回到解释执行的方式,例如基于类层次分析编译的代码,当有新的相应的接口实现类加入时,就执行逆优化。
除了C1、C2外,还有一种较为特殊的编译为:OSR(On Stack Replace) 。OSR编译和C1、C2最主要的不同点在于OSR编译只替换循环代码体的入口,而C1、C2替换的是方法调用的入口,因此在OSR编译后会出现的现象是 方法的整段代码被编译了,但只有在循环代码体部分才执行编译后的机器码,其他部分则仍然是解释执行方式。
默认情况下,Sun JDK根据机器配置来选择client或server模式,当机器配置CPU超过2核且内存超过2GB即默认为server模式,但在32位 Windows机器上始终选择的都是client模式时,也可在启动时通过增加-client或-server来强制指定,在JDK 7中可能会引入多层编译的支持。多层编译是指在-server的模式下采用如下方式进行编译:
解释器不再收集运行状况信息,只用于启动并触发C1编译;
C1编译后生成带收集运行信息的代码;
C2编译,基于C1编译后代码收集的运行信息来进行激进优化,当激进优化的假设不成立时,再退回使用C1编译的代码。
从以上介绍来看,Sun JDK为提升程序执行的性能,在C1和C2上做了很多的努力,其他各种实现的JVM也在编译执行上做了很多的优化,例如在IBM J9、Oracle JRockit中做了内联、逃逸分析等 。Sun JDK之所以未选择在启动时即编译成机器码,有几方面的原因:
1)静态编译并不能根据程序的运行状况来优化执行的代码,C2这种方式是根据运行状况来进行动态编译的,例如分支判断、逃逸分析等,这些措施会对提升程序执行的性能会起到很大的帮助,在静态编译的情况下是无法实现的。给C2收集运行数据越长的时间,编译出来的代码会越优;
2)解释执行比编译执行更节省内存;
3)启动时解释执行的启动速度比编译再启动更快。
但程序在未编译期间解释执行方式会比较慢,因此需要取一个权衡值,在Sun JDK中主要依据方法上的两个计数器是否超过阈值,其中一个计数器为调用计数器,即方法被调用的次数;另一个计数器为回边计数器,即方法中循环执行部分代 码的执行次数。下面将介绍两个计数器对应的阈值。
CompileThreshold
该值是指当方法被调用多少次后,就编译为机器码。在client模式下默认为1 500次,在server模式下默认为10 000次,可通过在启动时添加-XX:CompileThreshold=10 000来设置该值。
OnStackReplacePercentage
该值为用于计算是否触发OSR编译的阈值,默认情况下client模式时为933,server模式下为140,该值可通过在启动时添加-XX: OnStackReplacePercentage=140来设置,在client模式时,计算规则为CompileThreshold * (OnStackReplacePercentage/100),在server模式时,计算规则为(CompileThreshold * (OnStackReplacePercentage - InterpreterProfilePercentage))/100。InterpreterProfilePercentage的默认值为33,当 方法上的回边计数器到达这个值时,即触发后台的OSR编译,并将方法上累积的调用计数器设置为CompileThreshold的值,同时将回边计数器设 置为CompileThreshold/2的值,一方面是为了避免OSR编译频繁触发;另一方面是以便当方法被再次调用时即触发正常的编译,当累积的回边 计数器的值再次达到该值时,先检查OSR编译是否完成。如果OSR编译完成,则在执行循环体的代码时进入编译后的代码;如果OSR编译未完成,则继续把当 前回边计数器的累积值再减掉一些,从这些描述可看出,默认情况下对于回边的情况,server模式下只要回边次数达到10 700次,就会触发OSR编译。
用以下一段示例代码来模拟编译的触发。
public class Foo{
public static void main(String[] args){
Foo foo = new Foo();
for(int i = 0 ;i < 10 ;i++){
foo.bar();
}
}
public void bar(){
// some bar code
for(int i = 0 ;i < 10700 ;i++){
bar2();
}
}
private void bar2(){
// bar2 method
}
}
以上代码采用java -server方式执行,当main中第一次调用foo.bar时,bar方法上的调用计数器为1,回边计数器为0;当bar方法中的循环执行完毕 时,bar方法的调用计数器仍然为1,回边计数器则为10 700,达到触发OSR编译的条件,于是触发OSR编译,并将bar方法的调用计数器设置为10 000,回边计数器设置为5 000。
当main中第二次调用foo.bar时,jdk发现bar方法的调用次数已超过compileThreshold,于是在后台执行JIT编译,并 继续解释执行// some bar code,进入循环时,先检查OSR编译是否完成。如果完成,则执行编译后的代码,如果未编译完成,则继续解释执行。
当main中第三次调用foo.bar时,如果此时JIT编译已完成,则进入编译后的代码;如果编译未完成,则继续按照上面所说的方式执行。
由于Sun JDK的这个特性,在对Java代码进行性能测试时,要尤其注意是否事先做了足够次数的调用,以保证测试是公平的;对于高性能的程序而言,也应考虑在程序提供给用户访问前,自行进行一定的调用,以保证关键功能的性能。
反射执行
反射执行是Java的亮点之一,基于反射可动态调用某对象实例中对应的方法、访问查看对象的属性等,无需在编写代码时就确定要创建的对象。这使得 Java可以很灵活地实现对象的调用,例如MVC框架中通常要调用实现类中的execute方法,但框架在编写时是无法知道实现类的。在Java中则可以 通过反射机制直接去调用应用实现类中的execute方法,代码示例如下:
Class actionClass =Class.forName(外部实现类);
Method method = actionClass .getMethod("execute",null);
Object action = actionClass .newInstance();
method.invoke(action,null);
这种方式对于框架之类的代码而言非常重要,反射和直接创建对象实例,调用方法的最大不同在于创建的过程、方法调用的过程是动态的。这也使得采用反射 生成执行方法调用的代码并不像直接调用实例对象代码,编译后就可直接生成对对象方法调用的字节码,而是只能生成调用JVM反射实现的字节码了。
要实现动态的调用,最直接的方法就是动态生成字节码,并加载到JVM中执行,Sun JDK采用的即为这种方法,来看看在Sun JDK中以上反射代码的关键执行过程。
Class actionClass =Class.forName(外部实现类);
调用本地方法,使用调用者所在的ClassLoader来加载创建出的Class对象;
Method method = actionClass .getMethod("execute",null);
校验Class是否为public类型,以确定类的执行权限,如不是public类型的,则直接抛出SecurityException。
调用privateGetDeclaredMethods来获取Class中的所有方法,在privateGetDeclaredMethods对Class中所有方法集合做了缓存,第一次会调用本地方法去获取。
扫描方法集合列表中是否有相同方法名及参数类型的方法,如果有,则复制生成一个新的Method对象返回;如果没有,则继续扫描父类、父接口中是否有该方法;如果仍然没找到方法,则抛出NoSuchMethodException,代码如下:
Object action = actionClass .newInstance();
校验Class是否为public类型,如果权限不足,则直接抛出SecurityException。
如果没有缓存的构造器对象,则调用本地方法获取构造器,并复制生成一个新的构造器对象,放入缓存;如果没有空构造器,则抛出InstantiationException。
校验构造器对象的权限。
执行构造器对象的newInstance方法。
判断构造器对象的newInstance方法是否有缓存的ConstructorAccessor对象,如果没有,则调用sun.reflect.ReflectionFactory生成新的ConstructorAccessor对象。
判断sun.reflect.ReflectionFactory是否需要调用本地代码,可通过 sun.reflect.noInflation=true来设置为不调用本地代码。在不调用本地代码的情况下,可转交给 MethodAccessorGenerator来处理。本地代码调用的情况在此不进行阐述。
MethodAccessorGenerator中的generate方法根据Java Class格式规范生成字节码,字节码中包括ConstructorAccessor对象需要的newInstance方法。该newInstance方 法对应的指令为invokespecial,所需参数则从外部压入,生成的Constructor类的名字以sun/reflect/ GeneratedSerializationConstructorAccessor或sun/reflect /GeneratedConstructorAccessor开头,后面跟随一个累计创建对象的次数。
在生成字节码后将其加载到当前的ClassLoader中,并实例化,完成ConstructorAccessor对象的创建过程,并将此对象放入构造器对象的缓存中。
执行获取的constructorAccessor.newInstance,这步和标准的方法调用没有任何区别。
method.invoke(action,null);
这步的执行过程和上一步基本类似,只是在生成字节码时方法改为了invoke,其调用目标改为了传入对象的方法,同时类名改为了:sun/reflect/GeneratedMethodAccessor。
综上所述,执行一段反射执行的代码后,在debug里查看Method对象中的MethodAccessor对象引用(参数为-Dsun.reflect.noInflation=true,否则要默认执行15次反射调用后才能动态生成字节码),如图3.6所示:
(点击查看大图)图3.6 反射执行代码示例 |
Sun JDK采用以上方式提供反射的实现,提升代码编写的灵活性,但也可以看出,其整个过程比直接编译成字节码的调用复杂很多,因此性能比直接执行的慢一些。 Sun JDK中反射执行的性能会随着JDK版本的提升越来越好,到JDK 6后差距就不大了,但要注意的是,getMethod相对比较耗性能,一方面是权限的校验,另一方面是所有方法的扫描及Method对象的复制,因此在使 用反射调用多的系统中应缓存getMethod返回的Method对象,而method.invoke的性能则仅比直接调用低一点。一段对比直接执行、反 射执行性能的程序如下所示:
// Server OSR编译阈值:10700
private static final int WARMUP_COUNT=10700;
private ForReflection testClass=new ForReflection();
private static Method method=null;
public static void main(String[] args) throws Exception{
method=ForReflection.class.getMethod
("execute",new Class<?>[]{String.class});
Demo demo=new Demo();
// 保证反射能生成字节码及相关的测试代码能够被JIT编译
for (int i = 0; i < 20; i++) {
demo.testDirectCall();
demo.testCacheMethodCall();
demo.testNoCacheMethodCall();
}
long beginTime=System.currentTimeMillis();
demo.testDirectCall();
long endTime=System.currentTimeMillis();
System.out.println("直接调用消耗的时间为:"+
(endTime-beginTime)+"毫秒");
beginTime=System.currentTimeMillis();
demo.testNoCacheMethodCall();
endTime=System.currentTimeMillis();
System.out.println("不缓存Method,反射调用消耗的时间为:
"+(endTime-beginTime)+"毫秒");
beginTime=System.currentTimeMillis();
demo.testCacheMethodCall();
endTime=System.currentTimeMillis();
System.out.println("缓存Method,反射调用
消耗的时间为:"+(endTime-beginTime)+"毫秒");
}
public void testDirectCall(){
for (int i = 0; i < WARMUP_COUNT; i++) {
testClass.execute("hello");
}
}
public void testCacheMethodCall() throws Exception{
for (int i = 0; i < WARMUP_COUNT; i++) {
method.invoke(testClass, new Object[]{"hello"});
}
}
public void testNoCacheMethodCall() throws Exception{
for (int i = 0; i < WARMUP_COUNT; i++) {
Method testMethod=ForReflection.class.
getMethod("execute",new Class<?>[]{String.class});
testMethod.invoke(testClass, new Object[]{"hello"});
}
}
public class ForReflection {
private Map<String, String> caches=new
HashMap<String, String>();
public void execute(String message){
String b=this.toString()+message;
caches.put(b, message);
}
}
执行后显示的性能如下(执行环境: Intel Duo CPU E8400 3G, windows 7, Sun JDK 1.6.0_18,启动参数为-server -Xms128M -Xmx128M):
直接调用消耗的时间为5毫秒;
不缓存Method,反射调用消耗的时间为11毫秒;
缓存Method,反射调用消耗的时间为6毫秒。
在启动参数上增加-Xint来禁止JIT编译,执行上面代码,结果为:
直接调用消耗的时间为133毫秒;
不缓存Method,反射调用消耗的时间为215毫秒;
缓存Method,反射调用消耗的时间为150毫秒。
对比这段测试结果也可看出,C2编译后代码的执行速度得到了大幅提升。