在完成将class文件信息加载到JVM并产生Class对象后,就可执行Class对象的静态方法或实例化对象进行调用了。在源码编译阶段将源码编译为JVM字节码,JVM字节码是一种中间代码的方式,要由JVM在运行期对其进行解释并执行,这种方式成为字节码解释执行方式
字节码解释执行
由于采用的为中间码的方式,JVM有一套自己的指令,对于面向对象的语言而言,最重要的是执行方法的指令,JVM采用了invokestatic、invokevirtual、invokeinterface、invokespecial四个指令来执行不同的方法调用。invokestatic对应的是调用static方法,invokevirtual对应的是调用对象实例的方法,invokeinterface对应的是调用接口的方法,invokespecial对应的是调用private方法和编译源码后生成的方法,此方法为对象实例化时的初始化方法(构造方法)。
Sun
JDK基于栈的体系结构来执行字节码,基于栈方式的好处是代码紧凑,体积小。线程在创建后,都会产生程序计数器(PC)和栈(Stack):PC存放了下一条要执行的指令在方法内的偏移量,栈中存放了栈帧,每个方法每次调用都会产生栈帧。栈帧主要分为局部变量区和操作数栈两部分,局部变量区用于存放方法中的局部变量和参数,操作数栈中用于存放方法执行过程中产生的中间结果,栈帧中还会有一些杂用空间,例如指向方法已解析的常量池的引用、其他一些VM内部实现需要的数据等。
1、指令解释执行
对于方法的指令解释执行,执行方式为经典冯诺依曼体系中的FDX循环方法,即获取下一条指令,解码并分派,然后执行。在实现FDX循环式有switch-threading、token-threading、direct-threading、subroutine-threading、inline-threading等多种方式。Sun
JDK的重点为编译成机器码,并没有在解释器上做太复杂的处理,因此采用了token-threading方式。为了让解释执行能更加高效,Sun
JDK还做了一些其他的优化,主要有:栈顶缓存和部分栈帧共享。
2、栈顶缓存
在方法的执行过程中,可看到有很多操作要将值放入操作数栈,这导致了寄存器和内存要不断的交换数据,Sun
JDK采用了一个栈顶缓存,即将本来位于操作数栈的值直接缓存在寄存器上,这对于大部分只需要一个值的操作而言,无需将数据放入操作数栈,可直接在寄存器计算,然后放回操作数栈。
3、部分栈帧共享
当一个方法调用另一个方法时,通常传入另一个方法的参数为已存放在操作数栈的数据,Sun
JDK在此做了一个优化,就是当调用方法时,后一方法可将前一方法的操作数栈作为当前方法的局部变量,从而节省数据copy带来的消耗。
编译执行
编译执行的效率较低,为提升代码的执行性能,SunJDK提供将字节码编译为机器码的支持,编译在运行时进行,通常称为JIT编译器。SunJDK在执行过程中对执行频率高的代码进行编译,对执行不频繁的代码则继续采用解释执行的方式,因此SunJDK又称为Hotspot
VM,在编译上SunJDK提供了两种模式client compiler(-client)和server
compiler(-server)。
client
compliler又称为C1,较为轻量级,只做少量性能开销比高的优化,它占用内存较少,适合桌面交互式应用,在寄存器分配策略上,JDK6以后采用的为线性扫描寄存器分配算法,在其他方面的优化主要有:方法内嵌、去虚拟化、冗余消除等。
1、方法内联
对于java面向对象的语言,通常要调用多个方法来完成功能,执行时,要经历多次参数传递,返回值传递及跳转等,于是C1采用了方法内联的方式,即把调用到的方法的指令直接植入到当前的方法中。(可在debug版本的JDK的启动参数加速-XX:PrintInlining来查看方法内联的信息)
2、去虚拟化
去虚拟化是指在装载class文件后,进行类层次的分析,如发现类中的方法只提供一个实现类,那么对于调用此方法的代码,也可进行方法内联,从而提升执行的性能。
3、冗余消除
冗余消除是指在编译时,根据运行时状况进行代码折叠或消除。如某个判断条件为false则可将条件内的代码消除。
Server
Compiler又称为C2,较为重量级,C2采用了大量传统编译优化技巧,占用内存会相对多,适用于服务器应用。和C1不同的主要是寄存器分配策略和优化的范围,寄存器分配策略上C2采用的为传统的图着色寄存器分配算法:由于C2会收集程序的运行信息(收集的信息主要有,分支的跳转/不跳转的频率,某条指令出新过的类型、是否出现过控制、是否出现过一场),因此其优化的范围更多在于全局的优化,而不仅仅是一个方法块的优化。
逃逸分析是C2进行很多优化的基础,逃逸分析是指根据运行状况来判断方法中的变量是否会被外部读取,如不会则认为此变量是逃逸的,基于逃逸分析C2在编译时会做标量替换、栈上分配和同步消除等优化。
1、标量替换
变量替换的意思简单来说就是用标量替换聚合量。这种方式能带来的好处是,如果创建的对象并未用到其中的全部变量,则可以节省一定的内存。
2、栈上分配
如果变量没有逃逸,那么C2会选择在栈上直接创建对象实例,而不是在JVM堆上,在栈上分配的好处一方面是更加快速,另一方面是回收时随着方法的结束,对象也被回收了。
3、同步消除
同步消除是指如果发现同步的对象未逃逸,那也没有同步的必要了,在C2编译时会直接去掉同步。
除了C1\C2外,还有一种较为特殊的编译:OSR(On
Stack
Replace)。OSR和C1、C2的最主要的不同点在于OSR编译只替换循环代码体的入口,而C1、C2替换的是方法调用的入口,因此在OSR编译后会出现的现象是方法的整段代码都被编译了,但在只有循环代码体部分才执行编译后的机器码,其他部分仍然是是解释执行方法。
默认情况下,SunJDK根据机器配置来选择C1或C2模式,当机器配置CPU超过2核且内存超过2GB及默认为C2模式,但在32位机器上时钟选择的都是C1模式,也可在启动时通过增加-client或-server来强制指定。
SunJDK为提升程序执行性能,在C1好C2上做了很多努力,其他各种实现的JVM也在编译执行上做了很多的优化,SunJDK之所以未选择在启动时即编译成机器码,主要是因为:静态编译并不能根据程序的运行状况来优化执行的代码,C2这种方式是根据运行状况来进行动态编译的,如分支判断、逃逸分析等,这些措施会提升程序执行的性能,在静态编译的情况下是无法实现的,给C2收集运行数据越长时间,编译出的代码也会越优;解释执行比编译执行更节省内存;启动时解释执行的启动速度比编译在启动更快。
当程序在未编译期间解释执行会比较慢,因此需要取一个权衡值,在SunJDK中主要依据方法上的两个计数器是否超过阈值,其中一个计数器为调用计数器,即方法被调用的次数;另一个为回边计数器,即方法中循环执行部分代码的执行次数。下面介绍两个计数器对应的阈值
(1)ComplieThresold
该值是指方法被调用多少次后,就编译为机器码,在client模式下默认为1500次,在server模式下为10000次,可通过在启动时天机-XX:CompilerThreshold=10000来设置该值
(2)OnStackReplacePercentage
该值用于计算是否触发OSR编译的阈值,默认模式下client为933,server模式下为140,该值可通过在启动时添加-XX:OnStackReplacePercetage=140来设置。
反射执行
反射执行是Java的亮点之一,基于反射可动态调用某对象实例中对应的方法、访问查看对象的属性等,无需再编写代码时就确定要创建的对象。这使得Java可以很灵活的实现对象的调用,例如MVC框架中通常要调用实现类中的execute方法,但框架在编写时是无法知道实现类的,在Java中则可以通过反射机制直接去调用应用类的实现类中的execute方法。
这种方式对于框架之类的代码而言非常重要,反射和直接创建对象实例调用方法的最大不同在于创建的过程、方法调用的过程是动态的。这也是的采用反射生成执行方法调用的代码并不像直接调用实例对象代码,编译后就可直接生成对象方法调用的字节码,而是只能生成调用JVM反射实现的字节码了。
要实现动态的调用,最直接的方法就是动态生成字节码,并加载到JVM中执行,SunJDK采用的即为这种方法。