类执行机制

类执行机制

在完成将class文件信息加载到JVM并产生Class对象后,就可执行Class对象的静态方法或实例化对象进行调用了。

字节码解释执行方式 在源码编译阶段将源码编译为JVM字节码,JVM字节码是一种中间代码的方式,要由JVM在运行期对其进行解释并执行。

字节码解释执行

由于采用的为中间码的方式,JVM有一套自己的指令,对于面向对象的语言而言,最重要的是执行方法的指令,JVM采用四个指令来执行不同的方法调用:

  • invokestatic 对应的是调用static方法
  • invokevirtual 对应的是调用对象实例的方法
  • invokeinterface 对应的是调用接口的方法
  • invokespecial 对应的是调用private方法和编译源码后生成的方法,此方法为对象实例化时的初始化方法

下面一段代码经过javac 编译后,查看其字节码可以看到上面四种方法的调用。

public class Demo {
    public void execute() {
        A.execute();
        A a = new A();
        a.bar();

        IFoo b=new B();
        b.bar();
    }
    // Class A
    static class A {
        public static int execute() {
            return 1+2;
        }

        public int bar() {
            return 1+2;
        }
    }
    // Class B
    class B implements IFoo {
        public int bar(){
            return 1+2;
        }
    }
    public interface IFoo {
        public int bar();
    }
}

javac Demo.java # 生成class文件

javap -c Demo # 查看execute执行的字节码

类执行机制_第1张图片
类字节码

JDK 是基于栈的体系结构来执行字节码,我们在创建线程的时候,都会产生程序计数器(PC)(或称为PC registers)和栈(Stack)。

  • PC存放了下一条要执行的指令在方法内的偏移量;
  • 栈中存放了栈帧(StackFrame);

每个方法每次调用都会产生栈帧。栈帧主要分为局部变量区和操作数栈两部分:

  • 局部变量区用于存放方法中的局部变量和参数
  • 操作数栈中用于存放方法执行过程中产生的中间结果
  • 栈帧中还会有一些杂用空间,例如指向方法已解析的常量池的引用、其他一些VM内部实现需要的数据
类执行机制_第2张图片
JDK基于栈的体系结构

例如下面的代码:

public class DemoTwo {
    public static void execute() {
        int a = 1;
        int b = 2;
        int c = (a+b)*5;
    }
}

方法执行的过程如下:

Compiled from "DemoTwo.java"
public class DemoTwo {
  public DemoTwo();
    Code:
       0: aload_0       
       1: invokespecial #1                  // Method java/lang/Object."":()V
       4: return        

  public static void execute();
    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        
}


编译执行

解释执行的效率较低,为提升代码的执行性能,Sun JDK提供将字节码编译为机器码的支持,编译在运行时进行,通常称为JIT编译器。

Sun JDK在执行过程中对执行频率高的代码进行编译,对执行不频繁的代码则继续采用解释的方式,因此SunJDK又称为Hotspot VM。

在编译上Sun JDK提供了两种模式:

  • client compiler (-client)
  • server compiler (-server)

client compiler (C1)

又称为C1,较为轻量级,只做少量性能开销比高的优化,它占用内存较少,适合于桌面交互式应用。在寄存器分配策略上,JDK 6以后采用的为线性扫描寄存器分配算法,在其他方面的优化主要有:方法内联、去虚拟化、冗余削除等。

方法内联

方法执行时,要经历多次参数传递、返回值传递及跳转等,于是C1采取了方法内联的方式,即把调用到的方法的指令直接植入当前方法中。

如下代码:

public void bar() {
    …  
    bar2();  
    …
    
}

private void bar2() {
    // do something
}

当编译时,如bar2代码编译后的字节数小于等于35字节,那么,会演变成类似这样的结构:

35K 这个值可通过在启动参数中增加 -XX:MaxInlineSize=35 来进行控制。

public void bar() {  
    …  
    // do something  
    …
}

去虚拟化

去虚拟化是指在装载class文件后,进行类层次的分析,如发现类中的方法只提供一个实现类,那么对于调用了此方法的代码,也可进行方法内联,从而提升执行的性能。

如下代码:

public interface IFoo {  
    public void bar();
}

public class Foo implements IFoo {
    public void bar() {      
        // do something 
    }
}

public class Demo {  
    public void execute(IFoo foo) {
        foo.bar();  
    }
}

当整个JVM中只有Foo实现了IFoo接口,Demo execute方法被编译时,就演变成类似这样的结构:

public void execute() {  
    // do something
}

冗余消除

冗余削除是指在编译时,根据运行时状况进行代码折叠或削除。

例如一段这样的代码:

private static final Log log = LogFactory.getLog(“BLUEDAVY”);

public void execute(){
    if(log.isDebugEnabled()){      
        log.debug(“enter this method: execute”);  
    }  
    // do something
}

