JVM体系结构与工作方式概览

 约 17 分钟

Java之所以号称“一次编译,到处运行”,主要原因是JVM屏蔽了各个计算机平台相关的软件(大多指系统)或者硬件之间的差异,使得与平台相关的耦合统一由JVM提供者来实现。在本文,笔者将与大家概览JVM的体系结构与工作方式。

JVM体系结构详解

JVM和实体机器的体系结构有点相似,主要由以下几个部分组成:

  • 自己的指令集(篇幅过大,这里不会描述)
  • 类加载器(在JVM启动时或者在类运行时将需要的class加载到JVM中)
  • 执行引擎(执行引擎的任务是负责执行class文件中包含的字节码指令,相当于实际机器上的CPU)
  • 内存区(将内存划分成若干区以模拟实际机器上的存储、记录和调度功能模块,如实际机器上的各种功能的寄存器或者PC指针的记录器等)
  • 本地方法调用(调用C或C++实现的本地方法的代码返回结果)

JVM-System.jpg

下面简单介绍一下

执行引擎

执行引擎是JVM的核心部分,执行引擎的作用就是解析JVM字节码指令,得到执行结果。JVM虚拟机规范详细地定义了执行引擎遇到每条字节码指令时应该处理什么,并且应该得到什么结果。但是并没有规定执行引擎应该如何或采用什么方式处理而得到这个结果。因为执行引擎具体采取什么方式由JVM的实现厂家自己去实现,是直接解释执行还是采用JIT转换成本地代码去执行,还是采用寄存器这个芯片模式去执行都可以。所以执行引擎的具体实现有很大的发挥空间,如SUN的hotspot的基于栈的执行引擎,而Google的Dalvik的基于寄存器的执行引擎。

执行引擎也就是执行一条条代码的一个流程,而代码都是包含在方法体内的,所以执行引擎本质上就是执行一个个方法所串起来的流程,对应到操作系统中一个执行流程是一个Java进程还是一个Java线程呢?很显然是后者,因为一个Java进程可以有多个同时执行的执行流程。这样说来每个Java线程就是一个执行引擎的实例,那么在一个JVM实例中就会同时有多个执行在引擎在工作,这些执行引擎有的在执行用户的程序,有的在执行JVM内部的程序(如Java垃圾收集器)。

Java内存管理

执行引擎在执行一段程序时需要存储一些东西,如:操作码需要的操作数,操作码执行结果需要保存。Class类的字节码还有类的对象等信息都需要在执行引擎执行之前就准备好。一个JVM实例会有一个方法区、Java堆、Java栈、PC寄存器和本地方法区。其中方法区和Java堆是所有线程共享的,也就是可以被所有执行引擎实例访问。每个新的执行引擎实例被创建时会为这个执行引擎创建一个Java栈和一个PC寄存器,如果当前正在执行一个Java方法,那么在当前的这个Java栈中保存的是该线程中方法调用的状态,包括方法的参数、方法的局部变量、方法的返回值以及运算的中间结果等。而PC寄存器会指向即将执行的下一个指令。

如果是本地方法调用,则存储在本地方法调用栈中或者特定实现中的某个内存区域中。

考虑到篇幅大小,故另写一篇文章:浅析JVM之内存管理

JVM工作机制

之前简单分析了JVM的基本结构,下面再简单分析一下JVM是如何执行字节命令的,也就是前面介绍的执行引擎是如何工作的。

我们知道,计算机只接受机器指令,其他高级语言必须先经过编译器编译成机器指令才能被计算机正确执行。然而机器语言一般和硬件平台密切相关(指令集、CPU架构的因素等),但高级语言会屏蔽所有底层硬件平台甚至软件平台。之所以可以屏蔽是因为中间有个编译环节,与硬件耦合的麻烦就交给了编译器。所以,想说的是:编译器和操作系统的关系非常密切。比如C语言在win下编译器为Microsoft C,而Linux下通常是gcc。

通常一个程序从编写到执行会经历以下阶段:

  1. 源代码(source code)
  2. 预处理器(preprocessor)
  3. 编译器(compiler)
  4. 汇编程序(assembler)
  5. 目标代码(object code)
  6. 链接器(linker)
  7. 可执行程序(executables)

