对象的内存布局
在 HotSpot 虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
1. 对象头
1.1 Mark Word
第一类是用于存储对象自身的运行时数据,如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等。
这部分数据的长度在 32 位和 64 位的虚拟机(未开启压缩指针)中分别为 32 个比特和 64 个比特。
1.2 类型指针
另一部分是类型指针,即对象指向它的类型元数据的指针,Java 虚拟机通过这个指针来确定该对象是哪个类的实例。
2. 实例数据
实例数据保存的是对象真正存储的有效信息,包括在程序代码中定义的各种类型的字段内容,无论是从父类继承下来或者是子类中定义的字段,都必须记录下来。
3. 对齐填充
并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。
由于 HotSpot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,换句话说就是任何对象的大小都必须是 8 字节的整数倍。对象头部分已经被精心设计成正好是 8 字节的倍数(1 倍或者 2 倍),因此,如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。
对象的创建过程
1. 类加载检查
当 Java 虚拟机遇到一条字节码 new
指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。
2. 分配内存
内存的分配方式根据当前 Java 堆内存空间是否规整可分为:指针碰撞和空闲列表。
指针碰撞是指 Java 堆中内存是绝对规整的,所有被使用过的内存都被放在一边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离。
空闲列表是指 Java 堆中的内存并不是规整的,已被使用的内存和空闲的内存相互交错在一起,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。
3. 初始化零值
内存分配完成之后,虚拟机必须将分配到的内存空间(但不包括对象头)都初始化为零值。
这步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的零值。
4. 设置对象头
例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码(实际上对象的哈希码会延后到真正调用Object::hashCode()方法时才计算)、对象的GC分代年龄等信息。
根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
5. 执行()方法
这一步是指按照程序员的意愿对对象进行初始化,这样一个真正可用的对象才算完全被构造出来。
即执行程序的构造方法对对象属性进行初始化赋值(此前是零值)。
对象的访问定位
创建对象的目的是使用,我们的 Java 程序会通过栈上的 reference 数据来操作堆上的具体对象。
而通过什么方式去访问是由虚拟机实现决定的,主流的访问方式主要有使用句柄和直接指针两种。
对于 Hotspot 虚拟机主要采用的是直接指针方式。
Object obj = new Object();
上述代码创建了一个 Object
对象,其中包含了两部分内容,一部分是类数据(比如代表类的 Class 对象)、一部分是实例数据。
对于该对象的引用 obj 则保存在虚拟机栈局部变量表的 reference 中,下面根据本例分别介绍两种不同的访问方式。
1. 句柄
该方式下会在堆内存中划分一块区域作为句柄池,reference 中存储的就是对象的句柄地址,句柄中包含了对象的实例数据和类型数据的具体地址信息。
obj 指向的时句柄池中的句柄地址。句柄中保存着对象的实例数据地址和类型数据地址。
2. 直接指针
该方式下 reference 直接存储的就是对象地址,如果只访问对象本身,就无须多一次间接访问的开销。
obj 指向堆内存中的对象,对象中存储的实例数据和类型数据地址
3. 对比
使用句柄来访问的最大好处就是 reference 中存储的是稳定句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而 reference 本身不需要被修改。
使用直接指针来访问最大的好处就是速度更快,它节省了一次指针定位的时间开销,由于对象访问在 Java 中非常频繁,因此这类开销积少成多也是一项极为可观的执行成本。