代码编译的结果从本地机器码转换成字节码,是存储格式发展的一小步,却是编程语言发展的一大步。
一,字节码执行引擎概述
1,什么是JVM的字节码执行引擎
关于字节码执行引擎,并没有一个确切的概念,可以理解为JVM实现中的一个模块,这个模块主要职责是:处理JVM
加载到内存中的字节码文件,输出执行结果。
二,运行时栈帧结构
栈帧(Stack Frame)是一个数据结构。栈帧可以用来支持JVM进行方法调用和方法执行,是JVM运行时数据区中的虚
拟机栈(Virtual Machine Stack)的栈元素。栈帧中主要存储以下这些信息:
* 局部变量表
* 操作数栈
* 动态链接
* 方法返回地址
每一个方法从调用开始到执行结束,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。
1,局部变量表(Local Variable Table)
局部变量表是一组变量值的存储空间,用于存放方法参数和方法内定义的局部变量。在Java程序编译为Class文件
时,就在方法的Code属性的max_locals数据项中确定了该方法所需要分配的局部变量表的最大容量。
局部变量表的最小单位是变量槽,也就是Variable Slot,JVM规范中并没有明确规定一个Slot的大小,只是很有导
向性的说到每个Slot都应该能存放boolean、byte、char、short、int、float、reference或者returnAddress类型的数
据。对于64位的数据类型,比如long和double,JVM会以高位对齐的方式为其分配2个连续的Slot空间。这里把long和
double分割存储的做法与多线程环境下long和double的原子性的做法是一致的。我们知道,在多线程环境下,long或者
double的读具有原子性,而写不能保证具有原子性。
2,操作数栈(Operand Stack)
操作数栈,又叫操作栈,是一个后入先出(Last In First Out)栈。同局部变量表一样,操作数栈的最大深度也
在编译的时候,写入到Code属性的max_locals数据项中。操作数栈的每一个元素可以是任意的Java类型,32位的数据类
型所占的栈容量为1,64位的数据类型所占的栈容量为2。在方法执行的任何时候,操作数栈的深度都不会超过max_locals
数据项中设定的最大值。
当一个方法刚刚执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码执行往操作数栈
写入和提取内容,也就是入栈和出栈操作。
JVM的解释执行引擎称为基于栈的执行引擎,其中所指的栈就是操作数栈。
3,动态链接(Dynamic Linking)
每个栈帧都包含一个执行运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态
链接(Dynamic Linking)。我们知道Class文件中包含大量的符号引用,字节码中的方法调用指令就以常量池中指向方
法的符号作为引用参数。这些符号引用一部分会在类加载阶段或者第一次使用时转化为直接引用,这种转化成为静态解
析。另一部分将在每一次运行时转化为直接引用,这部分成为动态链接。
4,方法返回地址(Return Address)
当一个方法开始执行后,只有2种方式可以退出:正常退出和异常退出。正常退出是指执行引擎遇到任意一个方法
返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者。异常退出是指方法执行过程中出现了异常,异常
退出完成后,是没有返回值的。也就是说不会给上层调用者返回任何信息。
三,方法调用
方法调用阶段的唯一任务就是确定调用方法的版本,也就是调用哪一个方法,方法调用并不等同于方法执行,暂时
还不涉及方法内部的具体运行过程。Class文件的编译过程中,不包含传统编译中的连接步骤,一切方法调用在Class文
件中存储的只是符号引用,而不是方法在实际运行时的内存布局中的入口地址(直接引用)。这个方法调用的特性,给
Java带来了更强大的动态扩展能力,但也使得Java方法调用过程变得相对复杂,需要在类加载期间,甚至到运行期间才
能确定目标方法的直接引用。
1,方法的解析
什么是方法的解析呢?首先方法解析,是方法调用的一种,如果一个方法的调用版本在编译时就可以确定下来了,
在运行期间不会改变,那么我们说这种方法的调用,叫做方法的解析。
在Java中,进行方法解析的主要是静态方法和私有方法。静态方法因为是不可变的,所以在编译阶段就可以确定
调用版本。私有方法不允许外部类方法,所以在编译期间就可以确定最终的调用版本。这2种方法都不可能通过继承或
重写,生成其他版本,因此他们都适合在类加载阶段进行解析。
JVM提供了5条方法调用的字节码指令,他们分别是:
* invokestatic 调用静态方法
* invokespecial 调用实例构造器<init>方法,私有方法和父类方法
* invokevirtual 调用所有的虚方法
* invokeinterface 调用接口方法,会在运行期间再确定一个实现此接口的对象
* invokedynamic 先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法
前4条指令分派逻辑时固化在JVM内部的,最后一个invokedynamic指令的分派逻辑是由用户所设定的引导方法决定
的,只要能被invokestatic和invokespecial指定调用的方法,都可以在解析阶段中确定唯一的调用版本,符合和这个
条件的有静态方法、私有方法、实例构造器、父类方法4种。他们在类加载的时候,会把符号引用解析为直接引用。下面看
一个例子:
package com.yangcq.jvm;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
/**
*
* @author yangcq
* @description JVM方法调用 之 方法解析
*
*/
public class MethodInvokeTest {
final static Log log = LogFactory.getLog(MethodInvokeTest.class);
// 只要能被invokestatic和invokespecial指定调用的方法,都可以在解析阶段中确定唯一的调用版本
// 也就是说:sayHello()这个静态方法,是不可改变的,因为静态方法既不能被继承,也不能被覆盖。
public static void syaHello(){
log.info("我是不可改变的,任何方式都无法改变我的结构...");
}
// 为了对比说明,我们来看一下一个普通的方法sayHello()
public void eatApple(){
log.info("我是可以改变的,子类可以通过继承改变我的结构");
}
}
package com.yangcq.jvm;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
/**
*
* @author yangcq
* @description JVM方法调用 之 方法解析
*
*/
public class MethodInvokeExtendsClass extends MethodInvokeTest{
final static Log log = LogFactory.getLog(MethodInvokeExtendsClass.class);
// 通过继承,重写父类中的方法
public void eatApple(){
// 改变eatApple()方法的结构,输出自己想要输出的内容
log.info("我输出我自己的内容...");
}
public static void main(String[] args) {
MethodInvokeExtendsClass methodInvokeExtendsClass = new MethodInvokeExtendsClass(); // invokespecial指令
// 编译以后,使用javap -verbose MethodInvokeExtendsClass命令,可以看到调用methodInvokeExtendsClass.eatApple()方法,
// 使用的invokestatic指令
methodInvokeExtendsClass.eatApple(); // invokevirtual指令 --输出:我输出我自己的内容...
MethodInvokeTest.syaHello(); // invokestatic指令 --我是不可改变的,任何方式都无法改变我的结构...
}
}
上面的例子中,子类通过继承父类,可以重写父类中的方法,进而改变方法的结构。编译后,使用javap -verbose MethodInvokeExtendsClass
命令,可以看到,调用子类自己的eatApple()方法,使用的是invokevirtual指令,调用父类的syaHello()静态方法,使用的是invokestatic指
令。
2,方法的分派
方法解析是一个静态的过程,在编译期间就可以确定最终的版本,在类装载的解析阶段就会把涉及的符号引用全部转变成直接引用,不会延迟
到运行期再去完成。而方法的分派(Dsipatch)调用则可能是静态的,也可能是动态的,根据分派一句的宗量数可分为单分派和多分派。
首先来看静态分派,说到静态分派我们先来看一个例子,一个经常见到的例子,面试题经常考。
package com.yangcq.jvm;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
/**
*
* @author yangcq
* @description JVM方法调用 之 方法分派 >>> 静态分派
* @description 在Java界,我们通常有下面2种说法:
* 1,方法的重载是静态的;
* 2,方法的重写是动态的;
*
*/
public class StaticDispatchTest extends Object{
final static Log log = LogFactory.getLog(StaticDispatchTest.class);
// 父类
static abstract class Fish{
}
// 子类:鲫鱼
static class Jiyu extends Fish{
}
// 子类:鲤鱼
static class Liyu extends Fish{
}
// 下面写几个重载的方法
public void swimming(Fish fish){
log.info("我是鱼,我用鱼鳍游泳...");
}
public void swimming(Jiyu jiyu){
log.info("我是鲫鱼,我用鱼鳍游泳...");
}
public void swimming(Liyu liyu){
log.info("我是鲤鱼,我用鱼鳍游泳...");
}
// 测试
public static void main(String[] yangcq){
Fish jiyu = new Jiyu();
Fish liyu = new Liyu();
StaticDispatchTest staticDispatchTest = new StaticDispatchTest();
staticDispatchTest.swimming(jiyu); // 打印:我是鱼,我用鱼鳍游泳...
staticDispatchTest.swimming(liyu); // 打印:我是鱼,我用鱼鳍游泳...
}
}
在这篇文章里,我们从JVM的角度来分析一下这道面试题。首先,编译器会解析掉所有在编译期间可以确定方法
的最终调用版本的方法,也就是说,如果方法的调用版本在编译期间就可以确定,那么编译器就会对这个方法进行
解析,而不会等到运行期间再去确定方法的调用版本。那么什么样的方法,在运行期间就可以确定最终的调用版本
呢,前面已经介绍过,只要能被invokestatic或者invokespecial调用的方法,就可以在编译阶段解析。上面的例子
中,staticDispatchTest是一个实例构造器,方法能够被invokespecial指令调用,所以这个方法在编译期间就会被
解析。并且,编译器是通过方法的静态类型来确定方法的调用版本的,因为在编译阶段,只有静态类型是确定的。
jiyu和liyu的静态类型都是Fish,所以输出的结果都是:我是鱼,我用鱼鳍游泳...
使用javap -c StaticDispatchTest命令,从字节码文件中,我们很容易看到答案:
public static void main(java.lang.String[]);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=4, args_size=1
0: new #7 // class com/yangcq/jvm/StaticDispatchTest$Jiyu
3: dup
4: invokespecial #8 // Method com/yangcq/jvm/StaticDispatchTest$Jiyu."<init>":()V
7: astore_1
8: new #9 // class com/yangcq/jvm/StaticDispatchTest$Liyu
11: dup
12: invokespecial #10 // Method com/yangcq/jvm/StaticDispatchTest$Liyu."<init>":()V
15: astore_2
16: new #11 // class com/yangcq/jvm/StaticDispatchTest
19: dup
20: invokespecial #12 // Method "<init>":()V
23: astore_3
24: aload_3
25: aload_1
26: invokevirtual #13 // Method swimming:(Lcom/yangcq/jvm/StaticDispatchTest$Fish;)V
29: aload_3
30: aload_2
31: invokevirtual #13 // Method swimming:(Lcom/yangcq/jvm/StaticDispatchTest$Fish;)V
34: return
LineNumberTable:
line 24: 0
line 25: 8
line 26: 16
line 27: 24
line 28: 29
line 29: 34
}
接下来我们看一下动态分派,关于动态分派,我们来看一下关于方法重写的例子:
package com.yangcq.jvm;
/**
*
* @author yangcq
* @description JVM方法调用 之 方法分派 >>> 动态分派
* @description 在Java界,我们通常有下面2种说法:
* 1,方法的重载是静态的;
* 2,方法的重写是动态的;
*
*/
public class DynamicDispatchTest extends Object{
// 父类
static abstract class Fish{
public void swimming(){
System.out.println("我是鱼,我用鱼鳍游泳...");
}
}
// 子类:鲫鱼
static class Jiyu extends Fish{
public void swimming(){
System.out.println("我是鲫鱼,我用鱼鳍游泳...");
}
}
// 子类:鲤鱼
static class Liyu extends Fish{
public void swimming(){
System.out.println("我是鲤鱼,我用鱼鳍游泳...");
}
}
public static void main(String[] yangcq){
Fish jiyu = new Jiyu();
Fish liyu = new Liyu();
jiyu.swimming(); // 打印:我是鲫鱼,我用鱼鳍游泳...
liyu.swimming(); // 打印:我是鲤鱼,我用鱼鳍游泳...
}
}
方法重写的本质,可以从invokevirtual指令的解析过程来说,invokevirtual指令在运行期间的解析过程大致可以
分为以下几步:
* 找到操作数栈顶的第一个元素所指向的对象的实际类型,记做C;
* 如果在类型C中找到了方法,则返回直接引用;
* 否则,按照继承关系对C的父类进行查找;
由于invokevirtual指令执行的第一步就是在运行期间确定接收者的实际类型,所以2次调用中的invokevirtual指令
把常量池中的类方法的符号引用解析到了不同的直接引用上,这个过程就是Java语言中方法重写的本质。我们把这
种运行期间根据实际类型确定方法执行版本的分派过程称为动态分派。
最后,我们来说一下单分派和多分派
方法的接收者和方法的参数,统称为方法的宗量。根据分派基于多少种宗量,可以将分派划分为单分派和多分
派。单分派是根据一个宗量对目标方法进行选择,多分派是根据多个宗量对目标方法进行选择。
3,动态类型语言支持
什么是动态语言呢?动态语言的类型检查的主体过程发生在运行期而不是编译期。
四,java.lang.invoke包
说到java.lang.invoke包,我们有必要搞清楚MethodHandle与反射机制中的Method类的区别?
java.lang.invoke这个包的主要目的是在之前单纯依靠符号引用来确定调用的目标方法这种方式以外,提供一
种新的动态确定目标方法的机制,称为MethodHandle。在Java中,没有办法单独将一个函数作为参数进行传递,通常
的做法是使用接口,以实现这个接口的对象作为参数进行传递。不过在拥有MethodHandle之后,Java语言也可以拥有
类似于函数指针或者委托的方法别名的工具了。
package com.yangcq.jvm;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
/**
*
* @author yangcq
* @description java.lang.invoke包
*
*/
public class MethodHandlesTest extends java.lang.Object{
final static Log log = LogFactory.getLog(MethodHandlesTest.class);
public static void main(String[] args) {
(new MethodHandlesTest().new Son()).bloodType();
}
// 祖父类
class GrandFather{
public void bloodType(){
log.info("我的血型是:AB");
}
}
// 父类
class Father extends GrandFather{
public void bloodType(){
log.info("我的血型是:A");
}
}
// 子类
class Son extends Father{
public void bloodType(){
try{
MethodType methodType = MethodType.methodType(void.class);
//MethodHandles methodHandles = lookup().findSpecial(GrandFather.class,"bloodType",methodType,getClass());
MethodHandles.Lookup methodHandles = MethodHandles.lookup();
MethodHandle methodHandle = methodHandles.findSpecial(GrandFather.class,"bloodType",methodType,getClass());
// invoke 是一个本地方法
methodHandle.invoke(this);
/**
*
* invoke方法的定义:
* @PolymorphicSignature
* public final native Object invoke(Object[] paramArrayOfObject) throws Throwable;
*
*/
}
catch(Throwable throwable){
log.debug("catch 捕获异常..." + throwable);
}
}
}
}
上面的示例代码,演示了Son子类如何调用父类的父类,也就是GrandFather类中的方法。主要是使用到了MethodHandle,
MethodHandle的使用可以分为下面几步:
* 创建MethodType对象,指定方法的签名;
* 在MethodHandles.Lookup查找类型为MethodType的MethodHandle;
* 传入方法参数,并调用MethodHandle.invoke或者MethodHandle.invokeExat方法;
MethodType,可以通过MethodHandle类的type方法查看其类型,返回值是MethodType类的对象。也可以在得到MethodType
对象之后,调用MethodHandle.asType(methodType)方法适配得到MethodHandle对象。创建MethodType对象,主要有下面三种
方式:
* MethodType及其重载方法,需要指定返回值类型和参数,例如MethodType.methodType(void.class);
* MethodType.genericMethodType(0),需要指定参数的个数,类型都为Object;
* MethodType.fromMethodDescriptorString("bloodType", MethodHandlesTest.class.getClassLoader())
MethodHandles.Lookup相当于MethodHandles的工厂类,通过findSpecial方法,可以得到相应的MethodHandle。
在得到 methodHandle后,就可以进行方法调用了。methodHandle.invoke(this),方法调用一共有三种形式,上面
的methodHandle.invoke(this)只是其中一种方式。三种方式:
* methodHandle.invoke(this)
* methodHandle.invokeExact(this)
* methodHandle.invokeWithArguments(this)
接下来说一下JDK7新增的invokedynamic指令,也在java.lang.invoke包下。invokedynamic指令与其他4条指令的最大区别
是,invokedynamic的分派逻辑不是由虚拟机决定的,而是由程序员决定的。
我们都知道,在java程序中,使用super关键字,很容易调用父类的方法,但是如果要访问父类的父类中的房
法呢?在JDK7之前,要想达到这个目的,难度很大,原因是子类的方法中,无法获取到实际类型是父类的父类的对
象引用。invokevirtual指令的分派逻辑时按照方法的接收者的实际类型来进行分派的,这个逻辑是固化在JVM中的,
程序员无法改变。但是我们使用invokedynamic指令,就很容易实现了。代码稍后奉上。需要JDK7的支持。