除了1、7两步,其他都是由现代意义上的编译器统一完成的。最常见的栗子是在Linux平台下我们通常安装一个软件需要经过configure、make、make install、make clean这4个步骤来完成。

  • configure为这个程序在当前的操作系统环境下选择适合的编译器来编译这个程序代码,也就是为这个程序代码选择合适的编译器和一些环境参数;
  • make 可以猜到:对程序代码进行编译操作,将源码编译可执行的目标文件
  • make install 将已经编译好的可执行文件安装到操作系统指定或者默认的安装目录下
  • make clean 用删除编译时临时产生的目标文件

值得注意的是,我们通常所说的是编译器都是将某种高级语言直接编译成可执行的目标机器语言(实际上在某种操作系统中是ixuyao动态连接的二进制文件:在Windows下是dynamic link library,Dll;在linux下是Shared library,SO库)。但是实际上还有一些编译是将一种高级语言编译成另一种高级语言,或者将低级语言编译成高级语言(反编译),或者将高级语言编译成虚拟机目标语言,如Java编译器等。

再回到如何让机器(不管是实体机还是虚拟机)执行代码的主题,不管是如何指令集都只有集中最基本的元素:加、减、乘、除、求余、求模等。这些运算又可以进一步分解成二进制位运算:与、或、异或等。这些运算又通过指令来完成,而指令的核心目的就是确定需要运算的种类(操作码)和运算需要的数据(操作数),以及从哪里(寄存器或栈)获取操作数、将运算结果存放到什么地方(寄存器或是栈)等。这种不同的操作方式又将指令划分成:一地址指令、二地址指令、三地址指令和零地址指令等n地址指令。相应的指令集会有对应的架构实现,如基于寄存器的架构实现或基于栈的架构实现,这里的基于寄存器或栈都是指在一个指令中的操作数是如何存取的。

JVM为何选择基于栈的架构

学过数据结构的小伙伴都知道,对栈进行操作是要先将所有的操作数压入栈,然后根据指令中操作码选择一定的元素弹出计算后再压入栈。相对于寄存器操作(将两个操作数存入寄存器后进行加法运算后再将加过存入其中一个寄存器即可)是比较麻烦的。那么,JVM为什么还要基于栈来设计呢

  1. JVM要设计成与平台无关

    • 有些平台上的寄存器很少或者根本没有,而且以处理器架构的角度来说,设计一套通用的寄存器指令是很困难的。比如在android上,google的Dalvik VM就是基于ARM平台设计的寄存器架构,这样性能上的确更优了,但是牺牲了跨平台的移植性。
  2. 为了指令的紧凑型

执行引擎的架构设计

每当创建一个新的线程时,JVM会为这个线程创建一个Java栈,同时会为这个线程分配一个PC寄存器,并且这个PC寄存器会指向这个线程的第一行可执行代码。每当调用一个新方法时会在这个栈上创建一个新的栈帧数据结构,这个帧栈会保留这个方法的一些元信息——如这个方法中定义的局部变量、一些用来支持常量池的解析、正常方法返回及异常处理机制等。

JavaExecuteComponent.jpg

JVM调用某些指令时可能需要使用到常量池中的一些常量,或者是获取常量代表的数据或者这个数据指向的实例化对象,而这些信息都存储在所有线程共享的方法区和Java堆中。

 执行引擎的执行过程

下面以一个简单的程序来说明执行引擎的执行过程。

public class Math{
  public static void main(String[]args){
    int a=1;
    int b=2;
    int c = (a+b)*10;
  }
}

其中对应的字节码指令如下:

偏移量 指令 说明
0 iconst_1 常数1入栈
1 istore_1 将栈顶元素移入本地变量1存储
2 iconst_1 常数2入栈
3 istore_2 将栈顶元素移入本地变量1存储
4 iload_1 本地变量1入栈
5 iload_2 本地变量2入栈
6 iadd 弹出栈顶两个元素相加
7 bipush 10 将10入栈
9 imul 栈顶两个元素相乘
10 istore_3 栈顶元素移入本地变量3存储
11 return 返回

