JVM学习(一):JVM的结构

jkouu 74 0

首先先放一张JVM的工作流程图:

JVM学习(一):JVM的结构

 

就像上图所说的那样,我们编写的代码——也就是.java文件,会被编译器编译成类文件——也就是.class文件。如果大家有兴趣,可以自己在电脑里找找。对IDEA来说,生成的.class文件会放在同项目下与src文件夹同级的out文件夹内。像下图一样:

JVM学习(一):JVM的结构

类文件是一个二进制文件。编译器会将类的一系列属性按照一定顺序存储在对应的类文件中。由于我们关注的问题不是Java的编译,所以类文件的具体组织形式这里就不多说了。总之,大家可以把类文件理解成是Java的可执行文件。只要有类文件,Java的程序就可以执行,这也就是Java的“一次编译,到处运行”特点中“一次编译”的由来。

这里再顺便说另外一个问题。在刚学Java的时候,我也有这么一个问题:Java是编译型语言吗?

可能很多和我一样,觉得既然Java有编译器了,那肯定是编译型语言了。但是这想法是不对的,因为编译的定义是从源语言编写的源程序产生目标程序,但是我们产生的类文件是任何一个平台可以直接运行的程序吗?不是。从流程图中我们可以看到,类文件最终还是要被JVM的类加载器解析。那么很显然,Java就不是编译型语言了,它是解释型语言。JVM将类文件根据操作系统的不同解释为对应的操作,就实现了Java的“到处运行”的特性了。

回到正题,我们继续说Java的结构。从流程图中我们看到,类文件要在JVM中才能得到执行。JVM包括类加载器、执行引擎和运行时数据区三个部分。前面两个很好理解,那运行时数据区是什么呢?

JVM学习(一):JVM的结构

上图是运行数据区的一个图解。红色是所有线程共享的数据区,绿色是线程私有的数据区。下面我们来看一下图中的各部分。

程序计数器

首先来看程序计数器(Program Counter)。顾名思义,它就是指示当前执行的程序位置的。具体来说,Java的程序计数器指示的是当前线程执行的类文件字节码的偏移量。Java的解释器通过改变计数器的内容来改变执行的程序,实现循环、跳转等流程控制。

正因为程序计数器关系到线程的执行,所有线程的程序计数器在线程被创建的时候就会被一同创建起来。即使它可能永远都为空(当该线程执行的是操作系统自带的功能函数时,由于用不到字节码,程序计数器当然就是空的了)。

关于程序计数器还有一点要知道的是,它不会发生内存溢出错误(Out Of Memory Error)。这也是很好理解的,因为计数器只记录偏移量,而偏移量在进行改变时也只会覆盖原来的值,不需要开辟新的内存空间。

虚拟机栈

虚拟机栈和程序计数器一样,生命周期和线程的周期一样长。虚拟机栈负责记录该线程执行的方法的栈帧。什么是栈帧?你可以理解为一种描述一个方法的数据集合,它主要包括局部变量表、操作数栈、动态链接、方法返回地址。既然是栈,那么当然只有栈顶的栈帧才是有效的。换句话说,只有当前被线程执行的方法的栈帧才会被使用。当线程新调用一个方法时,就会创建一个栈帧并且将其入栈,方法执行完后再将其出栈。

虚拟机栈的大小是有限制的。JVM规定了两条关于虚拟机栈大小的规定:

1.当虚拟机栈超过了JVM允许的大小时,JVM会抛出Stack OverFlow Error异常。

2.当虚拟机栈在扩充时无法申请到足够的内存空间时,JVM会抛出Out Of Memory Error异常。

可以看到,影响虚拟机栈大小的有JVM允许的栈临界值和内存大小两个。一般来说,栈临界值在JDK5.0前是256K,JDK5.0后是1M。当然你也可以自己通过-Xss来调整。当临界值小时,理论上可以创建更多的线程,但是首先太小的栈很容易发生溢出,其次操作系统会限制一个进程创建的线程数量,所以副作用很大;当临界值大时,栈的深度就有了保证,你可以递归更多次,但相应地你也不能创建很多线程。

接下来我们再回头看一看栈帧。刚才说到,栈帧主要包括局部变量表、操作数栈、动态链接、方法返回地址,那这些都是什么呢?

局部变量表

局部变量表是一个顺序结构,它以槽(Slot)为单位。槽是一个32位的结构,可以用来存储boolean、byte、char、short、int、float、reference(对象的引用)和returnAddresss(一条程序地址)方法的返回这些32位以内的数据。像long、double这样64位的数据,就需要两个连续的槽来进行高位在前的存放。

局部变量表通过索引值来进行定位。0位默认存放方法所属的实例的引用,也就是我们说的this。其余的就按照出现的先后顺序依次排列。

还有一点要说的是,局部变量表是可重用的。当一个局部变量的生命周期结束后(即它的作用域已经过了),如果有需要的话它会被新的局部变量替换。局部变量表的重用不仅节约了空间,也对JVM的垃圾回收有影响。

操作数栈

顾名思义,操作数栈就是用来进行加减法、赋值等一些列运算操作的。当一个方法开始执行时,操作数栈是空的,随着字节码指令不断地进栈出栈,操作栈才不断地存入和放出数据。从这一点我们可以看出,JVM的执行引擎是依赖操作数栈的,这就是它被称为“基于栈的执行引擎”的由来,我们也因此成JVM是基于栈的。

