深入理解 Java 对象到底是什么。
内部布局

图片来源网络,其中 Class Pointer 应该为 Klass Pointer
一个 Java 对象包括对象头、实例数据和对齐字节三部分。
其中 Array Object 相比于非 Array Object 在对象头的位置还多一个 Length ,用于记录数组的长度。
注意:在实际内存里,用作对齐的字节不是直接跟在实例数据后面,下面会讲到。
对象头
对象头包括三部分:Mark Word、Klass Pointer 和 Length(只有当对象为 Array Object 才会有)
Mark Word
: 包含一系列的标记位,比如轻量级锁的标记位,偏向锁标记位等等。在 32 位系统占 4 字节,在 64 位系统占 8 字节;
Klass Pointer
: 用来指向对象对应的 Class 对象(其对应的元数据对象)的内存地址。在 32 位系统占 4 字节,在 64 位系统占 8 字节;
Length
: 如果是数组对象,还有一个保存数组长度的空间。再 32 位系统和 64 位系统均占 4 个字节;
实例数据
对象实际数据包括了对象的所有成员变量,其大小由各个成员变量的大小决定。
类型 | 大小(字节) |
---|---|
byte | 1 |
boolean | 1 |
char | 2 |
short | 2 |
int | 4 |
float | 4 |
long | 8 |
double | 8 |
reference | 4 or 8 |
其中 reference 引用类型比较特别:
32 位系统下占 4 个字节
64 位系统下 :
- 不启用指针压缩, 大小统一为 8 个字节
- 启用指针压缩(下面会介绍指针压缩),”普通对象指针“ 的大小占到 4 个字节,其余指针大小为 8 个字节
字节对齐
Java 对象都是 8 字节对齐。
所以当 对象头大小 + 实例数据大小 的总和不是 8 字节的整数倍,会由这部分补齐。
对齐的目的是为了提高存取效率,所以如果存在对齐部分,肯定不会添加在”实例数据“的后面。
举个例子:
1 | public class Clazz { |
64 位系统开启指针压缩的的情况下:clazz 对象的大小 = 8 字节的 Mark Word + 4 字节的 Klass Pointer + 8 字节的 long 实例数据 = 20 字节,之后补齐到 24 字节,即存在 4 个字节的对齐。
这 4 字节是加在哪里呢?是实例数据的后面吗?
我们知道,64 位系统计算机一次性可以处理 64 位的数据,也就是 8 个字节。
如果是加在实例对象的后面的话,那么当我们访问实例成员 aLong 的时候,需要读取两次
- 偏移 8 字节之后进行读取,第一次读取 8 个字节,读取内容为对象头的后 4 位(共 12 字节)+ 实例数据 aLong 的前 4 个字节
- 第二次读取为实例数据 aLong 的后 4 字节 + 补齐字节
需要花两次才能把 aLong 完全读取出来,这显然没有提高存取效率。
如果将补齐字节加载对象头之后,实例数据之前呢?是否可以实现一次性读取?
- 偏移 16 字节后开始读取(12 字节的对象头 + 4 字节的对齐内容)
- 一次性读取实例数据 aLong(8 字节)
看起来将对齐字节加到对象头和实例数据之间更为合理?如何可以证明 aLong 是从第 16 个字节开始,而不是从第 12 个字节开始:
1 | class Clazz { |
如果将实例数据改为 int 类型的话,那么最终对象大小 = 8 字节的 Mark Word + 4 字节的 Klass Pointer + 4 字节的 int 实例数据 = 16 字节,不需要进行字节对齐。
这时候打印结果为 12 。读取流程为先偏移 8 个字节,再读取一次(8字节),读取内容为对象头的后 4 个字节 + 实例数据 int ,也是一次性读取。
计算对象大小
理解了 Java 的对象布局之后,
Shallow Size & Retained Size
Retained Size : 对象自身占用的内存大小,不包括它引用的对象。
Retained Size : 当前对象大小 + 当前对象可直接或间接引用到的对象的大小总和 (不包括被 GC 直接或间接引用的对象)
换句话说,Retained Size 就是当前对象被 GC 后,从 Heap 上总共能释放掉的内存

图片来源网络:描述了对象引用关系和 GC Roots 引用
A 对象的 Retained Size = A 对象的 Shallow Size
B 对象的 Retained Size = B 对象的 Shallow Size + C 对象的 Shallow Size
B 对象的 Retained Size不包括 D 对象,因为 D 对象被 GC Roots 直接引用
指针压缩
在 JDK 1.6 之后,64 位的 JVM 可以使用 -XX:+UseCompressedOops
压缩“普通对象指针” 和 -XX:+UseCompressedClassPointer
压缩“对象头的 Klass Pointer”,起到节约内存占用的作用。
何为 ”普通对象指针“ ?
- 对象的属性指针
- Class 对象的属性指针(静态成员变量)
- 普通对象数组的元素指针
1 | public class Clazz { |
只有这几类指针会被压缩优化。其余的本地变量、堆栈元素、入参、返回值和 NULL 指针不会被压缩。
一些内置对象的 Retained Size
包装类型及 String:
类型 | 开启指针压缩(字节) | 关闭指针压缩(字节) |
---|---|---|
Byte | 16 | 24 |
Boolean | 16 | 24 |
Character | 16 | 24 |
Short | 16 | 24 |
Integer | 16 | 24 |
Float | 16 | 24 |
Long | 24 | 24 |
Double | 24 | 24 |
String | >= 24 | >= 32 |
String 类型比较特别,其大小取决于装载的字符串内容的大小,上面的 24 & 32 是对空字符串计算所得:
1 | public final class String |
查看 String 源码可以发现,主要属性有两个 value 和 hash(serialVersionUID 为静态属性,不参与大小计算)
- 32 位系统:4 字节的 Mark Word + 4 字节的 Klass Pointer + 4 字节的 value 数组引用 + 4 字节的 hash int 类型 + 实际存储的字符串大小(value 的实际大小)>= 16 字节
- 64 位系统:
- 关闭指针压缩:8 字节的 Mark Word + 8 字节的 Klass Pointer + 8 字节的 value 数组引用 + 4 字节的 hash int 类型 + 实际存储的字符串大小(value 的实际大小)>= 32 字节
- 开启指针压缩:8 字节的 Mark Word + 4 字节的 Klass Pointer + 4 字节的 value 数组引用 + 4 字节的 hash int 类型 + 实际存储的字符串大小(value 的实际大小)>= 24 字节
计算自定义类大小
计算如下对象的占用大小:
1 | public class Clazz { |
虽然现在 32 位的系统已经几乎没有人用了,但是为了练习,还是来计算 32 位系统的大小、64 位系统开启指针压缩和 64 位系统关闭指针压缩几种情况:
- 32 位系统:
- clazz 对象 Shallow Size = 4 字节的 Mark Word + 4 字节的 Klass Pointer + 4 字节的 string 引用 = 12 字节,对齐为 16 字节
- 内部引用到的 string 对象的 Shallow Size = 4 字节的 Mark Word + 4 字节的 Klass Pointer + 4 字节的 value 数组引用 + 4 字节的 hash int 类型 = 16 字节
- 计算 string 对象内部的 object 数组占用的大小 = 4 字节的 Mark Word + 4 字节的 Klass Pointer + 4 字节的 Length + 8 字节的 char = 20 字节,对齐为 24 字节
- 总大小 = 16 + 16 + 24 = 56 字节
- 64 位系统:
- 关闭指针压缩:
- clazz 对象 Shallow Size = 8 字节的 Mark Word + 8 字节的 Klass Pointer + 8 字节的 string 引用 = 24 字节
- 内部引用到的 string 对象 Shallow Size = 8 字节的 Mark Word + 8 字节的 Klass Pointer + 8 字节的 value 数组引用 + 4 字节的 hash int 类型 = 28 字节,对齐为 32 字节
- 计算 string 对象内部的 object 数组占用的大小 = 8 字节的 Mark Word + 8 字节的 Klass Pointer + 4 字节的 Length + 8 字节的 char = 28 字节,对齐为 32 字节
- 总大小 = 24 + 32 + 32 = 88 字节
- 开启指针压缩:
- clazz 对象 Shallow Size = 8 字节的 Mark Word + 4 字节的 Klass Pointer + 4 字节的 string 引用 = 16 字节
- 内部引用到的 string 对象 Shallow Size = 8 字节的 Mark Word + 4 字节的 Klass Pointer + 4 字节的 value 数组引用 + 4 字节的 hash int 类型 = 20 字节,对齐为 24 字节
- 计算 string 对象内部的 object 数组占用的大小 = 8 字节的 Mark Word + 4 字节的 Klass Pointer + 4 字节的 Length + 8 字节的 char = 24 字节
- 总大小 = 16 + 24 + 24 = 64 字节
- 关闭指针压缩:
对象头的几种状态
无论是否开启指针压缩,对象头 Mark Word 都占 8 个字节,那么这 8 个字节到底记录些什么内容?

图片来源网络,其中 “对象的 hashCode” 不准确,应该为 “对象的 identityHashCode( 包括 System.identityHashCode(o)
或者未被重写的 hashCode()
)” ,因为如果对象重写了 hashCode 方法,那么计算出来的 hashCode 将不会被记录到 Mark Word 中
Mark Word 中的 8 个字节记录什么内容,取决于当前对象是什么状态(由对象的后 3 位可确定对象状态):
普通对象
:Mark Word 的后两位为 01,第三位代表是否偏向,此时为 0。即普通对象状态下的锁对象 Mark Word 后三位为 010;之后的 4 位记录的是对象分代年龄;再之后的 25 位用于记录 identityHashCode ,只有调用了System.identityHashCode(o)
或者未被重写的hashCode()
才会被缓存起来,如果自身重写了hashCode()
方法,计算结果不会被缓存到 Mark Word 中(重写hashCode()
方法通常是使用实例变量的值计算哈希值,确保两个不同的的对象在逻辑上是相同的,也就是对象的哈希值随时会随着实例变量的值变化,这时候 Mark Word 也没有将哈希值缓存起来的必要)
偏向锁
:Mark Word 的后两位为 01,第三位代表是否偏向,此时为 1。即偏向锁状态时锁对象 Mark Word 的后三位为 101;之后的 4 位记录的是对象分代年龄;之后 25 位中有 23 位用于记录当前偏向的线程 id ,有 2 位是记录线程重入次数
轻量级锁
:Mark Word 的后两位为 00;之后的空间用于记录栈中锁记录指针。轻量级锁又称为“自旋锁”,底层是使用 CAS 锁(lock compareExchange
指令),当有大量的线程试图获取轻量级锁的时候,会很占用 CPU 资源
重量级锁
:Mark Word 的后两位为 10;之后的空间用于记录“重量级互斥锁”内容(ObjectMonitor)。所谓的重量级是指要向内核态申请执行指令,而之前的几种锁状态都是在用户态完成;重量级锁的底层是使用等待队列装载正在阻塞的线程,当锁释放后由操作系统从等待对象中取出线程进行调度
待 GC 回收
:Mark Word 的后两位为 11
评论