前面第一篇文章已经简单的介绍了下Java虚拟机,接下来要介绍下Java虚拟机内存模型。当我们运行一个Java程序时,其实是创建了一个Java虚拟机的实例,并且通过该实例来加载执行我们编写的Java代码。有过Java开发经验的程序员们,应该在某次运行Java程序时,遇到过OutOfMemory或者StackOverflow的异常。这说明我们不正确的使用了内存空间,例如:无限递归调用同一个方法。本文将介绍Java虚拟机是如何对内存进行划分使用的。

JVM

运行时数据区

当虚拟机运行一个程序时,需要在内存中存储许多运行时相关的信息,例如:class文件的相关信息、程序创建的对象、方法执行的相关指令、方法的局部变量、返回值以及执行过程中的中间结果。相信很多人都学过数据结构,不同的数据结构使用的场景也各不相同,但是都是对数据的一种组织方式。虚拟机也需要对要执行的程序的相关信息进行组织,通过有效的组织方式,能够提升程序的执行效率。下图是每个虚拟机实例运行时内存的一个抽象划分。
运行时数据区

从上图可以直观看出Java虚拟机运行时数据区域的整体划分,有些数据区域是线程独享的,有些数据区域是共享的。很多时候,我们会遇到线程安全的问题考虑,很大一部分原因就是因为我们访问的数据区域是允许线程共享的。在学习Java的时候,都清楚当new一个对象时申请的是在堆上的内存。而在执行方法时,其实就是一个不断的压栈和出栈的过程。这些都是由于虚拟机采用的内存模型所决定的。

我们都知道操作系统中线程是最小的执行单元,Java中的线程并不等同于操作系统层面的线程。Java中,当一个线程被创建时,它将拥有自己的程序计数器(PC)以及Java栈,如果有调用到本地方法时,将还有本地方法栈。其中程序计数器的值总是指向下一条被执行的指令,如果执行本地方法时,则为undefined。Java栈中存储的是该线程调用的Java方法的状态,由多个栈帧组成,一个栈帧包含了一个Java方法调用的状态。当线程调用一个Java方法时,虚拟机将一个新的栈帧压入Java栈中,当该Java方法返回时,将该栈帧出栈。可以看出Java虚拟机在执行程序时,是采用了基于栈的体系结构来执行相应的指令集。其实这也是为了考虑到Java平台的通用性,有些物理机器上是没有寄存器的,或者寄存器太少,而且能够保持指令集尽量紧凑。

方法区

方法区存储的是类加载器加载的class文件中相关的类型信息。通过访问方法区,能够获得该类的类型信息、常量池、字段信息、方法信息、类变量等信息。我们运行Java程序时,并不是所有class文件都被一下子加载到方法区,只有在需要时被加载器加载的类,才被加载到方法区。方法区的内存大小并不固定,可以根据应用的需要动态的调整。方法区的内存也是可以进行回收的,当程序中的一些类『不再引用』时,Java虚拟机可以卸载对应的类信息。

Java堆

Java程序运行时创建的所有实例和数组都是存储在Java堆中的,而且一个虚拟机实例中的所有线程都共享该Java堆,这时就需要考虑到多线程访问对象时的同步问题。Java中不同于C语言,只有分配对象的指令,并没有手动释放内存的指令。这里就必须提一下Java中让人又爱又恨的垃圾回收机制了。Java采用的自动垃圾回收机制,定时地回收不再被引用的对象所占用的内存,同时也可能移动在使用的对象,为了减少内存碎片。当然,虚拟机的实现并没有要求一定要有垃圾回收,但是垃圾回收是虚拟机中比较重要的技术,不同的虚拟机实现可能采用的垃圾回收策略也不尽相同。后面的文章将会详细介绍Java的垃圾回收机制。

我们创建的实例对象都存放在堆上,那么对象在堆中是如何表示的?虚拟机又是如何定位到堆中具体的实例对象?当访问一个对象时,必需能通过该对象访问到存储在方法区类型信息。通常存在两种堆空间设计,一种是把堆分为两部分,包含一个句柄池和一个对象池,一个对象引用是指向一个句柄池的本地指针。句柄池每个条目中包含了一个指向实例对象的指针和一个指向方法区类型数据的指针。这样的好处是有利于堆碎片的整理,当移动对象池中的数据时,只需要修改句柄中的指向实例对象的指针。这种设计的缺点就是访问实例对象时需要进行两次指针传递。另一种设计是将对象指针指向一组数据,该数据包含了实例对象数据和指向方法区类型数据的指针。这种设计的优缺点与前一种刚好相反。

为什么需要能通过对象引用获取方法区的类数据呢?我们在写Java程序时,难免会进行数据类型的转换,这是需要保证这种转换是否能被允许。还有比如当调用某个实例方法,虚拟机必须进行动态绑定,根据对象的实际类来调用方法。

程序计数器

前面提到每个线程都有自己的程序计数器,当线程执行Java方法时,程序计数器中保存着下一条将被执行的指令的地址。当执行本地方法时,值为”undefined”。

Java栈

前面提到Java栈是每个线程独享的,所以当创建一个新的线程都会分配一个Java栈。Java栈是有许多栈帧组成的,当执行Java方法时,通过栈帧来存储参数、局部变量和中间运行结果等数据。栈的特点就是后进先出,符合程序方法的执行过程。当某个方法执行时压栈,方法中又调用另一个方法,则先执行后面调用的方法,把调用的方法压栈,执行完后返回出栈,再执行原来的方法。Java栈的大小也是可以根据程序运行需要动态设置,很容易写出一个让Java栈空间溢出的程序,就是前面提到的无限递归同一个方法。

栈帧由三部分组成:局部变量表、操作数栈和帧数据区。局部变量表包含了方法的参数和局部变量,是以字长为单位,从0开始计数的数组。字节码指令通过从0开始的索引来访问其中的数据。对于实例方法而言,索引0总是代表着该实例的this变量,类方法则是按具体的方法参数来按序索引。操作数栈也是以一个字长为单位的数组,通过压栈和出栈来访问。Java虚拟机的指令是通过操作数栈来获取指令的操作数的,而不是通过寄存器,所以称它是基于栈运行的。帧数据区则包含了一些支持常量池解析的数据、正常方法返回和异常派发机制。当虚拟机要执行要到常量池数据的指令时,都会通过帧数据区中指向常量池的指针来访问。

本地方法栈

Java虚拟机是允许程序调用本地方法的,当一个程序调用了本地方法,通常它就不能成为是一个跨平台的程序了。执行本地方法时,已经超出了虚拟机的控制范围,虚拟机只是简单地动态连接并直接调用执行的本地方法。任何本地方法接口都会使用某种本地方法栈,例如:调用C程序方法时,就是C栈。当本地方法回调虚拟机中的Java方法时,线程会保存本地方法栈的状态进入到另一个Java栈中。

总结

前面介绍了Java虚拟机的内存模型,通过对Java虚拟机运行时内存的数据区域划分的了解,能够有助于我们加深了解Java程序的运行机制。在今后写Java程序时可以考虑得更深入,写出更高效地执行代码。