深入理解JVM:内存模型简析

Author Avatar
AngelMsger 1月 10, 2018

概述

最近在学习JVM,简单总结一下学习成果,帮助我们了解Java代码执行背后的本质。我的主要参考资料是周志明老师的《深入理解Java虚拟机(第二版)》。目前(JDK 9)默认使用的JVM是HotSpot VM,也是我接下来的文章中主要描述的JVM。

关于内存模型

Java程序运行于JVM之上。JVM帮助Java程序员动态的管理内存,使Java程序员无需像C++程序员一样经常对象构造和析构过程中内存的分配和回收而苦恼,降低了因为程序员的疏漏而导致内存泄漏的可能性。不过也正因为程序员对于内存的“失控”,而导致一旦出现问题,如果对JVM如何使用内存一无所知,排查错误就会非常艰难。并且对于Java语言更高层次的使用以及程序性能调优,也要求我们需要了解内存模型背后的知识。

Java程序运行时数据区域

JVM在执行Java程序的时候,会把使用到的内存分为若干个不同的内存区域,每个区域的用途,生命周期各不相同。

程序计数器

程序计数器是一块很小的内存区域,用来记录字节码当前执行位置。由于程序进程内不同线程执行位置不同,因此程序计数器是线程私有的。

Java程序的执行过程中程序计数器会不断改变,如果执行的是Java方法,则程序计数器的值为字节码指令的地址。如果执行的是Native代码,则为空。

地址的长度是固定的,因此程序计数器正常情况下不会发生溢出,此区域没有规定OutOfMemoryError情况。

虚拟机栈

虚拟机栈描述Java方法执行的内存模型,每个方法执行的时候创建一个栈帧,用于存储局部变量表,操作数栈,动态链接,方法出口等信息。方法的调用和完成的过程对应其在虚拟机栈中从入栈到出栈的过程。虚拟机栈也是线程私有的,生命周期与线程绑定。

局部变量表存放编译期可知的各种基本数据类型(基本数据类型和引用本身)。每个方法的局部变量表所需内存空间在编译期间完成分配,其虚拟栈帧大小完全确定,在运行期间不会改变。

JVM规范中允许JVM实现设置虚拟机栈的最大深度,如果虚拟机栈深度超过这个值将抛出StackOverflowError异常。但包括HotSpot VM在内的大多数虚拟机已经可以动态扩展虚拟机栈,而不受某个定值限制。当扩展时无法申请到足够内存,将抛出OutOfMemoryError异常。

本地方法栈

本地方法栈的作用与虚拟机栈类似,但本地方法栈为Native方法服务。在JVM中对实现方式并没有强制规定,在HotSpot VM中本地方法栈和虚拟机栈合二为一共同实现。

与虚拟机栈一样,本地方法栈也可能抛出StackOverflowError和OutOfMemoryError异常。

也既是Java堆,GC堆。通常是JVM管理内存中最大的一块,几乎所有对象和数组都在这块内存中存放(书中提到随着JIT编译器的发展与逃逸分析技术的成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化发生,不在本文讨论范围之内)。堆为线程共享空间。

从内存回收的角度看,包括HotSpot VM在内的大多数JVM采用分代收集算法,因此在堆中还可细分为新生代和老年代,或更加细分为Eden空间、From Survivor空间、To Survivor空间等。从内存分配的角度看,线程共享的堆又可划分出多个线程私有的分配缓冲区(TLAB)。进一步划分的目的是为了更好地回收内存或更快的分配内存,与存放内容无关。

对于目前主流的JVM,堆空间都是可扩展的,可以利用启动参数-Xmx和-Xms来控制。当堆中的内存耗尽且不能继续扩展时,会抛出OutOfMemoryError异常。

方法区

在JVM规范中方法区被描述为堆的一个逻辑部分,但用于存放已经被虚拟机加载的类信息,常量,静态变量,即时编译器编译后的代码等数据。方法区也是各个线程共享的内存区域。

原书提到,对于JDK 7前的HotSpot VM,存在一个“永久代(或永生代)”,用以实现方法区类似的功能,但同时允许垃圾收集器像管理堆内存一样管理方法区。但由于一些原因,这种设计被证明并不是个好主意。因此从JDK 7开始已经在逐步修改。

当方法区无法满足内存分配需求时将会抛出OutOfMemoryError异常。

以下内容为本人考证,可能存在错误,仅供参考:从JDK 8起,原永生代中类的原信息被放入直接内存中的元数据区(Metaspace),类的静态变量和内部字符串被放入Java堆中。

运行时常量池

Class文件中除了包含类的版本,字段,方法,接口等描述信息外,还包含常量池信息,用于存放编译期产生的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。但运行时常量池与Class文件中的常量池不同之处在于,Java并不要求常量一定只有编译期才能产生,运行期间也可能将新的常量放入池中,如String的intern()方法。

运行时常量池作为方法区的一部分,内存限制与方法区相同,无法申请到内存时会抛出OutOfMemoryError异常。

直接内存

