类执行机制
title: 2. 类执行机制
top: 98
categories:
- JAVA
- JVM
- 类执行机制
tags:
- JVM
在完成将class文件信息加载到JVM并产生Class对象后,就可执行Class对象的静态方法或实例化对象进行调用了。在源码编译阶段将源码编译为JVM字节码,JVM字节码是一种中间代码的方式,要由JVM在运行期对其进行解释并执行,这种方式称为字节码解释执行方式。
字节码解释执行
由于采用的为中间码的方式,JVM有一套自己的指令,对于面向对象的语言而言,最重要的是执行方法的指令,JVM采用了invokestatic、invokevirtual、invokeinterface和invokespecial四个指令来执行不同的方法调用。
- invokestatic对应的是调用static方法
- invokevirtual对应的是调用对象实例的方法
- invokeinterface对应的是调用接口的方法
- invokespecial对应的是调用private方法和编译源码后生成的方法,此方法为对象实例化时的初始化方法,例如下面一段代码:
1 |
|
通过javac编译上面的代码后,使用javap -c Demo查看其execute方法的字节码:
1 |
|
从以上例子可看到invokestatic、invokespecial、invokevirtual及invokeinterface四种指令对应调用方法的情况。
Sun JDK基于栈的体系结构来执行字节码,基于栈方式的好处为代码紧凑,体积小。
线程在创建后,都会产生程序计数器(PC)(或称为PC registers)和栈(Stack);PC存放了下一条要执行的指令在方法内的偏移量;栈中存放了栈帧(StackFrame),每个方法每次调用都会产生栈帧。栈帧主要分为局部变量区和操作数栈两部分,局部变量区用于存放方法中的局部变量和参数,操作数栈中用于存放方法执行过程中产生的中间结果,栈帧中还会有一些杂用空间,例如指向方法已解析的常量池的引用、其他一些VM内部实现需要的数据等
下面来看一个方法执行时过程的例子,代码如下:
1 | public class Demo(){ |
编译以上代码后,foo方法对应的字节码为以及相应的解释如下:
1 |
|
- 指令解释执行
方法的指令解释执行,执行方式:冯 * 诺依曼体系中FDX循环方式,即获取下一条指令,解码并分派,然后执行
实现FDX循环时,有
switch-threading
- 最简单方式
1 | while(true) { |
问题:每次执行完都得重新回到循环开始点,然后获取下一条指令,并继续switch,导致大部分时间消耗在跳转和获取下一条指令,真正的业务逻辑代码很短
- token-threading
改进
1 | IADD: { |
问题:冗余了fetch和dispatch,内存消耗大,性能稍好
- direct-threading
- subroutine-threading
- inline-threading
其余三项做了更多优化,但是JDK重点为编译成机器码,并未在解释器做太多复杂处理,因此采用token-threading方式。为了解释器能更高效,SUN JDK做了一些优化,主要:栈顶缓存和部分栈帧共享
- 栈顶缓存
在方法的执行过程中,有很多的操作要将值放入操作数栈,这回导致寄存器和内存要不断的交换数据,为此JDK采用了一个栈顶缓存,将本来位于操作数栈顶的值直接缓存到寄存器上,这对于需要一个值得操作而言,无须将数据放入操作数栈,可直接在寄存器计算,然后放回操作数栈。
- 部分栈帧共享
当一个方法调用另一个方法时,通常传入另一个方法的参数为已存放在操作数栈的数据,JDK做了个优化,就是当调用方法时,后一方法可将前一个方法的操作数栈作为当前方法的局部变量,从而节省数据copy的消耗。
编译执行
解释执行的效率较低,为提升代码的执行性能,Sun JDK提供将字节码编译为机器码的支持,编译在运行时进行,通常称为JIT编译器。Sun JDK在执行过程中对执行频率高的代码进行编译,对执行不频繁的代码则继续采用解释的方式,因此Sun JDK又称为Hotspot VM,在编译上Sun JDK提供了两种模式:client compiler(-client)和servercompiler(-server)。
client compiler又称为C1 ,较为轻量级,只做少量性能开销比高的优化,它占用内存较少,适合于桌面交互式应用。在寄存器分配策略上,JDK 6以后采用的为线性扫描寄存器分配算法 ,在其他方面的优化主要有:方法内联、去虚拟化、冗余削除等。
- 方法内联
通常业务操作需要调用多个方法来完成功能。执行时,要经历多次参数传递、返回值传递及跳转等,于是c1采用方法内联的方式,即把调用到的方法的指令植入到当前方法中
1 | //优化前 |
编译后字节数小于等于35字节,会出现上述情况
方法内连的好处多多,还能够辅助进行冗余消除等
2. 去虚拟化
指在装载class文件后,进行类层次的分析,如果发现类中的方法只提供一个实现类,那么对于调用了此方法的代码,也可以进行内联
1 | //优化前 |
- 冗余消除
指编译时,根据运行状况进行代码折叠或削除
1 | //优化前 |
Server Compile又称c2,较为重量级,采用了大量传统编译优化技巧来进行优化,
占内存相对较多,适合用于服务器端的应用。 采用的是传统的图着色寄存器分配方案。 优化的范围:全局优化,而不是一个方法块的优化。
收集的主要信息有:分支的跳转/不跳转的频率、某条指令上出现过的类型、是否出现过空值、是否出现过异常等。
逃逸分析是c2进行优化的基础。 逃逸分析是指:根据运行状况来判断方法中的变量是否会被外部读取。如果会,则认为该变量是逃逸的,基于逃逸分析C2在编译时会做标量替换、栈上分配和同步削除等优化。
- 标量替换
简单来说:就是用标量替换聚合量
1 | //编译前 |
之后,可再次做冗余削除
好处:如果创建对象并未用到其中的全部变量,则可以节省一定内存。
对于代码执行,由于无需去找对象的引用, 也会更快
2.
栈上分配
上边的例子中,如果point没有逃逸,那么C2会选择直接在栈上直接创建Point对象实例,而不是在JVM。 在栈上分配好处,一方面是更加快速,另一方面会收是随着方法的结束,对象也被回收,这也是栈上分配的概念
3.
同步削除
指,如果发现同步的对象未逃逸,那么也就没有同步的必要了,在C2编译时会去掉同步
1 | //编译前 |
除了基于逃逸分析的这些外,C2还会基于其拥有的运行信息来做其他优化。 例如编译分支频率、执行高的代码等
运行后C1、C2编译出来的机器码,如果不再符合优化条件,则会进行逆优化,也就是回到解释执行的方式。 例如基于类层次分析编译的代码,当有新的相应的接口实现类加入时。
还有一种特殊的编译为:OSR(On Stack Replace)。 OSR编译,只替换循环代码体的入口,而C1、C2替换的是方法调用的入口,因此在OSR编译后会出现的现象是:方法的整段代码被编译了,但只有在循环代码体部分才执行编译后的机器码,其他部分仍然是解释执行方式。
默认情况下,Sun JDK根据机器配置选择client和server模式。当机器配置CPU超过2核且内存超过2GB,即默认选择server模式, 但在32位windows机器上式中选择client模式,也可以在启动时通过增加-client或-server来强制指定。
在JDK7中可能会引入多层编译。 多层编译指:在-server模式下采用如下方式进行编译:
- 解释器不再收集运行状况信息,只用于启动并触发C1编译
- C1编译后生成带收集运行信息的代码
- C2编译,基于C1编译后代码收集的运行信息来进行激进优化,当激进优化不成立时,再退回C1编译的代码
Sun JDK之所以未选择在启动时即编译成机器码,有几方面原因:
- 静态编译并不能根据程序的运行状况来优化执行的代码,C2这种方式是根据运行状况来进行动态编译的,这些措施会对提升程序执行的性能起到很大帮助,静态��译时无法实现。 给C2收集运行数据越长的时间,编译出来的代码会越优。
- 解释执行比编译执行更省内存
- 启动时,解释执行比编译再启动执行,更快
因此,需要取一个权衡。 在Sun JDK中,主要依据方法上的两个计数器是否超过阈值。
-
调用计数器,即方法被调用的次数
-
回边计数器,即方法中循环执行部分代码的执行次数
- CompileThreshold
指当方法被调用多少次后,就被编译成机器码。
client模式下,默认1500次,server模式下,默认10000次
可通过启动时,添加-XX:CompileThreshold=10000,来设置
- 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是否完成编译:
完成:执行循环体的代码时,进入编译后的代码;
未完成:继续把当前的累计值再减小一些。
对于默认的回边来说,server模式,只要回边次数达到10700,就会触发OSR
反射执行
反射执行是Java的亮点之一,基于反射可动态调用某对象实例中对应的方法、访问查看对象的属性等,无需在编写代码时就确定要创建的对象。这使得Java可以很灵活地实现对象的调用,例如MVC框架中通常要调用实现类中的execute方法,但框架在编写时是无法知道实现类的。在Java中则可以通过反射机制直接去调用应用实现类中的execute方法,代码示例如下:
1 |
|
这种方式对于框架之类的代码而言非常重要,反射和直接创建对象实例,调用方法的最大不同在于创建的过程、方法调用的过程是动态的。这也使得采用反射生成执行方法调用的代码并不像直接调用实例对象代码,编译后就可直接生成对对象方法调用的字节码,而是只能生成调用JVM反射实现的字节码了。
要实现动态的调用,最直接的方法就是动态生成字节码,并加载到JVM中执行,Sun JDK采用的即为这种方法,来看看在Sun JDK中以上反射代码的关键执行过程。
1 | Class actionClass=Class.forName(外部实现类); |
调用本地方法,使用调用者所在的ClassLoader来加载创建出的Class对象;
1 | Method method=actionClass.getMethod("execute",null); |
校验Class是否为public类型,以确定类的执行权限,如不是public类型的,则直接抛出SecurityException。
调用privateGetDeclaredMethods来获取Class中的所有方法,在privateGetDeclaredMethods对Class中所有方法集合做了缓存,第一次会调用本地方法去获取。
扫描方法集合列表中是否有相同方法名及参数类型的方法,如果有,则复制生成一个新的Method对象返回;如果没有,则继续扫描父类、父接口中是否有该方法;如果仍然没找到方法,则抛出NoSuchMethodException,代码如下:
1 | 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所示:Sun JDK采用以上方式提供反射的实现,提升代码编写的灵活性,但也可以看出,其整个过程比直接编译成字节码的调用复杂很多,因此性能比直接执行的慢一些。Sun JDK中反射执行的性能会随着JDK版本的提升越来越好,到JDK 6后差距就不大了,但要注意的是,getMethod相对比较耗性能,一方面是权限的校验,另一方面是所有方法的扫描及Method对象的复制,因此在使用反射调用多的系统中应缓存getMethod返回的Method对象,而method.invoke的性能则仅比直接调用低一点。一段对比直接执行、反射执行性能的程序如下所示:
1 | private static final int WARMUP_COUNT=10700; |
执行后显示的性能如下(执行环境: 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编译后代码的执行速度得到了大幅提升。