今天来讲些抽象的东西 -- 对象头, 因为我在学习的过程中发现很多地方都关联到了对象头的知识点, 例如 JDK 中的 synchronized 锁优化 和 JVM 中对象年龄升级等等. 要深入理解这些知识的原理, 了解对象头的概念很有必要, 而且可以为后面分享 synchronized 原理和 JVM 知识的时候做准备.
对象内存构成
Java 中通过 new 关键字创建一个类的实例对象, 对象存于内存的堆中并给其分配一个内存地址, 那么是否想过如下这些问题:
这个实例对象是以怎样的形态存在内存中的?
一个 Object 对象在内存中占用多大?
对象中的属性是如何在内存中分配的?
在 JVM 中, Java 对象保存在堆中时, 由以下三部分组成:
对象头(object header): 包括了关于堆对象的布局, 类型, GC 状态, 同步状态和标识哈希码的基本信息. Java 对象和 vm 内部对象都有一个共同的对象头格式.
实例数据(Instance Data): 主要是存放类的数据信息, 父类的信息, 对象字段属性信息.
对齐填充(Padding): 为了字节对齐, 填充的数据, 不是必须的.
对象头
我们可以在 Hotspot 官方文档中找到它的描述 (下图). 从中可以发现, 它是 Java 对象和虚拟机内部对象都有的共同格式, 由两个字(计算机术语) 组成. 另外, 如果对象是一个 Java 数组, 那在对象头中还必须有一块用于记录数组长度的数据, 因为虚拟机可以通过普通 Java 对象的元数据信息确定 Java 对象的大小, 但是从数组的元数据中无法确定数组的大小.
它里面提到了对象头由两个字组成, 这两个字是什么呢? 我们还是在上面的那个 Hotspot 官方文档中往上看, 可以发现还有另外两个名词的定义解释, 分别是 mark Word 和 klass pointer.
从中可以发现对象头中那两个字: 第一个字就是 mark Word, 第二个就是 klass pointer.
Mark Word
用于存储对象自身的运行时数据, 如哈希码(HashCode),GC 分代年龄, 锁状态标志, 线程持有的锁, 偏向线程 ID, 偏向时间戳等等.
Mark Word 在 32 位 JVM 中的长度是 32bit, 在 64 位 JVM 中长度是 64bit. 我们打开 openjdk 的源码包, 对应路径 / openjdk/hotspot/src/share/vm/oops,Mark Word 对应到 C++ 的代码 markOop.hpp, 可以从注释中看到它们的组成, 本文所有代码是基于 Jdk1.8.
Mark Word 在不同的锁状态下存储的内容不同, 在 32 位 JVM 中是这么存的
在 64 位 JVM 中是这么存的
虽然它们在不同位数的 JVM 中长度不一样, 但是基本组成内容是一致的.
锁标志位 (lock): 区分锁状态, 11 时表示对象待 GC 回收状态, 只有最后 2 位锁标识(11) 有效.
biased_lock: 是否偏向锁, 由于正常锁和偏向锁的锁标识都是 01, 没办法区分, 这里引入一位的偏向锁标识位.
分代年龄(age): 表示对象被 GC 的次数, 当该次数到达阈值的时候, 对象就会转移到老年代.
对象的 hashcode(hash): 运行期间调用 System.identityHashCode()来计算, 延迟计算, 并把结果赋值到这里. 当对象加锁后, 计算的结果 31 位不够表示, 在偏向锁, 轻量锁, 重量锁, hashcode 会被转移到 Monitor 中.
偏向锁的线程 ID(JavaThread): 偏向模式的时候, 当某个线程持有对象的时候, 对象这里就会被置为该线程的 ID. 在后面的操作中, 就无需再进行尝试获取锁的动作.
epoch: 偏向锁在 CAS 锁操作过程中, 偏向性标识, 表示对象更偏向哪个锁.
ptr_to_lock_record: 轻量级锁状态下, 指向栈中锁记录的指针. 当锁获取是无竞争的时, JVM 使用原子操作而不是 OS 互斥. 这种技术称为轻量级锁定. 在轻量级锁定的情况下, JVM 通过 CAS 操作在对象的标题字中设置指向锁记录的指针.
ptr_to_heavyweight_monitor: 重量级锁状态下, 指向对象监视器 Monitor 的指针. 如果两个不同的线程同时在同一个对象上竞争, 则必须将轻量级锁定升级到 Monitor 以管理等待的线程. 在重量级锁定的情况下, JVM 在对象的 ptr_to_heavyweight_monitor 设置指向 Monitor 的指针.
Klass Pointer
即类型指针, 是对象指向它的类元数据的指针, 虚拟机通过这个指针来确定这个对象是哪个类的实例.
实例数据
如果对象有属性字段, 则这里会有数据信息. 如果对象无属性字段, 则这里就不会有数据. 根据字段类型的不同占不同的字节, 例如 boolean 类型占 1 个字节, int 类型占 4 个字节等等;
对齐数据
对象可以有对齐数据也可以没有. 默认情况下, Java 虚拟机堆中对象的起始地址需要对齐至 8 的倍数. 如果一个对象用不到 8N 个字节则需要对其填充, 以此来补齐对象头和实例数据占用内存之后剩余的空间大小. 如果对象头和实例数据已经占满了 JVM 所分配的内存空间, 那么就不用再进行对齐填充了.
所有的对象分配的字节总 SIZE 需要是 8 的倍数, 如果前面的对象头和实例数据占用的总 SIZE 不满足要求, 则通过对齐数据来填满.
为什么要对齐数据? 字段内存对齐的其中一个原因, 是让字段只出现在同一 CPU 的缓存行中. 如果字段不是对齐的, 那么就有可能出现跨缓存行的字段. 也就是说, 该字段的读取可能需要替换两个缓存行, 而该字段的存储也会同时污染两个缓存行. 这两种情况对程序的执行效率而言都是不利的. 其实对其填充的最终目的是为了计算机高效寻址.
至此, 我们已经了解了对象在堆内存中的整体结构布局, 如下图所示
Talk is cheap, show me code
概念的东西是抽象的, 你说它是这样组成的, 就真的是吗? 学习是需要持怀疑的态度的, 任何理论和概念只有自己证实和实践之后才能接受它. 还好 openjdk 给我们提供了一个工具包, 可以用来获取对象的信息和虚拟机的信息, 我们只需引入 jol-core 依赖, 如下
- <dependency>
- <groupId>org.openjdk.jol</groupId>
- <artifactId>jol-core</artifactId>
- <version>0.8</version>
- </dependency>
jol-core 常用的三个方法
ClassLayout.parseInstance(object).toPrintable()
: 查看对象内部信息.
GraphLayout.parseInstance(object).toPrintable()
: 查看对象外部信息, 包括引用的对象.
GraphLayout.parseInstance(object).totalSize()
: 查看对象总大小.
普通对象
为了简单化, 我们不用复杂的对象, 自己创建一个类 D, 先看无属性字段的时候
public class D { }
通过 jol-core 的 API, 我们将对象的内部信息打印出来
- public static void main(String[] args) {
- D d = new D();
- System.out.println(ClassLayout.parseInstance(d).toPrintable());
- }
最后的打印结果为
可以看到有 OFFSET,SIZE,TYPE DESCRIPTION,VALUE 这几个名词头, 它们的含义分别是
OFFSET: 偏移地址, 单位字节;
SIZE: 占用的内存大小, 单位为字节;
TYPE DESCRIPTION: 类型描述, 其中 object header 为对象头;
VALUE: 对应内存中当前存储的值, 二进制 32 位;
可以看到, d 对象实例共占据 16byte, 对象头 (object header) 占据 12byte(96bit), 其中 mark Word 占 8byte(64bit),klass pointe 占 4byte, 另外剩余 4byte 是填充对齐的.
这里由于默认开启了指针压缩 , 所以对象头占了 12byte, 具体的指针压缩的概念这里就不再阐述了, 感兴趣的读者可以自己查阅下官方文档. jdk8 版本是默认开启指针压缩的, 可以通过配置 vm 参数开启关闭指针压缩,-XX:-UseCompressedOops.
如果关闭指针压缩重新打印对象的内存布局, 可以发现总 SIZE 变大了, 从下图中可以看到, 对象头所占用的内存大小变为 16byte(128bit), 其中 mark Word 占 8byte,klass pointe 占 8byte, 无对齐填充.
开启指针压缩可以减少对象的内存使用. 从两次打印的 D 对象布局信息来看, 关闭指针压缩时, 对象头的 SIZE 增加了 4byte, 这里由于 D 对象是无属性的, 读者可以试试增加几个属性字段来看下, 这样会明显的发现 SIZE 增长. 因此开启指针压缩, 理论上来讲, 大约能节省百分之五十的内存. jdk8 及以后版本已经默认开启指针压缩, 无需配置.
数组对象
上面使用的是普通对象, 我们来看下数组对象的内存布局, 比较下有什么异同
- public static void main(String[] args) {
- int[] a = {1};
- System.out.println(ClassLayout.parseInstance(a).toPrintable());
- }
打印的内存布局信息, 如下
可以看到这时总 SIZE 为共 24byte, 对象头占 16byte, 其中 Mark Work 占 8byte,Klass Point 占 4byte,array length 占 4byte, 因为里面只有一个 int 类型的 1, 所以数组对象的实例数据占据 4byte, 剩余对齐填充占据 4byte.
结尾
经过以上的内容我们了解了对象在内存中的布局, 了解对象的内存布局和对象头的概念, 特别是对象头的 Mark Word 的内容, 在我们后续分析 synchronize 锁优化 和 JVM 垃圾回收年龄代的时候会有很大作用.
JVM 中大家是否还记得对象在 Suvivor 中每熬过一次 MinorGC, 年龄就增加 1, 当它的年龄增加到一定程度后就会被晋升到老年代中, 这个次数默认是 15 岁, 有想过为什么是 15 吗? 在 Mark Word 中可以发现标记对象分代年龄的分配的空间是 4bit, 而 4bit 能表示的最大数就是 2^4-1 = 15.
来源: https://www.cnblogs.com/jajian/p/13681781.html