[2] HotSpot VM 对象探秘
[2] HotSpot VM 对象探秘

1. 对象的内存布局

在 HotSpot VM 中,对象的内存布局分为以下 3 块区域:
  • 对象头(Object Header)
  • 实例数据(Instance Data)
  • 对齐填充(Padding)

1.1 对象头

对象头记录了对象在运行过程中所需要使用的一些数据:
  • 哈希码(Hash Code)
  • GC 分代年龄
  • 锁状态标志
  • 线程持有的锁
  • 偏向线程 ID
  • 偏向时间戳
notion image
这部分数据的长度在 32 位和 64 位的 JVM(未开启指针压缩)中分别是 32 bit 和 64 bit,官方称为 Mark Word 运行时元数据
对象头可能包含类型指针,即对象指向它的类型元数据的指针,通过该指针能确定对象属于哪个类。如果对象是一个数组,那么对象头还会包括数组长度
使用 JOL 工具类打印对象头:
<dependency>
  <groupId>org.openjdk.jol</groupId>
  <artifactId>jol-core</artifactId>
  <version>0.8</version>
</dependency>
public class A {
    boolean flag = false;
}

// ...

public static void main(String[] args){
    A a = new A();
    System.out.println(ClassLayout.parseInstance(a).toPrintable());
}

1.2 实例数据

对象的实例数据部分就是成员变量的值,其中包括父类成员变量和本类成员变量
  • 这部分的存储顺序会受到 JVM 分配策略参数(-XX:FieldsAllocationStyle)和字段在 Java 源码中定义顺序的影响。
  • HotSpot VM 默认的分配顺序为 longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers)。
  • 从默认的分配策略中可以看出,相同宽度的字段总被分配到一起存放。
  • 在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。
  • 如果 HotSpot VM 的 +XX:CompactFields 参数值为 true(默认),那么子类中较窄的变量也允许插入父类变量的空隙之间,以节省一点点空间。

1.3 对齐填充

用于确保任何对象的总长度为 8 字节的整数倍
HotSpot VM 的自动内存管理系统(GC)要求对象的大小必须是 8 字节的整数倍。而对象头部分正好是 8 字节的倍数(1 倍或 2 倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。
对齐填充并不是必然存在,也没有特别的含义,它仅仅起着占位符的作用。

2. 对象的创建过程

创建对象的方式:
  • new 指令
  • Class 中的 newInstance()
JDK 9 标记过时,反射的方式,只能调用空参的构造器,权限必须是 public
  • Constructor 中的 newInstance()
反射的方式,可以调用空参,带参的构造器,权限没有要求。
  • 使用 Clone()
不调用任何枸造器,当前类需要实现 Cloneable 接口中的 clone() 方法。
  • 使用反序列化
从文件、网络等获取一个对象的二进制流,反序列化为一个对象。
  • 第三方库 Objenesis

2.1 类加载检查

虚拟机在解析 .class 文件时,若遇到一条 new 指令,首先它会去检查常量池中是否有这个类的符号引用,并且检查这个符号引用所代表的类是否已被加载、解析和初始化过。如果没有,在双亲委派模式下,使用当前类加载器以 ClassLoader + 包名 + 类名 为 key 值进行查找对应的 .class 文件,如果没有找到文件,则抛出 ClassNotFoundException 异常。

2.2 分配内存

对象所需内存的大小在类加载完成后便可完全确定,首先计算对象占用空间的大小,接着在堆中划分一块内存给新对象,如果实例成员变量是引用变量,仅分配引用变量空间即可,即 4 个字节大小。接下来从堆中划分一块对应大小的内存空间给新的对象。分配堆中内存有两种方式:
  • 指针碰撞:如果 Java 堆中内存绝对规整(说明采用的是复制算法标记整理法),所有被使用过的内存放在一边,空闲的内存放在另一边,空闲内存和已使用内存中间放着一个指针作为分界点指示器,那么分配内存时只需要把指针向空闲内存挪动一段与对象大小一样的距离,这种分配方式称为指针碰撞(Bump The Pointer)
  • 空闲列表:如果 Java 堆中内存并不规整,已使用的内存和空闲内存交错(说明采用的是标记-清除法,有碎片),此时没法简单进行指针碰撞,JVM 必须维护一个列表,记录其中哪些内存块空闲可用。分配之时从空闲列表中找到一块足够大的内存空间划分给对象实例。这种方式称为空闲列表(Free List)
选择哪种内存分配方式由 Java 堆是否规整决定,Java 堆是否规整又由所采用的的垃圾收集器是否带有空间压缩整理(Compact)的能力决定:
  • 当使用 Serial,ParNew 等带有压缩整理过程(标记-整理算法)的垃圾收集器时,以指针碰撞的方式来分配内存更为简单高效。
  • 当使用 CMS 基于标记-清除算法的垃圾收集器时,只能采用空闲列表来分配内存。
CMS 为了能在多数情况下分配内存更快,设计了一个线性分配缓冲区(Linear Allocatioin Buffer ),通过空闲列表拿到一大块分配缓冲区后,在它里面仍可使用指针碰撞的方式分配内存。

处理并发安全问题
对象创建是非常频繁的行为,还需要考虑并发情况,仅仅修改一个指针所指向的位置也不是安全的,例如正在给对象 A 分配内存,指针还未修改,对象 B 又使用原来的指针分配内存。对此有两种可选解决方案:
  • 对分配内存空间的动作进行同步处理。实际上 JVM 采用 CAS 和失败重试的方式保证更新操作的原子性。
  • 把内存分配的动作按照线程划分到不同的空间中进行,每个线程在 Java 堆中,预先分配一小块内存(实际是从 Eden 区划出的),称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),只有本地缓冲区用完了,分配新的缓存区(扩展 TLAB 空间)时才需要同步锁定。这样一来即使在并发情况下,每个线程都是在自己的 TLAB 中进行内存分配,不会产生线程安全问题,间接的加速了对象的分配。
