下面就要讲代码到底是怎么执行的。在讲源码之前,我们看看从流程角度到底是怎么运行的。
执行引擎的概述
执行引擎是 Java 虚拟机核心的组成部分之一。
“虚拟机”是一个相对于“物理机”的概念,这两种机器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器、缓存、指令集和操作系统层面上的,而虚拟机的执行引擎则是由软件自行实现的,因此可以不受物理条件制约地定制指令集与执行引擎的结构关系,能够执行那些不被硬件直接支持的指令集格式。
JVM 的主要任务是负责装载字节码到其内部,但字节码并不能够直接运行在操作系统之上,因为字节码指令并非等价于本地机器指令,它内部包含的仅仅只是一些能够被 JVM 所识别的字节码指令、符号表,以及其他的辅助信息。
那么,如果想让一个 java 程序运行起来,执行引擎(Execution Engine) 的任务就是将字节码指令解释/编译为对应平台上的本地机器指令才可以。简单来说,JVM 中的执行引擎充当了将高级语言翻译为机器语言的译者。
执行引擎在执行的过程中究竟需要执行什么样子的字节码指令完全依赖于 PC 寄存器。
每当执行一项指令操作以后,PC 寄存器就会更新下一条需要被执行的指令地址。
当然方法在执行的过程中,执行引擎有可能会通过存储在局部变量表中的对象引用准确定位到存储在 Java 堆区中的对象实例信息,以及通过对象头中的元数据指针定位到目标对象的类型信息。
从外观上来看,所有的 Java 虚拟机的执行引擎输入、输出都是一致的:输入的是字节码二进制流,处理过程是字节码解释执行的等效过程,输出的是执行结果。
2.1 局部变量表
局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序编译为Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需要分配的局部变量表的最大容量。
类变量有两次赋初始值的过程,一次是准备阶段,赋予系统初始值,整型 = 0,布尔类型 = false;另一次是初始化阶段,赋予程序员定义的初始值。因此即使在初始化阶段程序员没有为类变量赋值也没有关系,类变量仍然具有一个确定的初始值。但是局部变量不一样,如果一个局部变量定义了但没有赋初始值是不能使用的,字节码校验的时候也会被虚拟机发现而导致类加载失败!
public static void main(String[] args) {
int value;
System.out.println(value); //程序编译失败,未给局部变量附初始值
}
操作数栈
操作数栈是一个后入先出栈。同局部变量表一样,操作数栈的最大深度也在编译的时候写入到方法表的 Code 属性的 max_locals 数据项中。操作数栈的每一个元素可以是任意的 Java 数据类型,包括 long 和 double;
当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法执行的过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈/入栈操作。
2.3 动态链接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,只有这个引用是为了支持方法调用过程中的动态链接(Dynamic Linking)。Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用,这种转化称为静态链接。另外一部分将在每一次运行期间转化为直接引用,这部分称为动态链接!
当前线程的栈帧通过获取方法的直接引用,指向着常量池对应方法的字节码,就可以利用常量池、操作数栈执行方法!
2.4 方法返回地址
当一个方法开始执行后,只有两种方式可以退出这个方法。
第一种:执行引擎遇到任意一个方法返回的字节码指令(return),会有返回值传递给上层的方法调用者,简称正常完成出口;
第二种:在方法的执行过程中遇到了异常(Exception),并且议程没有在方法体中处理,简称异常完成出口;
无论何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。
三、方法调用
Java具备三种特性:封装、继承、多态。
Java文件在编译过程中不会进行传统编译的连接步骤,方法调用的目标方法以符号引用的方式存储在Class文件中,这种多态特性给Java带来了更灵活的扩展能力,但也使得方法调用变得相对复杂,需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。
方法调用并不等于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还不涉及方法内部的具体运行过程。在程序运行时,进行方法调用是最普遍、最频繁的操作,但Class文件的编译过程中不包含传统编译中的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(相当于上面说的直接引用)。这个特性给Java带来了更强大的动态扩展能力,但也使得Java方法调用过程变得相对复杂起来,需要在类加载期间,设置到运行期间再能确定目标方法的直接引用!
解析
所有方法调用的目标方法在Class文件里面都是常量池中的符号引用。在类加载的解析阶段,如果一个方法在运行之前有确定的调用版本,且在运行期间不变,虚拟机会将其符号引用解析为直接调用。
这种 编译期可知,运行期不可变 的方法,主要包括静态方法和私有方法两大类,前者与具体类直接关联,后者在外部不可访问,两者都不能通过继承或别的方式进行重写。
JVM提供了如下方法调用字节码指令:
- invokestatic:调用静态方法;
- invokespecial:调用实例构造方法<init>,私有方法和父类方法;
- invokevirtual:调用虚方法;
- invokeinterface:调用接口方法,在运行时再确定一个实现此接口的对象;
- invokedynamic:在运行时动态解析出调用点限定符所引用的方法之后,调用该方法;
通过invokestatic和invokespecial指令调用的方法,可以在解析阶段确定唯一的调用版本,符合这种条件的有静态方法、私有方法、实例构造器和父类方法4种,它们在类加载时会把符号引用解析为该方法的直接引用。
public class InvokestaticTest {
InvokestaticTest(){
}
public static void sayHello() {
System.out.println("hello");
}
public static void main(String args[]) {
sayHello();
}
}
javap -verbose InvokestaticTest.class
可以发现实例构造器是通过invokespecial指令调用的, sayHello方法是通过invokestatic指令调用的。
通过invokestatic和invokespecial指令调用的方法,可以称为非虚方法,其余情况称为虚方法,不过有一个特例,即被final关键字修饰的方法,虽然使用invokevirtual指令调用,由于它无法被覆盖重写,所以也是一种非虚方法。
非虚方法的调用是一个静态的过程,由于目标方法只有一个确定的版本,所以在类加载的解析阶段就可以把符合引用解析为直接引用,而虚方法的调用是一个分派的过程,有静态也有动态,可分为静态单分派、静态多分派、动态单分派和动态多分派
静态分派和动态分派
首先明白一点:Java语言是一种静态多分派,动态单分派语言!
静态分派(方法重载关联)
静态分派发生在代码的编译阶段。针对于方法的重载
public class StaticDispatch {
static class Parent{}
static class Child1 extends Parent{}
static class Child2 extends Parent{}
public void sayHello(Parent parent){
System.out.println("parent sayHello");
}
public void sayHello(Child1 child1){
System.out.println("child1 sayHello");
}
public void sayHello(Child2 child2){
System.out.println("child2 sayHello");
}
public static void main(String[] args) {
Parent parent = new Parent();
Parent parent1 = new Child1();
Parent parent2 = new Child2();
StaticDispatch staticDispatch = new StaticDispatch();
staticDispatch.sayHello(parent);
staticDispatch.sayHello(parent1);
staticDispatch.sayHello(parent2);
staticDispatch.sayHello((Child2)parent2);
}
}
parent sayHello
parent sayHello
parent sayHello
child2 sayHello
javap -verbose StaticDispatch.class
通过字节码指令,可以发现四次hello方法都是通过invokevirtual指令进行调用,而且前三次调用的是参数为Parent类型的sayHello方法,最后一次进行强转后,调用Child2类型的sayHello方法。
再举个例子,代码如下:
public class StaticDispatchTest {
public void sayHello(short word){
System.out.println("short" + word);
}
public void sayHello(int word){
System.out.println("int" + word);
}
public void sayHello(long word){
System.out.println("long" + word);
}
public void sayHello(String word){
System.out.println("String" + word);
}
public void sayHello(char word){
System.out.println("char" + word);
}
public void sayHello(Character word){
System.out.println("Character" + word);
}
public void sayHello(Object word){
System.out.println("Object" + word);
}
public void sayHello(char ... word){
System.out.println("char ..." + word);
}
public static void main(String[] args) {
StaticDispatchTest staticDispatch = new StaticDispatchTest();
staticDispatch.sayHello('a');
}
}
chara
优先匹配到char方法,其次是int,long,Character, Objedt, char...
在编译阶段,Java编译器会根据参数的静态类型决定调用哪个重载版本,但在有些情况下,重载的版本不是唯一的,这样只能选择一个“更加合适的版本”进行调用,所以不建议在实际项目中使用这种模糊的方法重载。
动态分派(方法的重写关联)
invokevirtual指令的多态查找过程,运行时解析过程分为:
由于动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此在虚拟机的实际实现中基于性能的考虑,大部分实现都不会真正地进行如此频繁的搜索。面对这种情况,最常用的“稳定优化”手段就是为类在方法区中建立一个虚方法表(Virtual Method Table,也称为itable),使用虚拟机表索引来代替元数据以提高性能!
在运行期间根据参数的实际类型确定方法执行版本的过程称为动态分派,动态分派和多态性中的重写(override)有着紧密的联系。
由于动态分派是非常频繁的动作,因此在虚拟机的实际实现中,会基于性能的考虑,并不会如此频繁的搜索对应方法,一般会在方法区中建立一个虚方法表,使用虚方法表代替方法查询以提高性能。
虚方法表在类加载的连接阶段进行初始化,存放着各个方法的实际入口地址,如果某个方法在子类中没有被重写,那么子类的虚方法表中该方法的入口地址和父类保持一致。
一个类的方法表包含类的所有方法入口地址,从父类继承的方法放在前面,接下来是接口方法和自定义的方法。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同的方法的入口地址一致。如果子类重写了这个方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址。
public class DynamicDispatch {
static class Parent{
public void sayHello(){
System.out.println("Parent");
}
}
static class Child1 extends Parent {
public void sayHello(){
System.out.println("Child1");
}
}
static class Child2 extends Parent {
public void sayHello(){
System.out.println("Child2");
}
}
public static void main(String[] args) {
Parent parent = new Parent();
Parent parent1 = new Child1();
Parent parent2 = new Child2();
parent.sayHello();
parent1.sayHello();
parent2.sayHello();
}
}
Parent
Child1
Child2
javap -verbose DynamicDispatch.class
可以发现,25、29和33的指令完全一样,但最终执行的目标方法却不相同,这得从invokevirtual指令的多态查找说起了,invokevirtual指令在运行时分为以下几个步骤:
- 找到操作数栈的栈顶元素所指向的对象的实际类型,记为C;
- 如果类型C中存在描述符和简单名称都相符的方法,则进行访问权限验证,如果验证通过,则直接返回这个方法的直接引用,否则返回java.lang.IllegalAccessError异常;
- 如果类型C中不存在对应的方法,则按照继承关系,从下往上依次对类型C的各父类进行搜索和验证,进行第2步的操作;
- 如果各个父类也没对应的方法,则抛出异常AbstractMethodError;
invokevirtual和invokeinterface的区别
虚函数表上的虚方法是按照从父类到子类的顺序排序的,因此对于使用invokevirtual调用的虚函数,JVM完全可以在编译期就确定了虚函数在方法表上的offset,或者在首次调用之后就把这个offset缓存起来,这样就可以快速地从方法表中定位所要调用的方法地址。
然而对于接口类型引用,由于一个接口可以被不同的Class来实现,所以接口方法在不同类的方法表的offset当然就(很可能)不一样了。因此,每次接口方法的调用,JVM都会搜寻一遍虚函数表,效率会比invokevirtual要低。
单分派与多分派
方法的接受者与方法的参数统称为方法的宗量。根据分派基于多少宗量,可以将分派划分为单分派和多分派两种。
我们来看一段代码
public 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、看对象的静态类型时什么,即使 Father 还是 Son。 2、方法参数的类型和数量是什么是 QQ还是 360 。因为是根据两个宗量进行选择,所以 Java 语言的静态分派属于多分派类型。
在动态分派的过程中,由于编译器已经决定了目标方法的签名,因此只需要找到方法的接受者就可以了。因为是根据一个宗量进行选择,所以 Java 语言的动态分派属单分派类型。
动态分配的实现
由于动态分配是非常频繁的动作,而且动态分配的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此在虚拟机的实际实现中,基于性能的考虑,大部分实现都不会真正的进行如此频繁的搜索。最常用的手段就是为类在方法去中建立一个虚方法表(Virtual Method Table , 也称为 vtable ,与此对应的,在 invokeinterface 执行时也会用到接口方法表-Inteface Method Table , 简称 itable),使用虚方法表索引来代替元数据查找以提高性能。
以上面代码为例,虚方法表结构如图:
虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和弗雷相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类方法表中的地址将会替换为指向子类实现版本的入口。如上图,Son 重写了来自于 Father 的全部方法,因此 Son 的方发表没有指向 Father 类型数据的箭头。但是 Son 和 Father 都没有重写来自于 Object 的方法,所以他们的方法表中所有从 Object 继承来的方法都指向了 Object 的数据类型。
方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值之后,虚拟机会把该类的方法表也初始化完毕。
Java 代码编译和执行过程
大部分的程序代码转换成物理机的目标代码或虚拟机能执行的指令集之前,都需要经过上图中的各个步骤。
Java 代码编译是由 Java 源码编译器来完成,流程图如下所示:
编译原理的简单过程:词法分析 --> 语法分析 --> 语义分析和中间代码的产生 --> 优化 --> 目标代码生成!
基于栈的指令集
一段简单的算术代码:
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 //操作栈深度为2和4个Slot局部变量表
0:bipush 100 //将100压入操作数栈
2:istore_1 //将栈顶100数值存放到局变量Slot,index=1中
3:sipush 200 //将200压入操作数栈
6:istore_2 //将栈顶200数值存放到局部变量Slot,index=2中
7:sipush 300 //将300压入操作数栈
10:istore_3 //将栈顶200数值存放到局部变量Slot,index=3中
11:iload_1 //将index=1的局部变量表数值压入操作数栈(100)
12:iload_2 //将index=2的局部变量表数值压入操作数栈(200)
13:iadd //取栈顶两个数值相加,结果压入操作数栈(300)
14:iload_3 //将index=3的局部变量表数值压入操作数栈(300)
15:imul //取栈顶两个数值相乘,结果压入操作数栈(90000)
16:ireturn //取栈顶数值返回调用者结果
图解展示:
基于栈的解释器执行过程
Java语言中,javac 编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。这一部分动作是在Java虚拟机之外进行的,而解释器在虚拟机的内部,所以Java程序的编译就是半独立的实现!Java 字节码的执行是由 JVM 执行引擎来完成,流程图如下所示:
问题:什么是解释器(Interpreter),什么是 JIT 编译器?
解释器: 当 Java 虚拟机启动时会根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码中的内容“翻译”为对应平台的本地机器指令执行。
JIT ( Just In Time Compiler) 编译器: 就是虚拟机将源代码直接编译成和本地机器平台相关的机器语言。
问题:为什么说 Java 语言是半编译半解释型语言?
JDK 1.0 时代,将 Java 语言定位为“解释执行”还是比较准确的,再后来, Java 也发展出可以直接生成本地代码的编译器。
现在 JVM 在执行 Java 代码的时候,通常都会将解释执行和编译执行二者结合起来进行。
机器码、指令、汇编语言
- 机器码
各种用二进制编码方式表示的指令,叫做机器指令码。开始,人们就用它编写程序,这就是机器语言。
机器语言虽然能够被计算机理解和接受,但和人们的语言差别太大,不易被人们理解和记忆,并且用它编程容易出差错。
用它编写的程序一经输入计算机, CPU 直接读取运行,因此和其他语言编的程序相比,执行速度最快。
机器指令与 CPU 紧密相关,所以不同种类的 CPU 所对应的机器指令也就不同。
- 指令
由于机器码是有 0 和 1 组成的二进制序列,可读性实在太差,于是人们发明了指令。
指令就是把机器码中特定的 0 和 1 序列,简化成对应的指令(一般为英文简写,如 mov,inc 等),可读性稍好。
由于不同的硬件平台,执行的是同一个操作,对应的机器码可能不同,所以不同的硬件平台的同一种指令(比如 mov), 对应的机器码也可能不同。
- 指令集
不同的硬件平台,各自支持的指令是有差别的。因此每个平台所支持的指令,称之为对应平台的指令集。如常见的 X86 指令集,对应的是 x86 架构的平台, ARM 指令集,对应的是 ARM 架构的平台。
- 汇编语言
由于指令的可读性还是太差,于是人们又发明了汇编语言。
在汇编语言中,用助记符(Mnemonics)代替机器指令的操作码,用地址符号(Symbol)或标号(Label) 代替指令或操作数的地址。
在不同的硬件平台,汇编语言对应着不同的机器语言指令集,通过汇编过程转换成机器指令,由于计算机只认识指令码,所以用汇编语言编写的程序还必须翻译成机器指令码,计算机才能识别和执行。
- 高级语言
为了使计算机用户编程更容易些,后来就出现了各种高级计算机语言。高级计算机语言比机器语言、汇编语言更接近人的语言。
当计算机执行高级语言编写的程序时,仍然需要把程序解释和编译成机器的指令码。完成这个过程的程序就叫做解释程序或编译程序。
关于以上所述大致用图展示如下:
- 字节码
字节码是一种中间状态的(中间码)的二进制(文件),它比机器码更抽象,需要直译器转译后才能成为机器码。
字节码主要是为了实现特定软件运行和软件环境、与硬件环境无关。
字节码的实现方式是通过编译器和虚拟机器。编译器将原码编译成字节码,特定平台上的虚拟机器将字节码转译为可以直接运行的指令,字节码的典型应用为 Java bytecode 。
解释器
JVM 设计者的初衷仅仅只是单纯地为了满足 Java 程序实现跨平台特性,因此避免采用静态编译的方式直接生成本地机器指令,从而诞生了实现解释器在运行时采用逐行解释字节码执行程序的想法。
解释器真正意义上所承担的角色就是一个运行时“翻译者”,将字节码文件中的内容“翻译”为对应平台的本地机器指令执行。
当一条字节码指令被解释执行完成后,接着在根据 PC 寄存器中记录的下一条需要被执行的字节码指令执行解释操作。
在 Java 的发展历史里,一共有两套解释执行器,即古老的字节码解释器、现在普遍使用的模板解释器。
字节码解释器在执行时通过纯软件代码模拟字节码的执行,效率非常低下。
而模版解释器将每一条字节码和一个模板函数相关联,模板函数中直接产生这条字节码执行时的机器码,从而很大程度上提高了解释器的性能。在 HotSpot VM 中,解释器主要由 Interpreter 模块和 Code 模块组成。
- Interpreter 模块:实现了解释器的核心功能
- Code 模块 : 用于管理 HotSpot VM 在运行时生成的本地机器指令。
由于解释器在设计和实现上非常简单,因此除了 Java 语言之外,还有许多高级语言同样也是基于解释器执行的,比如 python 、Perl 、Ruby 等。但是在今天,基于解释器执行已经沦落为低效的代名词,并且时常被一些 C /C++ 程序员所调侃。
为了解决这个问题, JVM 平台支持一种叫做即时编译的技术。即时编译的目的是避免函数被解释执行,而是将整个函数体编译成为机器码,每次函数执行时,只执行编译后的机器码即可,这种方式可以时执行效率大幅度提升。
不过无论如何,基于解释器的执行模式仍然为中间语言的发展做出了不可磨灭的贡献。
JIT 编译器
HotSpot VM 是目前市面上高性能虚拟机的代表作之一。它采用解释器与及时编辑器并行的结构。在 Java 虚拟机运行时,解释器和即时编译器能够相互协作,各自取长补短,尽力去选择最合适的方式来权衡编译本地代码的时间和直接解释执行代码的时间。
在今天, Java 程序的运行性能早已脱胎换骨,已经达到了可以和 C/C++ 程序一较高下的地步。
有些开发人员会感觉到诧异,既然 HotSpot VM 中已经内置了 JIT 编译器了,那么为什么还使用解释器来“拖累”程序的执行性能呢? 比如 JRockit VM 内部就不包含解释器,字节码全部都依靠即时编译器编译后执行。
首先明确:当程序启动后,解释器可以马上发挥作用,省去编译的时间,立即执行。编译器要想发挥作用,把代码编译成本地代码,需要一定的执行时间。但编译为本地代码后,执行效率高。所以:尽管 JRockit VM中程序的执行性能会非常的高效,但程序在启动时必然会花费更长的时间来进行编译。对于服务器来说,启动时间并非时关注重点,但对于那些看中启动时间的应用场景而言,或许就需要采用解释器与及时编译器并存的架构来换取一个平衡点。在此模式下,当 Java 虚拟机启动时,解释器可以首先发挥作用,而不必等待即时编译器全部编译完成后在执行,这样可以省去许多不必要的编译时间。随着时间的推移,编译器发挥作用,把越来越多的代码编译成本地代码,获得更高的执行效率。
同时,解释执行在编译器进行激进优化不成立的时候,作为编译器的“逃生门”。
概念解释::
Java 语言的“编译期”其实是一段 “不确定”的操作过程,因为他可能是指一个前端编译器(其实叫 “编译器的前端” 更准确一些)把 .java 文件转变成 .class 文件的过程;
也可能是指虚拟机的后端运行期编译器( JIT 编译器, Just In Time Compiler )把字节码转变成机器码的一个过程。
还可能是指使用静态提前编译器 ( AOT 编译器, Ahead Of Time Compiler ) 直接把 .java 文件编译成本地机器代码的过程。
如何选择:
当然是否需要启动 JIT 编译器将字节码直接译为对应平台的本地机器指令,则需要根据代码被调用执行的频率而定。关于那些需要被编译为本地代码的字节码,也被称之为 “热点代码” JIT 编译器在运行时会针对那些频繁被调用的“热点代码”做出深度优化,将其直接编译为对应平台的本地机器指令,以此提升 Java 程序的性能。
缺省情况下 HotSpot VM 是采用解释器与即时编译器并存的架构,当然开发人员可以根据具体的应用场景,通过命令显示地为 Java 虚拟机指定在运行时到底是完全采用解释器执行,还是完全采用即时编译器执行。如下所示:
- -Xint : 完全采用解释器模式执行程序;
- -Xcomp : 完全采用编译器模式执行程序。如果即时编译出现问题,解释器会介入执行。
- -Xmixed :采用解释器+即时编译器的混合模式共同执行程序。