Java JVM 实例对象内存布局


当 Java 应用启动后, 基本就是在不断的创建对象, 回收对象的过程中。
而这些创建的对象基本都是存放在应用的堆 (heap) 中, 但是这些对象在堆中又是什么样子的呢?
在这篇文章中, 我们分析一下 Java JVM 中实例对象的内存布局。

在 HotSpot 虚拟机里, 对象在堆内存中的存储布局可以划分为三个部分: 对象头 (Object Header), 实例数据 (Instance Data) 和对齐填充 (Padding)。

大体的样子如下:
Alt 'JVM 实例的内存布局'

1 对象头 (Object Header)

Java 实例的对象头主要包含 2/3 个部分, 如果是对象的话, 只包含 2 部分 Mark Word 和 Klass Pointer, 如果是数组的话, 还会多一个 Array Length。

Mark Word
用于存储对象自身的运行时数据, 如哈希码 (HashCode), GC 分代年龄, 锁状态标志, 线程持有的锁, 偏向线程 ID, 偏向时间戳等。
这一部分在 32 位系统里面的大小为 4 个字节, 而 64 位系统里面则为 8 个字节。

Klass Pointer
类型指针, 即对象指向它的类型元数据的指针, Java 虚拟机通过这个指针来确定该对象是哪个类的实例 (并不是所有的虚拟机实现都必须在对象数据上保留类型指针, 即查找对象的元数据信息并不一定要经过对象本身)。
这一部分在 32 位系统里面的大小为 4 个字节, 而 64 位系统里面则为 8 个字节。

Array Length
当我们的对象实例是数组对象的话, 对象头里面还会有一个用于记录数组长度的数据, 大小为 4 个字节, 主要用于确定对象的大小。因为普通的 Java 对象可以通过
元数据 (即类中的属性, int 32 位, long 32 位, 所以通过属性基本可以确定一个类实例的大小) 推算出对象的大小, 但是数组的长度不确定时, 无法推算出数组的大小。

在 32 位系统中, HotSpot 里面的 Mark Work 正常情况 (对象没有被加锁, 即没有被 synchronized 加锁) 的分布如下:

Alt '32 位系统无锁状态 MarkWord 的结构'

64 位系统的话, 如图:
Alt '64 位系统无锁状态 MarkWord 的结构'

注: Mark Work 的内容不是一成不变的, 如果对象被当做 synchronized 锁的话, 其内部的内容会随锁的状态变更。

对象头一般情况下的大小:

32 位系统下: Class Pointer 4 个字节, MarkWord 4 个字节, 对象头为 8 个字节, 如果是数组的话, 再加上 4 个字节的数组长度。
64 位系统下: Class Pointer 8 个字节, MarkWord 8 个字节, 对象头为 16 个字节, 如果是数组的话, 再加上 4 个字节的数组长度。

Java 中还有一项技术会影响到对象头的大小: 指针压缩技术, 看后面的介绍。

2 实例数据 (Instance Data)

对象真正有效的信息, 也就是我们类中声明的各个字段 (包括从父类继承下来的), 每个字段都有自己的大小限制。

字段类型 内存大小(单位: 字节)
boolean 1
byte 1
short 2
char 2
int 4
float 4
long 8
double 8
reference(引用类型) 4 (32 位系统), 8 (64 位系统)

通过上面的大小的字段类型的, 基本可以确定每个对象的实际数据大小 (静态属性维护在类 (也就是具体的 Class 上)上, 所以不算在对象大小里面)。

每个实例的属性在内存的存储顺序会受到虚拟机的分配策略影响 (-XX:FieldsAllocationStyle) 和字段在 Java 源码中定义的顺序的影响。
HotSpot 虚拟机默认的分配顺序为 long/double, int/float, short/char, byte/boolean, reference。
在满足这个前提条件下, 父类中定义的变量会在子类的前面。
如果 HotSpot 虚拟机的 +XX:CompactFields 参数值为 true (默认为 true), 那子类之中较窄的变量也允许插入父类变量的空隙之中, 以节省出一点点空间。

举个列子, 当前父类有 2 个属性 long 和 int, 子类有 3 个属性 int, short, short。
如果按照上面的规则 4 个属性在内存的分配顺序为 long (8 个字节) int (4 个字节) int (4 个字节) short (2 个字节) short (2 个字节)。
+XX:CompactFields 设置为 true 后, 分配的顺序可能变为 long (8 个字节) int (4 个字节) short (2 个字节) short (2 个字节) int (4 个字节)。
子类 2 个 short 属性插入到父类的变量空隙中了。

3 对齐填充 (Padding)

