运行时栈帧结构
Java虚拟机以方法作为最基本的执行单元, “栈帧”(Stack Frame) 则是用于支持虚拟机进行方法调用和方法执行背后的数据结构, 它也是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack) [1]的栈元素。 栈帧存储了方法的局部变量表、 操作数栈、 动态连接和方法返回地址等信息。从Class文件格式的方法表中找到以上大多数概念的静态对照物。 每一个方法从调用开始至执行结束的过程, 都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。
局部变量表
局部变量表(Local Variables Table) 是一组变量值的存储空间, 用于存放方法参数和方法内部定义的局部变量。 在Java程序被编译为Class文件时, 就在方法的Code属性的max_locals数据项中确定了该方法所需分配的局部变量表的最大容量。
局部变量表的容量以变量槽(Variable Slot) 为最小单位,每个变量槽都应该能存放一个boolean、byte、 char、 short、 int、 float、 reference或returnAddress类型的数据, 而没有说一个变量槽应占用的内存空间大小。
操作数栈
- 操作数栈(Operand Stack) 也常被称为操作栈, 它是一个后入先出(Last In First Out, LIFO)栈。
- 同局部变量表一样, 操作数栈的最大深度也在编译的时候被写入到Code属性的max_stacks数据项之中。
- 32位数据类型所占的栈容量为1, 64位数据类型所占的栈容量为2。64位数据指的是long和double。
当一个方法刚刚开始执行的时候, 这个方法的操作数栈是空的, 在方法的执行过程中, 会有各种字节码指令往操作数栈中写入和提取内容, 也就是出栈和入栈操作。 譬如在做算术运算的时候是通过将运算涉及的操作数栈压入栈顶后调用运算指令来进行的, 又譬如在调用其他方法的时候是通过操作数栈来进行方法参数的传递。 举个例子, 例如整数加法的字节码指令iadd, 这条指令在运行的时候要求操作数栈中最接近栈顶的两个元素已经存入了两个int型的数值, 当执行这个指令时, 会把这两个int值出栈并相加, 然后将相加的结果重新入栈。
动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用, 持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking) 。
静态解析:Class文件的常量池中存有大量的符号引用, 字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数。 这些符号引用一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用, 这种转化被称为静态解析。
动态链接:另外一部分将在每一次运行期间都转化为直接引用, 这部分就称为动态连接。
方法返回地址
- 当一个方法开始执行后, 只有两种方式退出这个方法。
- 第一种方式是执行引擎遇到任意一个方法返回的字节码指令, 这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者或者主调方法) , 方法是否有返回值以及返回值的类型将根据遇到何种方法返回指令来决定, 这种退出方法的方式称为“正常调用完成”(Normal Method Invocation Completion) 。
- 另外一种退出方式是在方法执行的过程中遇到了异常, 并且这个异常没有在方法体内得到妥善处理。 无论是Java虚拟机内部产生的异常, 还是代码中使用athrow字节码指令产生的异常, 只要在本方法的异常表中没有搜索到匹配的异常处理器, 就会导致方法退出, 这种退出方法的方式称为“异常调用完成(Abrupt Method Invocation Completion) ”。 一个方法使用异常完成出口的方式退出, 是不会给它的上层调用者提供任何返回值的。
- 无论采用何种退出方式, 在方法退出之后, 都必须返回到最初方法被调用时的位置, 程序才能继续执行, 方法返回时可能需要在栈帧中保存一些信息, 用来帮助恢复它的上层主调方法的执行状态。一般来说, 方法正常退出时, 主调方法的PC计数器的值就可以作为返回地址, 栈帧中很可能会保存这个计数器值。 而方法异常退出时, 返回地址是要通过异常处理器表来确定的, 栈帧中就一般不会保存
这部分信息。 - 方法退出的过程实际上等同于把当前栈帧出栈, 因此退出时可能执行的操作有: 恢复上层方法的局部变量表和操作数栈, 把返回值(如果有的话) 压入调用者栈帧的操作数栈中, 调整PC计数器的值以指向方法调用指令后面的一条指令等。 笔者这里写的“可能”是由于这是基于概念模型的讨论, 只有具体到某一款Java虚拟机实现, 会执行哪些操作才能确定下来。
简略分析执行过程
public class Test {
public static void main(String[] args) {
int c = add(3,5);
System.out.println(c);
}
public static int add(int a, int b){
int c=2;
c=a+b+c;
return c;
}
}
public static int add(int, int);
descriptor: (II)I // 描述符:入参是两个int,返回值是int
flags: (0x0009) ACC_PUBLIC, ACC_STATIC // 访问标记:public static
Code:
stack=2, locals=3, args_size=2 // 最大栈的深度为2,最大本地变量的数量为3,入参数量为2
// 方法调用开始会将入参放到局部变量表中,即将a放到局部变量表第0个槽位,将b放入第2个槽位
0: iconst_2 // 将常量2压入栈中
1: istore_2 // 将栈顶的2存入到局部变量表的槽位2中,(槽位从0开始,即第3个)
2: iload_0 // 将局部变量表的槽位0的数据加载到栈中(a入栈)
3: iload_1 // 将局部变量表的槽位1的数据加载到栈中(b入栈)
4: iadd // 将栈顶的两个元素相加,并压入栈中(b,a出栈,相加后入栈)
5: iload_2 // 将局部变量表的槽位2的数据加载到栈中(c入栈)
6: iadd // 将栈顶的两个元素相加,并压入栈中(c,b+a出栈,相加后入栈)
7: istore_2 // 将栈顶的元素存到局部变量表的第2个槽位中(c=c+a+b)
8: iload_2 // 将局部变量表的槽位2的数据加载到栈中(c入栈)
9: ireturn // 方法返回
LineNumberTable:
line 11: 0
line 12: 2
line 13: 8
LocalVariableTable:
Start Length Slot Name Signature
0 10 0 a I
0 10 1 b I
2 8 2 c I
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=1
0: iconst_3
1: iconst_5
2: invokestatic #2 // Method add:(II)I
5: istore_1
6: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
9: iload_1
10: invokevirtual #4 // Method java/io/PrintStream.println:(I)V
13: return
LineNumberTable:
line 6: 0
line 7: 6
line 8: 13
LocalVariableTable:
Start Length Slot Name Signature
0 14 0 args [Ljava/lang/String;
6 8 1 c I
方法调用
方法调用并不等同于方法中的代码被执行, 方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法) , 暂时还未涉及方法内部的具体运行过程。 Class文件的编译过程中不包含传统程序语言编译的连接步骤, 一切方法调用在Class文件里面存储的都只是符号引用, 而不是方法在实际运行时内存布局中的入口地址(也就是之前说的直接引用) 。 这个特性给Java带来了更强大的动态扩展能力, 但也使得Java方法调用过程变得相对复杂, 某些调用需要在类加载期间, 甚至到运行期间才能确定目标方法的直接引用。
解析
方法调用的目标方法在Class文件里面都是一个常量池中的符号引用, 在类加载的解析阶段, 会将其中的一部分符号引用转化为直接引用, 这种解析能够成立的前提是: 方法在程序真正运行之前就有一个可确定的调用版本, 并且这个方法的调用版本在运行期是不可改变的。 换句话说, 调用目标在程序代码写好、 编译器进行编译那一刻就已经确定下来。 这类方法的调用被称为解析(Resolution) 。
在Java语言中符合“编译期可知, 运行期不可变”这个要求的方法, 主要有静态方法和私有方法两大类, 前者与类型直接关联, 后者在外部不可被访问, 这两种方法各自的特点决定了它们都不可能通过继承或别的方式重写出其他版本, 因此它们都适合在类加载阶段进行解析。
5中方法调用指令:
- invokestatic。 用于调用静态方法。
- invokespecial。 用于调用实例构造器()方法、 私有方法和父类中的方法。
- invokevirtual。 用于调用所有的虚方法。
- invokeinterface。 用于调用接口方法, 会在运行时再确定一个实现该接口的对象。
- invokedynamic。 先在运行时动态解析出调用点限定符所引用的方法, 然后再执行该方法。 前面4条调用指令, 分派逻辑都固化在Java虚拟机内部, 而invokedynamic指令的分派逻辑是由用户设定的引导方法来决定的。
只要能被invokestatic和invokespecial指令调用的方法, 都可以在解析阶段中确定唯一的调用版本,Java语言里符合这个条件的方法共有静态方法、 私有方法、 实例构造器、 父类方法4种, 再加上被final修饰的方法(尽管它使用invokevirtual指令调用) , 这5种方法调用会在类加载的时候就可以把符号引用解析为该方法的直接引用。 这些方法统称为“非虚方法”(Non-Virtual Method) , 与之相反, 其他方法就被称为“虚方法”(Virtual Method) 。
总结
- 可以被静态解析的方法,即类加载的连接的解析阶段可以将被调用的方法唯一确定,可以将符号引用解析为直接引用,种类有静态方法,实例构造器,私有方法,父类方法?,final方法,这些方法称为非虚方法。
- 其他的方法称为虚方法。虚方法需要在运行时才能确定调用的方法。这种称为动态分派。
- 方法的直接引用,即方法区中对应的方法表的内存地址(或者是可以找到内存地址的标记),对应信息主要有访问标记,方法描述符,指令等。
- 方法调用的过程就是找到方法指令的内存地址,并执行指令,当然还包括很多过程。
分派
静态分派
重载方法在解析阶段的确定的分派。也可以将它归为解析。
public class StaticDispatch {
static abstract class Human {
}
static class Man extends Human {
}
static class Woman extends Human {
}
public void sayHello(Human guy) {
System.out.println("hello,guy!");
}
public void sayHello(Man guy) {
System.out.println("hello,gentleman!");
}
public void sayHello(Woman guy) {
System.out.println("hello,lady!");
}
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
StaticDispatch sr = new StaticDispatch();
sr.sayHello(man);
sr.sayHello(woman);
}
}
sr.sayHello(man);
sr.sayHello(woman);
都会分派为public void sayHello(Human guy)方法
动态分派
重写方法的解析
public class DynamicDispatch {
static abstract class Human {
protected abstract void sayHello();
}
static class Man extends Human {
@Override
protected void sayHello() {
System.out.println("man say hello");
}
}
static class Woman extends Human {
@Override
protected void sayHello() {
System.out.println("woman say hello");
}
}
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
man.sayHello();
woman.sayHello();
man = new Woman();
man.sayHello();
}
}
public static void main(java.lang.String[]);
Code:
Stack=2, Locals=3, Args_size=1
0: new #16; //class org/fenixsoft/polymorphic/DynamicDispatch$Man
3: dup
4: invokespecial #18; //Method org/fenixsoft/polymorphic/Dynamic Dispatch$Man."<init>":()V
7: astore_1
8: new #19; //class org/fenixsoft/polymorphic/DynamicDispatch$Woman
11: dup
12: invokespecial #21; //Method org/fenixsoft/polymorphic/DynamicDispatch$Woman."<init>":()V
15: astore_2
16: aload_1
17: invokevirtual #22; //Method org/fenixsoft/polymorphic/Dynamic Dispatch$Human.sayHello:()V
20: aload_2
21: invokevirtual #22; //Method org/fenixsoft/polymorphic/Dynamic Dispatch$Human.sayHello:()V
24: new #19; //class org/fenixsoft/polymorphic/DynamicDispatch$Woman
27: dup
28: invokespecial #21; //Method org/fenixsoft/polymorphic/DynamicDispatch$Woman."<init>":()V
31: astore_1
32: aload_1
33: invokevirtual #22; //Method org/fenixsoft/polymorphic/Dynamic Dispatch$Human.sayHello:()V
36: return
0~15行的字节码是准备动作, 作用是建立man和woman的内存空间、 调用Man和Woman类型的实例构造器, 将这两个实例的引用存放在第1、 2个局部变量表的变量槽中, 这些动作实际对应了Java源码中的这两行:
Human man = new Man();
Human woman = new Woman();
17行,invokevirtual指令的参数是声明的类型:Human的方法符号引用,而不是运行时类型:Man的方法的符号引用。
invokevirtual指令的运行时解析过程大致分为以下几步:
1) 找到操作数栈顶的第一个元素所指向的对象的实际类型, 记作C。
2) 如果在类型C中找到与常量中的描述符和简单名称都相符的方法, 则进行访问权限校验, 如果通过则返回这个方法的直接引用, 查找过程结束; 不通过则返回java.lang.IllegalAccessError异常。
3) 否则, 按照继承关系从下往上依次对C的各个父类进行第二步的搜索和验证过程。
4) 如果始终没有找到合适的方法, 则抛出java.lang.AbstractMethodError异常。
字段没有多态
public class FieldHasNoPolymorphic {
static class Father {
public int money = 1;
public Father() {
money = 2;
showMeTheMoney();
}
public void showMeTheMoney() {
System.out.println("I am Father, i have $" + money);
}
}
static class Son extends Father {
public int money = 3;
public Son() {
money = 4;
showMeTheMoney();
}
public void showMeTheMoney() {
System.out.println("I am Son, i have $" + money);
}
}
public static void main(String[] args) {
Father gay = new Son();
System.out.println("This gay has $" + gay.money);
}
}
程序输出结果为:
I am Son, i have $0
I am Son, i have $4
This gay has $2
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=2, args_size=1
0: new #2 // class com/spacedo/design_pattern/behavioral_pattern/mediator/FieldHasNoPolymorphic$Son
3: dup
4: invokespecial #3 // Method com/spacedo/design_pattern/behavioral_pattern/mediator/FieldHasNoPolymorphic$Son."<init>":()V
7: astore_1
8: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
11: new #5 // class java/lang/StringBuilder
14: dup
15: invokespecial #6 // Method java/lang/StringBuilder."<init>":()V
18: ldc #7 // String This gay has $
20: invokevirtual #8 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
23: aload_1
24: getfield #9 // Field com/spacedo/design_pattern/behavioral_pattern/mediator/FieldHasNoPolymorphic$Father.money:I
27: invokevirtual #10 // Method java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
30: invokevirtual #11 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
33: invokevirtual #12 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
36: return
LineNumberTable:
line 27: 0
line 28: 8
line 29: 36
LocalVariableTable:
Start Length Slot Name Signature
0 37 0 args [Ljava/lang/String;
8 29 1 gay Lcom/spacedo/design_pattern/behavioral_pattern/mediator/FieldHasNoPolymorphic$Father;
因为:new Son()调用Son的无参实例构造器,执行前会调用父类Father的无参构造器,父类无参构造器将money的值置为2,而showMeTheMoney()是虚方法调用,运行时是Son的实例,调用的是子类的showMeTheMoney(),打印的钱是子类的钱,子类money还没有复值,因此是0,回到子类构造器中,调用子类的showMeTheMoney(),打印出来是4,最后打印gay.money是直接访问父类的money,所以是2。印证了属性没有多态。
基于栈的字节码解释执行引擎
指令集
基于栈指令集:字节码指令流(class文件code)里面的指令大部分都是零地址指令, 它们依赖操作数栈进行工作。
基于寄存器的指令集:我们主流PC机中物理硬件直接支持的指令集架构, 这些指令依赖寄存器进行工作。
计算1+1,
基于栈指令集:
iconst_1 // 将常量1压入栈顶
iconst_1 // 将常量1压入栈顶
iadd // 将栈顶的两个值出栈并相加,再压入栈
istore_0 // 将栈顶的值放到局部变量表的第0个变量槽中
基于寄存器的指令集:
mov eax, 1 // mov指令把EAX寄存器的值设为1
add eax, 1 // add指令再把这个值加1, 结果就保存在EAX寄存器里面
基于栈的指令集主要优点是可移植, 因为寄存器由硬件直接提供, 程序直接依赖这些硬件寄存器则不可避免地要受到硬件的约束。 例如现在32位80x86体系的处理器能提供了8个32位的寄存器, 而ARMv6体系的处理器(在智能手机、 数码设备中相当流行的一种处理器) 则提供了30个32位的通用寄存器, 其中前16个在用户模式中可以使用。 如果使用栈架构的指令集, 用户程序不会直接用到这些寄存器, 那就可以由虚拟机实现来自行决定把一些访问最频繁的数据(程序计数器、 栈顶缓存等) 放到寄存器中以获取尽量好的性能, 这样实现起来也更简单一些。 栈架构的指令集还有一些其他的优点,如代码相对更加紧凑(字节码中每个字节就对应一条指令, 而多地址指令集中还需要存放参数) 、 编译器实现更加简单(不需要考虑空间分配的问题, 所需空间都在栈上操作) 等。
栈架构指令集的主要缺点是理论上执行速度相对来说会稍慢一些, 所有主流物理机的指令集都是寄存器架构[3]也从侧面印证了这点。 不过这里的执行速度是要局限在解释执行的状态下, 如果经过即时编译器输出成物理机上的汇编指令流, 那就与虚拟机采用哪种指令集架构没有什么关系了。
在解释执行时, 栈架构指令集的代码虽然紧凑, 但是完成相同功能所需的指令数量一般会比寄存器架构来得更多, 因为出栈、 入栈操作本身就产生了相当大量的指令。 更重要的是栈实现在内存中,频繁的栈访问也就意味着频繁的内存访问, 相对于处理器来说, 内存始终是执行速度的瓶颈。 尽管虚拟机可以采取栈顶缓存的优化方法, 把最常用的操作映射到寄存器中避免直接内存访问, 但这也只是优化措施而不是解决本质问题的方法。 因此由于指令数量和内存访问的原因, 导致了栈架构指令集的执行速度会相对慢上一点。
基于栈的解释器执行过程
下面举的例子仅仅是概念模型
public int calc() {
int a = 100;
int b = 200;
int c = 300;
return (a + b) * c;
}
public int calc();
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
}
这段代码需要深度为2的操作数栈和4个变量槽的局部变量空间
首先, 执行偏移地址为0的指令, Bipush指令的作用是将单字节的整型常量值(-128~127) 推入操作数栈顶, 跟随有一个参数, 指明推送的常量值, 这里是100。
执行偏移地址为2的指令, istore_1指令的作用是将操作数栈顶的整型值出栈并存放到第1个局部变量槽中。 后续4条指令(直到偏移为11的指令为止) 都是做一样的事情, 也就是在对应代码中把变量a、 b、 c赋值为100、 200、 300。 这4条指令的图示略过。
执行偏移地址为11的指令, iload_1指令的作用是将局部变量表第1个变量槽中的整型值复制到操作数栈顶。
执行偏移地址为12的指令, iload_2指令的执行过程与iload_1类似, 把第2个变量槽的整型值入栈。
画出这个指令的图示主要是为了显示下一条iadd指令执行前的堆栈状况。
执行偏移地址为13的指令, iadd指令的作用是将操作数栈中头两个栈顶元素出栈, 做整型加法,然后把结果重新入栈。 在iadd指令执行完毕后, 栈中原有的100和200被出栈, 它们的和300被重新入栈。
执行偏移地址为14的指令, iload_3指令把存放在第3个局部变量槽中的300入栈到操作数栈中。 这时操作数栈为两个整数300。 下一条指令imul是将操作数栈中头两个栈顶元素出栈, 做整型乘法, 然后把结果重新入栈, 与iadd完全类似, 所以笔者省略图示。
执行偏移地址为16的指令, ireturn指令是方法返回指令之一, 它将结束方法执行并将操作数栈顶的整型值返回给该方法的调用者。 到此为止, 这段方法执行结束。