
深入理解JVM(一)——自动内存管理
本文深入剖析了 Java 虚拟机(JVM)底层的内存管理机制与对象生命周期。文章首先详解了运行时数据区域的架构,区分了线程私有区域(程序计数器、虚拟机栈、本地方法栈)与共享区域(堆、方法区)的特性与用途。 随后,文章从源码级别探讨了对象的创建过程,涵盖了类加载检查、内存分配策略(指针碰撞 vs 空闲列表)以及 TLAB 本地线程分配缓冲对并发性能的优化。 在对象内存布局部分,本文重点解析了对象头(Object Header)中 Mark Word 的精妙设计,详细阐述了 JVM 如何通过动态定义数据结构和复用存储空间,在偏向锁、轻量级锁及重量级锁之间进行状态流转与信息保护。 最后,文章对比了主流的两种对象访问定位方式——句柄访问与直接指针访问,帮助读者从微观视角构建完整的 Java 内存模型视图。
运行时区域
程序计数器
存储当前线程执行的字节码的行号,字节码解释器通过改变该计数器的值来选取下一条需要执行的字节码指令,是程序控制流的指示器。
为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
线程如果执行的是一个 Java 方法,则计数器记录的是当前正在执行的字节码指令的地址,如果正在执行的是本地方法(指使用 C/C++编写的代码),则此时计数器的值应为空,此内存区域是唯一一个没有规定任何 OutOfMemoryError 情况的区域。
虚拟机栈
线程私有,生命周期与线程相同,每个方法在执行的时候,Java 虚拟机都会同步创建一个栈帧来存储局部变量表、操作数栈、动态连接、方法出口等信息。
局部变量表存储编译期可知的虚拟机基本数据类型、对象引用类型和 returnAddress 类型。这些数据在局部变量表中的存储空间以局部变量槽表示。局部变量表所需内存空间会在编译期间完成分配,即进入方法时需要在栈帧中分配的局部变量空间大小是完全确定的。
本地方法栈
与虚拟机栈类似,本地方法栈是为虚拟机使用到的本地方法服务,某些虚拟机会将虚拟机栈和本地方法栈合二为一。
Java 堆
被所有线程共享,在虚拟机启动时创建,唯一的目的是存放对象实例。
Java 堆也是垃圾收集器管理的内存区域,基于分代收集理论设计的垃圾收集器会将堆进行区域划分,如“新生代”、“老年代”、“永久代”等。HotSpot 里面出现了不使用分代设计的新垃圾收集器,因此并不是所有的堆都一定会有这种类似的区域划分。
从分配内存的角度看,线程共享的 Java 堆可以划分出多个线程私有的分配缓冲区,提升对象分配的效率。但是无论是哪种划分,目的都是为了更好地回收内存或分配内存。
Java 堆可以处于物理上不连续的内存空间中,逻辑上应该被视为连续的(想起分页管理)。对于大对象则一般是连续的。
方法区
被所有线程共享,用于存储已经被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
在 JDK 8 以前使用永久代来实现方法区,但是这导致了更加容易遇到内存溢出的问题,在 JDK 8 之后放弃了永久代的概念,改用在本地内存中实现的元空间来实现方法区。
运行时常量池
运行时常量池是方法区的一部分,常量池表是 Class 文件的一部分,在类加载后存放到方法区的运行时常量池中。
运行时常量池相对于 Class 文件常量池的一个重要特征是动态性,Java 不要求常量一定只有在编译期才能产生,运行期间也可以将新的常量放入池中,如 String 类的 `intern()` 方法(如果字符串在常量池中不存在就放入常量池并返回,以节省内存)。
直接内存
直接内存并不是虚拟机运行时数据区的一部分,但是也被频繁使用。NIO 引入了一种基于通道与缓存区的 I/O 方式,通过使用堆外内存来避免在 Java 堆中和 Native 堆中来回复制数据。
对象的创建
JVM 在遇到 new指令时,首先检查该指令的参数能否在常量池中定位到一个类的符号引用,并且该符号引用代表的类是否已经被加载、解析和初始化,如果没有,则先执行相应的类的加载过程。在类加载检查通过后,会分配内存,对象所需的内存在类加载完成后便可完全确定。
分配的方式主要有指针碰撞和空闲列表。
指针碰撞就是在连续的空间中被使用的内存放在一边,空闲的内存放在另一边,用一个指针作为分界点的指示器,分配内存就是把指针向空闲空间方向挪动一段与对象大小相等的距离。
空闲列表即通过维护一个记录可用内存块的列表,在分配时找到一块足够大的空间划分给对象实例,并且更新列表上的记录。
Java 堆是否规整又取决于 GC 是否带有空间压缩整理的能力。Serial、ParNew 等带压缩整理过程的收集器一般采用指针碰撞,像是 CMS 类型的基于清除算法的收集器,则在理论上只能采用较为复杂的空闲列表来分配内存。
对象创建还要考虑是否线程安全,主要的解决方案一是堆分配内存空间的动作进行同步处理:通过 CAS 配上失败重试的方式保证更新操作的原子性;二是把内存分配的动作按照线程划分在不同的空间中进行,每个线程在 Java 堆中预先分配一小块内存,称为本地线程分配缓冲(TLAB),哪个线程要分配,就在线程的本地线程分配缓冲中分配,只有当本地缓冲区用完了,分配新的缓冲区时才需要同步锁定。
内存分配完毕后,JVM 要将分配到的内存空间(不包括对象头)初始化为零值,如果采用 TLAB,也可以在分配预缓冲阶段就进行。
接下来 JVM 还要对对象进行一些必要的设置,如表明该对象是哪个类的实例、如何找到类的元数据信息、对象的哈希码等,这些设置都存放在对象的对象头中。
在以上工作均完成后,从 JVM 的视角看,一个新的对象就已经产生了。
对象的内存布局
对象在堆内存中的布局主要可以分为对象头、实例数据和对齐填充。
对象头
对象头包括两类信息。第一类是对象自身运行时的数据,如哈希码、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等。这部分数据称为“Mark Word”,一般仅为 32/64 bit,同一块 32 bit 的内存区域,在 A 时刻存的是哈希码,在 B 时刻存的可能是内存地址(指针),在 C 时刻存的可能是线程 ID。它不是同时存这些信息,而是轮流存。
为了确保 Mark Word 中的原始信息(如 HashCode)在复用空间时绝不丢失,JVM 针对不同锁状态设计了三种截然不同的保护机制:
偏向锁采用“互斥”策略,它与 HashCode 不能共存——一旦对象计算过哈希码就无法进入偏向状态,反之若在偏向时计算哈希码,偏向锁会被立即撤销;轻量级锁采用“备份”策略,将原始信息完整拷贝到当前线程栈帧的 Lock Record 中暂存(Displaced Mark Word);而重量级锁则采用“转移”策略,将信息移动到外部的 ObjectMonitor 对象中妥善保存。
第二类是类型指针,即对象指向它的类型元数据的指针,JVM 通过该指针确定该对象是哪个类的实例,并不是所有的虚拟机实例都必需在对象数据上保留类型指针。
如果对象是 Java 数组,对象头中还必须有一块用于记录数据长度的数据。因为 JVM 需要通过普通 Java 对象的元数据来确定 Java 对象的大小。
实例数据
实例数据部分是对象真正存储的有效信息,即在程序代码中所定义的各种类型的字段内容,无论是从父类继承还是子类定义的字段都会被记录。相同宽度的字段总是会被分配到一起存放,在满足此前提的条件下,父类中定义的变量会出现在子类之前,子类中较窄的变量也允许插入父类变量的空隙中。
对齐填充
不是必然存在的,没有特别的含义,仅仅起着占位符的作用。HotSpot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,才会有此要求。
对象的访问定位
Java 程序通过栈上的 reference 数据来操作堆上的对象,主流的方式有句柄和直接指针两种。
若使用句柄,则划分出一块内存作为句柄池,reference 中存储的就是对象的句柄地址,句柄中包含了对象实例数据与类型数据各自具体的地址信息,要访问两次,但是在对象被移动时 reference 本身不需要被修改,只会改变句柄中的示例数据指针:
若使用直接指针,则则需要考虑内存布局如何放置访问类型数据的相关信息,reference 中存储的就是对象地址,访问对象本身不需要多一次间接访问的开销,速度更快:
HotSpot 主要是用第二种方式进行对象访问。
参考自《深入理解JAVA虚拟机:JVM高级特性与最佳实践》


