JVM字节码执行引擎思维导图
本文参考自来自周志明《深入理解Java虚拟机(第2版)》,拓展内容建议读者可以阅读下这本书。
文字版如下:
运行时栈帧结构
局部变量表
- 需要多少大小的局部变量表已写入到class字节码方法的Code属性的max_locals属性中
一个存储单位称为一个Slot(32位)
为了让所有数据类型的局部变量都能够存储到局部变量表中而设定了定长的Slot长度
- 32位以内的数据类型(boolean | byte | char | short | int | float | reference | returnAddress)变量使用1个Slot存储
- 32位以上的数据类型(long | double)变量使用2个Slot存储,会以高位对齐的方式进行存储
局部变量第0位Slot
- 实例方法的局部变量表的第0位存储的是用于传递方法所属对象实例的this引用
- 静态方法的局部变量表没有这个this引用
Slot重用:离开了作用域的局部变量占用的Slot可以被重用
对垃圾回收的影响:离开了作用域的局部变量所在的Slot只有在真的不存在对原有局部变量的引用时,GC才会回收相应的对象实例
- 花括号内的局部变量a在花括号外出了作用域,直接GC也不会回收a,就是由于这时还是存在Slot对a的引用
- 花括号内的局部变量a在花括号外出了作用域,花括号外再有一个局部变量赋值导致Slot被重用了,GC就会回收a,就是由于Slot对a的引用没有了
局部变量和类字段的区别
生命周期不同
- 类字段的生命周期是类的生命周期
- 局部变量的生命周期是方法中变量的生命周期
赋初值不同
- 类字段会在类加载的准备阶段赋零值,再会在类加载的初始化赋初值
- 局部变量的初值如果没有手动赋值的话是没有的,编译阶段即会报出未初始化就使用的错误
reference类型
- 可以从reference类型变量中直接或间接地查找到对象在Java堆中存放的起始地址索引
- 可以从reference类型变量中直接或间接地查找到对象所属类型在方法区中的类的存储信息
操作数栈
- 需要多大的栈深度已写入到class字节码方法的Code属性的max_stacks属性中,任何时候栈容量都不能超过最大栈深度
一个存储单位称为一个栈容量
为了让所有数据类型的局部变量都能够存储到栈中而设定了定长的栈容量
- 32位以内的数据类型(boolean | byte | char | short | int | float | reference | returnAddress)变量使用栈容量为1
- 32位以上的数据类型(long | double)变量使用栈容量为2
动态链接
为了满足方法指令的运行时解析
- 静态解析:把方法调用指令中的符号引用在类加载或首次运行时转换为直接引用,即这些方法引用是静态可确定的引用
- 动态链接:把方法调用指令中的符号引用在运行时才转换为直接引用,即这些方法引用是动态运行时才可确定的
方法返回地址
退出方法的方式
- 正常返回出口:执行到返回指令字节码的时候会将方法返回值传递给上层的方法调用者
- 异常完成出口:方法执行遭遇异常,如JVM内部异常或者athrow字节码指令,且在异常表中未找到匹配处理器导致退出,不会给上层的方法调用者任何返回值
退出方法执行的操作
- 恢复上层方法的局部变量表
- 把返回值压入调用者栈帧的操作数栈
- 调整PC计数器的值指向方法调用指令的后一条指令
附加信息
方法调用
方法执行的步骤
- 方法编译:将源码编译称class字节码中的符号引用
- 方法调用:将class字节码中的符号引用解析成直接引用
- 方法执行:执行解析出的直接引用指向地址的方法
虚方法和非虚方法
非虚方法:在类加载的时候就可以将符号引用解析为直接引用的方法,即不存在因覆盖而产生多版本的方法
- 静态方法
- 实例构造器<init>方法
- 私有方法
- 父类方法
- final方法
虚方法:在类加载的时候有可能无法确定符号引用可以解析为哪个直接引用的方法
- 因覆盖父类方法而出现的多版本方法无法在类加载时就确定方法的直接引用
- 目前没有产生覆盖的方法虽然不存在多版本但是也归为了虚方法,因为它可能会被新类覆盖方法
5种方法调用字节码
invokestatic
- 静态方法
invokespecial
- 实例构造器<init>方法
- 私有方法
- 父类方法
invokevirtual
- final方法
- 虚方法
invokeinterface
- 接口方法,会在运行时再确定实现接口的对象
invokedynamic
- 运行时动态解析出调用点限定符所引用的方法再执行
分派 Dispatch
静态分派 Method Overload Resolution
- 编译期即可确定方法的版本
- 由编译器直接解析来完成方法版本的判定,不需要虚拟机介入
典型应用是方法重载 Overload
- 重载:方法签名不同(方法名相同、参数列表不同)
- 编译阶段就会将重载方法调用解析为
invokevirtual 选择的方法版本的符号引用
- 方法m(Q)重载了方法m(P),编译器解析名为m的方法时会根据其参数p的静态类型(即外观类型或者直接类型)来选择方法的版本
- 如果参数是无显式的静态类型的字面量,那么编译期会最大可能地去推断字面量最贴近的参数类型。如print(‘a’)解析为重载的方法版本print(char) print(int)时会优先推断字面量类型为char选择前者,如果没有前者定义的话才会选择后者
动态分派
- 运行期才可确定方法的版本
- 由虚拟机来完成方法版本的判定
典型应用是方法重写 Override
- 重写:方法签名相同(方法名相同、参数列表相同)
- 编译阶段就会将重写方法调用解析为
invokevirtual 选择的方法版本的符号引用
类B的方法m(P)重写了B所继承的类A的方法m(P),实际类型为B,A b = new B(),即静态类型为A的对象b,发生了方法调用b.m(p)
- 编译期:编译器将b.m(p)编译成了
invokevirtual A.m
运行期
- 找到操作数栈顶的第一个元素指向对象即b的实际类型,也就是B
在B中寻找与invokevirtual调用的方法符号引用名称和签名一致的方法
找到了的话说明实际类型B中就有了这个方法m(P),然后判定该方法是否满足访问权限
- 满足:将调用的方法符号引用替换为该方法的直接引用
- 不满足:抛出java.lang.illegalAccessError异常
没找到
在B的父类中自下向上寻找与invokevirtual调用的方法符号引用名称和签名一致的方法
找到了的话说明实际类型B的父类中有这个方法m(P),然后判定该方法是否满足访问权限
- 满足:将调用的方法符号引用替换为该方法的直接引用
- 不满足:抛出java.lang.illegalAccessError异常
- 始终没找到:抛出java.lang.AbstractMethodError异常
- 编译期:编译器将b.m(p)编译成了
方法宗量:方法的接收者(所有者)和参数
单分派:根据单个方法宗量进行方法选择
- 静态阶段(编译期)的选择过程是依据方法的接收者和方法的参数两个总量来选择的,因此静态阶段多分派
多分派:根据多个方法宗量进行方法选择
- 动态阶段(运行时)的选择过程是依据方法的接收者来选择的,因此动态阶段单分派
虚拟机动态分派的实现
- 搜索虚方法的合适版本:代价太大
稳定优化手段:虚方法表 提前记录避免运行时搜索的手段
- 父类类加载时准备虚方法表,虚方法表将每个虚方法的表中索引指向方法区中的类信息的方法实际地址
- 子类类加载时准备虚方法表,虚方法表将重写的方法指向子类在方法区中类信息的方法实际地址,而未重写的方法仍旧指向其父类方法区中的类信息的方法实际地址
- 调用虚方法时可以直接获取到这个方法的实际地址而免去了搜索过程
非稳定的激进优化手段
- 内联缓存
- 基于“类型继承关系分析”技术的守护内联
基于栈的解释器的执行过程
下面来看一下静态分派和动态分派的例子
静态分派,使用方法重载作为例子看一下编译期的结果:
源码定义了继承了Human
的Man
和Woman
,关键在于看一下编译器将test.sayHi(human);
、test.sayHi(woman);
、test.sayHi(man);
编译成了什么,也就是说静态阶段编译器重载方法的方法分派是什么样的。
class Human{ } class Man extends Human{ } class Woman extends Human{ } public class DispatchTest { public void sayHi(Human human){ System.out.println("Hi human"); } public void sayHi(Man man){ System.out.println("Hi man"); } public void sayHi(Woman woman){ System.out.println("Hi woman"); } public static void main(String[] args) { Human human = new Human(); Human man = new Man(); Human woman = new Woman(); DispatchTest test = new DispatchTest(); test.sayHi(human); test.sayHi(woman); test.sayHi(man); } }
javap获取到的class字节码解释:
public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=2, locals=5, args_size=1 0: new #7 // class Human 3: dup 4: invokespecial #8 // Method Human."<init>":()V 7: astore_1 8: new #9 // class Man 11: dup 12: invokespecial #10 // Method Man."<init>":()V 15: astore_2 16: new #11 // class Woman 19: dup 20: invokespecial #12 // Method Woman."<init>":()V 23: astore_3 24: new #13 // class DispatchTest 27: dup 28: invokespecial #14 // Method "<init>":()V 31: astore 4 33: aload 4 35: aload_1 36: invokevirtual #15 // Method sayHi:(LHuman;)V 39: aload 4 41: aload_3 42: invokevirtual #15 // Method sayHi:(LHuman;)V 45: aload 4 47: aload_2 48: invokevirtual #15 // Method sayHi:(LHuman;)V 51: return LocalVariableTable: Start Length Slot Name Signature 0 52 0 args [Ljava/lang/String; 8 44 1 human LHuman; 16 36 2 man LHuman; 24 28 3 woman LHuman; 33 19 4 test LDispatchTest;
这段字节码的表义很清晰,就是创建了类型为Human
的Man
和Woman
的三个类的对象实例,然后在执行test.sayHi(human);
、test.sayHi(woman);
、test.sayHi(man);
的时候都将其编译成了调用方法sayHi:(LHuman;)V
,即参数类型是Human
的sayHi
方法。从这里我们可以看出来实际上静态编译重载方法的时候的只会使用方法参数的静态类型指定的方法,而实际类型由于在运行时可能会发生变化,没有办法在编译期获得其实际类型,因此采用了这种方式直接确定方法的分派结果。
动态分派,使用方法重写作为例子看一下编译期的结果:
源码定义了继承了Human
的Man
和Woman
,关键在于看一下编译器将man.sayHi();
、woman.sayHi();
编译成了什么,也就是说静态阶段编译器对重写方法的方法分派是什么样的。
abstract class Human{ protected abstract void sayHi(); } class Man extends Human{ @Override protected void sayHi() { System.out.println("Hi man"); } } class Woman extends Human{ @Override protected void sayHi() { System.out.println("Hi woman"); } } public class DispatchTest { public static void main(String[] args) { Human man = new Man(); Human woman = new Woman(); DispatchTest test = new DispatchTest(); man.sayHi(); woman.sayHi(); } }
javap获取到的class字节码解释:
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 #2 // class Man 3: dup 4: invokespecial #3 // Method Man."<init>":()V 7: astore_1 8: new #4 // class Woman 11: dup 12: invokespecial #5 // Method Woman."<init>":()V 15: astore_2 16: new #6 // class DispatchTest 19: dup 20: invokespecial #7 // Method "<init>":()V 23: astore_3 24: aload_1 25: invokevirtual #8 // Method Human.sayHi:()V 28: aload_2 29: invokevirtual #8 // Method Human.sayHi:()V 32: return LocalVariableTable: Start Length Slot Name Signature 0 33 0 args [Ljava/lang/String; 8 25 1 man LHuman; 16 17 2 woman LHuman; 24 9 3 test LDispatchTest;
这段字节码创建了类型为Man
和Woman
的两个类的对象实例,然后在执行man.sayHi();
、woman.sayHi();
的时候都将其编译成了调用方法Human.sayHi:()V
,即Human
类型的sayHi
方法。从这里我们可以看出来实际上静态编译重写方法的时候的只会使用实例对象的静态类型的方法,而实际类型由于在运行时可能会发生变化,没有办法在编译期获得其实际类型,因此采用了这种方式直接确定方法的分派结果。但是在运行时会去获取这个实例对象的实际类型,然后看这个实际类型是否定了名称和限定符满足的方法,如果有就会选择分派到这个方法上。这就是运行时动态分派产生的影响,这种选择在编译器是看不出来的。