文章目录
简介
Class字节码文件通过ClassLoader装在进入JVM里并且分配好内存后,字节码引擎去读取字节码进行解析并处理,最后输出执行的结果,执行的方式有两种:解释执行和编译执行,有的虚拟机这两种方式都采用,也有的只用到了其中一种。
- 解释执行:解释器直接解释执行字节码
- 编译执行:即时编译器产生机器码,然后执行机器码
栈帧
JVM以方法作为最基本的执行单元,栈帧则是用于支持虚拟机进行方法调用和方法执行背后的数据结构,也就是运行时内存虚拟机栈中的栈元素。处于栈顶的栈帧就是当前线程所执行的方法,执行引擎执行的所有字节码指令都只针对当前栈帧进行操作。
栈帧主要包含方法局部变量表、操作数栈、栈帧信息(由动态链接、方法返回地址和一些额外的附加信息组成)。
局部变量表
用来存放方法参数和方法内部定义的局部变量的存储空间,它所需的最大容量在编译为Class文件时就已确定,并且放在方法的Code属性的max_locals数据项中。
局部变量表以Slot(变量槽)为存储单位,JVM规范没有规定Slot多大,但是要求JVM实现的Slot能够存放一个boolean、byte、char、short、int、float、reference或returnAddress这8种数据类型,对于超出单个Slot长度的数据可以使用两个Slot来存储,比如long和double,为了保证32位和64位系统的局部变量表一致性,一般基本的8中类型都用一个Slot,其他用两个Slot,剩余位置对齐补白。
reference类型表示对一个对象实例的引用,JVM规范规定应能通过这个引用找到对象在堆中的地址和在方法区中的类型信息。returnAddress是早期用来实现异常跳转的,目前已经被弃用,使用异常表来替代
先写一个测试类:
public class TestException{
public static void main(String[] args){
try {
System.out.println("enter try block");
} catch (Exception e){
System.out.println("enter Exception block");
} finally {
System.out.println("enter finally block");
}
}
}
通过javap -verbose来查看它的class文件,其中就有异常表:
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String enter try block
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
11: ldc #5 // String enter finally block
13: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
16: goto 50
19: astore_1
20: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
23: ldc #7 // String enter Exception block
25: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
28: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
31: ldc #5 // String enter finally block
33: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
36: goto 50
39: astore_2
40: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
43: ldc #5 // String enter finally block
45: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
48: aload_2
49: athrow
50: return
Exception table:
from to target type
0 8 19 Class java/lang/Exception
0 8 39 any
19 28 39 any
在类的本地方法表中每个方法的异常表中存储着方法发生异常时的出口,这里的异常表显示:
0到8的指令操作如果发生Exception,就执行19号指令
如果没有Exception就执行39号指令
19到28的指令执行完就执行39号指令
实例方法的局部变量表的第0位Slot存放的是this,这也是为什么只有没被static修饰的方法才能使用this的原因。
实例方法:
public void delete(){
System.out.println(this.value);
}
class文件:
public void delete();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
3: aload_0
4: getfield #2 // Field value:I
7: invokevirtual #4 // Method java/io/PrintStream.println:(I)V
10: return
LocalVariableTable:
Start Length Slot Name Signature
0 11 0 this LTestException;
在实例方法delete的局部变量表中就可以看到第0个Slot存的是this
变量槽Slot是复用的,如果一个Slot里变量后续不会使用并且超出了它的作用域时JVM会复用它的变量槽,这为虚拟机节约了一部分空间但是需要开发者对于那些占用大量内存且后面又不会再使用的变量手动置空,让那一大块的存储空间与当前的GCRoot断开连接以便垃圾收集时能正确收集这部分垃圾。因为如果引用了大量内存的这个Slot后面没有被及时复用就会导致垃圾回收器无法回收那片不会再被使用的内存空间。
1.使用大内存变量不关闭代码:
byte[] bytes = new byte[64 * 1024 * 1024];
System.gc();
查看执行时GC日志(使用JVM参数为-XX:+PrintGC),发现没有回收那片大内存:
[GC (System.gc()) 68864K->66320K(125952K), 0.0181165 secs]
[Full GC (System.gc()) 66320K->66197K(125952K), 0.0202801 secs]
2.使用另一个变量来尝试复用byte的变量槽:
byte[] bytes = new byte[64 * 1024 * 1024];
int a = 0;
System.gc();
查看执行时的GC日志发现没有回收,因为byte的作用域还没结束,a不会复用byte的Slot:
[GC (System.gc()) 68864K->66352K(125952K), 0.0011234 secs]
[Full GC (System.gc()) 66352K->66197K(125952K), 0.0047135 secs]
3.使用另一个变量来复用byte变量的Slot即可回收byte所指向的大内存空间:
{
byte[] bytes = new byte[64 * 1024 * 1024];
}
int a = 0;
System.gc();
这是GC日志显示byte指向的空间被回收了,也就证明a复用了byte的Slot:
[GC (System.gc()) 68864K->66384K(125952K), 0.0019289 secs]
[Full GC (System.gc()) 66384K->661K(125952K), 0.0154837 secs]
4.手动对byte置空:
byte[] bytes = new byte[64 * 1024 * 1024];
bytes = null;
System.gc();
手动置空就能显示地将大内存空间与GC ROOT断开连接,使其能正常被回收:
[GC (System.gc()) 68864K->66288K(125952K), 0.0020404 secs]
[Full GC (System.gc()) 66288K->661K(125952K), 0.0056136 secs]
局部变量表里面的变量不像类变量那样会被自动赋初值:
public static void main(String[] args){
int a;
System.out.println(a); //编译报错,因为a没有赋值
}
操作数栈
JVM的解释执行引擎被陈为基于栈的执行引擎,这里的栈指的就是操作数栈,它用来存放在执行指令时的操作数。和局部变量表一样,操作数栈的最大深度也在Class文件生成时就被确定并且记录在Code属性的max_stacks数据项中。
当一个方法刚刚开始执行时,它的操作数栈是空的,在方法执行过程中会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈和入栈操作。
代码:
public class Test{
public int add(int a, int b){
return a + b;
}
public static void main(String[] args){
Test test = new Test();
int c = test.add(1, 2);
System.out.println(c);
}
}
字节码:
public int add(int, int);
descriptor: (II)I
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=3
0: iload_1
1: iload_2
2: iadd
3: ireturn
LocalVariableTable:
Start Length Slot Name Signature
0 4 0 this LTest;
0 4 1 a I
0 4 2 b I
其中表明了操作指令一共有iload、iadd、ireturn三种:
第一条指令 iload_1:表示将局部变量表里Slot为1的数据入栈,也就是变量a的值入栈
第二条指令 iload_2:表示将局部变量表里Slot为2的数据入栈,也就是变量b的值入栈
第三条指令 iadd:表示将两个int型数据相加,此时栈里面元素是3
代码编译阶段和类校验阶段都会验证操作数栈中的数据类型是否与字节码指令序列严格匹配,也就是iadd指令执行时栈顶必须是两个int型。
第四条指令 ireturn:将结果返回给main方法的局部变量表中的c变量
由上面实例可知add方法的参数a和b的值是从main方法的局部变量表传过来的,这在大多JVM的实现里都会进行一些优化处理,令两个栈帧出现一部分重叠,这样不仅节约一些空间,也避免了额外的参数复制传递。
栈帧信息
- 动态连接
动态链接指向运行时常量池该栈帧所属的方法,用来支持方法调用过程中的动态连接,也就是确定到底是执行的哪个类的方法。 - 方法返回地址
方法返回地址中存放调用该方法的PC寄存器的值,也就是主调方法所执行到的位置,如果方法正常返回,这个方法的栈帧出栈,恢复上层方法的局部变量表和操作数栈,如果有返回值就压入主调方法的操作数栈,然后调整PC计数器的值到后一条指令继续运行。
如果异常返回(方法内部没有正确处理异常或者向上抛出异常),则有异常处理器来决定返回地址。 - 其他附加信息
JVM规范里允许在此处写入一些额外信息,比如与调试、性能收集相关的信息。
静态类型的方法调用
方法调用指的是确定调用哪一个方法,而不是方法的执行,因为Class文件中的方法都是符号引用,而不是直接引用,这些符号引用会在不同阶段来确定实际调用的哪个方法,这样虽然使方法调用变得复杂但也让程序获得更强大的动态扩展能力。
这里面的不同阶段主要指编译期和运行期,有的方法在编译期就能解析出具体是哪个类的哪个方法,这时就会变成直接引用,比如静态方法;而有的方法需要等到运行期来分派,比如多态就是在这时来分派哪个类来执行方法的。
关于方法调用的指令主要有五种:
- invokestatic
调用静态方法 - invokespecial
调用实例构造器()方法、私有方法和父类中的方法 - invokevirtual
调用所有的虚方法 - invokeinterface
调用接口方法,会在运行时再确定一个实现该接口的对象 - invokedynamic
现在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法,这是唯一由开发者控制方法分派逻辑的地方,前面四个是JVM内部决定的。
解析
指那些在类加载的解析阶段就能将Class文件常量池中符号引用转化为直接引用的方法调用,这类方法在真正运行之前就能确定一个唯一的调用版本。
这类方法主要包含静态方法、私有方法、实例构造器、父类方法、final修饰的方法,它们统称为非虚方法。
代码:
public class Test{
public static void printName(){
System.out.println("dean");
}
public static void main(String[] args){
Test.printName();
}
}
class文件:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=0, locals=1, args_size=1
0: invokestatic #5 // Method printName:()V
3: return
LineNumberTable:
line 7: 0
line 8: 3
分派
上述说的5种非虚方法以外的其他方法就被称为虚方法,虚方法的调用形式是分派,典型的有重载方法和重写方法。
分派按照判断条件数量(也称宗量)分可分为单分派和多分派。
按照分派时用的是静态类型还是实际类型可分为静态分派和动态分派。
JVM中静态分派时判断条件一般大于一个,动态分派时判断条件是一个,所以JVM中的分派是静态多分派,动态单分派的。
静态(多)分派
静态分派:所有依赖静态类型来决定方法执行版本的分派动作(编译期发生),最典型的应用表现是方法重载
public class Test {
private static void showList(List list){
System.out.println("list say Hello");
}
private static void showList(ArrayList list){
System.out.println("ArrayList say Hello");
}
private static void showList(LinkedList list){
System.out.println("LinkedList say Hello");
}
public static void main(String[] args){
List a = new ArrayList();
List b = new LinkedList();
showList(a);
showList(b);
}
}
List 就是静态类型(外观类型),这是编译器可知的
ArrayList是实际类型(运行时类型),这是运行期间才可知的
List a = new ArrayList();
JVM中对于方法的重载是通过参数的静态类型作为判定依据的
因此在编译阶段Javac编译器就根据参数的静态类型决定了使用哪个重载版本
private static void showList(List list)
编译器会选择showList(List list)作为调用目标并把这个方法的符号引用写到main()方法的两条invokestatic指令参数中
17: invokestatic #11 // Method showList:(Ljava/util/List;)V
21: invokestatic #11 // Method showList:(Ljava/util/List;)V
所以运行结果是:
list say Hello
list say Hello
此时静态分派时判断宗量数是2:
首先得是Test类下的方法,其次参数的类型是List,编译期invokestatic 指令指向常量池中Test::showList(List)方法的符号引用
重载时静态分派出来的版本不一定就是能够执行的唯一方法,它只是相对更适合的版本,这个寻找相对更适合的版本基本遵循继承规则且可变长参数的重载优先级最低
public class Test {
private static void sayHello(Object object){
System.out.println("Object say Hello");
}
private static void sayHello(int i){
System.out.println("int say Hello");
}
private static void sayHello(Character character){
System.out.println("Character say Hello");
}
private static void sayHello(Serializable serializable){
System.out.println("Serializable say Hello");
}
private static void sayHello(char... chars){
System.out.println("char... say Hello");
}
public static void main(String[] args){
sayHello('a');
}
}
调用sayHello时传的是一个char型参数,但是没有方法参数的静态类型是char
按照自动类型转换:char>int>long>float>double
char也代表数字97,可以转为int,所以sayHello(int i)就被选为最适合的版本
运行结果:
int say Hello
如果去掉sayHello(int i),char会被自动装箱,所以会调用sayHello(Character character)
下面是源码中Character 的定义,他继承了两个接口:
public final class Character implements java.io.Serializable, Comparable<Character>
因此如果sayHello(Character character)也被删除的话,当前代码最适合的重载版本将变为sayHello(Serializable serializable)
但是如果此时加上一个重载版本sayHello(Comparable comparable),那将无法编译通过
因为静态分派时由于sayHello(Comparable comparable) 和 sayHello(Serializable serializable)等级相同,无法分派哪个版本来执行方法
去掉sayHello(Serializable serializable)将会执行sayHello(Object object)
而不是sayHello(char... chars),因为可变长参数优先级最低
动态(单)分派
动态分派:所有依赖实际类型来决定方法执行版本的分派动作(运行期发生),最典型的应用表现是方法重写
public class Test {
static class Person{
public void say(){
System.out.println("Person say Hello!");
}
}
static class Man extends Person{
public void say(){
System.out.println("Man say Hello!");
}
}
public static void main(String[] args){
Person man = new Man();
man.say();
}
}
上述代码JVM是如何调用的呢?首先看下生成的关键部分字节码:
8: aload_1
9: invokevirtual #4 // Method Test$Person.say:()V
LocalVariableTable:
Start Length Slot Name Signature
0 13 0 args [Ljava/lang/String;
8 5 1 man LTest$Person;
由此可以看出先执行aload_1是将局部变量表的slot为1的变量压入操作数栈顶,也就是“man”,然后执行invokevirtual 指令。
invokevirtual指令参数里明显写的是Person的say方法,那会执行到Person里面的say方法吗?这里主要先看下invokevirtual指令的执行流程:
- 找到操作数栈顶的第一个元素所执行的对象的实际类型,记作C
- 如果在C中找到与常量中的描述符合简单名称都相符的方法,则进行访问权限校验,通过则返回这个方法的直接引用,查找过程结束,不通过则抛出java.lang.IllegalAccessError异常
- 如果第二步没找到,则按照继承关系从下往上依次对C的各个父类进行第二部搜索和验证过程
- 如果以上都没找到,则抛出java.lang.AbstractMethodError异常。
由此可以看出上述代码invokevirtual指令执行流程是先找操作数栈顶man的实际类型,实际类型是Man类,找到后查看Man里面是否有say方法,有然后再判断访问权限,都通过了就执行Man里面的say方法,所以执行结果是:
运行结果:
Man say Hello!
动态分派时判断宗量只有一个:
在编译时,通过静态分派invokevirtual 指令已经指向常量池中Person::say()方法的符号引用
在运行时,invokevirtual 根据实际类型Man找到Man下的say方法然后执行。
由此可以看出Java的多态性完全由invokevirtual指令执行规则支持的,这也可以推导出另一个结论:只有方法才有多态性,属性没有
public class Test {
static class Person{
public int money = 1;
public Person(){
money = 2;
showMoney();
}
public void showMoney(){
System.out.println("Person money is " + money);
}
}
static class Man extends Person{
public int money = 3;
public Man(){
money = 4;
showMoney();
}
public void showMoney(){
System.out.println("Man money is " + money);
}
}
public static void main(String[] args){
Person man = new Man();
System.out.println("This person money is " + man.money);
}
}
这边实际类型是Man,但是在构造Man的时候会隐式触发Person的构造函数,Person构造方法里面调用的showMoney是一次虚方法调用,实际会调用到Man里面的showMoney。
然后执行Man的构造方法时会再调用一次showMoney,这次没有任何异议肯定是Man里面的showMoney,此时Man里面的money属性已经被初始化为4,Person里面的money属性被初始化为2,所以会输出"Man money is 4"和“This person money is 2”。
来看看最后的执行结果是:
Man money is 0
Man money is 4
This person money is 2
在父类Person中调用子类的showMoney时,Man还没有创建出来,所以money是0,但是如果将子类的money变成类变量,那么赋值就会在类加载的初始化阶段,此时父类再调用时就能看到3。
..
static class Man extends Person{
...
static public int money = 3;
...
}
运行结果:
Man money is 3
Man money is 4
This person money is 2
虚方法表
JVM中动态分派执行非常频繁,如果每次都去元数据中搜索合适的目标方法,将对性能产生影响,一种常见的优化手段就是为类型在方法区中建立一个虚方法表(接口方法表)****,虚方法表中存放着各个方法的实际入口地址,动态分派时就去这个类的虚方法表中查找,然后根据实际入口地址去执行方法。
虚方法表在类加载的连接阶段进行初始化,类变量赋初值后,JVM会把该类的虚方法表一同初始化完毕。
如果某个方法在子类中没有重写,那子类的虚方法表中的地址入口和父类相同方法的入口一样,都指向父类的实现入口,如果子类重写了,子类虚方法中地址就被替换成子类实现版本的入口地址。并且具有相同签名的方法在父类、子类的虚方法表中的索引序号是一样的,这样当类型变换时,仅需要变更查找的虚方法表,就可以从不同的虚方法表中按索引找出方法的入口地址。
动态类型的方法调用
目前流行的变成语言按照类型确定时机可分为静态类型语言和动态类型语言:
- 静态类型语言:在编译期确定变量类型,编译器可以提供全面严谨的类型检查,这样与数据类型相关的潜在问题能在编码时被及时发现
- 动态类型语言:运行期确定类型,提供极大的灵活性,让代码实现更加清晰简洁,提升开发效率。
Java是静态类型语言,但是JVM的目标不是只支持Java语言,它的目标是支持其他所有语言,这些语言必然包括动态类型语言.
之前提到的方法调用指令中除了invokedynamic,其他四个调用方法的指令的第一个参数都是被调用方法的符号引用,但是动态类型给不了这个符号引用。JDK7时加入了invokedynamic指令和java.lang.invoke包,这为JVM支持动态类型语言打下了基础。
java.lang.invoke
使用方法句柄来替代之前单纯依靠符号引用来确定调用的目标方法,这个句柄其实就是最终调用方法的一个“引用”,它由所指向方法的参数类型和返回类型组成,开发者可以通过它来执行最终方法,而如何找到这个最终方法(在哪个类里找指定名称的方法)完全由开发者自己控制。
public class Test{
static class ClassA{
//这个类的println和System.out里的println方法重名
public void println(String s){
System.out.println("ClassA print: " + s);
}
}
/**
* 自定义查找最终方法
*/
private static MethodHandle getPrintlnMH(Object receiver) throws Throwable{
//最终方法类型:第一个参数代表最终方法的返回类型,这把是void,所以应该传入void.class
// 第二个参数开始就是最终方法的参数
MethodType mt = MethodType.methodType(void.class, String.class);
//通过MethodHandles.lookup()去寻找最终方法
// 第一个参数:代表去哪个类里面找
// 第二个参数:需要寻找的方法名
// 第三个参数:需要寻找的方法的方法类型
//bindto:由于println是需方法,方法的第一个参数(隐式的)是this指向的当前对象,以前是放在参数列表中进行传递,现在是通过bindTo()
return MethodHandles.lookup().findVirtual(receiver.getClass(), "println", mt).bindTo(receiver);
}
public static void main(String[] args){
for (int i = 10; i > 0; i--){
Object object = new Random().nextInt() % 2 == 0 ? System.out : new ClassA();
try {
//getPrintlnMH根据传入的object类找到最终方法的方法句柄,然后使用invokeExact去执行。
getPrintlnMH(object).invokeExact("a");
} catch (Throwable throwable) {
throwable.printStackTrace();
}
}
}
}
执行结果:
ClassA print: a
ClassA print: a
a
a
ClassA print: a
ClassA print: a
a
a
ClassA print: a
ClassA print: a
MethodHandles.lookup().findXXX系列方法为不同的最终方法类型都提供了相应的实现
方法名 | 说明 |
---|---|
findStatic | 查找用invokestatic调用的静态方法 |
findVirtual | 查找用invokevirtual调用的实例方法和invokeinterface调用的接口方法 |
findSpecial | 查找用invokespecial调用的实例方法 |
拿到句柄后有两个方法可以执行最终方法:
- invoke:在调用时可以进行返回值和参数的类型转换,比如需要的是Integer参数,但是传的是int,那么它会自动装箱。
- invokeExact:精确匹配,如果传的是int,最会报错。
Reflection反射也能完成相同的功能,但是还是有些区别:
- Reflection是为Java设计的,MethodHandle是为其他语言设计的,更加接近底层细节。
- MethodHandle能够实施各类调用点优化措施,Reflection不能,因此Reflection性能比MethodHandle差
- Reflection中的java.lang.reflect.Method对象远比MethodHandle机制中的 java.lang.invoke.MethodHandle对象所包含的信息来得多
- 句柄的权限检查是在创建阶段完成的,后续调用不会检查权限,而反射是在调用阶段,重复调用同一个方法时反射多路权限检查的开销
方法句柄有几个注意点:
- 当方法句柄指向字段时,其实是指向字段的Getter和Setter方法,就是字段是private的访问权限,也能修改字段值,并且这里的Getter和Setter方法不是开发者自己为该字段写的Getter和Setter方法,因为JVM无法保证开发者
- 方法句柄的访问权限不取决于方法句柄的创建位置,而是取决于Lookup对象的创建位置
- 方法句柄支持的增删改操作都是通过生成另一个充当适配器的方法句柄来实现的
invokedynamic指令
将调用点(CallSite)抽象成一个Java类,并且将原本由JVM控制的方法调用以及方法链接暴露给应用程序,在运行过程中,每一条invokedynamic指令将捆绑一个调用点,并且会调用该调用点所链接的方法句柄。
先看例子,正常Java程序不会出现invokedynamic指令,而java提供的语法糖lambda就是通过invokedynamic指令实现的。
Java类:
public class Test {
public static void main(String[] args){
new Thread(() -> System.out.println("aa")).run();
}
}
查看字节码,这边只看关键部分:
Constant pool:
#3 = InvokeDynamic #0:#31 // #0:run()Ljava/lang/Runnable;
#31 = NameAndType #43:#44 // run:()Ljava/lang/Runnable;
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=1, args_size=1
0: new #2 // class java/lang/Thread
3: dup
4: invokedynamic #3, 0 // InvokeDynamic #0:run:()Ljava/lang/Runnable;
9: invokespecial #4 // Method java/lang/Thread."<init>":(Ljava/lang/Runnable;)V
12: invokevirtual #5 // Method java/lang/Thread.run:()V
15: return
BootstrapMethods:
0: #28 invokestatic java/lang/invoke/LambdaMetafactory.metafactory:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
Method arguments:
#29 ()V
#30 invokestatic Test.lambda$main$0:()V
#29 ()V
invokedynamic指令是为动态类型准备的,所以它的第一个参数不能和其他方法调用指令一样是方法的符号引用CONSTANT_Methodref_info常量,而编程新加入的CONSTANT_InvokeDynamic_info常量,从这个新常量中可以得到三项信息:引导方法(Bootstrap Method)、方法类型和名称。
从上面字节码中可以看到invokedynamic指令的参数是“ #3, 0”,后面的0是占位符主要看第一个参数,它指向常量池中“#3 = InvokeDynamic #0:#31”,第二个参数“#31”就是方法的名称和方法的类型。
从最下方可以看到有invokedynamic指令的字节码文件会多个BootstrapMethods方法表,这里的“#0”就是指invokedynamic指令的引导方法在BootstrapMethods方法表中的位置。
BootstrapMethods方法表里面的方法是自动生成的,引导方法的主要作用就是生成一个包含实际调用方法句柄(MethodHandle)的CallSite对象,然后将这个对象返回给invokedynamic指令实现对具体方法的调用。
invokedynamic指令有几个注意点:
- 在第一次执行invokedynamic指令时,JVM才会调用改指令所对应的启动方法(BootStrap Method),生成调用点(CallSite)并且将它绑定至该指令中,后续调用JVM直接调用绑定的调用点所链接的方法句柄。
- Lambda表达式到函数式接口的转换是通过invokedynamic指令来实现的,该invokedynamic指令对应的启动方法将通过ASM生成一个适配器类。
- 对于没有捕获其他白能量的Lambda表达式,该invokedynamic指令始终返回同一个适配器类的实例,对于捕获了其他变量的Lambda表达式,每次执行invokedynamic指令将创建一个适配器类实例。
- 即时编译器能够将方法句柄完全内联起来,使它的性能上限可达到与直接调用一样。
JVM基于栈解释执行实例
JVM执行的字节码文件里面的指令流是一种基于栈的指令集架构,它的工作依赖操作数栈,与之相对的一套指令流是基于寄存器的,它们的区别主要是:
- 基于栈的指令不需要带参数,使用操作数栈中的数据作为指令的运算输入;基于寄存器的指令需要参数。
- 栈架构指令集执行速度比寄存器架构指令集慢,因为栈架构指令不需要参数,那么它就必须包含大量出栈和入栈的操作,也就是完成相同功能所需的指令比寄存器架构指令要更多。
- 栈架构指令集移植性比寄存器指令集好,因为寄存器由硬件直接提供,程序执行依赖当前所处的硬件寄存器。
下面查看一段代码基于栈的概念执行流程,实际上每个虚拟机都会有不同的优化:
代码:
public int add(){
int a = 100;
int b = 200;
int c = 300;
return (a + b) * c;
}
生成的字节码文件:
public int add();
descriptor: ()I
flags: ACC_PUBLIC
Code:
stack=2, locals=4, args_size=1
0: bipush 100
2: istore_1
3: sipush 200
6: istore_2
7: sipush 300
10: istore_3
11: iload_1
12: iload_2
13: iadd
14: iload_3
15: imul
16: ireturn
LocalVariableTable:
Start Length Slot Name Signature
0 17 0 this LTest;
3 14 1 a I
7 10 2 b I
11 6 3 c I
首先编译时就已经确定该方法所需的是深度为2的操作数栈和4个Solt的局部变量空间,执行时程序计数器从0开始执行,偏移地址为0的指令是bipush,Bipush指令需要一个整型参数,范围是-128~127,作用是将单字节的整型常量值推入操作数栈顶。
Bipush执行完程序计数器变为2,执行偏移地址为2的指令istore_1,该指令表示将操作数栈顶的整型值出栈并方法编号为1的局部变量Slot中
后面几个指令都是往局部变量槽里填充数据,直到偏移量为11的指令iload_1,该指令是将局部变量表Slot为1里的整型值复制到操作数栈顶。
iload_2也是,执行完操作数栈顶是两个整型200和100.
iadd指令是将操作数栈顶的两个元素出栈,做整型加法,然后把结果入栈。
偏移地址14是将局部变量Slot为3的整型入栈,此时栈顶是300和300
偏移地址15 指令imul和iadd指令差不多,将栈顶300和300出栈后相乘得到90000后入栈。
偏移地址16指令ireturn,该指令将结束方法执行并将栈顶的整型值返回给方法的调用者。