这个不是必须, 也没有具体的含义, 只是单纯的起占位作用。他的出现与否取决于当前对象实例的内存大小。
所有的 Java 对象所占用的字节数必须是 8 的倍数。比如 一个对象的对象头的大小为 12 byte, 实例数据为 13 byte, 当前对象所占的大小为 25 byte。
但是 JVM 要求每个对象的大小必须是 8 的倍数, 这时候 padding 就其作用了, 填充 7 个字节, 凑够 32, 达到 8 的倍数。
而当对象头和实例数据刚好达到 8 的倍数, 这时候就不需要 padding 了。

之所以强制为对象大小为 8 的倍数是为了内存访问的效率, 数据对齐对处理器的访问是最佳的。

4 指针压缩 (CompressedOops)

在了解指针压缩之前, 先了解一点别的。
我们在买电脑的时候, 很多时候都会说多少位系统, 内存是多少的, 比如 64 位系统 16g 内存, 32 位系统 4g 内存。
那么是否存在 32 位系统 16g 内存, 64 位系统 64g 的内存呢?

这里面涉及一点计算机的知识。32 位系统最大支持的内存为 4g, 64 位系统最大支持的则为 1T。能支持的内存的大小取决于 CPU 的寻址能力。

CPU 的寻址能力以字节为单位。

  1. 内存把 8 个比特 (8 bit) 排成一组, 每一组为一个单位, 记为一个字节 (Byte), CPU 每次只能访问去访问一个字节 (Byte), 不能去访问每一个比特
  2. 计算机系统会给内存中的每一个字节分配一个内存地址, CPU 只要知道某个数据类型的地址, 就可以到地址所指向的内存去读取数据
  3. 在 32 位系统中, 内存地址就是 32 位的二进制数, 既从 0x00000000 到 0xFFFFFFFF, 即一共有 2^32 个地址, 每个地址对应一个字节
  4. 32 位系统的 2^32 个地址, 对应了 2^32 个字节, 也就是 4GB 的内存 (如果给 32 位系统配上了 8G 内存, 操作系统最多只能给其中的 4GB 分配地址, 其他 4GB 是没有地址的)

而我们常说的指针, 在程序中内存地址映射, 可以理解一个指针就对应了一个内存地址。通过指针就能定位到内存对应的某个位置。
在 32 位系统, 指针的大小只要 4 个字节就能包含所有的地址了, 同样的 64 位系统, 指针的大小只要 8 个字节就够了。

OK, 聊完了。下面就开始真正的指针压缩的分析!

在 JVM 中, 32 位系统的对象引用 (指针) 占 4 个字节 (4 个字节已经能够囊括所有的内存地址), 而 64 位系统的对象引用占 8 个字节。
也就是说, 64 位的对象引用大小是 32 位的 2 倍。
64 位 JVM 在支持更大堆内存的同时, 由于对象引用指针的变大却带来了其他的性能问题, 如对象占用的内存变大, 降低 CPU 缓存命中率等。

为了能够利用 64 位系统的大内存的前提, 又能使用到 32 位系统的的小内存引用指针, 就有了压缩指针 (CompressedOops) 技术 (在 64 位的机器上使用 32 位的引用的同时, 使用超过 4g 的内存)。

要达到这个的前提:
Java 中实例对象的内存大小都是 8 的倍数, 一个数是 8 的倍数的话, 那么其二进制的表示的后面三位必定是 3 个 000 (8->1000, 16->10000)。

JVM 知道每个 Java 对象的大小都是 8 的倍数, 正常存储的话, 每个的指针最后的 3 为必定都是 0, 那么这最后的 3 位完全没必要存了。
32 位的引用空出来的 3 位, 完全可以用来存储多 3 位数据, 既将一个 35 位的指针用 32 位的形式存储在 JVM 中。

Alt '35 位指针达到 32 位指针的效果'

落实到实现中就是, 将引用的数字向左移动 3 位, 得到真正的内存地址, 通过这个转换就能实现指针和真正的内存进行关联。
比如我们堆中某个变量的指针为 0, 那么都内存中的 0 (00000000 << 3, 还是 0) 的位置就能找到这个对象的实际内容。
变量的指针为 1, 那么到内存 8 (00000001 << 3, 00001000, 十进制 8) 的位置查找就行了。

说到底, 压缩指针的前提就是存储的内容本身是一个指针引用, 那么在 Java 实例中, 哪些数据是指针引用呢

  1. the klass field of every object (每个普通对象对象头里面的 Klass Pointer)
  2. every oop instance field (每个普通对象的属性)
  3. every element of an oop array (objArray) (每个普通对象数组里面的每个元素)

5 参考

《深入理解Java虚拟机》- 周志明
为什么32位系统最大只支持4G内存
CompressedOops
Compressed OOPs in the JVM


  目录