Java虚拟机执行引擎知识总结
作者:蝴蝶刀刀 时间:2023-06-05 17:34:31
执行引擎
也只有几个概念, JVM方法调用和执行的基础数据结构是 栈帧, 是内存区域中 虚拟机栈中的栈元素, 每一个方法的执行就对应着一个栈帧在虚拟机栈中出栈入栈的过程.
栈帧:则是包含有局部变量表, 操作数栈, 动态连接, 方法返回地址, 附加信息.
1 局部变量表:
存储单位是 slot, 一个slot占据32位, 对于64位的数据类型, 则是分配连续两个slot空间. 而对于一个非静态方法而言, 有一个隐藏参数, 为 this, 而在局部变量表中的变量存储顺序则是
this -> 方法参数 -> 方法体内的变量(slot可以重用, 超出作用域即可复用.) 方法在编译完成后, 其所需的空间已经确定.
(这里也是需要注意的一个地方, 变量的作用域常常会覆盖整个方法, 即使变量已经不再使用, 但只要还在作用域内, 其slot空间就无法给其他变量使用, 因此, 最好是在需要使用到变量时, 定义在合理的作用域范围内.)
2 操作数栈:
在操作数栈中需要注意,其数据类型必须与字节码指令的序列严格匹配.
3 动态连接: 稍后详解
4 方法返回地址:
方法有两种退出方式, 正常退出, 异常退出, 当正常退出后, 会恢复上层方法的局部变量表, 操作数栈, 并把方法返回结果压入调用者的操作数栈.
方法调用
方法调用阶段的唯一目的是, 确定调用方法的版本究竟是哪一个.
在Java虚拟机中提供了5条方法调用的相关指令:
invokestatic: 调用静态方法
invokespecial: 调用实例构造器方法, 私有方法, 父类方法
invokevirtual: 调用所有的虚方法
invokeinterface: 调用所有的接口方法
invokedynamic: 先在运行时动态解析出调用点限定符所引用的方法, 然后再执行该方法.
虚方法是非虚方法的补集, 什么是非虚方法呢? 能够在编译器就确定将要调用的究竟是哪个方法, 进而将该方法的符号引用 转换为 相应的直接引用的 方法就被称作非虚方法.
我们知道在类加载时, 在相应的类信息中, 存有对应方法的相关信息, 常量池中存有相关直接引用. 在类加载的解析阶段, 即会将这部分的符号引用转换为直接引用.
那么什么方法才满足这种条件呢?
能够被invokespecial 和 invokestatic指令调用的方法, 都是可以在编译器确定的方法, 即静态方法, 私有方法, 父类方法(super.), 实例构造器.
在final方法是个特殊点, 虽然final方法的执行为 invokevirtual, 但它依然属于非虚方法, 不难理解, final方法不能够被重写.
方法分派(dispatch)
1 静态分派
对于代码
Human man = new Man();
其中Human被称为变量的静态类型, 也叫外观类型, 而 Man则是变量的实际类型. 而一个变量的静态类型, 在声明时即已经确定, 仅仅在使用时才能够临时转换静态类型, 但变量本身的静态类型并不会改变, 实际类型的变化只有在运行期才能确定.
//实际类型变化
Human man = new Man();
man = new Woman(); //静态类型的变化
method((Man) man);
method((Woman) man);
而当我们在重载方法时, 向方法中传入的参数类型, 即是静态类型.因此 重载是一种 可以在编译期就被确定执行方法版本 的行为.
2 动态分派
动态分派 与 重写息息相关.
static class Human{ void sayHello() {
System.out.println("human say hello");
}
} static class Man extends Human{ @Override
void sayHello() {
System.out.println("man say hello");
}
} void sayHello(Human man) {
man.sayHello();
} public static void main(String[] args) {
Human man = new Man();
Human human = new Human(); new Main().sayHello(man); new Main().sayHello(human);
} //out:
man say hello
human say hello
结果不必多做解释, 而现在的问题在于, 虚拟机如何知道, 究竟调用的是哪个方法?
0: new #3 // class Main$Man
3: dup 4: invokespecial #4 // Method Main$Man."<init>":()V 7: astore_1 8: new #5 // class Main$Human
11: dup 12: invokespecial #6 // Method Main$Human."<init>":()V 15: astore_2 16: new #7 // class Main 19: dup 20: invokespecial #8 // Method "<init>":()V 23: aload_1 24: invokevirtual #9 // Method sayHello:(LMain$Human;)V
27: new #7 // class Main 30: dup 31: invokespecial #8 // Method "<init>":()V 34: aload_2 35: invokevirtual #9 // Method sayHello:(LMain$Human;)V
38: return
其中主要关注几个方法的执行点, invokespecial不用多说, 之前提到过, 是执行 构造器方法时 的指令
而 invokevirtual 则正是执行 main.sayHello(), 方法的指令, 指令的运行时解析过程大致如下:
而其中的关键点就在于, 取到的是 对象的实际类型.
1 找到操作数栈顶的第一个元素的所指对象的实际类型, 记做C
2 如果在C中找到与描述符 和 简单名称都相符的方法, 进行访问校验, 如果可以则返回方法的直接引用, 否则抛出 IllegalAccessError异常
3 否则按照继承关系 从下向上对C的各个父类进行第二步的搜索验证过程.
4 如果始终找不到, 抛出异常.
动态类型语言
这也是要提到的关于 invokedynamic指令的主要目的。
动态类型语言的概念是: 意思就是类型的检查是在运行时做的而非编译期。
而Java本身则是静态类型语言, 这一点又在哪里能够体现呢?
obj.println("language");
如果处在java环境中,且obj的静态语言类型是 java.io.PrintStream, 那么obj本身的实际类型也必须是PrintStream的子类才行, 哪怕本身存在 println方法也不可以, 但同样的问题放在 javascript中就不同了, 只要实际类型中存在println方法, 执行就不会有任何问题.
这点就是因为, java在编译时已经将其完整的符号引用生成出来, 如果注意到的话, 会发现无论是动态分派还是静态分派, 在编译的指令中都是已经精确到相应类的某一个方法中了, 如此, 自然只能够在有限的范围内略做调整, 如果超出了当前类的范围, 就无法调用了.
jvm虚拟机并不仅仅是java语言的虚拟机, 那么如何为动态类型语言提供支持就是一个问题了, 并且在目前java8中的lamda表达式中也应用的是 invokedynamic指令.
MethodHandle
而与之相关的jar包则是 java.lang.invoke, 相关的类则是 MethodHandle.
在这里我也并不想再谈 MethodHandle的使用方法, 网上资料实在不少.
需要提到的是, 它的功能和java的反射略有相似, 通过方法名, class, 就可以调用相应的方法. 但它比起反射要轻量级; 且Reflection是在模拟Java代码的调用, MethodHandle是在模仿字节码层面的调用.
这个方法不失为是在动态调用中除了反射之外的另一种选择.
基于栈解释器的执行过程
其实本文更像是在 前一篇博客中 java内存区域中的虚拟机栈的一种补充说明.
而真实的执行流程, 我想通过下文的代码来看:
public int add() { int a = 100; int b = 200; int c = 300; return (a + b) * c;
}
-- javap -verbose Mainpublic int add();// 返回类型为 intdescriptor: ()Iflags: ACC_PUBLICCode://需要深度为2的操作数栈, 4个slot的局部变量空间, 有一个参数为 this
stack=2, locals=4, args_size=1
//将100推入操作数栈顶, 栈:100 0: bipush 100
//将栈顶的数据出栈并存储到局部变量表的第一个slot中(从0开始)
//此时:栈: - 局部变量表: slot1 100 2: istore_1 //与上面类似,重复过程 3: sipush 200 6: istore_2 7: sipush 300
//此时:栈: - 局部变量表: slot1 100 slot2 200 slot3 300 10: istore_3 //将局部变量表 slot1的值复制到 栈顶 11: iload_1 //将局部变量表 slot2的值复制到 栈顶 此时:栈: 200 100 12: iload_2 //栈顶两个元素出栈, 并相加, 结果重新入栈. 此时: 栈: 300 13: iadd //将局部变量表 slot3的值复制到 栈顶 此时:栈: 300 300 14: iload_3 //将栈顶元素相乘, 结果重新入栈 15: imul //将栈顶的结果返回给方法调用者. 方法执行结束 16: ireturn LineNumberTable:
line 85: 0
line 86: 3
line 87: 7
line 88: 11
基于栈的执行引擎正是通过这样出栈入栈的方式完成指令, 而基于寄存器的则不然, 是将操作数存入寄存器, 同时将输入值也就是指令参数 与 某寄存器的存储值相加. 区别就在于存储位置, 以及参数问题, 基于栈的大部分指令都是无参数指令, 指令很明确的规定了 要用哪几个栈元素, 栈元素的类型是什么.
我们平常所使用的电脑, 其 X86指令集, 正是基于寄存器的指令集.
优缺点则是: 基于栈, 可移植性较强, 但速度比较慢, 慢的原因一是需要许多冗余操作, 代码. 二是基于栈是基于内存的操作方式, 而内存的速度比起寄存器更是要慢上许多.
总结
本文大致介绍这样几点:
java多态在 jvm层次的实现.
为什么说jvm执行引擎是基于栈的执行引擎, 以及究竟是怎样一个流程.
来源:https://www.imooc.com/article/270273