JVM 是否使用 TLAB,可以通过 -XX: +/-UseTLAB 参数来设定,默认是开启的。

2.3 初始化

分配完内存后,为对象中的成员变量赋上初始值,设置对象头信息,调用对象的构造函数方法进行初始化。

初始化分配到的空间
内存分配完成后,虚拟机将分配到的内存空间(不包括对象头)都初始化为零值。如果使用了 TLAB,这个工作可以提前到 TLAB 分配时进行。
这步操作保证对象的实例字段在 Java 代码中,可以不赋初始值就直接使用,程序可以访问到字段对应数据类型所对应的零值。

设置对象的对象头
接下来 JVM 还要对对象进行必要的设置,例如对象属于哪个类的实例、如何才能找到类的元数据信息,对象的哈希码(实际上对象的 HashCode 会延后真正调用 Object::hashCode() 方法时才计算)、对象的 GC 分代年龄等信息。这些信息存放到对象的对象头中。

执行 init 方法进行初始化
上面工作完成后,从 JVM 角度来说,一个新的对象已经产生了,但是从 Java 程序的视角来说,对象创建才刚刚开始,对象的构造方法(Class 文件中的 init() 方法)还未执行,所有字段都是默认的零值。JVM 执行完 new 指令之后接着执行 init 方法,按照程序员的意愿对对象进行初始化,这样一个真正可用的对象才算完全构造出来。

至此,整个对象的创建过程就完成了。

3. 对象的访问定位

所有对象的存储空间都是在堆中分配的,但是这个对象的引用却是在堆栈中分配的。也就是说在建立一个对象时两个地方都分配内存,在堆中分配的内存实际建立这个对象,而在堆栈中分配的内存只是一个指向这个堆对象的指针(引用)而已。 那么根据引用存放的地址类型的不同,对象有不同的访问方式。

3.1 句柄访问方式

使用句柄访问方式,Java 堆中将划分一块叫做句柄池的内存空间,句柄中包含了对象实例数据与类型数据各自的具体地址信息。
引用(reference)类型的变量存放的是该对象的句柄地址,位于 Java 虚拟机栈的栈帧中的局部变量表里。访问对象时,首先需要通过引用类型的变量找到该对象的句柄,然后根据句柄中对象的地址找到对象。
notion image

3.2 直接指针访问方式

使用指针访问方式,引用(reference)类型的变量直接存放对象的地址,从而不需要句柄池,通过引用(reference)能够直接访问对象本身,不需要多一次的间接访问的开销。但对象所在的内存空间需要额外的策略存储对象所属的类信息的地址。
notion image

需要说明的是,HotSpot VM 采用第二种方式,即直接指针方式来访问对象,只需要一次寻址操作,所以在性能上比句柄访问方式快一倍。但像上面所说,它需要额外的策略来存储对象在方法区中类信息的地址。
两种方式各有优势:
  • 使用句柄最大好处是 reference 中存放的是稳定句柄地址,在对象被移动(垃圾收集时会发生)时只改变句柄中实例数据指针,reference 本身不用改变。
  • 使用指针最大好处就是速度快,节省了一次指针定位的时间开销,由于对象访问在 Java 中非常频繁,所以积少成多也是一项可观的执行成本。
  • 如果使用 Shenandoah 垃圾收集器的话,就算使用直接指针访问方式也会有一次额外的转发