当 Java 应用启动后, 基本就是在不断的创建对象, 回收对象的过程中。
而这些创建的对象基本都是存放在应用的堆 (heap) 中, 但是这些对象在堆中又是什么样子的呢?
在这篇文章中, 我们分析一下 Java JVM 中实例对象的内存布局。
在 HotSpot 虚拟机里, 对象在堆内存中的存储布局可以划分为三个部分: 对象头 (Object Header), 实例数据 (Instance Data) 和对齐填充 (Padding)。
大体的样子如下:
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 加锁) 的分布如下:
64 位系统的话, 如图:
注: Mark Work 的内容不是一成不变的, 如果对象被当做 synchronized 锁的话, 其内部的内容会随锁的状态变更。
对象头一般情况下的大小:
32 位系统下: Class Pointer 4 个字节, MarkWord 4 个字节, 对象头为 8 个字节, 如果是数组的话, 再加上 4 个字节的数组长度。
64 位系统下: Class Pointer 8 个字节, MarkWord 8 个字节, 对象头为 16 个字节, 如果是数组的话, 再加上 4 个字节的数组长度。
Java 中还有一项技术会影响到对象头的大小: 指针压缩技术, 看后面的介绍。
对象真正有效的信息, 也就是我们类中声明的各个字段 (包括从父类继承下来的), 每个字段都有自己的大小限制。
字段类型 | 内存大小(单位: 字节) |
---|---|
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 属性插入到父类的变量空隙中了。
这个不是必须, 也没有具体的含义, 只是单纯的起占位作用。他的出现与否取决于当前对象实例的内存大小。
所有的 Java 对象所占用的字节数必须是 8 的倍数。比如 一个对象的对象头的大小为 12 byte, 实例数据为 13 byte, 当前对象所占的大小为 25 byte。
但是 JVM 要求每个对象的大小必须是 8 的倍数, 这时候 padding 就其作用了, 填充 7 个字节, 凑够 32, 达到 8 的倍数。
而当对象头和实例数据刚好达到 8 的倍数, 这时候就不需要 padding 了。
之所以强制为对象大小为 8 的倍数是为了内存访问的效率, 数据对齐对处理器的访问是最佳的。
在了解指针压缩之前, 先了解一点别的。
我们在买电脑的时候, 很多时候都会说多少位系统, 内存是多少的, 比如 64 位系统 16g 内存, 32 位系统 4g 内存。
那么是否存在 32 位系统 16g 内存, 64 位系统 64g 的内存呢?
这里面涉及一点计算机的知识。32 位系统最大支持的内存为 4g, 64 位系统最大支持的则为 1T。能支持的内存的大小取决于 CPU 的寻址能力。
CPU 的寻址能力以字节为单位。
而我们常说的指针, 在程序中内存地址映射, 可以理解一个指针就对应了一个内存地址。通过指针就能定位到内存对应的某个位置。
在 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 中。
落实到实现中就是, 将引用的数字向左移动 3 位, 得到真正的内存地址, 通过这个转换就能实现指针和真正的内存进行关联。
比如我们堆中某个变量的指针为 0, 那么都内存中的 0 (00000000 << 3, 还是 0) 的位置就能找到这个对象的实际内容。
变量的指针为 1, 那么到内存 8 (00000001 << 3, 00001000, 十进制 8) 的位置查找就行了。
说到底, 压缩指针的前提就是存储的内容本身是一个指针引用, 那么在 Java 实例中, 哪些数据是指针引用呢
- the klass field of every object (每个普通对象对象头里面的 Klass Pointer)
- every oop instance field (每个普通对象的属性)
- every element of an oop array (objArray) (每个普通对象数组里面的每个元素)
《深入理解Java虚拟机》- 周志明
为什么32位系统最大只支持4G内存
CompressedOops
Compressed OOPs in the JVM