这里再提一下另外一个小问题。Android虚拟机是基于寄存器的虚拟机。我们把JVM和AVM的指令集进行一下对比。

比如一个加法:c = a + b;

ARM的指令集就是一条三地址指令:ADD DST SRC1 SRC2。我们看到,这条指令太长了,从指令集编写的角度来说开销很大。

x86系列的指令集采用了好一点的二地址指令:ADD DST SRC。也就是说在指令层面上把c = a + b改成了a += b。这样开销就比三地址指令好些了。

当然,也有一些很古老的机器的指令集是单地址指令:ADD SRC。它把存放结果的寄存器省了,其实不是省了,而是默认指定了一个叫累加器的寄存器。这应该大家都知道,不知道的可以在网上看一看简单的计算机组成或者汇编原理。

那么有没有更省的办法?当然了,还有零地址指令嘛。如果所有的操作数都放到了栈里,那么我要做加法的时候直接弹出最上面两个数,加完再放回去就行了,根本不需要指明地址。

可能大家都已经想到了,AVM使用的就是二地址和三地址指令,JVM使用的是零地址指令。这就是基于栈的指令集和基于寄存器的指令集的区别。我们可以很明显地看出,基于栈的指令集只需要很小的空间就可以放下所有指令,从开销来看显然是更划算的。但是为什么ARM、x86这些真正的处理器都用基于寄存器的指令集呢?那当然是因为快了。因为同样的一系列操作,零地址指令集要完成的话,就要使用比二地址三地址指令集更多数量的指令,这意味着访存次数就多了,当然也就慢了。

从虚拟机的角度来看,采用基于栈的指令集的话就不需要太多的寄存器,这样对宿主机的要求就低,可移植性就好,不过速度就会慢;反过来,基于寄存器的指令集可移植性就低,但是速度就更快。

动态链接

我们知道,一个方法里会有很多的符号引用。对于像final、static这样不允许被改变的引用,在类文件被加载或者第一次被使用的时候就会被转化为实际的值,这被称为静态链接;相对的,那些可能会变化的引用就需要实时的去寻找它们引用的值,这就是动态链接了。为了支持动态链接,每个方法的栈帧都会包含一个指向运行时常量池(就是上图中的常量池)中该帧所属方法的引用,

方法返回地址

这个就很好理解了,方法执行完后要继续执行调用该方法的方法,那就有必要知道对应的字节码偏移量,这就是方法返回位置了。

本地方法栈

本地方法栈和虚拟机栈的工作原理一样,不同点在于虚拟机栈服务于类文件中的方法,而本地方法栈服务操作系统自带的方法。

堆区

堆区是JVM中内存最大的一块区域,它被所有线程共享。几乎所有的对象实例和数组都在这里被分配内存,因此这里也是垃圾回收器的主要工作区域,故被称为“GC堆"。

JVM规定堆区是逻辑上连续的而物理上可以不连续的。当堆区已经被分配完且无法被扩展时,JVM会抛出Out Of Memory Error异常。

方法区

方法区是另外一个被所有线程共享的区域。JVM规定把方法区视作堆区的一个逻辑组成部分。类文件被解析后产生的类信息、常量、静态变量和符号引用会被放进方法区,让方法被执行时,相关的常量数据会进入运行时常量池。运行时常量池是可以被动态扩充的,比如String类的intern()方法就可以向常量池中放入新的常量。同堆区一样,方法区也是逻辑上连续的而物理上可以不连续的。当方法区已经被分配完且无法被扩展时,JVM会抛出Out Of Memory Error异常。

方法区、堆区和虚拟机栈的关系

我们举一个例子:

一个线程执行了这么一句话:Object object = new Object();

执行这句话的过程中,object作为一个Object实例的引用,被存放在该线程虚拟机栈的对应方法的局部变量表中。而实例本身则被放在了堆区中,因为是堆区为它分配的空间。但是实例的对象类型、方法、接口、父类等,是被放在方法区中的,因为这些属于类信息。

这里说明一下,实例本身被放在了堆区,指的是实例拥有的数据就存在堆区。但是实例并不知道数据是被解析成Int还是char,这是由实例的对象类型决定的。

引用的实现

我们再回头看一下引用(reference)这个数据类型。我们都知道它是用来定位一个特定的实例对象的,但是究竟是怎么定位的呢?

主流的方式有两种。

第一种是句柄访问。就是在堆区中再创建一个句柄池,存放所有实例的句柄。句柄有一个指向堆区中实例本身的指针,还有一个指向方法区实例类型数据的指针。就像下图一样:

JVM学习(一):JVM的结构

这个方式的优点在于当垃圾回收器移动实例的位置时,只用改变句柄的实例指针,不用改变reference。这样reference的值就相对稳定了。

第二种是直接访问。reference直接指向实例数据,实例数据再包含一个指向类型数据的指针。就像下图一样:

JVM学习(一):JVM的结构

这种方式的好处就是快。目前Java默认采用的虚拟机就使用了这种方式。

关于JVM的结构到这里就差不多了,下篇学习JVM的类加载机制。

 

发表评论 取消回复
您必须 [登录] 才能发表评论!
分享