概述
本章主要从概念模型的角度讲解虚拟机的方法调用和字节码执行。
一、运行时栈帧结构
1.1 运行时栈帧概述
作用:方法是Java虚拟机的最基本的执行单元,“栈帧”则是用于支持虚拟机进行方法调用和方法执行背后的数据结构。
**存储内容:**局部变量表、操作数栈、动态连接和方法返回地址等其他信息。
**注意事项:**在编译Java程序源码的时候,栈帧中需要多大的局部变量表,需要多深的操作数栈就已经被分析计算出来,并且写入到Code属性中。一个栈帧需要分配多少内存,并不会受到程序运行期变量数据的影响
1.2 局部变量表
作用
局部变量表是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。
最小单位
局部变量表的容量是以变量槽为最小单位的。《Java虚拟机规范》并没有明确指明一个变量槽应占用的内存空间大小,只是很有导向性地说到每个变量槽都应该能存放一个boolean、 byte、char、short、int、float、reference或returnAddress类型的数据。
上述的几种类型都可以用32位或者更小的空间来存储,如果要存储double或long时,就会同时使用第N和N+1两个变量槽。对于两个相邻的共同存放一个64位数据 的两个变量槽,虚拟机不允许采用任何方式单独访问其中的某一个。
存储顺序(如果调用的是实例方法)
0: this关键字
1~m: 方法参数
m + 1 ~ n: 方法内部局部变量
槽重用
为了节省栈帧用的内存空间,局部变量表中的变量槽是可以重用的。如果当前字节码PC计数器的值已经超出了某个变量的作用 域,那这个变量对应的变量槽就可以交给其他变量来重用。
1.3 操作数栈
作用
存储字节码指令要操作的内容。
Java虚拟机的解释执行引擎被称为“基于栈的执行引擎”,里面的“栈”就是操作数栈。在第四节中有更详细的讲解。
注意事项
操作数栈中元素的类型必须与字节码指令的序列严格匹配。以iadd指令为例,这个指令只能用于整型数的加法,它在执行时,最接近栈顶的两个元素的数据类型必须为int型, 不能出现一个long和一个float使用iadd命令相加的情况。
举例
整数加法的字节码指令iadd,这条指令在运行的时候要 求操作数栈中最接近栈顶的两个元素已经存入了两个int型的数值,当执行这个指令时,会把这两个int 值出栈并相加,然后将相加的结果重新入栈。
1.4 动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。
1.5 方法返回地址
在方法退出之后,都必须返回到最初方法被调用时的位置,程序才能继 续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层主调方法的执行状态。 一般来说,方法正常退出时,主调方法的PC计数器的值就可以作为返回地址,栈帧中很可能会保存这 个计数器值。
1.6 附加信息
《Java虚拟机规范》允许虚拟机实现增加一些规范里没有述的信息到栈帧之中,例如与调试、 性能收集相关的信息,这部分信息完全取决于具体的虚拟机实现,这里不再详述。在讨论概念时,一 般会把动态连接、方法返回地址与其他附加信息全部归为一类,称为栈帧信息。
二、方法调用
方法调用并不等同于方法中的代码被执行,方法调用阶段唯一的任务就是确定要调用的是哪个方法,暂时还未涉及到方法内部的具体运行过程。Class文件的编译过程不包含传统语言编译的连接步骤,一切方法调用在Class文件里存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(直接引用)。某些方法调用需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。
2.1 解析
定义
调用目标在程序代码写好、编译器进行编译那一刻就已经确定下来。这类方法的调用被称为解析(Resolution)。满足“编译器可知,运行期不可变”要求的方法,Java语言里有静态方法、私有方法、实例构造器、父类方法、final修饰的方法,这些方法称为“非虚方法”。
2.2 分派
2.2.1 静态分派
定义
**所有依赖静态类型来决定方法执行版本的分派动作,都称为静态分派。**静态分派的最典型应用表 现就是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行 的,这点也是为何一些资料选择把它归入“ 解析”而不是“ 分派”的原因。
代码解释
静态分派:
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);
}
}
//输出:
hello,guy!
hello,guy!
上述代码中,"Human"称为变量的“静态类型”或“外观类型”,后面的man/woman称为变量的“实际类型”或“运行时类型”。静态类型在编译期间可确定,而实际类型在运行期才确定。
虚拟机在重载时是通过参数的静态类型而不是实际类型作为判定依据的。由于静态类型在编译期可知,所以在编译阶段,Javac编译器就根据参数的静态类型决定 了会使用哪个重载版本,因此选择了sayHello(Human)作为调用目标,并把这个方法的符号引用写到 main()方法里的两条invokevirtual指令的参数中。
2.2.2 动态分派
定义
在程序运行期间才能确定要执行方法的版本,称为动态分派。动态分派的重要体现——重写
代码示例
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();
}
}
//输出
man say hello
woman say hello
woman say hello
通过看生成的字节码指令,发现几次调用都是指向了Human.sayHello()方法,但是程序实际执行的目标方法却不一样。所以要从invokevirtual指令入手来分析,invokevirtual指令的运行时解析过程大致分为一下几步:
- 找到操作数栈顶的第一个元素所指向的实际类型,记作C。
- 如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;不通过则返回ava.lang.IllegalAccessError异常。
- 否则,按照继承关系从下往上依次对C的各个父类进行第二部的搜索和验证过程。
- 如果始终没有找到合适的防范,就抛出java.lang.AbstractMethodError异常。
正是因为invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,**所以两次调用中的 invokevirtual指令并不是把常量池中方法的符号引用解析到直接引用上就结束了,还会根据方法接收者 的实际类型来选择方法版本,**这个过程就是Java语言中方法重写的本质。
三、动态类型语言支持
动态语言核心特点:“变量无类型而变量值才有类型”这个特点也是动态类型语言的一个核心特征。
略。目前看了一遍大致看懂了,但是因为平时没有做动态语言的实践、所以等到后面有做的时候再补充。
四、基于栈的解释执行引擎
4.1 解释执行
Java语言经常被人们定位成“解释执行”的语言,在JDK1.0时代,这种定义还算比较准确,但是后来但当主流的虚拟机中都包含了即时编译器后,Class文件中的代码到底会被解释执行还是编译 执行,就成了只有虚拟机自己才能准确判断的事。再后来,Java也发展出可以直接生成本地代码的编译器(如Jaotc、GCJ[1],Excelsior JET),这时候再笼统地说“解释执行”,对于整个Java语言来说就成了几乎是没有意义的概念,只有确定了谈论对象是某种具体的Java实现版本和执行引擎运行模式时,谈解释执行还是编译执行才会比较合理确切。
上图中,下面的分支是传统编译原理中程序代码到目标机器代码的生成过程;中间的分支是解释执行过程。
在Java语言中,Javac编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。因为这一部分动作是在Java虚拟机之外进行的,而解释器在虚拟机的内部,所以Java程序的编译时半独立的实现。
4.2 基于栈的指令集和基于寄存器的指令集
Javac编译器输出的字节码指令流,基本上是一种基于栈的指令集架构,字节码指令流里面的指令大部分都是零地址指令,它们依赖操作数栈进行工作。与之相对的另一套常用的指令集架构是基于寄存器的指令集。
分别是什么意思
举例:分别使用这两种指令集去计算“1 + 1”的结果
基于栈的指令集
iconst_1
iconst_1
iadd
istore_0
两条iconst_1指令连续把两个常量1压入栈中,iadd指令把栈顶的两个值出栈、相加,然后把结果放回栈顶,最后istore_0把栈顶的值放到局部变量表的第0个变量槽中。这种指令流中的指令通常都是不带参数的,使用操作数栈中的数据作为指令的运算输入,指令的运算结果也存储在操作数栈中。
基于寄存器的指令集
mov eax, 1
add eax, 1
mov指令把eax寄存器的值设为1,然后add指令再把这个值加1,结果就保存在eax寄存器里面。每个指令都包含两个单独的参数,依赖于寄存器来访问和存储数据。
优缺点
优点:
- 可移植。因为寄存器由硬件直接提供,程序直接依赖硬件会收到约束
- 代码相对更紧凑。字节码中每个字节就对应一条指令,而多地址指令集中还需要存放参数。
- 编译器实现更加简单。不需要考虑空间分配的问题,所需空间都在栈上操作。
缺点:
- 执行慢。栈的实现是在内存中,频繁的栈访问也就意味着频繁的内存访问,而内存始终是执行速度的瓶颈。尽管虚拟机可以采取栈顶缓存的优化方法,把最常用的操作映射到寄存器中避免直接内存访问,但这也只是优化措施而不是解决本质问题的方法。