引言:对象在内存中的真实面貌
当你创建一个对象时:
User user = new User("张三", 25);
这个对象在JVM堆中究竟占用多少字节?内存是如何布局的?
答案可能出乎意料:即使 User 类只有两个字段(name和age),这个对象也可能占用 32字节甚至更多。
为什么?
- 对象不仅包含实例数据(字段值)
- 还包含 对象头(存储对象元信息)
- 还可能有 对齐填充(保证内存对齐)
理解对象的内存布局是掌握JVM内存优化、锁机制、GC原理的基础。
对象的内存布局结构
三大组成部分
在HotSpot虚拟机中,对象在内存中分为3个部分:
┌─────────────────────────────────────────────────┐
│ 对象内存布局 │
├─────────────────────────────────────────────────┤
│ 1. 对象头 (Object Header) │
│ ├─ Mark Word(8字节,64位JVM) │
│ └─ 类型指针(4字节,开启指针压缩) │
│ │
│ 2. 实例数据 (Instance Data) │
│ · 字段1: String name │
│ · 字段2: int age │
│ │
│ 3. 对齐填充 (Padding) │
│ · 确保对象大小是8字节的倍数 │
└─────────────────────────────────────────────────┘
对象头(Object Header)
对象头的组成
对象头包含两部分:
1️⃣ Mark Word(标记字)
大小:
- 32位JVM:4字节
- 64位JVM:8字节
作用:存储对象的 运行时元数据。
存储内容(64位JVM为例):
| 锁状态 | 29位 | 2位 | 1位 | 4位 | 1位锁标志位 | 2位锁标志位 |
|---|---|---|---|---|---|---|
| 无锁 | unused | 对象hashCode(31位) | unused | 分代年龄(4位) | 0 | 01 |
| 偏向锁 | 线程ID(54位) | epoch(2位) | unused | 分代年龄(4位) | 1 | 01 |
| 轻量级锁 | 指向栈中锁记录的指针(62位) | 00 | ||||
| 重量级锁 | 指向互斥量的指针(62位) | 10 | ||||
| GC标记 | 空 | 11 |
关键信息:
- 哈希码(HashCode):调用
obj.hashCode()时生成并存储 - 分代年龄(Age):对象在Survivor区经历的Minor GC次数(最大15)
- 锁状态标志:无锁、偏向锁、轻量级锁、重量级锁
- 线程ID:偏向锁时,记录持有锁的线程ID
2️⃣ 类型指针(Class Pointer)
大小:
- 64位JVM(未开启指针压缩):8字节
- 64位JVM(开启指针压缩):4字节
作用:指向对象的类元数据(Class对象)。
为什么需要类型指针?
- 确定对象的类型
- 调用方法时查找方法表
- 反射操作
对象头的总大小
64位JVM(开启指针压缩,默认):
- Mark Word:8字节
- 类型指针:4字节
- 总计:12字节
64位JVM(未开启指针压缩):
- Mark Word:8字节
- 类型指针:8字节
- 总计:16字节
实例数据(Instance Data)
定义
实例数据 存储对象的 字段值,包括:
- 继承自父类的字段
- 当前类定义的字段
字段存储顺序
HotSpot的存储规则:
- 相同宽度的字段分配在一起
- 父类字段在子类字段之前
- 宽度从大到小排列:long/double(8字节) → int/float(4字节) → short/char(2字节) → byte/boolean(1字节) → 引用类型(4或8字节)
各类型字段的大小
| Java类型 | 大小(字节) | 说明 |
|---|---|---|
| byte | 1 | 8位 |
| boolean | 1 | 8位(实际只用1位,但占1字节) |
| short | 2 | 16位 |
| char | 2 | 16位 |
| int | 4 | 32位 |
| float | 4 | 32位 |
| long | 8 | 64位 |
| double | 8 | 64位 |
| 引用类型 | 4或8 | 开启指针压缩为4字节,否则8字节 |
示例:实例数据布局
class User {
private String name; // 引用类型,4字节(开启指针压缩)
private int age; // int,4字节
private boolean active; // boolean,1字节
}
实例数据布局:
┌────────────────────────┐
│ name (String引用) │ 4字节
├────────────────────────┤
│ age (int) │ 4字节
├────────────────────────┤
│ active (boolean) │ 1字节
├────────────────────────┤
│ 对齐填充 │ 3字节(补齐到8的倍数)
└────────────────────────┘
实例数据总大小: 12字节
对齐填充(Padding)
为什么需要对齐填充?
HotSpot要求:对象大小必须是 8字节的倍数。
原因:
- CPU访问内存时,按地址对齐的数据更高效
- 减少CPU缓存miss
- 统一内存管理
规则:
- 如果对象头 + 实例数据不是8的倍数,需要 补齐
- 补齐的字节称为 对齐填充(Padding)
示例:对齐填充计算
class SimpleObject {
private int value; // 4字节
}
内存布局:
┌────────────────────────┐
│ 对象头 (Mark Word) │ 8字节
├────────────────────────┤
│ 对象头 (类型指针) │ 4字节
├────────────────────────┤
│ value (int) │ 4字节
├────────────────────────┤
│ 对齐填充 │ 0字节(12+4=16,已是8的倍数)
└────────────────────────┘
对象总大小: 16字节
class TinyObject {
private byte value; // 1字节
}
内存布局:
┌────────────────────────┐
│ 对象头 (Mark Word) │ 8字节
├────────────────────────┤
│ 对象头 (类型指针) │ 4字节
├────────────────────────┤
│ value (byte) │ 1字节
├────────────────────────┤
│ 对齐填充 │ 3字节(12+1=13,补齐到16)
└────────────────────────┘
对象总大小: 16字节
指针压缩(Compressed Oops)
什么是指针压缩?
Oops(Ordinary Object Pointers):普通对象指针。
指针压缩:将64位指针压缩为32位,节省内存。
开启条件:
- 64位JVM
- 堆大小 < 32GB
- JDK 6 Update 23及之后默认开启
压缩原理
核心思想:利用对象地址的对齐特性。
- 对象地址都是 8字节对齐 的(末尾3位始终为0)
- 可以用32位存储 地址右移3位 后的值
- 寻址时,将32位值 左移3位 恢复为64位地址
可寻址范围:
- 32位指针:2^32 × 8 = 32GB
示例:
原始64位地址: 0x0000 0000 1234 5678 (假设末尾3位为000)
压缩后32位: 0x1234 5678 (右移3位,去掉末尾000)
恢复64位地址: 0x1234 5678 << 3 = 0x0000 0000 1234 5678
指针压缩的参数
# 查看是否开启指针压缩(默认开启)
java -XX:+PrintFlagsFinal -version | grep UseCompressedOops
# 输出: bool UseCompressedOops = true
# 关闭指针压缩(不推荐)
java -XX:-UseCompressedOops MyApp
指针压缩的影响
开启指针压缩(默认):
- 类型指针:4字节
- 引用类型字段:4字节
- 节省内存约 20-30%
关闭指针压缩:
- 类型指针:8字节
- 引用类型字段:8字节
- 内存占用增加
使用JOL工具查看对象布局
什么是JOL?
JOL(Java Object Layout) 是OpenJDK提供的官方工具,用于查看对象的精确内存布局。
添加JOL依赖
<!-- Maven -->
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.17</version>
</dependency>
示例1:简单对象
import org.openjdk.jol.info.ClassLayout;
public class JOLDemo {
static class User {
private String name;
private int age;
}
public static void main(String[] args) {
User user = new User();
System.out.println(ClassLayout.parseInstance(user).toPrintable());
}
}
输出:
com.example.JOLDemo$User object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001
8 4 (object header: class) 0x00001234
12 4 java.lang.String name null
16 4 int age 0
20 4 (object alignment gap)
Instance size: 24 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
解读:
- OFF:字段在对象中的偏移量(字节)
- SZ:字段大小(字节)
- 0-7:Mark Word(8字节)
- 8-11:类型指针(4字节)
- 12-15:name字段(4字节引用)
- 16-19:age字段(4字节int)
- 20-23:对齐填充(4字节)
- 总大小:24字节
示例2:空对象
static class EmptyObject {
// 没有字段
}
public static void main(String[] args) {
EmptyObject obj = new EmptyObject();
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}
输出:
com.example.JOLDemo$EmptyObject object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001
8 4 (object header: class) 0x00005678
12 4 (object alignment gap)
Instance size: 16 bytes
解读:
- 空对象也占用 16字节(对象头12字节 + 对齐填充4字节)
示例3:数组对象
public static void main(String[] args) {
int[] array = new int[5];
System.out.println(ClassLayout.parseInstance(array).toPrintable());
}
输出:
[I object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001
8 4 (object header: class) 0x00001234
12 4 (array length) 5
16 20 int [I.<elements> N/A
36 4 (object alignment gap)
Instance size: 40 bytes
解读:
- 数组对象比普通对象多4字节(存储数组长度)
- 对象头:12字节(Mark Word 8 + 类型指针 4)
- 数组长度:4字节
- 数组元素:5个int = 20字节
- 对齐填充:4字节
- 总大小:40字节
实战案例:对象大小优化
案例:字段顺序对对象大小的影响
优化前:
class User {
private boolean active; // 1字节
private int age; // 4字节
private String name; // 4字节
}
内存布局:
对象头: 12字节
boolean active: 1字节
对齐填充: 3字节(补齐到4的倍数)
int age: 4字节
String name: 4字节
总大小: 24字节
优化后:
class User {
private String name; // 4字节
private int age; // 4字节
private boolean active; // 1字节
}
内存布局:
对象头: 12字节
String name: 4字节
int age: 4字节
boolean active: 1字节
对齐填充: 3字节
总大小: 24字节
结论:本例中两种顺序大小相同,因为最终都需要对齐填充。但在复杂对象中,合理排列字段可以减少对齐填充的浪费。
常见问题与误区
❌ 误区1:对象只包含字段数据
真相:
- 对象包含对象头(12或16字节)
- 即使空对象也占用16字节
- 实例数据只是对象的一部分
❌ 误区2:boolean只占1位
真相:
- boolean 字段 占用1字节
- boolean 数组元素 占用1字节
- boolean在栈上可能优化为1位(具体实现相关)
❌ 误区3:对象大小 = 字段大小之和
真相:
- 对象大小 = 对象头 + 实例数据 + 对齐填充
- 必须考虑对象头(12或16字节)
- 必须考虑对齐填充(8字节的倍数)
总结
核心要点
对象内存布局包含3部分:对象头、实例数据、对齐填充
对象头包含Mark Word和类型指针,存储对象元数据和类型信息
Mark Word存储运行时信息:哈希码、分代年龄、锁状态
对齐填充确保对象大小是8字节的倍数
指针压缩节省内存20-30%,默认开启
使用JOL工具可以精确查看对象布局
阶段总结
至此,我们完成了 第三阶段:内存结构篇 的全部8篇文章:
✅ 11. JVM内存结构全景:5大区域详解 ✅ 12. 程序计数器:最小的内存区域 ✅ 13. 虚拟机栈:方法执行的内存模型 ✅ 14. 本地方法栈:Native方法的秘密 ✅ 15. 堆内存:对象的诞生地与分代设计 ✅ 16. 方法区:类元数据的存储演变 ✅ 17. 直接内存:堆外内存与NIO ✅ 18. 对象的内存布局:对象头、实例数据、对齐填充
学习成果:
- 全面理解JVM内存结构
- 掌握各内存区域的作用和特点
- 理解对象在内存中的存储方式
- 为后续学习垃圾回收打下坚实基础
与下篇文章的衔接
下一篇文章,我们将进入 第四阶段:垃圾回收篇,学习 什么是垃圾?如何判断对象已死?,理解对象存活判定的两种算法。
参考资料
- 《深入理解Java虚拟机(第3版)》- 周志明
- JOL (Java Object Layout)
- Java虚拟机规范(Java SE 8版)
下一篇预告:《什么是垃圾?如何判断对象已死?》 深入理解垃圾的定义、对象存活判定的两种算法(引用计数 vs 可达性分析)、以及finalize方法的秘密。