《深入理解Java虚拟机》学习笔记之字节码执行引擎
Java虚拟机的执行引擎不管是解释执行还是编译执行,根据概念模型都具有统一的外观:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。
运行时栈帧结构
栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址以及一些额外的附加信息。每一个方法从调用开始到执行完成的过程,就对应着一个栈帧在虚拟机里面从入栈到出栈的过程。
一个线程中的方法调用链可能会很长,很多方法都同时处于执行状态。对于执行引擎来讲,活动线程中,只有栈顶的栈帧是有效的,称为当前栈帧(Curren Stack Frame),这个栈帧所关联的方法称为当前方法(Current Method)。执行引擎所运行的所有字节码指令都只针对当前栈帧进行操作,栈帧的概念结构如下:
- 局部不变量表
局部变量表的容量是以变量槽(Variable Slot)为最小单位,而虚拟机规范并为明确规定一个Slot所占用的内存空间大小,但可以存放一个32位以内的数据类型,如Java的boolean、byte、char、short、int、float、refernce和returnAddress,而对于long和double类型虚拟机以高位在前的方式为其分配两个连续的Slot空间。
局部变量表中的Slot是可重用的,因为方法体中定义的变量,其作用域并不一定覆盖整个方法体:如package net.oseye; public class App { public static void main(String[] args) { byte[] temp = new byte[64 * 1024 * 1024]; } }
Code: stack=1, locals=2, args_size=1 0: ldc #16 // int 67108864 2: newarray byte 4: astore_1 5: return LineNumberTable: line 5: 0 line 6: 5 LocalVariableTable: Start Length Slot Name Signature 0 6 0 args [Ljava/lang/String; 5 1 1 temp [B
package net.oseye; public class App { public static void main(String[] args) { byte[] temp = new byte[64 * 1024 * 1024]; System.gc(); } }
[GC 514K->362K(15872K), 0.0014079 secs]
[Full GC 362K->362K(15872K), 0.0050833 secs]
[Full GC 66077K->65898K(81476K), 0.0047269 secs]package net.oseye; public class App { public static void main(String[] args) { { byte[] temp = new byte[64 * 1024 * 1024]; } System.gc(); } }
[GC 514K->362K(15872K), 0.0014608 secs]
[Full GC 362K->362K(15872K), 0.0050291 secs]
[Full GC 66077K->65898K(81476K), 0.0046965 secs]
在解释为什么之前,我们先对这段代码进行第二次修改package net.oseye; public class App { public static void main(String[] args) { { byte[] temp = new byte[64 * 1024 * 1024]; } int i=0; System.gc(); } }
[GC 514K->362K(15872K), 0.0014145 secs]
[Full GC 362K->362K(15872K), 0.0047972 secs]
[Full GC 66077K->362K(81476K), 0.0044866 secs]package net.oseye; public class App { public static void main(String[] args) { { byte[] temp = new byte[64 * 1024 * 1024]; temp=null; } System.gc(); } }
对局部变量表还有一点需要强调的是它不像类变量那样在准备阶段就被自动初始化为系统零值,它没有被初始化是不能被使用的,好在这点在编译时编译器会提醒我们。 -
操作数栈
同局部变量表一样,操作数栈的最大深度也在编译的时候被写入到Code属性数据项中了,操作数栈的每一个元素可以是任意的Java数据类型,包括long和double,32位内的数据类型所占的栈容量是1,64位数据类型的栈容量为2。
当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中会有各种字节码指令像操作数栈写入和提取内容,也就是入栈和出栈操作。如整数加法的字节码指令iadd在运行的时候要求操作数栈最接近栈顶的两个元素已经存入了两个int型的数值,当执行iadd时会将这两个int值出栈并相加,再把结果入栈。操作数栈中元素的数据类型必须严格与字节码指令匹配,这点由编译器来保证。
在概念模型中,两个栈帧作为虚拟机栈的元素,相互之间是完全独立的,但大多数虚拟机的实现都做了一些优化处理:令两个栈帧出现一部分重叠,这样再进行方法调用时就可以共用一部分数据而无须进行额外的参数复制传递了。重叠的过程如下:
Java虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中所指的“栈”就是操作数栈。 -
动态连接
每个栈帧都包含一个执行运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。我们知道Class文件的常量池中存有大量的符号引用,这些符号引用一部分会在类加载阶段或第一次使用的时候转化为直接引用,这称为静态解析;另一部分将在每一次的运行期间转化为直接引用,这称为动态连接。 -
方法返回地址
当一个方法被执行后有两种方式退出:正常完成和异常完成。无论是那种方式退出,在退出后都需要返回到方法被调用的位置,程序才能继续执行,这就需要在栈帧中保存一些信息。正常
正常退出时,调用者的PC计数器的值就可以作为返回地址存储在栈帧中,而异常退出时返回地址是要通过异常处理器表来确定的,而栈帧一般不会保存这部分信息。
方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,并把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。 -
附加信息
虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧中,例如与调试相关的信息,这部分取决于具体的虚拟机。
方法调用
方法调用并不等于方法执行,方法调用阶段的唯一任务就是确定被调用方法的版本(即调用哪一个方法),暂时还不设计方法内部的具体运行过程。而Class文件的编译过程并不包括传统的连接步骤,因此一切方法的调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局的入口地址,需要在类加载期间甚至到运行期间才能确定目标方法的直接引用,这虽然给Java的调用过程带来了复杂,但这个特性也给Java带来了更强大的动态扩展能力。
而方法的调用有一类是静态的过程,这类方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本再运行期是不可变的,换句话说,调用目标在程序代码写好、编译器进行编译时就必须确定下来,在类加载的解析阶段会将这些方法的涉及到的符号引用转化为直接引用,这称为解析调用;而另外还有一种叫分派(Dispatch)调用,可以静态也可以是动态,而根据宗量数又可以分为单分派和多分派,与解析调用两两组合又可以构成静态单分派、静态多分派、动态单分派和动态多分派四种。
在Java虚拟机里面提供了四条方法调用字节指令:
- invokestatic:调用静态方法;
- invokespecial:调用实例构造器<init>方法、私有方法和父类方法;
- invokevirtual:调用所有虚方法;
-
invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象;
- 解析
只要能被invokestatic和invokespecial指令调用的方法都可以在解析阶段确定唯一的调用版本,在解析阶段会把符号引用解析为改方法的直接引用,这些方法称为非虚方法,与之相反,其他方法就称为虚方法(被final修饰的方法除外)。
虽然final方法是使用invokevirtual指令来调用的,但由于它无法被覆盖,没有其他版本,所以也无须对方法接收者进行多台选择,又或者说多台选择的结果肯定是唯一的。在Java语言规范中明确说明了final方法是一种非虚方法。package net.oseye; public class App { public static void main(String[] args) { sayHello("oseye"); } public static void sayHello(String userNmae){ System.out.println("Hello,"+userNmae+"."); } }
.... Constant pool: #1 = Class #2 // net/oseye/App .... #18 = Methodref #1.#19 // net/oseye/App.sayHello:(Ljava/lang/String;)V #19 = NameAndType #20:#21 // sayHello:(Ljava/lang/String;)V #20 = Utf8 sayHello #21 = Utf8 (Ljava/lang/String;)V .... public net.oseye.App(); flags: ACC_PUBLIC Code: stack=1, locals=1, args_size=1 0: aload_0 1: invokespecial #8 // Method java/lang/Object."<init>":()V 4: return LineNumberTable: line 3: 0 LocalVariableTable: Start Length Slot Name Signature 0 5 0 this Lnet/oseye/App; public static void main(java.lang.String[]); flags: ACC_PUBLIC, ACC_STATIC Code: stack=1, locals=1, args_size=1 0: ldc #16 // String oseye 2: invokestatic #18 // Method sayHello:(Ljava/lang/String;)V 5: return LineNumberTable: line 5: 0 line 6: 5 LocalVariableTable: Start Length Slot Name Signature 0 6 0 args [Ljava/lang/String; public static void sayHello(java.lang.String); .....
- 分派
- 静态分派
我们先来了解两个概念,先看代码:
Human man=new Man();
package net.oseye; public class StaticDispatch { public static void main(String[] args) { Human man=new Man(); Human woman=new Wowen(); StaticDispatch sd=new StaticDispatch(); //编译类型 sd.sayHello(man); sd.sayHello(woman); //运行时类型 sd.sayHello((Man)man); sd.sayHello((Wowen)woman); } public void sayHello(Human guy){ System.out.println("Hello,guy."); } public void sayHello(Man guy){ System.out.println("Hello,gentleman."); } public void sayHello(Wowen guy){ System.out.println("Hello,lady."); } } class Human{} class Man extends Human{} class Wowen extends Human{}
Hello,guy.
Hello,guy.
Hello,gentleman.
Hello,lady......... 25: aload_1 26: invokevirtual #23 // Method sayHello:(Lnet/oseye/Human;)V 29: aload_3 30: aload_2 31: invokevirtual #23 // Method sayHello:(Lnet/oseye/Human;)V 34: aload_3 35: aload_1 36: checkcast #16 // class net/oseye/Man 39: invokevirtual #27 // Method sayHello:(Lnet/oseye/Man;)V 42: aload_3 43: aload_2 44: checkcast #19 // class net/oseye/Wowen 47: invokevirtual #30 // Method sayHello:(Lnet/oseye/Wowen;)V ........
package net.oseye; public class StaticDispatch { public static void main(String[] args) { StaticDispatch sd=new StaticDispatch(); Human woman=new Wowen(); sd.sayHello((Wowen)woman); } public void sayHello(Human guy){ System.out.println("Hello,guy."); } public void sayHello(Man guy){ System.out.println("Hello,gentleman."); } } class Human{} class Man extends Human{} class Wowen extends Human{}
Hello,guy.
- 动态分派
动态分派主要是用于重写(Override),如package net.oseye; public class StaticDispatch { public static void main(String[] args) { Human man=new Man(); man.sayHello(); man=new Wowen(); man.sayHello(); } } class Human { protected void sayHello() { System.out.println("Human say hello."); } } class Man extends Human { public static int abc=100; @Override protected void sayHello() { System.out.println("Man say hello."); } } class Wowen extends Human { @Override protected void sayHello() { System.out.println("Wowen say hello."); } }
Man say hello.
Wowen say hello......... public static void main(java.lang.String[]); flags: ACC_PUBLIC, ACC_STATIC Code: stack=2, locals=2, args_size=1 0: new #16 // class net/oseye/Man 3: dup 4: invokespecial #18 // Method net/oseye/Man."<init>":()V 7: astore_1 8: aload_1 9: invokevirtual #19 // Method net/oseye/Human.sayHello:()V 12: new #24 // class net/oseye/Wowen 15: dup 16: invokespecial #26 // Method net/oseye/Wowen."<init>":()V 19: astore_1 20: aload_1 21: invokevirtual #19 // Method net/oseye/Human.sayHello:()V 24: return LineNumberTable: line 5: 0 line 6: 8 line 7: 12 line 8: 20 line 9: 24 LocalVariableTable: ........
invokevirtual指令的运行时解析过程:
1).找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C;
2).如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,通过则返回该方法直接引用,不通过抛出java.lang.IllegalAccessError;
3).否则,按照继承关系从下往上一次对C的各个父类进行第二步的搜索和验证;
4).没找到合适方法,抛出java.lang.AbstractMethodError异常;
由于第一步是解析成对象的实际类型,因此两次调用的结果不一样,这也是重写的本质,我们把这种在运行时期根据实际类型确定方法执行版本的分派过程称为动态分派。
- 静态分派
-
单分派与多分派
方法的接受者与方法的参数统称为方法的宗量。根据分派基于多少种宗量,可以将分派划分为单分派和多分派。package net.oseye; public class StaticDispatch { static class QQ {} static class _360 {} public static class Father { public void hardChoice(QQ arg) { System.out.println("father choose qq"); } public void hardChoice(_360 arg) { System.out.println("father choose 360"); } } public static class Son extends Father { public void hardChoice(QQ arg) { System.out.println("son choose qq"); } public void hardChoice(_360 arg) { System.out.println("son choose 360"); } } public static void main(String[] args) { Father father = new Father(); Father son = new Son(); father.hardChoice(new _360()); son.hardChoice(new QQ()); } }
father choose 360
son choose qq
在运行阶段,执行 son.hardChoice(new QQ()); 的时候,由于编译器已经在编译阶段决定目标方法的签名必须是 “hardChoice(QQ)”,jvm此时不会关心传递过来的QQ参数到底是 “腾讯QQ”还是“奇瑞QQ”,因为这个时候参数的静态类型,实际类型都不会对方法的分派构成任何影响,唯一可以影响jvm进行方法分派的只有该方法的接受者,也就是son。这个时候,其实就是一个宗量作为分派的选择,也就是Java是动态单分派的语言. -
虚拟机动态分派的实现
栈和寄存器指令集
由于目前的主流虚拟机都支持即时编译器,所以Java已经不再是纯“解释执行”的语言,而且Java编译器输出地指令集是基于栈的。
基于栈的指令集的指令大不部分都是零地址指令,他们依赖操作数栈进行工作,与之相对应的另外一套常用指令集架构是基于寄存器的指令集。
基于栈的指令集相对于基于寄存器指令集,它的优点是可移植,而寄存器是由硬件直接提供,所以对硬件依赖很强;者也就显示出了基于栈的指令集比基于寄存器的指令集的执行速度稍慢一下。