`

《分布式JAVA应用 基础与实践》 第三章 3.1 Java代码的执行机制(二)

阅读更多

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编译后代码的执行速度得到了大幅提升。

 

 

分享到:
评论

相关推荐

Global site tag (gtag.js) - Google Analytics