对应到执行引擎的各执行部件如图

执行引擎过程分析1-1.jpg

在开始执行方法之前,PC寄存器存储的指针是第1条指令的地址,局部变量区和操作栈都没有数据。从第1条和第4条指令分别将a、b两个本地变量赋值,对应到局部变量区就是1和2分别存储常数1和2。

前4条指令执行完后,PC寄存器当前指向的是下一条指令地址,也就是第5条指令,这时局部变量区已经保存了两个局部变量(也就是变量a和变量b的值),而操作栈里仍然没有值,因为两次常数入栈后又分别出栈了。

执行引擎过程分析1-2.jpg

将第5条和第6条指令分别是将两个局部变量入栈,然后相加。如图

执行引擎过程分析1-3.jpg

1先入栈2后入栈,栈顶元素是2,第7条指令是栈顶的两个元素弹出后相加,将结果再入栈,这时整个部件状态如图

执行引擎过程分析1-4.jpg

可以看出,变量a和变量b想加的结果3存在当前栈的栈顶中,接下来是第8条指令将10入栈,如图

当前PC寄存器执行的地址是9,下一个操作是将当前栈的两个操作数弹出进行相乘并把结果压入栈,如图

执行引擎过程分析1-5.jpg

第10条指令是将当前的栈顶元素存入局部变量3中,这是状态如图

执行引擎过程分析1-6.jpg

第10条指令执行完后栈中元素出栈,出栈的元素存储在局部变量区3中,对应的是变量c的值。最后一条指令是return ,这条指令执行完后当前的这个方法对应的这些部件会被JVM回收,局部变量区的所有值将全部释放,PC寄存器会被销魂,在Java栈中与这个方法对应的栈帧将消失。

 JVM方法调用栈

JVM的方法调用分别为两种:

  1. Java方法调用
  2. 本地方法调用

由于本地方法调用各个虚拟机的实现不太相同,所以这里主要介绍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)
    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中,然后调用静态math方法。从math的字节码指令可以看出,math方法的两个参数也存储在其对应的方法栈帧中的局部变量区0和1中,先将这两个局部变量分别入栈,然后进行相加操作再和常数10相乘。

那么来看一下实际的操作,如图

JVM方法调用栈分析2-0.jpg

上图是JVM执行到第5条指令时,执行引擎各部件的状态图,PC寄存器指向的是下一条执行math方法的地址。当执行invokestatic指令时JVM会为math方法创建一个新的栈帧,并且将两个参数存在math方法的栈帧的前两个局部变量区中,这时PC寄存器会清零,并且会指向math方法对应栈帧的第一条指令地址,这时的状态如下图

JVM方法调用栈程分析2-1.jpg

执行invokestatic指令时,创建了一个新的栈帧,这是栈帧的局部变量中已经有了两个变量了,这两个变量是从main方法的栈帧中的操作栈中传过来的。当执行math方法时,math方法对应的栈帧成为当前的活动栈帧,PC寄存器保存的是当前这个战争中的下一条指令地址,所以是0。

math方法先将a、b两个变量相加,再乘以10,最后返回这个结果执行到第5条指令的状态,如下图

JVM方法调用栈过程分析2-2.jpg

math的操作栈中的栈顶元素相乘的结果是30,最后一条指令是ireturn,这条指令是将当前栈帧中的栈顶元素返回到调用这个方法的栈中,而这个栈帧也将撤销,PC寄存器的值回复调用栈的下一条指令地址,如下图

JVM方法调用栈过程分析2-3.jpg

main方法将math方法返回的结果再除以10存放在变量区3中,这时的状态如图所示

JVM方法调用栈过程分析2-4.jpg

当执行return指令时main方法对应的栈帧也将撤销,如果当前线程对应的Java栈中没有栈帧,这个Java栈也将被JVM撤销,整个JVM退出。

阅读 2.7k

推荐阅读
泊浮说
用户专栏

作者是个热爱分享交流的人,所以有了这个专栏。你的点赞是我最大的更新动力。

56 人关注
45 篇文章
专栏主页
目录