JVM(Java虚拟机)知识概述(一)–内存区域
参考文献:深入理解Java虚拟机:JVM高级特性与最佳实战(第三版)周志明
1.Java运行时数据区
Java虚拟机在执行Java程序的过程中会把它所管理的内存划分成若干个不同的数据区域,如下图所示,其中有些区域会随着虚拟机进程的启动而一直存在,有些区域则是依赖用户现成的启动和结束而建立和销毁。
图1-1. JVM运行时数据区
1.1程序计数器
程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。它是线程私有的,即每个线程都拥有自己独立的程序计数器,并且各线程之间计数器互不影响,独立存储。
在Java虚拟机概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,当程序执行分支、循环等操作都需要依赖程序计数器来完成。
当执行本地(native)方式时,计数器值为空;当执行虚拟机字节码指令时,计数器的值为该字节码指令的地址。
1.2 Java虚拟机栈
Java虚拟机栈线程私有,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。
其中局部变量表存放了编译器可知的各种Java虚拟机基本数据类型(boolean、byte、char等)、对象引用(reference类型,可能是指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)和returnAddress(指向了一条字节码指令的地址)。
每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。它通常会发生两种常见的StackOverflowError异常和OutOfMemoryError异常。
1.3本地方法栈
它与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native) 方法服务。其常见异常与Java虚拟机栈相同。许多教材和虚拟机将本地方法栈和Java虚拟机栈合并成一个概念。
1.4 Java堆
Java堆是被所有线程共享的一块内存区域,同时也是虚拟机所管理的内存中最大的一块。其在物理上不一定连续,但是在逻辑上连续的一段内存空间。此内存的唯一目的就是存放对象实例(但不代表所有对象实例都保存在Java堆)。如果从分配内存的角度看,所有线程共享的Java堆中可以划分出多个线程私有的分配缓冲区(TLAB),以提升对象分配时的效率。
各个版本的文献将Java堆分成类似“新生代”、“老年代”等目的只是为了更好地回收内存,或者更快地分配内存。Java堆既可以被设定为固定大小的,也可以是可以扩展的,当堆无法再扩展时,会抛出OutOfMemoryError异常。
1.5方法区
方法区与Java堆一样,是各个线程共享的内存区域,包含运行时常量池。方法区用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。Class文件中的常量池表就是用来存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
《Java虚拟机规范》对方法区的约束是非常宽松的,除了和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,甚至还可以选择不实现垃圾收集。它常见的垃圾回收目标主要是针对常量池和对类型的卸载。当方法区无法满足新的内存分配需求时,将抛出OutOfMemoryError异常。
注意方法区不是“永久代”,方法区不属于Java堆,故不能用Java堆的概念去称呼它。
2.对象的创建过程
①当Java虚拟机遇到一条字节码new指针时,首先将要去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
②在类加载检查通过后,接下来虚拟机将为新生对象在堆中分配内存。所谓的分配内存有两种方式:“指针碰撞”和“空闲列表”。具体选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有空间压缩整理的能力决定。如果在并发情况下,则在分配内存时可以采用以下两种方案:一种是对分配内存空间的动作进行同步处理,另一种是采用本地线程缓冲(TLAB)方式。
③内存分配完成后,虚拟机将分配到的内存空间(不包括对象头)都初始化为初始零值。 如果采用了TLAB方式,此阶段可以提前至TLAB分配时顺便进行。
④接下来将要将一些基础信息保存到对象头。基础信息包括但不限于:这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等。
以上是虚拟机视角创建对象,接下来继续讲述从Java程序视角看。
⑤执行构造函数,即Class文件中的()方法(此时由上一步得到的所有字段均为初始零值),将对象所需的其他资源和状态信息按照预定的意图构造。
3.对象的内存布局
在HotSpot虚拟机中,对象在堆内存中的布局可以划分为三个部分:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
3.1 对象头
对象头部分包括两类信息:第一类是用于存储对象自身的运行时数据,如哈希码(HashCode)、GC年龄分代、锁状态标志、线程持有的锁等。第二类是类型指针,即对象指向它的类型的元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例。
3.2实例数据
实例数据部分是对象真正存储有效信息的部分,即我们在程序代码里面所定义的各种类型的字段内容。
3.3 对齐填充
对齐填充部分不是必然存在的,也没有特别的含义,它仅仅起到占位符的作用。由于HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,所以如果对象实例数据部分没有对齐的话,就需要通过对齐填充补全。
4.对象的访问定位
对象访问主要由虚拟机实现,主流的访问方式主要有句柄访问和直接指针访问,其中对于现如今主流HotSpot虚拟机而言,它主要使用直接指针访问。
4.1句柄访问
Java堆中可能会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息,其结构如下图所示:
图4-1. 通过句柄访问对象
4.2直接指针访问
Java堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息,reference中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销,如下图所示:
图4-2. 通过直接指针访问对象
这两种对象访问方式各有优势,使用句柄访问的最大好处就是reference中存储的是稳定句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而reference本身不需要被修改。直接使用指针访问的最大好处就是速度更快,它节省了一次指针定位的时间开销。