JVM的全名是Java Virtual Machine(Java虚拟机)。它是通过模拟一个计算机的形式来实现到计算机所具有的计算功能。让我们先来看看一台真实的计算机具备计算功能的条件:
指令集 | 这个计算机所能识别的机器语言的命令集合。 |
计算单元 | 即能够识别并且控制指令执行的功能模块 |
寻址方式 | 地址的位数、最小地址和最大地址范围,以及地址的运行规则 |
寄存器定义 | 包括操作数寄存器、变址寄存器、控制寄存器等的定义、数量和使用方法 |
存储单元 | 能够存储操作数和保存操作结构的单元,如内核级缓存、内存和磁盘等 |
指令集:所谓指令集就是在CPU中用来计算和控制计算机系统的一套指令的集合,每种CPU在设计的时候就已经规定了一套与硬件电路想配合的指令系统。指令集的先进与否是体现CPU性能 的一个重要指标。
指令集和汇编语言有什么关系?指令集是可以直接被机器识别的机器码,也就是说它必须以二进制格式存在于计算机中。而汇编语言则是能够被人识别的指令,汇编语言在顺序和逻辑上是与机器指令一一对应的。换句话说,就是汇编语言是为了让人能够更容易记住机器指令而使用的助记符。
RISC)和复杂指令集(Complex Instruction Set Computing, CISC)。当前我们普遍使用的桌面操作系统中基本上使用的都是CISC,如x86架构的CPU都使用复杂指令集。除了这两种指令集之外Intel和AMD公司还在它们的基础上开发出了很多扩展指令集,如MMX(MultiMediaeXtension,多媒体扩展指令)使得在处理多媒体数据时性能更强,还有AMD公司为提高3D处理性能开发的3DNow!指令集等。
JVM体系结构分析
结构基本上由四部分组成:
类加载器 | 在JVM启动时或者是在类运行时将需要的class文件加载到JVM中 |
执行引擎 | 负责执行class文件中包含的字节码指令,相当于实际机器上的CPU |
内存区 | 将内存划分为若干个区以模拟实际机器上的存储、记录和调度功能模块 |
本地方法调用 | 调用C或C++实现的本地方法的代码返回结果 |
类加载器:
每个被JVM装载的类型都有一个对应的java.lang.Class类的实例来表示该类型,该类型可以唯一的表示被JVM装载的class类,要求这个实例和其他类的实例一样都存放在Java的堆中。
执行引擎:
执行引擎的作用就是解析JVM字节码指令,得到执行结果。在《Java虚拟机规范》中详细地定义了执行引擎遇到每条字节码指令时应该处理什么,并且应该得到什么结果。但是没有规定执行引擎应该如何过采取什么方式处理而得到这个结果。不同的厂商有自己不同的实现方式。执行引擎也就是执行一条条代码的一个流程,而代码都是包含在方法体内的,所以执行引擎本质上就是执行一个个方法所串起来的流程。对应到操作系统中一个执行流程就是一个Java线程。因为一个Java进程可以有多个同时执行的执行流程。所以每个Java线程就是一个执行引擎的实例。
Java内存管理:
执行引擎在执行一段程序时需要存储一些东西(如操作数、执行结果等)。class类的字节码还有类的对象等信息都需要在执行引擎执行之前就准备好。JVM实例会有一个方法区、Java堆、Java栈、PC寄存器和本地方法区。其中方法区和Java堆是所有线程共享的,也就是说可以被所有的执行引擎实例访问。每个新的执行引擎实例被创建时会为这个执行引擎创建一个Java栈和一个PC寄存器,如果当前正在执行一个Java方法,那么当前的这个Java栈中保存的是该线程中方法调用的状态,包括方法的参数、方法的局部变量、方法的返回值以及运算的中间结果等。而PC寄存器会指向即将执行的下一条指令。
JVM的工作机制
JVM执行字节码指令是基于栈的架构,也就是所有的操作系统必须先入栈,然后根据指令中的操作码选择从栈顶弹出若干个元素进行计算后再将结果压入栈。在JVM中操作数可以存放在每一个栈帧中的一个本地变量集中,即在每个方法调用时就会给这个方法分配一个本地变量集,这个本地变量集在编译时就已经确定,所有操作数入栈可以直接是常量入栈或者从本地变量集中取一个变量压入栈中。这和一般的基于寄存器的操作有所不同,一个操作需要频繁地入栈和出栈,如进行一个加法运算,如果两个操作数都在本地变量中,那么一个加法操作就要有5次栈操作,分别是将两个操作数从本地变量入栈(2次入栈操作),再将两个操作数出栈用于加法运算(2次出栈),再将加法结果压入栈顶(1次入栈)。如果是基于寄存器的话,一般只需要将两个操作数存入寄存器进行加法运算后
再将结果存入其中一个寄存器即可,不需要这么多的数据移动的操作。
那么为什么JVM还要基于栈来设计呢?
第一个理由是JVM要设计成与平台无关的,而平台无关性就是要保证在没有或者有很少的寄存器的机器上也要同样能正确地执行Java代码。例如,在80x86的机器上寄存器就是没有规律的,很难针对某一款机器设计通用的基于寄存器的指令,所以基于寄存器的架构很难做到通用。在手机操作系统方面,Google的Android平台上的DalvikVM就是基于特定芯片(ARM)设计的基于寄存器的架构,这样在特定芯片上实现基于寄存器的架构可能更多考虑性能,但是也牺牲了跨平台的移植性,当然在当前的手机上这个需求还不是最迫切的。
第二个理由是为了指令的紧凑性,因为Java的字节码可能在网络上传输,所以class文件的大小也是设计JVM字节码指令的一个重要因素,如在class文件中字节码除了处理两个表跳转的指令外,其他都是字节对齐的,操作码可以只占一个字节大小,这都是为了尽量让编译后的class文件更加紧凑。为了提高字节码在网络上的传输效率,Sim设计了一个Jar包的压缩工具Pack200,它可以将多个class文件中的重复的常量池的信息进行合并,如一般在每个class文件中都含有“Ljava/lang/String;”,那么多个class文件中的常量就可以共用,从而起到减少数据量的作用。
执行引擎的架构设计
每当创建一个新的线程时,JVM会为这个线程创建一个Java栈,同时会为这个线程分配一个PC寄存器,并且这个PC寄存器会指向这个线程的第一行可执行代码。每当调用一个新方法时会在这个栈上创建一个新的栈帧数据结构,这个栈帧会保留这个方法的一些元信息,如在这个方法中定义的局部变量、一些用来支持常量池的解析、正常方法返回及异常处理机制等。JVM在调用某些指令时可能需要使用到常量池中的一些常量,或者是获取常量代表的数据或者这个数据指向的实例化的对象,而这些信息都存储在所有线程共享的方法区和Java堆中。
单一执行过程举例
public class Math {
public static void main(String[] args) {
int a=l;
int b=2;
int c = (a+b)*10;
}
}
偏移量 指令 说明
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 返回
前4条指令执行完后,PC寄存器当前指向的是下一条指令地址,也就是第5条指令,这时局部变量区已经保存了两个局部变量(也就是变量a和b的值),而操作栈里仍然没有值,因为两次常数入栈后又分别出栈了(store会让变量出栈)。第5条和第6条指令分别是将两个局部变量入栈,然后相加,1先入栈2后入栈,栈顶元素是2,第7条指令是将栈顶的两个元素弹出后相加,将结果再入栈。可以看出,变量a和b相加的结果3存在当前栈的栈顶中,接下来是第8条指令将10入栈,当前PC寄存器执行的地址是9,下一个操作是将当前栈的两个操作数弹出进行相乘并把结果压入栈中,第10条指令是将当前的栈顶元素存入局部变量3中,第10条指令执行完后栈中元素出栈,出栈的元素存储在局部变量区3中,对应的是变量c的值。最后一条指令是return,这条指令执行完后当前的这个方法对应的这些部件会被JVM回收,局部变量区的所有值将全部释放,PC寄存器会被销毁,在Java栈中与这个方法对应的栈帧将消失。
JVM方法调用栈举例
JVM的方法调用分为两种:一种是Java方法调用,另一种是本地方法调用。本地方法调用由于各个虚拟机的实现不太相同,所以这里主要介绍Java的方法调用情况。
public class Math {
public statie 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相乘,最后将结果返回。
当执行invokestatic指令时JVM会为math方法创建一个新的栈帧,并且将两个参数存在math方法对应的栈帧的前两个局部变量区中,这时PC寄存器会清零,并且会指向math方法对应栈帧地第一条指令地址,执行invokestatic指令时,创建了一个新的栈帧,这时栈帧中的局部变量区中已经有两个变量了,这两个变量是从mam方法的栈帧中的操作栈中传过来的。当执行math方法时,math方法对应的栈帧成为当前的活动栈帧,PC寄存器保存的是当前这个栈帧中的下一条指令地址,所以是0。math方法先将a、b两个变量相加,再乘以10,最后返回这个结果执行到第5条指令的状态,math的操作栈中的栈顶元素相乘的结果是30,最后一条指令是ireturn,这条指令是将当前栈帧中的栈顶元素返回到调用这个方法的栈中,而这个栈帧也将撤销,PC寄存器的值恢复调用桟的下一条指令地址, main方法将math方法返回的结果再除以10存放在局部变量区3中,当执行return指令时main方法对应的栈帧也将撤销,如果当前线程对应的Java栈中没有栈帧,这个Java栈也将被JVM撤销,整个JVM退出。