1.前言
(借鉴了周志明老师的《深入理解Java虚拟机》,不详之处还请亲阅此书)
执行引擎是虚拟机中最核心部分之一,虚拟机是相对于物理机而言的,只不过物理机的执行能力是建立在处理器、硬件、指令集等等层面上的。而虚拟机都是由自己实现的。所有虚拟机都是输入字节码文件,处理过程是字节码解析过程,输出是执行结果。接下来主要讲一下虚拟机的方法调用和字节码执行。
2.运行时栈帧结构
栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中虚拟机栈的栈元素。
栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。
在编译过程中,局部变量表的大小已经确定,操作数栈深度也已经确定,因此栈帧在运行的过程中需要分配多大的内存是固定的,不受运行时影响。
对于执行引擎来讲,活动线程中,只有栈顶的栈帧是最有效的,称为当前栈帧,这个栈帧所关联的方法称为当前方法。执行引擎所运行的字节码指令仅对当前栈帧进行操作。
2.1 局部变量表
局部变量表 是一组变量值存储空间, 用于存放方法参数和方法内部定义的局部变量. 在Java程序编译为Class文件时, 就在方法的Code属性的max_loacls数据项中确定了该方法所需要分配的局部变量表的最大容量
局部变量表的存储单位是槽(Slot),slot复用,当一个变量的pc寄存器的值大于slot的作用域的时候,slot是可以复用的。下面通过代码解释一下slot复用对垃圾回收的影响。
作用域对垃圾回收的影响
public static void main(String[] args) {
byte[] buffer = new byte[64 * 1024 * 1024];
System.gc();
}
public static void main(String[] args) {
{
byte[] buffer = new byte[64 * 1024 * 1024];
}
System.gc();
}
看这两段代码,第二段代码的buffer已经超出作用域,本该被回收,但是却没有被回收,这是因为在修改后的代码虽然离开了buffer的作用域,但是buffer原本占用的Slot还没有被其他变量复用,局部变量表没有更新,所以不会被回收。
public static void main(String[] args) {
{
byte[] buffer = new byte[64 * 1024 * 1024];
}
int a = 0;
System.gc();
}
这样就会被回收了
2.2 操作数栈
同局部变量表一样,是LIFO的栈结构。在编译的时候就确定了操作数栈的深度,这个栈中存放的是JAVA的基本类型。
当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法执行过程中会有各种字节码指令往操作数栈写入和读取,例如整数加法字节码指令 iadd 在运行的时候操作数栈中最接近栈顶的两个元素已经存入了两个int型数值,当执行这个命令时,会将这两个 int 值 出栈并相加然后结果入栈。
两个栈帧之间是完全独立的,但是大多数虚拟机都会做一些优化处理,令两个栈帧之间出现一部分重叠部分,让下面栈帧部分操作数栈与上面栈帧的部分局部变量表重叠,这样在进行方法调用的时候就不用进行额外的参数复制传递。
虚拟机的执行引擎是基于栈的执行引擎,这里的栈就是指的操作数栈。
2.3 动态连接
每个栈帧中都有一个指向运行时常量池中该栈帧所属方法的引用。这个引用主要是为了支持方法调用过程中的动态链接。我们知道Class文件中存在大量符号引用,引用又分成静态引用和动态引用。
- 静态引用:符号引用在类初始化阶段或者第一次使用的时候就转换成直接饮用。
- 动态引用:符号在每一次运行期间转换成直接引用。
2.4 方法返回地址
当一个方法开始执行后, 只有两种方式可以退出这个方法, 第一种方式是执行引擎遇到任意一个方法返回的字节码执行, 这时候可能会有返回值传递给上层方法的调用者, 是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定, 这种退出方法的方式称为正常完成出口
另外一种退出方式是, 在方法执行过程中遇到了异常, 并且这个异常没有在方法体内得到处理, 这种退出方法的方式称为异常完成出口
无论采取何种退出方式, 在方法退出后, 都需要返回到方法被调用的位置, 程序才能继续执行, 方法返回时可能需要在栈帧中保存一些信息, 用来恢复它的上层方法的执行状态. 一般来说, 方法正常退出时, 调用者的PC计数器的值可以作为返回地址, 栈帧中很可能会保存这个计数器值.
方法退出过程实际上就是把当前栈帧出栈, 因此退出时的可能操作有 : 恢复上层方法的局部变量表和操作数栈, 把返回值亚茹调用者栈帧的操作数栈中, 调整PC计数器的值以指向方法调用后面一条指令等
3.方法调用
方法调用并不是方法执行,方法调用其实是确定接下来要执行的方法是哪一个方法,尚未涉及方法内部。
原因:Class文件的编译过程不包含传统编译中的连接过程,一切方法调用在Class文件里存储的都只是符号引用, 而不是方法在实际运行时内存布局的入口地址(直接引用). 这个特性给Java带来了更强大的扩展能力, 但也使Java方法调用过程变得复杂起来, 需要在类加载期间, 甚至到运行期间才能确定目标方法的直接引用
3.1 解析
所有方法调用的目标方法在Class文件中都只是常量池中的一个符号引用。在解析的过程中,这些符号引用会有一部分转换成直接引用。
Java语言中符合"编译期可知,运行期不变"这个要求的方法, 主要包括静态方法和私有方法两种, 前者与类型直接关联, 后者在外部不可访问, 这两种方法各自的特点都决定了它们不可能通过继承或别的方式重写其他版本, 因此它们都适合在类加载阶段进行解析
- invokestatic : 调用静态方法
- invokespecial : 调用实例构造器 方法, 私有方法和父类方法
- invokevirtual : 调用所有的虚方法
- invokeinterface : 调用接口方法, 会在运行时再确定一个实现此接口的对象
- invokedynamic : 现在运行时动态解析出调用点限定符所引用的方法, 然后再执行该方法. 前面的4条调用指令, 分派逻辑是固化在Java虚拟机内部的, 而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的
只要能被invokestatic和invokespecial方法调用的方法都可以在解析的时候确定其唯一的调用版本。符合这个条件的有静态方法、私有方法、父类方法、构造器方法。
3.2 分派
分派可以分为 静态分派和动态分派,也可以分成静态多分派、静态单分派和动态多分派、静态单分派。
- 1.静态分派
静态分派的典型应用就是方法重载。
一起看一个例子
public class Test{
static class Human{
}
static class Man extends Human{
}
static class Woman extends Human{
}
public static void testMethod(Human human){
System.out.println("a human");
}
public static void testMethod(Man man){
System.out.println("a man");
}
public static void testMethod(Woman woman){
System.out.println("a woman");
}
public static void main(String[] args) {
Human human= new Human ();
Human man= new Man();
Human woman= new Woman();
testMethod(man);
testMethod(woman);
}
}
Human man = new Man();
我们先了解两个概念,上式中的 Human 我们称为静态类型,后面的 Man 称为实际类型
在程序编译的过程中,编译器是根据静类型作为判断的类型而不是实际类型,因为静态类型是编译的阶段就可知的类型,而实际类型是在运行期可知,所以javac编译器会根据静态类型决定使用哪个重载方法的版本。
所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派发生在编译阶段,所以静态分派并不是由虚拟机实现的。
还有一种是字面量没有静态类型的
例如
public class Test{
public static void sayHello(int a){
System.out.println("Hello int");
}
public static void sayHello(long a){
System.out.println("Hello long");
}
public static void sayHello(char a){
System.out.println("Hello char");
}
public static void sayHello(Object a){
System.out.println("Hello Object");
}
public static void sayHello(Character a){
System.out.println("Hello Character");
}
public static void sayHello(Serializable a){
System.out.println("Hello Serializable");
}
public static void sayHello(char... a){
System.out.println("Hello char...");
}
public static void main(String[] args) {
sayHello('a');
}
}
依次注释char 、int 、long 、 Character 、 Serializable 、Object 会依次打印int 、long 、 Character 、 Serializable 、Object 、char… 的相关信息,这就涉及到当没有具体静态类型的时候,他会自动匹配一个最合适的方法来实现。这里涉及到优先级和转换安全的知识以这个部分为例。char >> int >>long >>Character>> Serializable>>Object>>char…
- 2.动态分派
静态分派的典型应用就是方法重写。
看一下例子
public class test {
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();
}
}
run
从代码中分析可以很明显的确定这里已经不再是根据静态类型来选取的了,原因是他们拥有同一个静态类型,却得到了两个不同的输出结果。导致这个现象的原因很明显实际变量不同。
确定调用那个方法版本会用到invokevirtual指令,分为以下几个步骤:
- 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。
- 如果在类型C中找到与常量中的描述符和简单名称相符合的方法,然后进行访问权限验证,如果验证通过则返回这个方法的直接引用,查找过程结束;如果验证不通过,则抛出java.lang.IllegalAccessError异常。
- 否则未找到,就按照继承关系从下往上依次对类型C的各个父类进行第2步的搜索和验证过程。
- 如果始终没有找到合适的方法,则跑出java.lang.AbstractMethodError异常。
这个过程就是Java语言方法重写的本质, 把这种在运行期根据实际类型确定方法执行版本的过程称为动态分派
- 3.单分派与多分派
方法的接受者与方法的参数统称为方法的宗量. 根据分派基于多少宗量, 可以把分派划分为单分派和多分派两种.单分派是根据一个宗量对目标方法进行选择, 多分派则是根据多个宗量对目标方法进行选择
Father father = new Father();
Father son = new Son();
father.hardChoice(new _360());
son.hardChoice(new QQ());
编译阶段 : 静态分派. 这时选择目标方法的依据有两点. 一是静态类型是Father还是Son, 二是方法参数是 QQ还是 360. 这次选择的结果是产生了两组invokevirtual指令, 两条指令的参数分别为常量池中指向Father.hardChoice(360)以及Father.hardChoice(QQ)方法的符号引用. 因为是根据多个宗量进行选择, 所以Java语言的静态分派属于多分派类型
运行阶段 : 动态分派. 在执行 son.hardChoice(new QQ())这句代码对应的 invokevirtual指令时, 由于编译期已经决定目标方法的签名是hardChoice(QQ). 虚拟机这时不会关心传递过来的参数, 因为这时参数的静态类型, 实际类型都对方法的选择不造成影响, 唯一可以影响虚拟机选择的因素只有此方法的接受者的实际类型是Father还是Son. 因为只有一个宗量作为选择依据, 所以Java语言的动态分派属于单分派类型
- 4.虚拟机动态分派的实现
由于动态分派是非常频繁的动作, 而且动态分派的方法版本选择过程需要运行在累的方法元数据中搜索合适的目标方法, 因此在虚拟机的实际实现中基于性能的考虑, 大部分 不会进行如此频繁地 搜索. 最常用的"稳定优化"手段就是为类在方法区建立一个虚方法表, 与此对应的, 在invokeinterface执行的时候也会用到接口方法表.
虚方法表中存放着各个方法的实际入口地址, 如果某个方法在子类中没有重写, 那子类的虚方法表里的地址入口就和父类相同方法的入口地址是一样的, 都指向父类的实现入口. 如果子类中重写了这个方法, 子类方法表中的地址会替换为指向子类实现版本的入口地址