如log.isDebugEnabled返回的为false,在执行C1编译后,这段代码就演变成类似下面的结构:

public void execute() {  
    // do something
}

这是为什么会在有些代码编写规则上写不要直接调用log.debug,而要先判断的原因。

server compiler(C2)

又称为C2,较为重量级,C2采用了大量的传统编译优化技巧来进行优化,占用内存相对会多一些,适合于服务器端的应用。

和C1不同的主要是寄存器分配策略及优化的范围,寄存器分配策略上C2采用的为传统的图着色寄存器分配算法;由于C2会收集程序的运行信息,因此其优化的范围更多在于全局的优化,而不仅仅是一个方法块的优化。收集的信息主要有:分支的跳转/不跳转的频率、某条指令上出现过的类型、是否出现过空值、是否出现过异常。

逃逸分析 是C2进行很多优化的基础,逃逸分析是指根据运行状况来判断方法中的变量是否会被外部读取。如不会则认为此变量是逃逸的,基于逃逸分析C2在编译时会做标量替换、栈上分配和同步削除等优化。

在6.0的逃逸分析实现上有些影响性能,因此在update 18里临时禁用了,在Java 7中则默认打开。

标量替换

标量替换的意思简单来说就是用标量替换聚合量。

例如如下代码:

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);

之后基于此可以继续做冗余削除。这种方式能带来的好处是,如果创建的对象并未用到其中的全部变量,则可以节省一定的内存。而对于代码执行而言,由于无须去找对象的引用,也会更快一些。

栈上分配

在上面的例子中,如果p没有逃逸,那么C2会选择在栈上直接创建Point对象实例,而不是在JVM堆上。在栈上分配的好处一方面是更加快速,另一方面是回收时随着方法的结束,对象也被回收了,这也是栈上分配的概念。

同步削除

同步削除是指如果发现同步的对象未逃逸,那也没有同步的必要了,在C2编译时会直接去掉同步。

例如如下代码:

Point point=new Point(1,2);  
synchronized(point){      
    // do something
}

经过分析如果发现point未逃逸,在编译后,代码就会变成下面的结构:

Point point=new Point(1,2);
// do something

除了基于逃逸分析的这些外,C2还会基于其拥有的运行信息来做其他的优化,例如编译分支频率执行高的代码等。

OSR编译

OSR编译和C1、C2最主要的不同点在于OSR编译只替换循环代码体的入口,而C1、C2替换的是方法调用的入口,因此在OSR编译后会出现的现象是方法的整段代码被编译了,但只有在循环代码体部分才执行编译后的机器码,其他部分则仍然是解释执行方式。


默认情况下,Sun JDK根据机器配置来选择client或server模式,当机器配置CPU超过2核且内存超过2GB即默认为server模式,但在32位Windows机器上始终选择的都是client模式时,也可在启动时通过增加-client或-server来强制指定

Sun JDK之所以未选择在启动时即编译成机器码,有几方面的原因:

  1. 静态编译并不能根据程序的运行状况来优化执行的代码,C2这种方式是根据运行状况来进行动态编译的,例如分支判断、逃逸分析等,这些措施会对提升程序执行的性能会起到很大的帮助,在静态编译的情况下是无法实现的。给C2收集运行数据越长的时间,编译出来的代码会越优;
  2. 解释执行比编译执行更节省内存;
  3. 启动时解释执行的启动速度比编译再启动更快。

但程序在未编译期间解释执行方式会比较慢,因此需要取一个权衡值,在Sun JDK中主要依据方法上的两个计数器是否超过阈值,其中一个计数器为调用计数器,即方法被调用的次数;另一个计数器为回边计数器,即方法中循环执行部分代码的执行次数。

调用计数器:CompileThreshold

该值是指当方法被调用多少次后,就编译为机器码。在client模式下默认为1 500次,在server模式下默认为10000次,可通过在启动时添加-XX:CompileThreshold=10 000来设置该值。

-XX:CompileThreshold=10 000

回边计数器:OnStackReplacePercentage

该值为用于计算是否触发OSR编译的阈值,默认情况下client模式时为933,server模式下为140,该值可通过在启动时添加-XX:OnStackReplacePercentage=140来设置。

client模式下计算规则:

CompileThreshold*(OnStackReplacePercentage/100)

server模式下计算规则:

(CompileThreshold*(OnStackReplacePercentage-InterpreterProfilePercentage))/100

Interpreter-ProfilePercentage的默认值为33,当方法上的回边计数器到达这个值时,即触发后台的OSR编译,并将方法上累积的调用计数器设置为Com-pileThreshold的值,同时将回边计数器设置为CompileThreshold/2的值,一方面是为了避免OSR编译频繁触发;另一方面是以便当方法被再次调用时即触发正常的编译,当累积的回边计数器的值再次达到该值时,先检查OSR编译是否完成。

你可能感兴趣的:(类执行机制)