阅读本文大概需要 12.8 分钟。
JVM 能够跨计算机体系机构来执行 Java 字节码,也就是我们所说的 Java 可以跨平台执行,主要是由于 JVM 屏蔽了与各个计算机平台相关的软件或者硬件之间的差异,使得与平台相关的耦合统一由 JVM 提供者来实现。
本文将介绍下面内容:
在前面的章节,已经深入介绍了 Class 类,并且简单了解如何通过类加载器将 Java 字节码加载到 JVM 中。下面来看看 JVM 的体系结构是如何设计的,这里从宏观的角度进行分析,让大家了解一下最基本的 JVM 结构和工作模式。
首先我们思考下面这个问题
JVM 的全称是 Java Virtual Machine,Java 虚拟机,它通过模拟一个计算机来达到一个计算机所具有的计算功能。
我们先来看一下真实的计算机如何才能具备计算功能。
以计算为中心看计算机的体系结构可以分为以下几个部分:
上面几个部分和我们所说的代码执行最密切的还是指令集,下面会详细介绍指令集是如何定义的。
指令集是在 CPU 中用来计算和控制计算机系统的一套指令的集合,每一种新型的 CPU 在设计时都规定了一系列与其他硬件电路相配合的指令系统。而指令集的先进与否也关系到 CPU 的性能发挥,是 CPU 性能的一个重要标志。
指令集是可以直接被机器识别的机器码,也就是必须以二进制格式存在于计算机中。
汇编语言是能够被人识别的指令,汇编语言在顺序和逻辑上是与机器指令一一对应的。也就是说,汇编语言是为了让人能够更容易地记住机器指令的助记符。
汇编语言中都是对寄存器和段的直接操作的命令,寄存器和段等芯片都是架构的一部分,所以不同的芯片架构设计一定会对应到不同的机器指令集合。但是不同的芯片厂商往往都会采用兼容的方式来兼容其他不同架构的指令集,因为垄断操作系统的微软,操作系统是管理计算的的真正入口,几乎所有程序都要经过操作系统的调用,如果操作系统不支持某种芯片的指令集,用户的程序是不可能执行的。
通过 cpu-z 软件来查看 CPU 支持哪些指令集,看下图:
在指令集这行我们能看到,当前的 CPU 支持 11 种指令集。
说完了指令集,我们回到 JVM 主题,想一下我们在前面提出的问题 JVM 和实体机到底有何不同?
大体有如下几点:
JVM 和实体机一样也必须有一套合适的指令集,这个指令集能够被 JVM 解析执行, 这个指令集我们称为 JVM 字节码指令集,符合 class 文件规范的字节码都可以被 JVM 执行 。
下面我们再看看除了指令集之外,JVM 还需要哪些组成部分。如下图所示,JVM 的结构基本上只由 4 部分组成。
那详细描述下这 4 个部分:
ClassLoader 工作机制会在后面详细写一篇文章介绍。这里需要说明的是,每个被 JVM 装载的类型都有一个对应的 java.lang.Class 类的实例来表示该类型,该实例可以唯一表示被 JVM 装载的 Class 类, 这个实例和其他类的实例一样存放在 Java 的堆中 。
执行引擎是 JVM 的核心部分, 执行引擎的作用就是解析 JVM 字节码指令 ,得到执行结果。在《Java 虚拟机规范》中详细定义了执行引擎遇到每条字节码指令时应该处理什么,并且得到什么结果。但是并没有规定执行引擎应该如何或采取什么方式处理而得到这个结果。因为执行引擎具体采取什么方式由 JVM 的实现厂家去实现,是直接解释执行还是采用 JIT(just-in-time 即使编译) 技术转成本地代码去执行,还是采用寄存器这个芯片模式去执行都可以。所以执行引擎的具体实现有很大的发挥空间,如 SUN 的 hotspot 是基于栈的执行引擎,而 Google 的 Dalvik 是基于寄存器的执行引擎。
执行引擎也就是执行一条条代码的一个流程,而代码都是包含在方法体内的, 所以执行引擎本质上就是执行一个个方法所串起来的流程,对应到操作系统中一个执行流程是一个 Java 线程,Java 进程可以有多个同时执行的执行流程。这样说,每个 Java 线程就是一个执行引擎的实例,那么在一个 JVM 实例中就会同时有多个执行引擎在工作,这些执行引擎有的在执行用户的程序,有的在执行 JVM 内部的程序(如 Java 垃圾收集器)。
执行引擎在执行一段程序时需要存储一些东西,如操作码需要的操作数,操作码的执行结果需要保存。class 类的字节码还有类的对象等信息都需要在执行引擎执行之前就准备好。从最开始的图中,可以看出一个 JVM 实例会有一个方法区、Java 堆、Java 栈、PC 寄存器和本地方法区。 其中方法区和 Java 堆是所有线程共享的,也就是可以被所有的执行引擎实例访问。每个新的执行引擎实例被创建时会为这个执行引擎创建一个 Java 栈和一个 PC 寄存器,如果当前正在执行一个 Java 方法,那么在当前的这个 Java 栈中保存的是该线程中方法调用的状态,包括方法的参数、方法的局部变量、方法的返回值以及运算符中间的结果等。而 PC 寄存器会指向即将执行的下一条指令。
本地方法调用,则存储在本地方法调用栈中或者特定实现中的某个内存区域中。
前面简单分析了 JVM 的基本结构,下面简单分析一下 JVM 是如何执行字节码命令的,就是介绍执行引擎是如何进行工作的。
在分析 JVM 的执行引擎如何工作之前,我们不妨先看看在普通的实体机上程序是如何执行的。通过下图来带大家理解一下。
计算机只接受机器指令,高级语言必须要经过编译器编译成机器指令才能被计算机执行,所以从高级语言到机器语言之间必须要有个翻译过程。我们知道机器语言是和硬件平台密切相关,编译器通过编译解决了高级语言与硬件的耦合。那不同硬件平台就会所需的编译器也是不同的,现在的硬件平台已经被更上一层的软件平台代替了,这个软件平台就是操作系统。所以就有了上图中 C 语言的编译器在不同的操作系统是不同的。当然也有很多不同的厂家的编译器和操作系统关系不大,只是实现上有差异。
通常一个程序从编写到执行会经历以下一些阶段:
除了源代码和最后的可执行程序,中间的所有环节都是由现代意义上的编译器统一完成的。
如在 Linux 平台上我们通常安装一个软件需要经过 confrgure、make、make install、make clean 这 4 个步骤来完成。
configure 为这个程序在当前的操作系统环境下选择合适的编译器来编译这个程序代码,也就是为这个程序代码选择合适的编译器和一些环境参数;
make 对程序代码进行编译操作,它会将源码编译成可执行的目标文件,
make install 将已经编译好的可执行文件安装到操作系统指定或者默认的安装目录下。
make clean 删除编译临时产生的目录或文件。
我们说的编译器通常是高级语言翻译成目标机器语言,也就是低级语言。还有一些编译器是高级语言编辑成高级语言,高级语言编译成虚拟机目标语言(Java 编译器),低级语言翻译成高级语言(反编译)。
指令集最基本的元素:加、减、乘、求余、求模等。这些运算又可以进一步分解成二进制运算:与、或、异或等。这些运算又通过指令来完成,而指令的核心目的就是确定需要运算的种类(操作码)和运算需要的数据(操作数),以及从哪里(寄存器或栈)获取操作数、将运算结果存到什么地方(寄存器或是栈)等。这种不同的操作方式又将指令划分成:一指令地址、二指令地址、三指令地址和零指令地址等 n 地址指令。相应的这些指令集就会有相应的架构实现,如基于寄存器的架构实现或者基于栈的架构实现,这里的基于寄存器或者栈都是指一个指令中的操作数是如何获取的。
JVM 执行字节码指令是基于栈的架构,也就是所有的操作数必须先入栈,然后根据指令中的操作数选择从栈顶弹出若干元素进行计算,将计算的结果再压入到栈中。
JVM 中操作数可以存放在每一个栈帧中的一个本地变量集中,每个方法调用时会给这个方法分配一个本地变量集,这个本地变量集在编译的时候已经确定,所以操作数入栈可以直接是常量入栈或者从本地变量中取出一个变量压入栈中。
和一般的基于寄存器的操作有所不同,一个操作需要频繁地入栈和出栈,进行一个加法运算,如果两个操作数都在本地变量中,一个加法操作就要有 5 次栈操作,分别是将两个操作数从本地变量入栈(2 次入栈操作),再将两个操作数出栈用于加法运算(2 次出栈),再将结果压入栈顶(1 次入栈)。如果是基于寄存器的一般只需要将两个操作数存入寄存器进行加法运算后再将结果存入其中一个寄存器即可,不需要这么多的数据移动操作。那为什么 JVM 还要基于栈来设计呢?
要与平台无关,就要保证在没有或者有很少的寄存器的机器上也能运行 Java 代码。
对于 Java 来说,JVM 可作为连接器(动态)使用,也可以作为优化器使用。这种以栈为中心的体系结构可以将运行时进行的优化工作与执行即时编译或者自适应优化的执行引擎集合起来,从而可以更好地优化执行 Java 字节码指令。
操作码可以只占一个字节大小,为了尽量让编译后的 class 文件更加紧凑,提高字节码在网络上的传输效率。
了解了 Java 以栈为架构的原因后,再详细看一下 JVM 是如何设计 Java 的执行部件的,如下图所示。
每当创建一个新的线程,JVM 会为这个线程创建一个 Java 栈,同时会为这个线程分配一个 PC 寄存器,并且这个 PC 寄存器会指向这个线程的第一行可执行代码。 每当调用一个新方法时会在这个栈上创建一个新的栈帧数据结构,这个栈帧会保留这个方法的一些元信息,如这个方法中定义的局部变量、一些用来支持常量池的解析、正常方法返回及异常处理机制等 。
下面以一个具体的例子看一下执行引擎时如何将一段代码在执行部件执行的,如下一段代码:
public class Math {
public static void main(String[] args) {
int a=1;
int b=2;
int c=(a+b)*10;
}
}
看一下 main 方法的字节码指令:
0: iconst_1 常量1入栈
1: istore_1 将栈顶元素移入到本地变量1存储
2: iconst_2 常量2入栈
3: istore_2 将栈顶元素移入到本地变量2存储
4: iload_1 本地变量1入栈
5: iload_2 本地变量2入栈
6: iadd 弹出栈顶两个元素相加
7: bipush 10 将10入栈
9: imul 栈顶两个元素相乘
10: istore_3 栈顶元素移入到本地变量3存储
11: return 返回
对应到执行引擎各部件如下图所示。
在开始执行方法之前,PC 寄存器存储的指针是第 1 条指令的地址,局部变量区和操作栈都没有数据。从第 1 条到第 4 条指令分别将 a、b 两个本地变量赋值,对应到局部变量区就是 1 和 2 分别存储常量 1 和 2,如下图所示。
前 4 条指令执行完后,PC 寄存器当前指向的是下一条指令地址,也就是第 5 条指令,这时局部变量区已经保存了两个局部变量(也就是 a 和 b 的值),而操作栈里仍然没有值,因为两次常数入栈后又分别出栈了。
第 5 条和第 6 条指令分别是将两个局部变量入栈,然后相加。
1 先入栈 2 后入栈,栈顶元素是 2,第 7 条指令是将栈顶的两个元素弹出后相加,结果再入栈,这时整个部件状态如下图所示。
当前 PC 寄存器执行的地址是9,下一个操作时将当前栈的两个操作数弹出进行相乘并把结果压入栈中。
第 10 条指令是将当前的栈顶元素存入局部变量 3 中。
第 10 条指令执行完后栈中元素出栈,出栈的元素存储在局部变量区 3 中,对应的是变量 c 的值。最后一条指令是 return,这条指令执行完后当前的这个方法对应的这些部件会被 JVM 回收,局部变量区的所有值将全部释放,PC 寄存器会被销毁,在 Java 栈中与这个方法对应的栈帧将消失。
JVM 的方法调用分为两种,一种是 Java 方法调用,另一种是本地方法调用。本地方法调用由于各个虚拟机的实现不太相同,所以这里主要介绍 Java 的方法调用情况。
如下面一段方法调用的代码:
public class Math {
public static void main(String[] args) {
int a=1;
int b=2;
int c=math(a,b)/10;
}
public static int math(int a, int b){
return (a+b)*10;
}
}
对应的字节码如下:
public static void main(java.lang.String[]);
Code:
0: iconst_1
1: istore_1
2: iconst_2
3: istore_2
4: iload_1
5: iload_2
6: invokestatic #2 // Method math:(II)I
9: bipush 10
11: idiv
12: istore_3
13: return
public static int math(int, int);
Code:
0: iload_0
1: iload_1
2: iadd
3: bipush 10
5: imul
6: ireturn
当 JVM 执行 main 方法时,首先将两个常量 1 和 2 分别存储到局部变量区 1 和 2 中,然后调用静态 main 方法。从 math 的字节码指令可以看出,math 方法的两个参数也存储在对应的方法栈帧中的局部变量区 0 和 1中,先将这两个局部变量分别入栈,然后进行相加操作再和常数 10 相乘,最后将结果返回。下面看一下世纪的执行操作部件中是如何操作的。
上图是 JVM 执行到第 5 条指令时,执行引擎各部件的状态图,PC 寄存器指向的是下一条执行 main 方法的地址。 当执行 invokestatic 指令时 JVM 会为 math 方法创建一个新的栈帧,并且将两个参数存在 math 方法对应的栈帧的前两个局部变量区中,这时 PC 寄存器会清零,并且会指向 math 方法对应栈帧的第一条指令地址,这时的状态如图所示。
执行 invokestatic 指令时,创建了一个新的栈帧,这时栈帧中的局部变量区已经有两个变量了,这两个变量是从 main 方法的栈帧中的操作栈中传过来的。当执行 math 方法时,math 方法对应的栈帧成为当前活动栈帧,PC 寄存器保存的是当前这个栈帧中的下一条指令地址,所以是 0.
math 方法先将 a、b 两个变量相加,再乘 10,最后返回这个结果执行到第 5 条指令的状态。
math 的操作栈中的栈顶元素相乘的结果是 30,最后一条指令是 ireturn,这条指令是将当前栈帧的栈顶元素返回到调用这个方法的栈中,而这个栈帧也将撤销,PC 寄存器的值回复调用栈的下一条指令地址。
当执行 return 指令时 main 方法对应的栈帧也将撤销,如果当前线程对应的 Java 栈中没有栈帧,这个 Java 栈也将被 JVM 撤销,整个 JVM 退出。
本篇文章主要介绍了 JVM 的体系结构,以及 JVM 的执行引擎和 JVM 指令的过程,实际上 JVM 的设计非常复杂,包括 JVM 在执行字节码时如何来自动优化这些字节码,并将它们再编译成本地代码,也就是 JIT 技术,这个技术再我们执行测试时可能会有影响,如果你的程序没有经过充分的“预热”,那么得出的结果可能会不准确,例如,JVM 再执行程序时会记录某个方法的执行次数,如果执行的次数到一个阀值时 JIT 就会编译这个方法为本地代码。
在文章的结尾,大家考虑一个问题,为什么递归会引发栈溢出呢?
本文是从 《深入分析 Java Web 技术内幕》第 7 章 摘录的,我在这个基础上加了一些图片便于理解。电子书可以关注我的公众号,回复【 电子书 】,即可获取。