java虚拟机的实现必须遵循《java虚拟机规范》,而规范只是规定了java虚拟机实现的一些必要细节,并没有指出具体该如何实现,所以具体的厂商可以有自己的实现方法。在这里以jdk自带的虚拟机Hotspot为例,介绍其实现架构。
如上图所示,Hotspot JVM总体结构分为3个部分:类加载器子系统,运行时数据区,执行引擎子系统。以下是对每个部分的简单介绍。
1. 类加载子系统
虚拟机类加载子系统负责将Class文件加载到内存中,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的数据结构。
类加载流程
虚拟机将Class文件加载到内存中,需要以下3个步骤:
- 加载。在加载阶段,虚拟机需要完成3件事情,1)通过一个类的全限定名来获取此类的二进制字节流。2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。3)在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据访问入口。
- 链接。在链接阶段将验证类的Class文件的字节流,为静态域分配存储空间,然后将常量池类的符号引用替换为直接引用。
- 初始化。初始化过程的主要操作是执行静态代码块和初始化静态域。
类加载器
类加载器用于实现加载阶段的类加载动作。一般来说,类加载器分成两类:启动类加载器(bootstrap)和用户自定义的类加载器(user-defined)。两者的区别在于启动类加载器是由JVM的原生代码实现的,而用户自定义的类加载器都继承自Java中的java.lang.ClassLoader类。在用户自定义类加载器的部分,一般JVM都会提供一些基本实现。应用程序的开发人员也可以根据需要编写自己的类加载器。JVM中最常使用的是系统类加载器(system),它用来启动Java应用程序的加载。通过java.lang.ClassLoader的getSystemClassLoader()方法可以获取到该类加载器对象。
2. 运行时数据区
Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同数据区域。这些区域都有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。
如上图所示,java虚拟机运行时数据区分为方法区、堆、Java线程栈、程序计数寄存器、本地内部线程栈,其中方法区和堆由所有线程共享,Java线程栈、程序计数寄存器和本地内部线程栈由每个线程私有。以下将做简单介绍:
- 方法区:用于存储已被虚拟机加载的类信息、常量、静态变量、编译器编译后的代码等数据。
- 堆:用来存放对象实例和数组(特殊的对象)。堆内存由所有线程共享,在虚拟机启动时创建。
- java线程栈:每个方法在执行时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用到执行完成的过程,就对应一个栈帧在java线程栈中入栈到出栈的过程。
- 程序计数寄存器:用来指示一个线程下一步要执行的字节码序列。
- 本地内部线程栈:与java线程栈的作用非常相似,主要区别是java线程栈为虚拟机执行java方法服务,而本地内部线程栈则为虚拟机使用到的Native方法服务。
3. 虚拟机执行引擎
java虚拟机执行引擎是将字节码解析执行,最后输出结果。
运行时栈帧结构
栈帧时用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的java线程栈的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态链接和方法返回地址等信息。
针对上面的栈结构,我们重点解释一下局部变量表,操作栈,动态链接,方法返回地址这几个概念:
1. 局部变量表:是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。方法所需要的最大局部变量表的容量是由编译为Class文件的时候,在方法Code属性中的max_locals数据项中存放。
2. 操作数栈:方法开始执行的时候栈为空,执行过程中不断出栈和入栈。比如方法调用的时候就是通过操作数栈来进行参数传递。 操作数栈的最大深度也在编译的时候写入到code属性的max_stacks数据项中。
3. 动态链接:每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用为了支持方法调用过程中的动态连接。将class文件中常量池中的符号引用在每一次的运行期间转化为直接引用,这部分叫做动态连接。另外一部分在类加载阶段或者在第一次使用的时候转为直接引用的过程叫做静态解析。
4. 方法返回地址:当一个方法开始执行后,只有两种方式可以退出这个方法,正常完成出口 和 异常完成出口。前者使用调用者的PC计数器的值作为返回地址,后者通过异常处理器表来确定,栈帧中一般不会保存这部分信息。
基于栈的字节码解释执行引擎
虚拟机如何执行方法里面的字节码指令的呢?java虚拟机的执行引擎在执行java代码的时候都有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码进行执行)两种执行。
1、解释执行
上图中下面的分支是传统编译原理中程序代码到目标机器代码的生成过程,而中间那条分支自然就是解释执行的过程。
java语言中,javac编译器完成了程序代码经过词法分析、语法分析到抽象语法数、再遍历语法生成线性的字节码指令流的过程,因为这一部分动作是在java虚拟机之外进行的,而解释器在虚拟机的内部,所以java程序的编译就是半独立的实现。
2、基于栈的指令集与基于寄存器的指令集
java编译器输出的指令流,基本上是一种基于栈的指令集架构,指令流里面的指令大部分都是零地址指令,它们依赖操作数栈进行工作,与之相对的另外一套常用的指令集架构是基于寄存器的指令集,最典型的就是x86的二地址指令集,更通俗一些, 就是现在我们驻留pc中直接支持的指令集架构,这些指令依赖寄存器进行工作。那么基于栈的指令集和基于寄存器的指令集在这两者之间又什么不同呢?
例如,分别使用这两种指令集去计算“1+1”的结果,基于栈的指令计算过程:
iconst_1
iconst_1
iadd
istore_0
两个iconst_1指令连续的把两个常量1压入栈后,iadd指令把栈顶的两个值出栈并相加,然后把结果放回栈顶,最后istore_0把栈顶的值放到局部便量表的第0个slot中。
如果是基于寄存器的指令集,程序会是这样的:
mov eax,1
add eax,1
mov指令把EAX寄存器的值设为1,然后add指令再把这个值加1,结果就保存在EAX寄存器中。
两套指令集谁更好一些呢?两套指令集同时并存和发展,各有优势,基于栈的指令集最主要的优点就是可移植性,寄存器由硬件直接提供,程序直接依赖这些硬件寄存器则不可避免的要受到硬件的约束。
栈架构指令集的主要缺点是执行速度相对来说稍慢一些。栈架构指令集的代码虽然紧凑,但是完成相同功能所需的指令数量一般会比寄存器架构多,因为出栈、入栈操作本身就产生了相当多的指令。更重要是栈实现在内存中,频繁的栈访问也就意味着频繁的内存访问,相对于处理器来说,内存始终是执行速度的瓶颈,尽管虚拟机可以采用栈顶缓存的手段,把最常用的操作映射到寄存器中以避免直接内存访问,但这也只是优化措施而不是解决本质问题的方法,因此,由于指令数量和内存访问的原因,导致了栈架构指令集的执行速度相对较慢。
参考:
《深入理解java虚拟机》周志华著;