各位观众老爷,晚上好!我是你们今晚的 Java 内存布局导游,今天咱们不聊诗和远方,就聊聊你写的对象在 JVM 里面是怎么安家的。
开场白:对象,你的家在哪里?
咱们 Java 程序员,天天 new 对象,new 得不亦乐乎。但是,你有没有想过,new
出来的对象,它住在哪里?它的房间长什么样?邻居都是谁?今天,我们就来扒一扒 Java 对象的底裤,啊不,是内存布局。
第一站:对象的基本结构——三室一厅
Java 对象在堆内存里,至少有这么三部分:
- 对象头 (Object Header): 这是对象的门牌号,记录着对象的身份信息。
- 实例数据 (Instance Data): 这是对象真正存储数据的地方,也就是对象的属性。
- 对齐填充 (Padding): 这是为了让对象的大小是 8 字节的倍数,方便 CPU 读取,就像装修房子的时候,为了美观做的填缝一样。
我们暂且把它们比作“三室一厅”。对象头是客厅,实例数据是卧室,对齐填充是卫生间(虽然有点不雅,但是形象啊!)。
第二站:客厅——对象头 (Object Header)
对象头是重中之重,它包含了两部分:
- Mark Word: 这玩意儿信息量巨大,记录了对象的哈希码、GC 分代年龄、锁状态等等。可以把它想象成客厅的电视墙,上面挂满了照片和奖状,记录着对象的辉煌历史。
- Klass Pointer: 这玩意儿指向对象所属的类,也就是对象从哪个模板(类)来的。可以把它想象成客厅的户口本,证明你是谁家的孩子。
2.1 Mark Word 的秘密
Mark Word 的长度在 32 位 JVM 上是 4 字节,在 64 位 JVM 上是 8 字节。但是里面的内容会根据对象的状态而变化。
状态 | 标志位 | 内容 |
---|---|---|
无锁 | 01 | 对象的哈希码 (HashCode)、GC 分代年龄 (age) |
偏向锁 | 01 | 偏向线程 ID、时间戳、epoch |
轻量级锁 | 00 | 指向栈中锁记录的指针 |
重量级锁 | 10 | 指向 monitor 对象的指针 |
GC 标记 | 11 | 空,表示对象不可用 |
可偏向 | 01 | 匿名偏向或者偏向锁启用 |
哇,是不是很复杂?没关系,记住几个关键点:
- HashCode: 对象的身份标识,用于
hashCode()
方法。 - GC 分代年龄: 记录对象被垃圾回收器扫描的次数,用于判断对象是否需要被回收。
- 锁状态: 标记对象是否被锁住,以及锁的类型(偏向锁、轻量级锁、重量级锁)。
2.2 Klass Pointer 的作用
Klass Pointer 简单来说就是指向对象所属类的指针。通过这个指针,JVM 就能知道对象的类型,以及对象有哪些方法和属性。
第三站:卧室——实例数据 (Instance Data)
实例数据就是对象真正存储数据的地方。它包含了对象的所有字段。
- 基本类型:
int
,long
,float
,double
,boolean
,byte
,char
,short
- 引用类型: 指向其他对象的指针
例如:
class Person {
int age;
String name;
}
Person person = new Person();
person
对象的实例数据就包含了 age
(int) 和 name
(String) 两个字段。age
直接存储了 int 值,name
存储的是指向 String
对象的指针。
第四站:卫生间——对齐填充 (Padding)
为什么要对齐填充?这是因为 CPU 在读取数据的时候,通常是按照 8 字节(64 位系统)或者 4 字节(32 位系统)对齐读取的。如果对象的大小不是 8 字节的倍数,CPU 可能需要多次读取才能获取完整的数据,影响性能。
JVM 为了提高性能,会对对象进行对齐填充,保证对象的大小是 8 字节的倍数。
第五站:代码实战——使用 JOL 工具查看对象布局
光说不练假把式。咱们用 JOL (Java Object Layout)
工具来实际看看对象的内存布局。
-
引入 JOL 依赖:
在 Maven 项目中,添加以下依赖:
<dependency> <groupId>org.openjdk.jol</groupId> <artifactId>jol-core</artifactId> <version>0.16</version> </dependency>
-
编写测试代码:
import org.openjdk.jol.info.ClassLayout; public class ObjectLayoutExample { public static void main(String[] args) { Object o = new Object(); System.out.println(ClassLayout.parseInstance(o).toPrintable()); Person person = new Person(); System.out.println(ClassLayout.parseInstance(person).toPrintable()); Person2 person2 = new Person2(); System.out.println(ClassLayout.parseInstance(person2).toPrintable()); } } class Person { int age; String name; } class Person2 { boolean flag; int age; String name; }
-
运行结果 (64 位 JVM):
java.lang.Object object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1) 4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) 8 4 (object header) e5 01 00 20 (11100101 00000001 00000000 00100000) (536871397) 12 4 (loss due to the next object alignment) Instance size: 16 bytes Space losses: 0 bytes internal + 4 bytes external = 4 bytes total ObjectLayoutExample$Person object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1) 4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) 8 4 (object header) e5 01 00 20 (11100101 00000001 00000000 00100000) (536871397) 12 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) 16 4 int Person.age 0 20 4 java.lang.String Person.name null 24 8 (loss due to the next object alignment) Instance size: 32 bytes Space losses: 0 bytes internal + 8 bytes external = 8 bytes total ObjectLayoutExample$Person2 object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1) 4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) 8 4 (object header) e5 01 00 20 (11100101 00000001 00000000 00100000) (536871397) 12 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) 16 1 boolean Person2.flag false 17 3 (alignment/padding gap) 20 4 int Person2.age 0 24 4 java.lang.String Person2.name null 28 4 (loss due to the next object alignment) Instance size: 32 bytes Space losses: 3 bytes internal + 4 bytes external = 7 bytes total
- OFFSET: 字段的起始地址(相对于对象起始地址的偏移量)。
- SIZE: 字段的大小(字节数)。
- TYPE: 字段的类型。
- DESCRIPTION: 字段的描述。
- VALUE: 字段的值。
解释:
Object
对象:对象头占用 12 字节,因为需要对齐到8字节的倍数,因此需要 4 字节的填充,总大小为 16 字节。Person
对象:对象头占用 12 字节,int age
占用 4 字节,String name
占用 4 字节(指针),总共 20 字节。由于需要对齐到 8 字节的倍数,因此需要 8 字节的填充,总大小为 32 字节。Person2
对象:对象头占用 12 字节,boolean flag
占用 1 字节,然后填充 3 字节来对齐int age
,int age
占用 4 字节,String name
占用 4 字节(指针),总共 24 字节。由于需要对齐到 8 字节的倍数,因此需要 4 字节的填充,总大小为 32 字节。
第六站:内存对齐的策略
JVM 在进行内存对齐的时候,会遵循一些策略:
- 字段的排序: JVM 会对字段进行排序,尽量将相同类型的字段放在一起,减少填充。
- 对齐的大小: 通常是 8 字节对齐(64 位 JVM)或者 4 字节对齐(32 位 JVM)。
- 顺序: 按照字段的声明顺序进行排列。
第七站:总结与优化建议
- 了解对象布局: 理解对象在内存中的布局,可以帮助我们更好地理解 JVM 的工作原理,以及优化内存使用。
- 合理安排字段顺序: 将相同类型的字段放在一起,可以减少填充,节省内存。
- 避免过大的对象: 过大的对象容易导致内存碎片,影响性能。
- 使用基本类型代替对象: 如果可以使用基本类型,尽量避免使用对象,可以减少内存开销。
第八站:进阶思考
- 压缩指针 (Compressed Oops): 在 64 位 JVM 上,可以使用压缩指针技术,将指针的大小从 8 字节压缩到 4 字节,节省内存。
- 逃逸分析 (Escape Analysis): JVM 可以通过逃逸分析,判断对象是否只在方法内部使用。如果是,可以将对象分配在栈上,而不是堆上,减少 GC 的压力。
第九站:问答环节
欢迎大家提问,我会尽力解答。
结束语:
今天我们一起探索了 Java 对象的内存布局,希望大家以后在写代码的时候,能够更加关注内存使用,写出更加高效、健壮的程序。下次再见!
彩蛋:
记住,一切皆对象,对象皆有家。了解对象的家在哪里,才能更好地管理它们,让你的程序跑得更快、更稳!