前言
下面将会说明 HotSpot 虚拟机在 Java 堆中对象分配、布局和访问的全过程。
注意:这里的对象仅仅是指 Java 中的普通对象,不包括数组和 Class 对象等等。
1 对象的创建
这里的核心是 JVM 为对象分配内存的细节
一般我们创建对象都是通过关键字 new 来创建的,当 JVM 遇到 new 的字节码指令时,首先去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那么必须先执行类加载过程。
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可以完全确定,只要为对象在 Java 堆中划分一块和它同等大小的内存即可(大部分场景都是在 Java 堆中分配的,不过也存在栈上分配,标量替换等情况。但是核心原理是一样的,可以触类旁通)。
1.1 对象内存分配方式
目前分配方式有两种:
- 指针碰撞
- 空闲列表
1.1.1 指针碰撞
假设 Java 堆内存是绝对规整的(一边是已用内存,一边是空闲内存),它们的分割边界有一个指针,那么我们只需要将指针往空闲内存的方向移动和待分配对象同等大小的内存即可。这种分配方式就叫“指针碰撞”。
优点:简单,高效
1.1.2 空闲列表
如果 Java 堆中的内存并不是规整的(已用内存和空闲内存交错在一起),那么就没有办法简单的进行 指针碰撞 了,需要一个 列表 来记录空闲内存。当需要为对象分配内存时,就需要在列表上找一个足够大的空闲内存为其分配其所需的空间,并且完成分配后需要更新列表。这种分配方式就叫“空闲列表”。
优点:处理指针碰撞无法处理的情况
1.1.3 JVM 决定采用内存分配方式的条件
JVM 采用哪种分配方式是由 Java 堆内存是否规则决定的,而 Java 堆内存是否规则有和我们垃圾收集器的回收机制密切相关(看使用的垃圾收集器是否具有空间整理的能力)。因此,当使用 Serial、ParNew 等带压缩整理过程的收集器时,采用的是 指针碰撞;当使用的是 CMS 时,那么采用的是 空闲列表。
1.1.4 分配内存时面临的线程安全问题
由于 Java 堆是属于线程共享的,所以在多个线程需要为对象分配空间时,可能会出现 线程A 为 O1 申请分配内存的同时,线程B 也在为 O2 申请分配内存。如果 JVM 无法保存分配内存是线程安全的,那么就有可能出现 O1 刚创建完成,反手就被 O2 给覆盖了,从而造成程序错误。
为解决这个问题有两种可选方案:
- CAS
实际上虚拟机是采用 CAS 配上失败重试的方式保证内存分配的原子性的。 - 本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)
虚拟机把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在 Java 堆中预先分配一小块内存,称为本地线程分配缓冲(TLAB),只有本地线程缓冲用完了,分配新的缓存区时才需要同步锁定。是否需要使用 TLAB 可以通过 -XX:+UseTLAB(或者 -XX:-UseTLAB)来设定。
1.1.5 构造函数的调用时机
当完成内存分配之后,虚拟机必须将分配到的内存空间(但不包括对象头)都初始化为零值,如果使用了 TLAB 的话,这一工作可以前至 TLAB 分配时顺便进行。这样就保证了对象的实例字段在 Java 代码中可以不赋初始值就能直接使用,程序能访问到默认值(数值类型-0,boolean-false等等)。
接下来,JVM 还要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码(实际哈希码会延后到调用 Object.hashCode() 时才计算)、对象的 GC 分代年龄等信息。
在上面的工作都完成后,在虚拟机的角度看,一个新的对象已经产生了,但是在 Java 程序的角度看,对象创建才刚开始,因为将要调用构造函数了,即 Class 文件中的 <init>() 方法还没有执行。
2 对象在内存中的布局
在 HotSpot 虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:对象头、实例数据、对齐填充。
2.1 对象头(Header)
对象头包括两类信息:自身的运行时数据(Mark Word)和类型指针(Class Metadata Address)。如果对象是一个 Java 数组,那么在对象头中还必须有一块用于记录数组的长度。具体如下图
2.1.1 Mark Word
Mark Word 包括:
- 哈希码
- GC 分代年龄
- 锁状态标志
- 线程持有的锁
- 偏向线程ID
- 偏向时间戳
这部分数据的长度在 32 位 和 64 位的虚拟机(未开启压缩指针)中分别为 32 bit 和 64 bit(用 Bitmap 实现),官方称这部分数据为“Mark Word”。Mark Word 被设计成一个有着动态定义的数据结构,以便在极小的空间内存储尽量多的数据,根据对象的状态复用自己的存储空间。
32 位 HotSpot 虚拟机对象头 Mark Word 示例图
64 位 HotSpot 虚拟机对象头 Mark Word 示例图
这里需要注意的是,我们要知道对象的锁状态是需要先看后两位,再去决定要不要看倒数第三位的,具体可对照下表
锁标志位 | 状态 |
---|---|
01 | 未锁定/可偏向 |
00 | 轻量级锁定 |
10 | 膨胀(重量级锁定) |
11 | GC 标记 |
当是 01 时就要看倒数第三位,如果是0,那么就是未锁定的状态;如果是1,那么就是可偏向(偏向锁)。
2.2 实例数据(Instance Data)
实例数据是对象真正存储的有效信息,即我们代码里面定义的各种字段数据。
2.3 对齐填充(Padding)
这部分不是一定会有的,它仅仅是用来占位而已。因为 HotSpot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 byte 的整数倍,对象头是 8 的整数倍了,但是实例数据是动态的,所以不满足时就需要通过对齐填充来补全。
3 访问对象
Java 程序中是通过栈上的 reference 数据来操作堆上的具体对象。由于 reference 类型在《Java 虚拟机规范》里面只规定了它是一个指向对象的引用,没有更多具体的规定,所以对象的访问方式是由虚拟机自行决定的。主流的访问方式主要有使用句柄和直接指针两种。
3.1 使用句柄
Java 堆中划分一部分内存作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息,如下图
优点:reference 存储的是指针,而不是实际数据,当对象发生迁移时(垃圾收集时移动对象是很常见的现象),只需要改变指针即可,reference 本身不需要修改。
3.2 直接指针
reference 中存储的直接就是对象地址,如果直接访问对象本身,就可以减少一次访问的开销,如下图
优点:我们程序中更多的是访问对象本身,直接指针访问速度更快,在 Java 程序中访问对象是十分频繁的,因此节省的这笔开销积小成多,是十分可观的执行成本。
就 HotSpot 虚拟机而言,它选择的是直接指针的访问方式。