Java的技术体系包括
- 支持Java程序运行的虚拟机(JVM)
- 提供接口支持的Java API
- Java 编程语言
- 第三方Java框架(如Spring等)
代码编译的结果从本地机器码转变为字节码,是存储格式发展的一小步,确实编程语言的一大步。
执行引擎
执行引擎是java虚拟机执行字节码指令的发动机和核心部件。虚拟机是一种相对于物理机的概念,两种机器都有执行代码的能力。物理机的执行引擎是建立在具体的处理器,硬件,指令集,操作系统之上的,而虚拟机执行引擎是不依赖上述这些具体实现,建立在自己的概念模型之上,因此可以自行指定指令集和引擎的结构体系。
java虚拟机的执行引擎从外部来看是一样的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。不过从内部来讲,各种不同的虚拟机又有自己的实现方式,比如最常见的有解释执行和便已执行等。不过从虚拟机执行引擎的概念模型角度来看,他们都是执行的过程都是一样的
栈帧
栈帧是虚拟机在进行方法调用和方法执行过程中使用到的数据结构,它是虚拟机内存中虚拟机栈的单位元素。栈帧存储了方法的局部变量表,操作数栈,动态连接,和方法返回地址还有一些额外的信息。在代码编译过程中,一个方法需要用到的栈帧需要多少内存空间就已经完全确定了,并放在了Class文件中方法表的Code属性中,因此栈帧的大小不会首运行时的影响。
每个方法从调用开始到完成的过程,都对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。实际一个线程运行过程中,方法调用链可能很长,所以一个典型的虚拟机栈中的栈帧结构如下图所示
下面详细介绍一下栈帧中各个部分的作用以及数据结构
局部变量表
局部变量表是用来存储方法的参数和方法内局部变量的存储空间。java代码在编译为Class文件的时候,会在方法表的Code属性的max_locals数据项中确定局部变量表的大小。它的容量以变量槽(Slot)为基本单位,Slot的大小通常为32位(虚拟机规范中的定义为,Slot应该能存放一个boolean, byte, char, short, int, float, refrence, returnAdress)。虚拟机通过索引的方式使用局部变量表,索引值从0开始到最大的Slot值。局部变量表的空间分配,0位索引默认存放该方法所属对象的引用,也就是java语言关键字"this",然后按照参数表顺序分配,之后是方法体内部的局部变量。同时,虚拟机会根据Slot的作用域重用其空间。
对于refrence类型,指的是一个实例对象的引用,虚拟机应当能通过这个引用做到两点,1.从此引用直接或间接的查找到对象在Java堆内存中的起始地址,2.从此引用中直接或者间接地查找到对象所述数据类型在方法区中的类型信息。
操作数栈
操作数栈顾名思义是一个后入先出的栈,用来存放程序执行过程中所有的操作数。它的最大深度也在编译的时候就已经写入了Code属性的max_stacks数据项中,操作数栈的每一个元素可以是任意的java数据类型,32为数据类型占用一个栈容量,64为数据类型占用两个栈容量。
操作数栈的功能是在方法执行的过程中,会有各种字节码指令在操作数栈中写入和提取内容。比如算数运算中的操作数和结果,方法调用时候参数的传递等。
所以Java虚拟机的执行引擎被称之为“基于栈的执行引擎”,就是指的操作数栈,也就是说所有的运算过程都是围绕栈进行的。
动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,目的是为了支持方法调用过程中的动态连接。常量池中的符号引用通常情况下会在类加载阶段或者第一次使用的时候,被转化为直接引用,这种称为静态连接,有时候符号引用会在每一次使用的时候转化为直接引用,这种被称之为动态链接。
方法返回地址
当退出一个方法执行的时候,需要返回到被调用的位置。因此方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的调用者的执行状态。比如,方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧中很可能会保存这个地址。
一个方法退出时,执行的操作可能会有:
- 恢复上层方法的局部变量表和操作数栈,
- 把返回值压入调用栈帧的操作数栈中,
- 调整PC计数器的值指向继续执行的下一条指令
附加信息
虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧当中,这部分称之为附加信息。
一般我们把动态连接、方法返回地址、其他附加信息归为一类,称之为栈帧信息
方法调用
方法调用是在调用方法的时候,确定调用的是哪个版本的方法。由于java语言在编译过程中不包含连接过程,也就是说Class文件中存储的是符号引用,而不是实际运行过程中的入口地址,这就导致Java的方法调用过程更为复杂,需要在类加载甚至运行期间才能确定方法的入口地址。方法的调用可以分为一下三种情况:解析、分派、动态类型语言支持。
解析
在类加载阶段,会有一部分的符号引用转化为直接引用,这样的解析能成立的条件是,方法在程序运行之前就有一个可确定的调用版本,并且这个版本在运行过程中是不可改变的。这也就是类加载的解析阶段。
java语言规范中,符合这样“编译器可知,运行期间不可变”的要求的方法,主要是静态方法和私有方法两大类。与java语言规范相对应的,在java虚拟机中提供了5条方法调用字节码指令。分别为
- invokestatic 调用静态方法
- invokespecial 调用实例构造器,私有方法和父类方法
- invokevirtual 调用所有虚方法
- invokeinterface 调用接口方法
- invokedynamic
能被前两条字节码指令也就是 invokestatic invokespecial 调用的方法,是可以在解析阶段就确定唯一版本的,符合这个条件的包括静态方法、私有方法、构造器、父类方法这四类,因此也被称之为非虚方法。其他方法则被称之为虚方法。
final方法,虽然在字节码指令层面是通过invokevirtual调用,但是java语言规范明确规定,final方法是一种非虚方法。
分派
分派对应的java语言的“重写”和“重载”。分派调用可能是静态的,也可能是动态的。下面来看一下虚拟机中的方法分派具体是如何进行的
静态分派
我们看下面一段代码
Human man = new Man();
在这段代码中,Human 称之为变量的静态类型,Man 称之为变量的实际类型。静态类型是在编译期间就可以知道的,并且变量的静态类型是不会发生变化的,而实际类型是要到运行期间才可以知道。编译器在面对重载的方法时,是通过参数的静态类型作为判别的依据,因此,在编译期间,编译器便会根据静态类型决定使用方法的哪个重载版本,并把该方法的符号引用写到调用的字节码指令 invokevirtual指令的参数中。
所以,依赖静态类型来定位方法执行版本的动作称之为静态分派,最典型的就是方法的重载。静态分派发生在编译阶段,因此静态分派并不是由虚拟机来完成的。
动态分派
动态分派对应着多态性的另外一个重要的体现,就是重写。调用重写的方法对应着 invokevirtual 的字节码指令,该指令的运行解析过程大致如下
- 找到操作数栈顶的第一个元素所指向的对象的实际类型,记为C
- 如果在C中找到需要调用的方法,并且权限允许访问,则返回该方法
- 否则,按照继承关系从下往上一次对C的父类进行第2步的搜索和验证
- 如果还是没有找到,则抛出 java.lang.AbstraceMethodError 异常
我们把上述这种运行期间根据实际类型确定方法执行版本的过程,称之为动态分派
单分派和多分派
方法的接受者和方法的参数统称为,方法的宗量。单宗量是根据一个方面对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。比如,一个方法版本的选择,既要依赖参数的静态类型,同时又要调用者的实际类型,则就是多分派的方式。
基于栈的执行引擎
在知道了方法时如何调用的问题之后,就需要解决方法是如何执行的问题了。java虚拟机在执行的时候通常有 解释执行 和 编译执行 两种方式。java语言代码经过编译器编译后形成字节码指令流,解释器位于虚拟机的内部,将进入虚拟机的字节码指令流解释执行。
java编译器输出的指令流,是一种基于栈的指令集架构,指令集中的大部分指令都是零地址指令,它们依赖于操作数栈进行工作。与之相对应的另一套指令集架构就是基于寄存器的指令集架构,也就是我们PC机中使用的指令集架构。
基于栈的指令集
- 优点,可移植性好不依赖于具体硬件,代码更加紧凑,编译器实现更加简单
- 缺点,由于基于栈的操作,就导致出栈入栈的很多操作都需要进行,增加了指令的数量,同时操作数栈位于内存中,相比寄存机位于处理器而言,速度会更慢。因此主要的缺点就是执行会慢一些
基于寄存器的指令集
- 有点就是速度快
- 缺点就是可移植性差
总结
通过以上六篇文章,我们分别阐述了Java程序是如何存储的(Class文件),如何被载入虚拟机的(类加载过程),以及如何执行的(基于栈的字节码执行过程)。这就是虚拟机执行系统最核心的三个问题。