java虚拟机字节码执行引擎
程序员文章站
2022-05-24 11:40:24
...
一、引言
Java 虚拟机在执行 Java 程序的过程中会把他所管理的内存划分为若干个不同的数据区域。其中有的区域是线程共享的有的区域是线程私有的,如下图所示。(更详细说明请看:http://smallbug-vip.iteye.com/blog/2274277)现在要讨论的java虚拟机字节码执行引擎就是执行在虚拟机栈中(本地方法暂不考虑),它是线程私有的。
二、运行时栈帧结构
现在放大虚拟机栈结构:
局部变量表
1)变量值的存储空间,由方法参数和方法内部定义的局部变量组成,其容量用Slot1作为最小单位。在编译期间,就在方法的Code属性的max_locals数据项中确定了该方法所需要分配的局部变量表的最大容量。
2)由于局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题。
3)在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。如果是实例方法,那局部变量表第0位索引的SLot存储的是方法所属对象实例的引用,因此在方法内可以通过关键字this来访问到这个隐含的参数。其余的参数按照参数表顺序排列,参数表分配完毕之后,再根据方法体内定义的变量的顺序和作用域分配。
4)类变量表有两次初始化的机会,第一次是在“准备阶段”,执行系统初始化,对类变量设置零值,另一次则是在“初始化”阶段,赋予程序员在代码中定义的初始值。和类变量初始化不同的是,局部变量表不存在系统初始化的过程,这意味着一旦定义了局部变量则必须人为的初始化,否则无法使用。
操作数栈
1)后入先出(LIFO)栈。当一个方法开始执行时,它的操作数栈是空的。在方法执行过程中,会有各种字节码指令往操作数栈写入和提取内容。
2)在概念模型中,两个栈帧是完全独立的。但大多虚拟机实现都会做优化,让两个栈帧出现一部分重叠。让下面的栈帧的部分操作数栈与上面的栈帧的部*部变量表重叠在一起,无须进行额外的参数复制。
动态链接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。Class 文件的常量池中存在有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用,一部分会在类加载阶段或第一次使用的时候转化为直接引用(如 final、static 域等),称为静态解析,另一部分将在每一次的运行期间转化为直接引用,这部分称为动态连接。
方法返回地址
当方法开始执行后,有两种方式退出。一是遇到方法返回的字节码指令;二是遇到异常并且这个异常没有在方法体内得到处理。无论哪种退出方式,方法退出之后都要返回到方法被调用的位置。一般来说,方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是通过异常处理器表来确定的,栈帧中一般不会保存此信息。
方法退出的过程就等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,修改PC计数器的值以指向后一条指令等。
三、方法调用
方法调用的主要任务就是确定被调用方法的版本(即调用哪一个方法),该过程不涉及方法具体的运行过程。按照调用方式共分为两类:
解析(Resolution)
所有方法调用中的目标方法在Class文件里都是一个常量池中的符号引用。在解析阶段,会将其中一部分符号引用转化为直接引用:如果方法在真正运行之前就有一个可确定的调用版本,并且这个调用版本在运行期是不可改变的,那么就会被转化为直接引用。
符合这个条件的有静态方法、私有方法、实例构造器和父类方法4类。这4类方法和final方法都称为非虚方法。非虚方法在编译期间就完成了解析调用,将符号引用转变为可确定的直接引用。
分派(Dispatch)
静态分派
先看一段代码:
运行结果是:
在main方法中,Bug称为变量的Static类型或Apparent类型,而Smallbug和Bigbug则为变量的实际类型。
将这段代码javap反编译之后截取面main方法局部:
程序调用的是参数实际类型不同的方法,但是虚拟机最终分派了相同外观类型(静态类型)的方法,这说明在重载的过程中虚拟机在运行的时候是只看参数的外观类型(静态类型)的,而这个外观类型(静态类型)是在编译的时候就已经确定的,和虚拟机没有关系。
所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派的典型应用是方法重载(Overload)。虚拟机在重载时通过参数的静态类型而不是实际类型作为判定依据。并且静态类型是编译期可知的,因此在编译阶段,编译器会根据参数的静态类型决定使用哪个方法的重载版本
动态分派
将上面的代码修改一下:
运行结果:
在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。典型应用是方法重写(Override)
将这段代码javap反编译之后截取面main方法局部:
程序调用的是不同实际类型的同名方法,虚拟机依据对象的实际类型去寻找是否有这个方法,如果有就执行,如果没有去父类里找,最终在实际类型里找到了这个方法,所以最终是在运行期动态分派了方法。在编译的时候我们可以看到字节码指示的方法都是一样的符号引用,但是运行期虚拟机能够根据实际类型去确定出真正需要的直接引用。这种依赖实际类型来做方法的分配叫做动态分派。
单分派与多分派
首先明白什么是宗量?
方法的接收者与方法的参数统称为方法的宗量。这是《深入理解java虚拟机第二版》的解释。根据分派基于多少种宗量,可以分为单分派和多分派。单分派根据一个宗量对目标方法进行选择,而多分派则根据多于一个宗量对目标方法进行选择。下面看一段代码:
对于吃来说有两个宗量,1、是谁要吃小虫子还是虫子?2、要吃什么吃豆子还是叶子。
静态分派时考虑的问题是,谁要吃,吃什么,这是两个宗量,所以静态分派又是多分派。javap反编译这段代码:
重点观察24,35行,静态分派之后吃什么就一定确定下来了,24行吃叶子,35行吃豆子。那么下一个问题就是运行时该方法对应的是哪个实例。即是虫子的子类小虫子要吃叶子呢还是虫子要吃叶子呢,这是不清楚的。所以动态分派只有一个宗量。即单分派。
综上所述,可以总结:Java是一门静态多分派,动态单分派的语言
四、虚拟机动态分派的实现
动态分派在Java中被大量使用,使用频率及其高,如果在每次动态分派的过程中都要重新在类的方法元数据中搜索合适的目标的话就可能影响到执行效率,因此JVM在类的方法区中建立虚方法表(virtual method table)来提高性能。每个类中都有一个虚方法表,表中存放着各个方法的实际入口。如果某个方法在子类中没有被重写,那子类的虚方法表中该方法的地址入口和父类该方法的地址入口一样,即子类的方法入口指向父类的方法入口。如果子类重写父类的方法,那么子类的虚方法表中该方法的实际入口将会被替换为指向子类实现版本的入口地址。
虚方法表会在类加载的连接阶段被创建并开始初始化,类的变量初始值准备完成之后,JVM会把该类的方法表也初始化完毕。
Java 虚拟机在执行 Java 程序的过程中会把他所管理的内存划分为若干个不同的数据区域。其中有的区域是线程共享的有的区域是线程私有的,如下图所示。(更详细说明请看:http://smallbug-vip.iteye.com/blog/2274277)现在要讨论的java虚拟机字节码执行引擎就是执行在虚拟机栈中(本地方法暂不考虑),它是线程私有的。
二、运行时栈帧结构
现在放大虚拟机栈结构:
局部变量表
1)变量值的存储空间,由方法参数和方法内部定义的局部变量组成,其容量用Slot1作为最小单位。在编译期间,就在方法的Code属性的max_locals数据项中确定了该方法所需要分配的局部变量表的最大容量。
2)由于局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题。
3)在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。如果是实例方法,那局部变量表第0位索引的SLot存储的是方法所属对象实例的引用,因此在方法内可以通过关键字this来访问到这个隐含的参数。其余的参数按照参数表顺序排列,参数表分配完毕之后,再根据方法体内定义的变量的顺序和作用域分配。
4)类变量表有两次初始化的机会,第一次是在“准备阶段”,执行系统初始化,对类变量设置零值,另一次则是在“初始化”阶段,赋予程序员在代码中定义的初始值。和类变量初始化不同的是,局部变量表不存在系统初始化的过程,这意味着一旦定义了局部变量则必须人为的初始化,否则无法使用。
操作数栈
1)后入先出(LIFO)栈。当一个方法开始执行时,它的操作数栈是空的。在方法执行过程中,会有各种字节码指令往操作数栈写入和提取内容。
2)在概念模型中,两个栈帧是完全独立的。但大多虚拟机实现都会做优化,让两个栈帧出现一部分重叠。让下面的栈帧的部分操作数栈与上面的栈帧的部*部变量表重叠在一起,无须进行额外的参数复制。
动态链接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。Class 文件的常量池中存在有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用,一部分会在类加载阶段或第一次使用的时候转化为直接引用(如 final、static 域等),称为静态解析,另一部分将在每一次的运行期间转化为直接引用,这部分称为动态连接。
方法返回地址
当方法开始执行后,有两种方式退出。一是遇到方法返回的字节码指令;二是遇到异常并且这个异常没有在方法体内得到处理。无论哪种退出方式,方法退出之后都要返回到方法被调用的位置。一般来说,方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是通过异常处理器表来确定的,栈帧中一般不会保存此信息。
方法退出的过程就等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,修改PC计数器的值以指向后一条指令等。
三、方法调用
方法调用的主要任务就是确定被调用方法的版本(即调用哪一个方法),该过程不涉及方法具体的运行过程。按照调用方式共分为两类:
- 解析调用:静态的过程,在编译期间就完全确定目标方法。
- 分派调用:可能是静态,也可能是动态的,根据分派标准可以分为单分派和多分派。两两组合有形成了静态单分派、静态多分派、动态单分派、动态多分派
解析(Resolution)
所有方法调用中的目标方法在Class文件里都是一个常量池中的符号引用。在解析阶段,会将其中一部分符号引用转化为直接引用:如果方法在真正运行之前就有一个可确定的调用版本,并且这个调用版本在运行期是不可改变的,那么就会被转化为直接引用。
符合这个条件的有静态方法、私有方法、实例构造器和父类方法4类。这4类方法和final方法都称为非虚方法。非虚方法在编译期间就完成了解析调用,将符号引用转变为可确定的直接引用。
分派(Dispatch)
静态分派
先看一段代码:
class Bug { } class Smallbug extends Bug { } class Bigbug extends Bug { } public class DispatchTest { public void sayHello(Bug bug) { System.out.println("bug say hello!"); } public void sayHello(Smallbug smallbug) { System.out.println("smallbug say hello!"); } public void sayHello(Bigbug bug) { System.out.println("bigbug say hello!"); } public static void main(String[] args) { DispatchTest assign = new DispatchTest(); Bug smallbug = new Smallbug(); Bug bigbug = new Bigbug(); assign.sayHello(smallbug); assign.sayHello(bigbug); } }
运行结果是:
bug say hello! bug say hello!
在main方法中,Bug称为变量的Static类型或Apparent类型,而Smallbug和Bigbug则为变量的实际类型。
将这段代码javap反编译之后截取面main方法局部:
Code: stack=2, locals=4, args_size=1 0: new #7 // class DispatchTest 3: dup ▽ 4: invokespecial #8 // Method "<init>":()V 7: astore_1 8: new #9 // class Smallbug 11: dup 12: invokespecial #10 // Method Smallbug."<init>":()V 15: astore_2 16: new #11 // class Bigbug 19: dup 20: invokespecial #12 // Method Bigbug."<init>":()V 23: astore_3 24: aload_1 25: aload_2 26: invokevirtual #13 // Method sayHello:(LBug;)V 29: aload_1 30: aload_3 31: invokevirtual #13 // Method sayHello:(LBug;)V 34: return
程序调用的是参数实际类型不同的方法,但是虚拟机最终分派了相同外观类型(静态类型)的方法,这说明在重载的过程中虚拟机在运行的时候是只看参数的外观类型(静态类型)的,而这个外观类型(静态类型)是在编译的时候就已经确定的,和虚拟机没有关系。
所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派的典型应用是方法重载(Overload)。虚拟机在重载时通过参数的静态类型而不是实际类型作为判定依据。并且静态类型是编译期可知的,因此在编译阶段,编译器会根据参数的静态类型决定使用哪个方法的重载版本
动态分派
将上面的代码修改一下:
class Bug { public void sayHello() { System.out.println("bug say hello!"); } } class Smallbug extends Bug { public void sayHello() { System.out.println("smallbug say hello!"); } } class Bigbug extends Bug { public void sayHello() { System.out.println("bigbug say hello!"); } } public class DispatchTest { public static void main(String[] args) { Bug smallbug = new Smallbug(); Bug bigbug = new Bigbug(); smallbug.sayHello(); bigbug.sayHello(); } }
运行结果:
smallbug say hello! bigbug say hello!
在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。典型应用是方法重写(Override)
将这段代码javap反编译之后截取面main方法局部:
Code: stack=2, locals=3, args_size=1 0: new #2 // class Smallbug 3: dup 4: invokespecial #3 // Method Smallbug."<init>":()V 7: astore_1 8: new #4 // class Bigbug 11: dup 12: invokespecial #5 // Method Bigbug."<init>":()V 15: astore_2 16: aload_1 17: invokevirtual #6 // Method Bug.sayHello:()V 20: aload_2 21: invokevirtual #6 // Method Bug.sayHello:()V 24: return
程序调用的是不同实际类型的同名方法,虚拟机依据对象的实际类型去寻找是否有这个方法,如果有就执行,如果没有去父类里找,最终在实际类型里找到了这个方法,所以最终是在运行期动态分派了方法。在编译的时候我们可以看到字节码指示的方法都是一样的符号引用,但是运行期虚拟机能够根据实际类型去确定出真正需要的直接引用。这种依赖实际类型来做方法的分配叫做动态分派。
单分派与多分派
首先明白什么是宗量?
方法的接收者与方法的参数统称为方法的宗量。这是《深入理解java虚拟机第二版》的解释。根据分派基于多少种宗量,可以分为单分派和多分派。单分派根据一个宗量对目标方法进行选择,而多分派则根据多于一个宗量对目标方法进行选择。下面看一段代码:
//定义叶子 class Leaf { } //定义豆子 class Bean { } //我是虫子 class Bug { //虫子吃叶子 public void eat(Leaf leaf) { System.out.println("I am bug and I am going to eat leafs!"); } //虫子吃豆子 public void eat(Bean bean) { System.out.println("I am bug and I am going to eat beans!"); } } //我是小虫子 class Smallbug extends Bug { //小虫子吃叶子 public void eat(Leaf leaf) { System.out.println("I am smallbug and I am going to eat leafs!"); } //小虫子吃豆子 public void eat(Bean bean) { System.out.println("I am smallbug and I am going to eat beans!"); } } public class DispatchTest { public static void main(String[] args) { Bug smallbug = new Smallbug(); Bug bug = new Bug(); //小虫子吃叶子 smallbug.eat(new Leaf()); //虫子吃豆子 bug.eat(new Bean()); } }
对于吃来说有两个宗量,1、是谁要吃小虫子还是虫子?2、要吃什么吃豆子还是叶子。
静态分派时考虑的问题是,谁要吃,吃什么,这是两个宗量,所以静态分派又是多分派。javap反编译这段代码:
Code: stack=3, locals=3, args_size=1 0: new #2 // class Smallbug 3: dup 4: invokespecial #3 // Method Smallbug."<init>":()V 7: astore_1 8: new #4 // class Bug 11: dup 12: invokespecial #5 // Method Bug."<init>":()V 15: astore_2 16: aload_1 17: new #6 // class Leaf 20: dup 21: invokespecial #7 // Method Leaf."<init>":()V 24: invokevirtual #8 // Method Bug.eat:(LLeaf;)V 27: aload_2 28: new #9 // class Bean 31: dup 32: invokespecial #10 // Method Bean."<init>":()V 35: invokevirtual #11 // Method Bug.eat:(LBean;)V
重点观察24,35行,静态分派之后吃什么就一定确定下来了,24行吃叶子,35行吃豆子。那么下一个问题就是运行时该方法对应的是哪个实例。即是虫子的子类小虫子要吃叶子呢还是虫子要吃叶子呢,这是不清楚的。所以动态分派只有一个宗量。即单分派。
综上所述,可以总结:Java是一门静态多分派,动态单分派的语言
四、虚拟机动态分派的实现
动态分派在Java中被大量使用,使用频率及其高,如果在每次动态分派的过程中都要重新在类的方法元数据中搜索合适的目标的话就可能影响到执行效率,因此JVM在类的方法区中建立虚方法表(virtual method table)来提高性能。每个类中都有一个虚方法表,表中存放着各个方法的实际入口。如果某个方法在子类中没有被重写,那子类的虚方法表中该方法的地址入口和父类该方法的地址入口一样,即子类的方法入口指向父类的方法入口。如果子类重写父类的方法,那么子类的虚方法表中该方法的实际入口将会被替换为指向子类实现版本的入口地址。
虚方法表会在类加载的连接阶段被创建并开始初始化,类的变量初始值准备完成之后,JVM会把该类的方法表也初始化完毕。
下一篇: 电脑老是弹出热点新闻怎么办