一些JDK中的方法会使用Native函数库直接分配堆外内存,并通过堆内的对象引用和操作这些内存,能在一些场景中提升性能,避免Java堆与Native堆中来回复制数据带来的开销。直接内存不是虚拟机运行时数据区的一部分。

直接内存不会受到Java堆大小限制,因此不受-Xmx参数的约束。但肯定还是会受到本机总内存的限制,如果忽略直接内存开销设置-Xmx参数,很有可能导致动态扩展时出现OutOfMemoryError异常。

虚拟机对象布局

以下讨论针对HotSpot VM,提到的对象暂时只考虑普通Java对象,不包括数组和Class对象。

对象的创建

JVM遇到一条new指令时,首先检查这个指令的参数能否在常量池中定位到这个类的符号引用,符号引用代表的类是否已加载,解析和初始化过。如果没有,那必须首先执行相应的类加载过程。

在类加载检查通过后,虚拟机将为新生对象分配内存,对象所需内存大小在类加载完成后就已完全确定,而为对象分配空间仅仅相当于在由JVM维护的堆内划分一块确定大小的内存。这种划分的方法取决于堆内存是否是规整的,所谓规整是指存在一个边界指针,已分配的内存在边界指针一侧,而未分配的内存在指针的另一侧。堆内存是否规整取决于垃圾收集器是否带有压缩整理的功能。若内存是规整的,这种划分仅需要将边界指针移动一个确定的偏移量。如果堆内存不规整,则需维护一个标识哪些内存还可用的Free List,那么这种划分需要在这张表中找到一个足够大的空间用来存放新的实例,并更新这张表。

除了考虑空间划分问题,还要注意到对象的创建在虚拟机中是非常频繁且存在并发的行为,因此不得不考虑到并发过程的安全性。目前有两种解决方案,其一是对分配空间的动作进行同步处理保证其原子性,其二是把内存分配动作按照线程划分在不同空间中进行,即前文所提到的TLAB,只有在TLAB用尽需要分配新的TLAB时才需要同步锁定。是否启用TLAB可以有JVM参数-XX:+/-UseTLAB来设定。

在内存分配之后,虚拟机会将内存空间初始化为零(不包括对象头),如果使用TLAB,这一工作过程也可以提前到TLAB分配时进行。这一步保证了对象实例字段在Java代码中不符初值即可使用。接下来虚拟机会设置对象的对象头,对象头包括描述如何找到类的元信息,对象的哈希码,GC分代年龄等信息。

在上述过程完成之后,通常来说(由字节码中是否跟随invokespecial指令所决定),会执行对应的构造函数。

对象的内存布局

在HotSpot VM中,对象在内存中存储的布局可以分为3块区域:对象头、实例数据和对齐填充。

对象头用于存储对象自身的运行时数据,其长度固定,在32位、64位JVM(未开启压缩指针)中分别为32bit和64bit。处于对存储空间利用的考虑,对象头首先存在标志位,不同标志位对应对象的不同状态,而不同状态的对象其对象头剩余空间内存储结构又有所不同。大家可以查阅文档,这里不再赘述。

实例数据部分是对象真正存储的有效信息,也就是程序代码中锁定义的各种类型的字段内容,包括从父类中继承的和本身定义的。存储顺序则受到虚拟机分配策略参数(FieldAllocationStyle)和字段在Java源码中定义的顺序影响。HotSpot VM默认的分配策略是将相同宽度的字段总是分配到一起,并在满足这个前提条件下,在父类中定义的变量会出现在本身定义的变量之前。如果压缩域(CompactField)参数位true(默认即为true)的情况下,子类中较狭窄的变量也可能会插入到父类变量的空隙中。

对齐填充则是为了满足HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍而补全的内容,不一定存在,也没有特别的意义。

对象访问定位

在使用对象的时候,首先需要通过栈上的引用找到堆上的具体对象。有两种实现方式比较常见,基于句柄和直接使用指针。

如果基于句柄,Java堆会划分一块内存作为句柄池,句柄存储到对象实例数据(堆上,实例池之中)的指针和到对象类型数据(方法区之中)的指针,而引用则存储句柄地址。通过引用找到句柄,再通过句柄拿到实例数据和类型数据。

如果直接使用指针,则引用本身即为指向Java堆中对象的指针,如果需要类型数据,则虚拟机在通过指针拿到对象后在通过对象内指向类型数据的指针进一步访问方法区。

两种方法各有优势,当对象被移动时,基于句柄则无需修改栈上引用的值,而调整句柄内的指针即可,而直接使用指针则需修改引用本身。当访问对象实例数据时,直接使用指针能减少一次指针定位开销。由于在Java中访问对象实例数据非常频繁,HotSpot VM使用直接使用指针的方式实现对象访问定位。

总结

本文简单地介绍了JVM内存模型,在后续的文章中我还会总结垃圾回收器的实现,并由此得出一些性能调优和故障处理策略。我对此也还在学习之中,文中如有错误欢迎留言指出。

参考资料

《深入理解Java虚拟机(第二版)》,周志明,机械工业出版社

许可协议: CC BY-NC-SA 4.0
本文链接:https://blog.angelmsger.com/深入理解JVM:内存模型简析/