众所周知,Java源代码被编译器编译成class文件。而并不是底层操作系统可以直接执行的二进制指令(比如Windows OS的.exe文件)。因此,我们需要有一种平台可以解释class文件并运行它。而做到这一点的正是Java 虚拟机(JVM)。
实际上,JVM是一种解释执行class文件的规范技术。各 个提 供商都可以根据规范,在不同的底层平台上实现不同的JVM。
下面是JVM实现的基本结构框图。其中类装载子系统、运行时数据区、执行引擎等 是JVM的必须要解决的几大问题。
★ 类装载器子系统
在JVM中,类装载器子系统负责查找并装载Class文件。关于这部分的装载细节详见《JVM加载class文件的原理 》
★ 运行时数据区
当Java虚拟机运行一个程序时,需要内存在存储许多东西。比如字节码,程序创建的对象,传递的方法参数,返回值,局部变量等等。JVM会把这些东西都组织到几个“运行时数据区”中便于管理。
(1) 方法区
当JVM使用类装载器定位Class文件,并将其输入到内存中时。会提取Class文件的类型信息,并将这些信息存储到方法区中。同时放入方法取中的还有该类型中的类静态变量。下面我们具体看看需要存储哪些信息?
●该类型的全限定名。如java.io.FileOutputStream
●该类型的直接超类的全限定名。如java.io.OutputStream
●该类型是类类型还是接口类型。
●该类型的访问修饰符(public、abstract、final)。
●任何直接超接口的全限定名的有序列表。如java.io.Closeable, java.io.Flushable。
●该类型的常量池。比如所有类型、方法和字段的符号。基本数据类型的直接数值等。
●字段信息。对类型中声明的每个字段,方法区中必须保存下面的信息。除此之外,这些字段在类或者接口中的声明顺寻也必须保存。
字段名
字段的类型
字段的修饰符(public, private, protected, static, final, volatile, transient的某个子集)
●方法信息。和字段一样保存方法的相关信息。
方法名
方法的返回类型
方法的参数的数量和类型
方法的修饰符
方法的字节码
操作数栈和栈帧中局部变量的大小 (见下面Java栈的内容)
异常表
●类静态变量。这里要注意:类中的静态变量时存放在方法区中的。并不是存放在堆中某一个该类型的对象中的。 也就是我们常说的“类静态变量属于类,而不属于对象”这句话的由来了。
●指向ClassLoader类的引用。任何类都需要被类装载器装入内存。如果是被用户自定义类装载器装载的,那么JVM必须在类型信息中存储对该装载器对象的引用。
●指向Class类的引用。对于每一个被装载的类型,虚拟机都会相应的为它创建一个java.lang.Class类的实例,而且虚拟机还必须以某种方式把这个实例和存储在方法区中的类型信息关联起来。 这就使得我们可以在程序运行时查看某个加载进内存的类的当前状态信息。也就是反射机制实现的根本。 ●方法表。 为了能快速定位到类型中的某个方法。JVM对每个装载的类型都会建立一个方法表,用于存储该类型对象可以调用的方法的直接引用,这些方法就包括从超类中继承来的。而这张表与Java动态绑定机制( 参见《java动态绑定机制实现多态 》 )的实现是密切相关的。
方法区是多线程共享的。也就是当虚拟机实例开始运行程序时,边运行边加载 进Class文件。不同的Class文件都会提取出不同类型信息存放在方法区中。同样,方法区中不再需要运行的类型信息会被垃圾回收线程丢弃掉。右图形象的显示出了方法区的样子。 |
(2) 堆
Java 程序在运行时创建的所有类型对象和数组都存储在堆中。JVM会根据new指令在堆中开辟一个确定类型的对象内存空间。但是堆中开辟对象的空间并没有任何 人工 指令可以回收,而是通过JVM的垃圾回收器负责回收。
堆中对象存储的是该对象以及对象所有超类的实例数据(但不是静态数据), 比如下面的类型:
class X{
private int data;
private static int stcdata=0;
public X(int d){
this.data=d;
}
}
X x1=new X(100);
X x2=new X(200);
这样在堆中开辟了两个对象x1和x2的内存空间。其中x1中的一个实例数据data=100,而x2的data=200。但是这两个对象中都没有stcdata这样的数据,这个静态数据存储在上面讲到的方法区中。
此外,堆中对象还必须有指向方法区中的类信息数据(见上面方法区)。 为什么需要这个信息呢?因为当程序在运行时需要对象转型,那么JVM必须检查当前对象所属类型及父类的信息。以判断转型是否是合法的,而这一点也是instanceof操作符实现的基础。
当然,上述只是JVM的规范,具体堆的实现是由JVM设计者来决定。下面两幅图就直观的表现出了堆对象的不同实现结构:
其中一个对象的引用可能在整个运行时数据区中的很多地方存在,比如Java栈,堆,方法区等。
堆中对象还应该关联一个对象的锁数据信息以及线程的等待集合。 这些都是实现Java线程同步机制的基础。但实际上很多具体实现中并不在对象自身内部保存一个指向锁数据的指针。而只有当第一次需要加锁的时候才分配对应锁数据。另外,每个对象都会从Object中继承三个Object方法(wait、notify、notifyAll),当某个线程在一个对象上调用了等待方法时。JVM就会阻塞这个线程,并把这个线程放在该对象的等待集合中。知道另外一个线程在该对象上调用了notify/notifyAll,JVM才会在等待集合中唤醒一个或全部的等待线程(参见《正确理解线程等待和释放(wait/notify) 》)。
【数组对象】
在Java中,数组也是对象,那么自然在堆中会存储数组的信息。事实也确实如此,对于JVM而言,数组与其他类对象没有任何区别。
数组也有属于的类Class,具有相同维度和类型的数组都是同一个类的实例,而不管数组的长度是多少。
数组类的名称由两部分构成:(1)每一维用一个方括号“[”表示。(2) 用字符或字符串表示元素类型。比如一维数组对象int[] a所属类型名为"[I",二维数组对象byte[] b所属类型名为"[[B"。
下图是二维数组对象在堆中的具体实现方式:
(3) 程序计数器
对于一个运行的Java而言,每一个线程都有一个PC寄存器。当线程执行Java程序时,PC寄存器的内容总是下一条将被执行的指令地址。
(4) Java栈 - 栈帧
每启动一个线程,JVM都会为它分配一个Java栈,用于存放方法中的局部变量,操作数以及异常数据等。当线程调用某个方法时,JVM会根据方法区中该方法的字节码组建一个栈帧。并将该栈帧压入Java栈中,方法执行完毕时,JVM会弹出该栈帧并释放掉。
注意,Java栈中的数据是线程私有的,一个线程是无法访问另一个线程的Java栈的数据。这也就是为什么多线程编程时,两个相同线程执行同一方法时,对方法内的局部变量时不需要数据同步的原因。
【栈帧 】
栈帧有三部分构成:局部变量区、操作数栈和帧数据区。在编译器编译Java代码时,就已经在字节码中为每个方法都设置好了局部变量区和操作数栈的数据和大小。并在JVM首次加载方法所属的Class文件时,就将这些数据放进了方法区。因此在线程调用方法时,只需要根据方法区中的局部变量区和操作数栈的大小来分配一个新的栈帧的内存大小,并堆入Java栈。
局部变量区: 用来存放方法中的所有局部变量值,包括传递的参数。这些数据会被组织成以一个字长(32bit或64bit)为单位的数组结构(以索引0开始)中。其中类型为int, float, reference(引用类型,记录对象在堆中地址)和returnAddress(一种JVM内部使用的基本类型)的值占用1个字长,而byte, char和shot会扩大成1个字长存储,long,double则使用2个字长。
操作数栈: 用来在执行指令的时候存储和使用中间结果数据。
帧数据区: 常量池的解析,正常方法返回以及异常派发机制的信息数据都存储在其中。
下图展示了addAndPrint()调用addTwoTypes()时,Java栈的变化:
★ 执行引擎
运行Java的每一个线程都是一个独立的虚拟机执行引擎的实例。从线程生命周期的开始到结束,他要么在执行字节码,要么在执行本地方法。一个线程可能通过解释或者使用芯片级指令直接执行字节码,或者间接通过JIT执行编译过的本地代码。
指令集: 实际上,Class文件中方法的字节码流就是有JVM的指令序列构成的。每一条指令包含一个单字节的操作码,后面跟随0个或多个操作数。
Java虚拟机指令集关注的中心是操作数栈和局部变量集合。我们可以看看下面一组指令在执行引擎中执行的过程:
很显然,上面的指令反复用到了Java栈中的某一个方法栈帧。实际上执行引擎运行Java字节码指令很多时候都是在不停的操作Java栈,也有的时候需要在堆中开辟对象以及运行系统的本地指令等。但是Java栈的操作要比堆中的操作要快的多,因此反复开辟对象是非常耗时的。这也是为什么Java程序优化的时候,尽量减少new对象。
下面将会是很有趣的过程,我们用一段代码来生动的展现JVM是如何运行这段程序的。
通过编译器将下面的代码编译成edu/hr/jvm/Test.class 和 edu/hr/jvm/bean/Act.class。然后开始启动JVM:
(1) 首先OS会创建一个JVM实例(进行必要的初始化工作,比如初始启动类装载器,初始运行时内存数据区等)。
(2) 然后通过自定义类装载器 加载Test.class。并提取Test.class字节码中的信息存放在方法区 中(具体的信息在上面已经讲过)。右图展示了方法区中的Test类信息,其中在常量池中有一个符号引用"Act"(注意:这个引用目前还没有真正的类信息的内存地址)。
(3) 接着JVM开始从Test类的main字节码处开始解释执行。在运行之前,会在Java栈中组建一个main方法的栈帧 。如右图Java栈所示。JVM需要运行任何方法前,通过在Java栈 中压入一个帧栈。在这个帧栈的内存区域中进行计算。
(4) 现在可以开始执行main方法的第一条指令——JVM需要为常量池 的第一项的类(符号引用Act)分配内存空间。但是Act类此时还没有加载进JVM(因为常量池目前只有一个"Act"的符号引用)。 |
(5) JVM加载进Act.class,并提取Act类信息 放入方法区中。见上图方法区所示,然后以一个直接指向方法区Act类信息的直接引用替换开始在常量池中的符号引用"Act",这个过程就是常量池解析 。以后就可以直接访问Act的类信息了。
(6) 此时JVM可以根据方法区中的Act类信息,在堆中开辟一个Act类对象 act。见上图堆所示。
(7) 接着开始执行main方法中的第二条指令调用doMathForever。这个可以通过堆中act对象所指的方法表 中查找,然后定位到方法区中的Act类信息中的doMathForever方法字节码。在运行之前,仍然要组建一个doMathForever栈帧压入Java栈,如上图所示。(注意:JVM会根据方法区中doMathForever的字节码来创建栈帧的局部变量区和操作数栈的大小)
(8) 接下来JVM开始解释运行Act.doMathForever字节码的内容了。下面我们详细的描述一下这个JVM的运行过程: ● 我们首先看一下doMathForever方法的字节码在方法区中的指令如右图,其中bytecode是指令的二进制编码 ,mnemonic是指令助记符 ,pc为程序计数器 (指向当前运行指令的下一条),offset为指令存放在方法区中的地址偏移 。
●然后在上面的图Java栈中已经显示出了doMathForever方法的栈帧,其中比较重要的两个部分是局部变量区和 操作数栈 。而此时在运行指令之前,局部变量区中只有一个整型i 的存储位置(1个字长)。而操作数栈中还没有被创建了2个字长的大小(存储大小是帧栈创建的时候由方法区中的数据确定的)。
局部变量区 index hex value value (变量i) 0
optop: 0 操作数栈 offset hex value value optop-> 0 1
|
● 下面运行每一条指令后,看一下局部变量区和操作数栈的变化:
① 指令[iconst_0] 将int类型变量的数据0压入操作数栈。
局部变量区 操作数栈
index hex value value offset hex value value
(变量i) 0 0 00000000 0
optop-> 1
② 指令[istore_0] 弹出操作数栈顶的数据0,将结果存储在局部变量区中index=0的空间中。
局部变量区 操作数栈
index hex value value offset hex value value
(变量i) 0 00000000 0 optop-> 0
1
③指令[iinc 0 1] 把常量值1加到局部变量区中index=0的空间上。
局部变量区 操作数栈
index hex value value offset hex value value
(变量i) 0 00000001 1 optop-> 0
1
④指令[iload_0] 把局部变量区index=0中的数据堆入操作数栈。
局部变量区 操作数栈
index hex value value offset hex value value
(变量i) 0 00000001 1 0 00000001 1
optop-> 1
⑤指令[iconst_2] 把int类型变量的数据2压入操作数栈。
局部变量区 操作数栈
index hex value value offset hex value value
(变量i) 0 00000001 1 0 00000001 1
1 00000002 2
optop->
⑥指令[imul] 弹出操作数栈中的两个数据1和2,相乘之后的结果2堆入操作数栈
局部变量区 操作数栈
index hex value value offset hex value value
(变量i) 0 00000001 1 optop-> 0 00000002 2
1
⑦指令[istore_0] 弹出操作数栈顶的数据2,将结果存储在局部变量区中index=0的空间中。
局部变量区 操作数栈
index hex value value offset hex value value
(变量i) 0 00000002 2 optop-> 0
1
⑧指令[goto 2] 跳转到指令iinc 0 1处循环执行下去.....
当然,这个例子不停的执行下去只会出现算术溢出,也就是一个字长(2bytes)的整型变量i 无法表示不停计算的结果了。但是JVM不会抛出任何异常,