本文分析下我们开发中经常接触的对象,在虚拟机上的形态。本文主要从对象的构成,对象的创建,分配和访问几个方面来分析。
- 对象的内存结构
- 对象的创建
- 对象的访问
1. 对象的内存结构
对象在内存中的结构由三部分组成:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
1.1 对象头
对象头包括两部分信息,一部分是标记字段(Mark Word),一部分是类型指针。
- 标记字段(Mark Word):用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分的数据长度在32位和64位的虚拟机中分别为32bit和64bit。在32位的虚拟机中,如果对象处于未被锁定的状态,Mark Word的32bit空间中有25bit用于存储哈希码,4bit存储分代年龄,1bit固定为0,表示不是偏向锁,2bit表示锁标记状态。Mark Word中锁状态的存储结构如下图:
在Mark Word中不同的锁状态,对应着该对象锁的不同级别,知道了这些锁的类型,对分析JVM锁实现线程安全是非常重要的,会面会专门写一篇关于synchronized锁和自旋锁的文章,这里不做深入探讨。
- 类型指针(Klass Pointer):即指向对象的类的元数据的指针。通过该指针可以确定当前对象属于哪个类的实例,这里特别说明下,在不同的虚拟机实现上有一定差异,该指针指向不一定是对象的类元数据,还有可能是句柄池中的地址,在句柄池中间接的指向类元数据,这个对象访问时再详细说明。当对象是数据时,在对象头中还必须记录数组的长度,因为通过元数据可以确认该对象需要分配的内存大小,但是类元数据无法确认有多少个对象,所以需要记录下数组的长度,用来计算数组所需要的内存大小(类元数据空间x数组长度)。
1.2 实例数据
实例数据是真正存储的有效信息,即程序代码中定义的各种类型的字段数据。包括从父类继承的和当前子类中定义的,均需要记录。该部分的存储顺序受虚拟机分配策略参数(FieldsAllocationStyle)和字段在Java源码中定义顺序影响。一般满足以下原则:父类字段一般位于子类字段的前面,相同类型的字段分配在一起。
1.3 对齐填充
对齐填充主要是起到占位符的作用,不是必然存在的。虚拟机自动内存管理系统要求对象起始地址必须是8字节的整数倍,即对象的大小必须是8字节的整数倍,对象头正好是8字节的倍数(1倍或2倍),对象实例会存在数据项的差异,所以需要通过对齐填充来确保,对象是8字节的整数倍。
2. 对象的创建
在Java程序中,对象可以通过克隆,反序列化,直接创建等方式得到,但归根结底均是通过new关键字来实现的,在虚拟机中遇到new关键字需要做哪些操作呢?
在虚拟机中遇到一条new指令时,首先会去检查这个指令的参数是否能在运行时常量池中定位到一个类的符号引用,然后对获取到的符号引用的类进行检查,判断类是否已经被加载、解析和类的初始化过,如果没有则进行上述操作。
在类的检查通过后,对新生对象分配内存,对象所需大小在类加载完成后即可被确定。然后从Java堆中分配出对应大小的内存空间。
对象内存分配需要考虑的两个问题:内存空间划分方式和内存空间划分安全问题。
1. 内存空间划分一般两种方式:指针碰撞和空闲列表
- 1.1 指针碰撞(Bump The Pointer):需要Java堆中内存绝对规整,所有用过的内存在一侧,未使用的在另一侧,中间有一个指针作为分界点,当非配对象内存时,将临界指针移动对象大小的距离即可。对应的GC收集算法是标记-整理算法,复制算法。
- 1.2 空闲列表(Free List):Java堆内存不是规整的,使用的内存和未使用的内存相互交错,虚拟机维护一个列表,记录空闲的内存块,在分配内存时,从列表中寻找合适的内存块分配,然后更新空闲列表。对应的GC收集算法是标记-清除算法。
2. 内存空间划分安全问题
对象是分配到Java堆空间的,在JVM虚拟机系列(一)运行时数据区域这篇文章介绍Java堆空间时,该区域属于线程共享区域。所以在多个线程同时创建对象时,如何保证在Java堆上对象内存的安全分配呢?
主要有两种处理方式:CAS加失败重试方式和线程本地缓存区。
- 2.1 CAS加失败重试方式:对分配内存空间的动作进行同步处理,保证更新操作的原子性,如果操作失败,则重新申请内存。
- 2.2 线程本地缓冲区(Thread Local Allocation Buffer,TLAB):把内存分配的动作按照线程划分在不同的空间中进行,为每个线程预先分配一小块内存,当TLAB的空间使用完后需要重新非配时,才需要加上同步锁。虚拟机可以使用-XX:+/UseTLAB参数来设定大小。
内存分配完成后,虚拟机将分配到的内存空间初始化为零值(不包括对象头),这一步也可以称为类的初始化,在Java代码中类似static修饰的部分,然后设置对象头中的相关数据,类的元数据,哈希码,对象分代年龄等信息。
当上述工作完成后,还有最后一步,即进行对象实例的初始化,在构造方法中需要初始化的部分。所有工作都完成后,对象的创建结束。将对象的地址返回给栈帧中的对象引用类型即可。
3. 对象的访问
对象创建完成之后,存在于Java堆中,在Java方法中我们如何访问到Java堆中的对象实例呢?在上一篇运行时数据区中,我们直到在不同的Java线程中都存在着私有的虚拟机栈区域,在该区域内每个方法有各自对应的栈帧,栈帧存储的数据类型除了和Java类似的8种基本类型外,还有returnAddress和reference,其中reference类型表示引用类型,代表一个地址指针,该地址记录的信息可以分为两种,一种是间接指针(也称句柄指针),一种是直接指针。虚拟机实现采用何种方式,取决于虚拟机自身的选择。
3.1 句柄指针访问
该方式会在Java堆中再划分出一个区域,叫句柄池,句柄池中包含对象实例和类元数据的具体地址,reference中存储的是对象的句柄地址。句柄访问如下图所示:
特点:
- reference中存储的是稳定的句柄地址,对象被移动时,只需要改变句柄地址即可,reference中持有的地址无需改动。
- 访问速度相对于直接访问较慢,需要查询两次才可以访问到真实数据。
3.2 直接指针访问
reference类型直接持有Java堆中对象实例的地址,直接指针访问如下图所示:
特点:
- 直接指针访问速度较快
- GC操作,需要更改reference保存的对象地址。
3.3 直接指针访问演示
在HotSpot虚拟机内,采用了直接指针访问的方式,Java对象在JVM数据区中的内存关系,如下图所示:
结语:本文主要讲解了Java对象在虚拟机中的构成、创建和访问方式,掌握了相关知识,后续在处理JVM锁相关的知识时,会更加得心应手。