本博客为《深入理解java虚拟机》的学习笔记,所以大部分内容来自此书,另外一部分内容来自网络其他博客和源码分析。
主要内容包括:虚拟机执行引擎的机构;方法调用过程;方法分派(即java支持方法多态,调用中如何确定方法版本的问题)。
一 运行时栈帧结构
1 虚拟机栈(VirtualMachine Stack)
虚拟机栈是线程私有的。虚拟机栈描述的是java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(用于存储局部变量表、操作数栈、动态链接、方法出口信息等)。每个方法从调用到执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
2 栈帧(StackFrame)
栈帧是虚拟机栈的栈元素,是用于支持虚拟机进行方法调用和方法执行的数据结构。用于存储局部变量表、操作数栈、动态链接、方法返回地址和一些额外的附加信息。
1) Code属性
在编译期,栈帧需要多大的局部变量表,多深的操作数栈都已经全部确定了,他们在Code的属性max_locals 、max_stack中,因此一个栈帧需要的内存大小也是确定的。以下为Code的属性定义。
类型 |
名称 |
数量 |
u2 |
attribute_name_index |
1 |
u4 |
attribute_length |
1 |
u2 |
max_stack |
1 |
u2 |
max_locals |
1 |
u4 |
code_length |
1 |
u1 |
code |
code_length |
u2 |
exception_table_length |
1 |
exception_info |
exception_table |
exception_length |
u2 |
attributes_count |
1 |
attribute_info |
attributes |
attributes_count |
2) Code示例
代码
public int add(int a, int b) {
int c= a + b;
return c;
}
编译后的内容如下
public int add(int, int);
descriptor: (II)I
flags: ACC_PUBLIC
Code:
stack=2, locals=4, args_size=3
0: iload_1
1: iload_2
2: iadd
3: istore_3
4: iload_3
5: ireturn
LineNumberTable:
line 6: 0
line 7: 4
LocalVariableTable:
Start Length Slot Name Signature
0 6 0 this Lcom/wzf/greattruth/jvm/Tester;
0 6 1 a I
0 6 2 b I
4 2 3 c I
3 栈帧结构
4 局部变量表
局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在前面讲述的Code属性中max_locals确定了此方法需要分配的局部变量表的最大容量。局部变量表以变量槽(variable slot,简称slot)为最小单位。
虚拟机通过索引定位的方式使用局部变量表,索引的取值范围是[0-max_locals]。一个局部变量可以保存一个类型为 boolean、byte、char、short、float、reference 和returnAddress 的数据,两个局部变量可以保存一个类型为 long 和 double 的数据,如果将double类型的值存储在索引值为 n 的局部变量中,实际上的意思是索引值为 n 和 n+1 的两个局部变量都用来存储这个值。虚拟机规范明确要求:不允许采用任何方式单独访问其中的某一个槽。说明:如果是非静态方法,局部变量表第0个slot是this,即当前的实例对象。
以下是上面示例的部分字节码示例的含义:
0: iload_1 // 将局部变量表第1个slot的值加载到操作数栈栈顶
1: iload_2 // 将局部变量表第2个slot的值加载到操作数栈栈顶。
2: iadd
3: istore_3// 将操作数栈栈顶元素存储到局部变量表中第3个slot中
5 操作数栈
也称操作栈,是一个后入先出(LIFO)栈。操作数栈的最大深度在编译的时候已经确定,见Code属性表max_stacks数据项。32位数据类型占栈容量为1,64位数据类型占栈容量为2。
当一个方法刚开始执行的时候,这个方法的操作数栈是空的,在方法执行过程中,会有各种字节码指令往操作数栈中写入或从操作数栈中提取内容,即入栈、出栈操作。之前写的一篇博客中有一个示例,详细讲述指令执行过程,见:https://yq.aliyun.com/articles/326368?spm=5176.8091938.0.0.b3219cdtr00mC
在概念模型中,两个栈帧是完全独立的,但是大多数虚拟节实现会做一些优化,将两个栈帧的一部分重叠,这样进行方法调用时可以共用一部分数据,无需进行额外的参数复制传递。如下图所示:
6 动态连接
1) 动态链接
每个栈帧中都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。
在Class文件里面,描述一个方法调用了其他方法,或者访问其成员变量是通过符号引用(Symbolic Reference)来表示的,动态链接的作用就是将这些符号引用所表示的方法转换为实际方法的直接引用。
2) 符号引用
符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能够无歧义的定位到目标即可。
例如,在Class文件中它以CONSTANT_Class_info、CONSTANT_Fieldref_info等类型的常量出现。
符号引用与虚拟机的内存布局无关,引用的目标并不一定加载到内存中。在Java中,一个java类将会编译成一个class文件,在编译时,java类并不知道所引用的类的实际地址,因此只能使用符号引用来代替。
3) 直接引用
直接引用可以是:
Ø 直接指向目标的指针(比如,指向“类型”【Class对象】、类变量、类方法的直接引用可能是指向方法区的指针);
Ø 相对偏移量(比如,指向实例变量、实例方法的直接引用都是偏移量);
Ø 一个能间接定位到目标的句柄。
直接引用是和虚拟机的布局相关的,同一个符号引用在不同的虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经被加载入内存中了。
7 方法返回地址
有两种方式退出方法执行。
正常完成出口:执行引擎遇到返回的字节码指令;
异常完成出口:执行过程中遇到异常,并且这个异常没有在方法体内得到处理。
无论哪种方式,退出方法后都需要返回到方法被调用的位置,程序才能继续执行。一般来说,方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器的值。
方法退出过程实际上等同于当前栈帧出栈的过程,退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈;如果有返回值,将返回值压入调用者栈帧的操作数栈中;调整PC计数器的值一致性方法调用后的一条指令等。
二 方法调用
方法调用阶段唯一的任务时确定被调用方法的版本,即确定调用哪一个方法。
1 解析(Resolution)
在类加载的解析阶段,会将一部分符号引用转换成直接引用,这里有一个前提条件:方法在程序真正运行前就可以确定一个调用版本,并且这个版本在运行期不可改变,即:编译器可知,运行期不变。对这类方法的调用称之为解析。
满足“编译器可知,运行期不变”这个条件的方法,主要包括静态方法和私有方法两类。静态方法与类型直接关联,私有方法外部不可访问,它们的特点决定了不可能通过继承或者别的方式重写它们。
2 方法调用字节码指令
java虚拟机提供了5个方法调用字节码指令。
指令 |
描述 |
invokestatic |
调用静态方法 |
invokespecial |
调用构造器方法、私有方法、父类方法 |
invokevirtual |
调用所有虚方法 |
invokeinterface |
调用接口方法,在运行期再确定一个实现此接口的对象。 |
invokedynamic |
先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。 |
前4条指令的分派逻辑固化在虚拟机内部,invokedynamic指令的分派逻辑由用户所设定的引导方法决定。
能被invokestatic、invokespecial指令调用的方法,在解析阶段可以确定一个唯一的调用版本,所以在类加载的解析阶段会将符号引用解析为直接引用。
静态方法、构造器方法、私有方法、父类方法、被final修饰的方法被称之为非虚方法;与之相对应的,其他方法称之为虚方法。
需要注意的是:final方法虽然使用invokevirtual指令调用,但是由于它无法被覆盖,所以无需进行多态选择(或者说多台选择的结果是唯一的),java语言规范中明确说明final方法是非虚方法。
3 分派(Dispatch)
分派调用可以是动态的,也可以是静态的;可以使单分派,也可以是多分派,所以存在四种组合:静态单分派,静态多分派,动态单分派,动态多分派。
1) 静态单分派
a) 示例代码
public class Human {
}
public class Man extends Human {
}
public class Woman extends Human {
}
public classStaticSingleDispatchTester {
public voidsayHello(Human human) {
System.out.println("human say hello!");
}
public voidsayHello(Man man) {
System.out.println("man say hello!");
}
public voidsayHello(Woman woman) {
System.out.println("woman say hello!");
}
public staticvoid main(String[] args){
StaticSingleDispatchTester tester= new StaticSingleDispatchTester();
Human human = new Man();
tester.sayHello(human);
}
}
运行结果:
human sayhello!
b) 分析
代码【Parent parent = new Child()】中,Parent称之为静态类型(或者外观类型),Child称之为实际类型。静态类型、实际类型在使用时都可能发生变化;区别是静态类型的变化仅仅发生在使用时,变量本身的静态类型不会被改变,并且静态类型在编译器可知;实际类型在运行期方可以确定。示例代码如下:
//实际类型变化
Human human = new Man();
human = new Man();
//静态类型变化
tester.sayHello((Man)human);
所有依赖静态类型来定位方法执行版本的分派称之为静态分派。静态分派发生在编译阶段。静态分派典型应用就是方法的重载。
2) 静态多分派
静态分派场景中,有些时候合适的版本不止一个,即多分派;此时静态类型只能根据语言上的规则去理解和推断。
示例代码:
public voidsayHello(char obj){
System.out.println("hello char!");
}
public voidsayHello(int obj){
System.out.println("hello int!");
}
public voidsayHello(long obj){
System.out.println("hello long!");
}
public staticvoid main(String[] args){
StaticMultiDispatchTester tester= new StaticMultiDispatchTester();
tester.sayHello('a');
}
依次注释掉sayHello(charobj),sayHello(int obj)方法,程序均能正常运行。
3) 动态分派
子类重写父类方法时,实际运行中根据实际类型,运行对应实例的方法。这种在java中非常常见(所有继承重写父类方法的均属于此范畴),所以示例略去。
在前面方法调用字节码指令中我们知道,我们将会通过invokevirtual指令进行相应的方法调用,此指令的解析过程如下:
Ø 查找操作数栈顶的第一个元素所执行的对象的实际类型,记作C。
Ø 如果在类型C中找到与常量池中的描述符和简单名称都相符的方法,则进行权限访问校验;如果通过则返回这个方法的直接引用;如果不通过则返回IllegalAccessError异常。
Ø 如果为找到,则按照继承关系从下往上依次进行查找(按照上一步的逻辑查找)。
Ø 如果没有找到合适的方法,抛出AbstractMethodError异常。
根据第二步的逻辑我们可以知道,如果是不同实例的实际类型不同,将会解析到不同的直接引用上。
4) 虚拟机动态分派的实现
由于动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此在虚拟机的实际实现中基于性能的考虑,大部分实现都不会真正的进行如此频繁的搜索。最常用的“稳定优化”手段是在类的方法区中建立一个虚方法表(Virtual Method Table,如果是接口的话则是接口坊发表InterfaceMethod Table),使用虚方法表来替代元数据查询以提高性能。虚方法表中存放着各个方法的实际入口地址。
a) 示例代码
public class Human {
public voidsayHello(){
System.out.println("hello human");
}
public voiddoSomething(){
System.err.println("do something");
}
}
public class Man extends Human {
@Override
public voidsayHello() {
System.err.println("hello man");
}
}
b) 虚方法表模型示例
以上示例代码的虚方法表示例如下。
如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中实现了这个方法,子类虚方法表中的地址将会替换为指向子类实现的地址。
为了程序实现的方便,具有相同签名的方法,在父类、子类的虚拟方发表中应该具有相同的索引序号,这样当类型变更时,仅需要变更查找的方发表,就可以从不同的虚拟方发表中按照索引转换出所需要的入口地址。