JVM字节码(六)
栈帧
每一次函数的调用,都会在调用栈(call stack)上创建并维护一个独立的栈帧(stack frame)。每个独立的栈帧一般包括:方法的局部变量表、动态链接信息、方法的返回地址以及操作数栈等信息。
动态链接是Java比较强大的一点,C++在编译期间就确定好类与类之间的关系,包括地址的偏移量等等。而在Java中,比如存在A类和B类,A类存在对B类的方法调用,在编译期间,他们之间的地址关系其实是还没确定下来的,只有在类加载的时候才能知道地址关系、或者真正开始调用的时候,才能确定目标方法的地址是什么。基于这一点,Java产生两种引用,一种是符号引用,一种是直接引用。
有些符号引用是在类加载阶段或是第一次使用时就会转换为直接引用,这种转换叫做静态解析;另外一些符号引用则是在每次运行期转换为直接引用,这种转换叫做动态链接,这体现为Java的多态性。
在JVM中,slot是存储局部变量的最小单位,有10个局部变量就有可能有10个slot。对于不超过32位的数据,占据一个slot,比如int、short,但对于long、double,就会占据两个slot,并且slot有可能被复用。我们来看下面的代码:
package com.leolin.jvm.bytecode; public class MyTest4 { public void test() { int a = 3; if (a < 4) { int b = 4; int c = 5; } int d = 7; int e = 8; } }
根据我们之前所学的理论,MyTest4的test()方法除却我们声明的a、b、c、d、e这5个变量,还有一个隐式的this变量,所以这里应该是6个局部变量。我们反编译下MyTest4,看看反编译结果打印出来的局部变量是多少:
…… public void test(); descriptor: ()V flags: ACC_PUBLIC Code: stack=2, locals=4, args_size=1 0: iconst_3 1: istore_1 2: iload_1 3: iconst_4 4: if_icmpge 11 7: iconst_4 8: istore_2 9: iconst_5 10: istore_3 11: bipush 7 13: istore_2 14: bipush 8 16: istore_3 17: return LineNumberTable: line 5: 0 line 6: 2 line 7: 7 line 8: 9 line 10: 11 line 11: 14 line 12: 17 LocalVariableTable: Start Length Slot Name Signature 9 2 2 b I 0 18 0 this Lcom/leolin/jvm/bytecode/MyTest4; 2 16 1 a I 14 4 2 d I 17 1 3 e I StackMapTable: number_of_entries = 1 frame_type = 252 /* append */ offset_delta = 11 locals = [ int ] ……
可以看到,反编译的结果中test()方法只需要4个局部变量,这是因为b和c这两个局部变量是声明在if块中,离开if块b和c就无法引用了,所以他们之前所占据的slot空间可以让给d和e变量。
对于JVM来说,方法调用的字节码指令存在五种情况:
- invokeinterface:调用接口中的方法,实际上是在运行期决定的,决定到底调用实现该接口的那个对象的特定方法。
- invokestatic:调用静态方法。
- invokespecial:调用自己的私有方法、构造方法<init>以及父类的方法。
- invokevirtual:调用虚方法,存在运行期动态查找的过程。
- invokedynamic:动态调用方法。
如果调用方法时使用的是invokestatic和invokespecial指令,那么在类加载阶段直接把符号引用转换为直接引用。
我们来看下面一个例子:
package com.leolin.jvm.bytecode; public class MyTest5 { public void test(Grandpa grandpa) { System.out.println("grandpa"); } public void test(Father father) { System.out.println("father"); } public void test(Son son) { System.out.println("son"); } public static void main(String[] args) { Grandpa g1 = new Father(); Grandpa g2 = new Son(); MyTest5 myTest5 = new MyTest5(); myTest5.test(g1); myTest5.test(g2); } } class Grandpa { } class Father extends Grandpa { } class Son extends Father { }
运行上面的程序,得到如下结果:
grandpa grandpa
再来看反编译后main函数对应的字节码:
public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=2, locals=4, args_size=1 0: new #7 // class com/leolin/jvm/bytecode/Father 3: dup 4: invokespecial #8 // Method com/leolin/jvm/bytecode/Father."<init>":()V 7: astore_1 8: new #9 // class com/leolin/jvm/bytecode/Son 11: dup 12: invokespecial #10 // Method com/leolin/jvm/bytecode/Son."<init>":()V 15: astore_2 16: new #11 // class com/leolin/jvm/bytecode/MyTest5 19: dup 20: invokespecial #12 // Method "<init>":()V 23: astore_3 24: aload_3 25: aload_1 26: invokevirtual #13 // Method test:(Lcom/leolin/jvm/bytecode/Grandpa;)V 29: aload_3 30: aload_2 31: invokevirtual #13 // Method test:(Lcom/leolin/jvm/bytecode/Grandpa;)V 34: return LineNumberTable: line 18: 0 line 19: 8 line 20: 16 line 21: 24 line 22: 29 line 23: 34 LocalVariableTable: Start Length Slot Name Signature 0 35 0 args [Ljava/lang/String; 8 27 1 g1 Lcom/leolin/jvm/bytecode/Grandpa; 16 19 2 g2 Lcom/leolin/jvm/bytecode/Grandpa; 24 11 3 myTest5 Lcom/leolin/jvm/bytecode/MyTest5;
可以看到,在进行MyTest5.test()方法调用时,使用的是invokevirtual指令。g1和g2被声明为Grandpa类型,这是g1和g2的静态类型,虽然g1和g2的实际类型被指向Father和Son,但进行重载时,依旧是根据静态类型匹配的,这是编译时多态。
我们再来看运行时多态:
package com.leolin.jvm.bytecode; public class MyTest6 { public static void main(String[] args) { Fruit apple = new Apple(); Fruit orange = new Orange(); apple.test(); orange.test(); apple = new Orange(); apple.test(); } } class Fruit { public void test() { System.out.println("Fruit"); } } class Apple extends Fruit { @Override public void test() { System.out.println("Apple"); } } class Orange extends Fruit { @Override public void test() { System.out.println("Orange"); } }
运行程序,得到结果:
Apple Orange Orange
再看反编译后的main函数:
public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=2, locals=3, args_size=1 0: new #2 // class com/leolin/jvm/bytecode/Apple 3: dup 4: invokespecial #3 // Method com/leolin/jvm/bytecode/Apple."<init>":()V 7: astore_1 8: new #4 // class com/leolin/jvm/bytecode/Orange 11: dup 12: invokespecial #5 // Method com/leolin/jvm/bytecode/Orange."<init>":()V 15: astore_2 16: aload_1 17: invokevirtual #6 // Method com/leolin/jvm/bytecode/Fruit.test:()V 20: aload_2 21: invokevirtual #6 // Method com/leolin/jvm/bytecode/Fruit.test:()V 24: new #4 // class com/leolin/jvm/bytecode/Orange 27: dup 28: invokespecial #5 // Method com/leolin/jvm/bytecode/Orange."<init>":()V 31: astore_1 32: aload_1 33: invokevirtual #6 // Method com/leolin/jvm/bytecode/Fruit.test:()V 36: return LineNumberTable: line 5: 0 line 6: 8 line 7: 16 line 8: 20 line 9: 24 line 10: 32 line 11: 36 LocalVariableTable: Start Length Slot Name Signature 0 37 0 args [Ljava/lang/String; 8 29 1 apple Lcom/leolin/jvm/bytecode/Fruit; 16 21 2 orange Lcom/leolin/jvm/bytecode/Fruit;
这里调用Fruit.test()方法时,依旧用的是invokevirtual指令,但这里调用重写的test()方法,和之前调用重载的test()方法不一样,调用重写的test()方法,会去具体的类型中查找相应的方法。这里我们看下invokevirtual指令在调用方法时的步骤:
- 当被在子类中声明了被重写的方法,则该方法会被执行。
- 如果子类中没有重写该方法,则自下而上的到子类的父类去查找,一直找到匹配的方法。
- 如果从子类遍历到最顶层的类依旧没有找到匹配的方法,则抛出AbstractMethodError异常。
虚方法出现在Java的多态特性中,父类与子类之间的多态性,对父类的函数进行重新定义。可以被覆写的方法都可以称作虚方法,因此虚方法并不需要做特殊的声明,也可以理解为除了用static、final、private修饰之外的所有方法都是虚方法。每个类都会维护一张虚方法表,虚方发表存放着各个方法入口的调用地址,如果子类继承父类时存在部分没有重写的方法,在子类的虚方法表中,这些方法直接指向父类方法的入口地址。
为了查找效率,不管是子类还是父类,只要两个方法的描述符一样,在父类和子类当中的索引也是一样的,便于提升查找效率。比如:子类第五个方法为test,但没有重写,就找父类第五个,不需要遍历父类,方法表一般都是在类加载的连接阶段进行初始化。
我们再来看下面这段代码:
class Parent { public void test1() { } public void test2() { } } class Child extends Parent { @Override public void test1() { } @Override public void test2() { } public void test3() { } public static void main(String[] args) { Parent child = new Child(); child.test3(); } }
这段代码其实在编译期的时候就会报错,过去我们从Java语法层面上来理解,因为child变量声明为Parent类型,虽然child是指向Child实例,但Parent类中没有test3()这个方法,所以编译失败。现在,我们从字节码层面上考虑,因为Parent中不存在test3()方法,所以invokevirtual指令无法执行Parent.test()。
现代JVM在执行Java代码的时候,通常都会将解释执行与编译执行二者结合起来进行。所谓解释执行就是通过解释器来读取字节码,遇到相应的指令就去执行该指令。编译执行就是通过即时编译器(just in time,JIT)将字节码转换为本地机器码来执行,现代jvm会根据代码特点来生成相应的本地机器码。
基于栈的指令集与基于寄存器的指令集之间的关系:
- JVM执行指令时所采取的的方式是基于栈的指令集。
- 基于栈的指令集主要的操作有入栈和出栈。
- 基于栈的指令集的优势在于可以在不同平台之间移植,而基于寄存器的指令集是与硬件架构密切相关的,无法做到可移植。
- 基于栈的指令集缺点在于完成相同的操作,指令数量通常要比基于寄存器的指令集数量要多;基于栈的指令集是在内存中完成操作的,而寄存器指令集是直接由CPU来执行的,他是在高速缓存区中进行执行的,速度要快的多。虽然JVM采取了一些优化手段,但总体来说,基于栈的指令集执行速度要慢一些。