引:我们知道方法的代码的字节码是放在方法区的Code属性表里面,但是里面的字节码是怎么执行我们却不知道,这里通过理解虚拟机字节码执行引擎去看看这些字节码是怎么走的?
概述
物理机与虚拟机
- 物理机的执行引擎:直接建立在处理器、硬件、指令集、和操作系统层面上。
- 虚拟机的执行引擎:自己实现,可以自行制定指令集与执行引擎的结构体系,并且能够执行那些不被硬件直接支持的指令集格式。
虚拟机怎么执行代码
- 解释执行:通过解释器执行
- 编译执行:通过即时编译器产生本地代码执行
运行时栈帧结构
栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,他是虚拟机运行时数据区中的虚拟机栈的栈元素,栈帧存储了方法的局部变量表、操作数栈、动态链接和方法返回地址等信息。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。
一个线程的方法的调用链可能会很长,很多方法同时处于执行状态。对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧,与这个栈帧相关联的方法称为当前方法,执行引擎运行的字节码指令都只针对当前栈帧进行操作,在概念模型上,典型的栈帧结构如下图:
接下来具体解释一下栈帧中的局部变量表、操作数栈、动态链接以及方法调用。
局部变量表
- 局部变量表示一组变量值存储空间,用于存放方法和方法内部定义的局部变量。
- 局部变量表的容量以变量槽Slot为单位。虚拟机规范说每个Slot都应该存放一个boolean、byte、char、int、float、reference或returnAddress类型的数据。long和double使用两个Slot。
- reference类型在虚拟机中至少要实现能从该引用中直接或间接地查找到对象在Java堆中的数据存放的起始地址索引以及从该引用直接或者间接查找到对象所属数据类型在方法区中存储的类型信息。
- 虚拟机通过索引定位的方式使用局部变量表,如果执行的实例方法,那局部变量表中第0位索引的Slot默认是用于传递方法所属对象实例的引用,可以使用“this”来访问,其余参数按照参数表顺序排列。
局部变量表中的Slot是可以重用的,当某个变量除了它的作用域,那这个变量所对应的Slot就可以复用了,Slot的复用会直接影响到系统的垃圾收集行为,如下代码所示:
1
2
3
4
5
6
7
8
9
10
11
12// 例1
// VM args : -verbose:gc
public class StackFrameTest {
public static void main(String[] args) {
byte[] placeholder = new byte[64 * 1024 * 1024];
System.gc();
}
}输出结果:
1
2[GC (System.gc()) 68209K->66072K(125952K), 0.0019584 secs]
[Full GC (System.gc()) 66072K->65962K(125952K), 0.0069900 secs]1
2
3
4
5
6
7
8
9
10
11
12// 例2
// VM args : -verbose:gc
public class StackFrameTest {
public static void main(String[] args) {
{
byte[] placeholder = new byte[64 * 1024 * 1024];
}
System.gc();
}
}输出结果
1
2[GC (System.gc()) 68875K->66072K(125952K), 0.0010138 secs]
[Full GC (System.gc()) 66072K->65962K(125952K), 0.0069811 secs]1
2
3
4
5
6
7
8
9
10
11
12
13// 例3
// VM args : -verbose:gc
public class StackFrameTest {
public static void main(String[] args) {
{
byte[] placeholder = new byte[64 * 1024 * 1024];
}
int a = 0;
System.gc();
}
}输出结果
1
2[GC (System.gc()) 68875K->66072K(125952K), 0.0016694 secs]
[Full GC (System.gc()) 66072K->426K(125952K), 0.0054452 secs]placeholde能否被回收的根本原因是:局部变量的Slot是否还存有关于placeholder数组对象的引用。例1没有垃圾回收,例2代码虽然已经离开了placeholder的作用域,但是之后没有任何对局部变量表的读写操作,所有placeholder原来的Slot还没有被其他变量复用,所以GC Roots一部分的局部变量表仍然保持着对它的关联,当例3修改了局部变量表,那么就会进行垃圾回收了。
这里解释一个编码建议:不使用的对象应手动赋值null
我们看看下面的代码:
1
2
3
4
5
6
7
8
9
10
11
12// 例1
// VM args : -verbose:gc
public class StackFrameTest {
public static void main(String[] args) {
byte[] placeholder = new byte[64 * 1024 * 1024];
placeholder = null;
System.gc();
}
}输出结果:
1
2[GC (System.gc()) 68875K->66072K(125952K), 0.0016915 secs]
[Full GC (System.gc()) 66072K->426K(125952K), 0.0058140 secs]我们可以看到当placeholder赋值为null,会发生垃圾回收。
局部变量表没有赋初始值不能使用。
操作数栈
- 操作数栈常被称为操作栈,一个方法刚开始执行的时候是空的,在方法执行的过程中,会有各种字节码指令往操作数栈中写入和提取内容,例如,在做算术运算的时候是通过操作数栈来 进行的,又或者是在调用方法的时候通过操作数栈来进行参数传递的。
在概念模型中,两个栈帧是完全独立的,但在大多虚拟机的实现里会做一些优化处理,令两个栈帧的部分操作数出现一部分重叠,避免方法调用时额外的参数复制,如下图:
Java虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中的栈就是操作数栈。
动态链接
- 每个栈帧都包含一个执行运行时常量池中该栈帧所属方法的引用,持有这个引用就是为了支持方法调用过程中的动态链接。
- Class文件的常量池存有大量的符号引用,一部分是在类加载中的解析阶段完成的,称为静态解析,另一部分是在每一次运行期间转化为直接引用,称为动态链接。
方法返回地址
当一个方法开始执行后,只有两种方式退出这个方法。
- 正常完成出口:执行引擎遇到一个方法返回的字节码指令,调用者的PC计数器的值可以作为返回地址
- 异常完成出口:在方法执行过程中遇到异常,并且这个异常没有在方法体得到处理,它不会给调用者产生任何返回值,它的返回值是要通过异常处理器表来确定的。
方法调用
方法调用不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本。Class文件的编译过程不包含传统编译找那个的链接步骤,所以Java方法调用过程需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。
解析
解析: 方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用在运行期是不可变的,这类方法的调用成为解析。
所有方法可以分为两类虚方法和非虚方法,其中非虚方法都可以在类加载的时候就会把符号引用解析为该方法的直接引用。
- 非虚方法:静态方法、私有方法、实例构造器、父类方法和final方法
- 虚方法:与非虚方法相反
解析调用一定是个静态的过程,在编译器就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期再去完成。而分派调用则可能是静态也可能是动态的,根据分派的宗数量(方法的接收者与方法的参数统称为方法的总量)可分为单分派和多分派,这两种分派方式地组合可分为静态单分派、静态多分派、动态单分派、动态多分派。
分派
静态分派
1 | Human man = new Man(); |
上面代码中Human称为变量的静态类型,或者叫做外观类型,后面的Man称为实际类型,静态类型是编译器可知,实际类型是运行期才能确定的。虚拟机在重载时是通过参数的静态类型而不是实际类型来作为判断依据的。
所有依赖静态类型来定位方法执行版本的分派动作称为静态分派,静态分派的典型应用是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的,另外,编译器虽然能确定出方法的重载版本,当在很多情况下这个重载版本不是“唯一的”,往往只能确定一个“更加合适的”版本,具体例子可以看看《深入理解Java虚拟机》。
动态分派
在运行期根据实际类型确定方法执行版本的分派过程称为动态分派,典型应用是方法重写。它主要是通过invokevirturl指令来实现的。invokevirtual指令的运行时解析过程大致分为如下几个步骤:
- 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。
- 如果在类型C中找到与常量中的描述符合简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回java.lang.IllegalAccessError异常。
- 否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程。
- 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。
单分派和多分派
单分派是根据一个宗量对目标方法进行选择,多分派则是根据一个宗量对目标方法进行选择。我们结合下面的代码来理解:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34public class Dispatch {
static class QQ {}
static class _360 {}
public static class Father {
public void hardChoice(QQ arg) {
System.out.println("father choose qq");
}
public void hardChoice(_360 arg) {
System.out.println("father choose _360");
}
}
public static class Son extends Father {
public void hardChoice(QQ arg) {
System.out.println("son choose qq");
}
public void hardChoice(_360 arg) {
System.out.println("son choose _360");
}
}
public static void main(String[] args) {
Father father = new Father();
Father son = new Son();
father.hardChoice(new _360());
son.hardChoice(new QQ());
}
}
输出结果:1
2father choose _360
son choose qq
- 编译阶段编译器的选择过程(静态分派过程):这时选择目标方法的依据有两点:一是静态类型是Father还是Son,二是方法参数是QQ还是360。因为是根据两个宗量进行选择,所有Java语言的静态分派属于多分派类型。
- 运行阶段虚拟机的选择过程(动态分派过程):由于编译器已经已经决定目标方法的签名必须为hardChoice(QQ),所以这时参数的静态类型,实际类型都对方法的选择不会构成影响,唯一可以影响虚拟机选择的因素只有此方法的接受者的实际类型是Father还是Son,因为只有一个宗量作为选择依据,所以Java语言的动态分派属于单分派类型。
- jdk1.8之前的Java语言是一门静态多分派,动态单分派的语言。
虚拟机动态分派的实现
由于动态分派是非常频繁的操作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此虚拟机会进行优化。常用的方法就是为类在方法区中建立一个虚方法表(Virtual Method Table,在invokeinterface执行时也会用到接口方法表,Interface Method Table),使用虚方法表索引来替代元数据查找以提升性能。下图就是前面代码的虚方法表结构:
虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类重写了父类的方法,子类方法表中的地址会替换为指向子类实现版本的入口地址。在上图中,Son重写了Father的全部方法,所以Son的方法表替换了父类的地址。但是Son和Father都没有重写Object的方法,所以方法表都指向了Object的数据类型。
为了程序实现上的方便,具有相同签名的方法,在父类和子类的虚方法表中都应该具有一样的索引号,这样当类型变换时,仅仅需要变更查找的方法表,就可以从不同的虚方法表中按索引转换出所需的入口地址。
方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的方法表也初始化完毕。
动态类型语言支持
动态类型语言的关键特征是它的类型检查的主体过程是在运行期间而不是编译期间,可以理解为变量无类型而变量值才有类型,Javascript就是这样的语言,而目前Java属于静态类型语言。当然Java也有它实现动态性的方法,这块知识点蛮大,就不展开说了。
基于栈的字节码解释执行引擎
解释执行
先看看编译过程流图:
- 上图最下面的那一条就是传统编译原理中程序代码到目标机器代码生成过程(C)
- 上图中间那一条就是解释执行的过程(Java)
基于栈的指令集与基于寄存器的指令集
- 基于栈的指令集:指令流中的大部分都是零地址指令(无显示参数),他们依赖操作数栈进行工作。
- 优点:可移植性,代码紧凑,编译器实现简单
- 缺点:执行速度稍慢(频繁访问内存)
- 基于寄存器的指令集:指令集依赖寄存器进行工作
基于栈的解释器执行流程
其实就是按照指令解释执行,随便看了例子应该就能明白。
总结
其实这里谈的主要还是解释执行,其中有一点很重要就是分派的概念,明白invokevirtual指令的执行过程。
参考
- 《深入理解Java虚拟机》
- 虚拟机字节码执行引擎