本篇是「JVM 与性能调优系列」第 2 篇。
你每天new成百上千个对象,但有没有想过:这行new Object()创造出来的东西,在内存里到底占多少字节、长什么样?
很多人以为「对象就是几个字段加起来的大小」,结果线上一算内存占用,差出好几倍。今天我们用java -XX:+PrintFieldLayout之外的更直观方式,把对象拆开看个明白。
一、一个对象 = 对象头 + 实例数据 + 对齐填充
在 HotSpot 里,对象在堆中的布局分三块:
| 对象头 Mark Word | 对象头 Klass Pointer | 实例数据 | 对齐填充 |- 对象头(Header):固定开销,承载「对象自己的元数据」
- 实例数据(Instance Data):你定义的字段,包括父类继承来的
- 对齐填充(Padding):JVM 要求对象大小是 8 字节的整数倍,不足就补
二、对象头之 Mark Word:对象的「身份证」
Mark Word 在 64 位 JVM 上占8 字节,存的是和对象状态强相关的运行时数据,而且同一块 8 字节,在不同状态下存的东西会变:
| 状态 | Mark Word 存的内容 |
|---|---|
| 无锁 | 哈希码(hashCode) + 分代年龄 + 是否偏向锁标志 |
| 偏向锁 | 偏向的线程 ID |
| 轻量级锁 | 指向栈中锁记录的指针 |
| 重量级锁 | 指向互斥量(monitor)的指针 |
| GC 标记 | 空(被标记时复用) |
看到没?分代年龄就在这 8 字节里——占 4 个比特位,所以最大就是 15(MaxTenuringThreshold默认 15 不是拍脑袋定的,是位数限制)。hashCode()第一次调用才会写进 Mark Word,这也是为什么未调用过hashCode的对象和调用过的对象布局不同。
三、对象头之 Klass Pointer:指向「类」的指针
Klass Pointer 指向方法区的类元数据(Klass 结构),JVM 靠它知道「这个对象是哪个类的实例」。
指针压缩是关键优化:64 位 JVM 指针本应 8 字节,但开启-XX:+UseCompressedOops(JDK 6 起默认开)后,Klass Pointer 压成4 字节。原理是堆上限通常 < 32G,用「基址 + 偏移×8」就能寻址,省下一半头开销。一旦堆超过约 32G,压缩自动失效,对象头回到 8 字节,内存占用明显上升——这就是为什么堆不是越大越好,32G 是条隐形的分水岭。
四、实例数据:字段怎么排
字段按类型宽度排列,相同宽度会分到一起,顺序大致是:long/double→int/float→short/char→byte/boolean→ 引用类型。父类字段在子类字段之前。
引用类型字段在开启指针压缩时也只占 4 字节。所以一个只有一个int字段的对象:8(Mark) + 4(Klass) + 4(int) = 16 字节,正好 8 的倍数,无需填充。
五、数组对象多了「长度」
数组对象比普通对象多一个4 字节的长度字段(因为数组需要知道自己多长),所以new int[0]也要 16 字节:8 + 4 + 4(长度) = 16。
六、算一笔账:为什么你的对象比你以为的大
一个看似轻量的Point(x, y)只有两个int:
8(Mark) + 4(Klass) + 4(x) + 4(y) = 20 → 对齐到 24 字节如果换成两个Integer(对象)引用,每个Integer本身又是 16 字节对象 + 4 字节引用,开销直接翻倍。这就是大量小对象场景里,用基本类型数组(int[])比List<Integer>省内存几个数量级的原因——也是调优时「对象膨胀」排查的着眼点。
七、对齐填充的副作用:伪共享(铺垫)
填充除了对齐,有时还被用来故意撑大对象避免伪共享(两个变量在同一缓存行被不同 CPU 核心改写,互相失效)。第 12 篇讲性能时会再提@Contended。这里先记住:对齐不是浪费,是硬件友好的代价。
八、亲手量一量:JOL 工具
别光听我说,OpenJDK 有个JOL(Java Object Layout)工具能打印真实布局:
System.out.println(ClassLayout.parseInstance(newPoint(1,2)).toPrintable());输出会清楚列出 Mark Word、Klass Pointer、各字段的偏移(offset)与大小,以及末尾 padding。强烈建议你在自己项目里跑一遍——你会发现很多「以为很小」的对象其实带着 12~16 字节的固定税。
九、字段顺序也影响内存
前面说相同宽度的字段会聚到一起。如果你把boolean和long交错定义,JVM 仍会按宽度重排,但父类字段永远先于子类这一条无法改变。所以继承层级越深,对象头前面的「父类实例数据」就越多。高频小对象(如 DTO、事件对象)应尽量减少不必要的父类字段,避免无谓膨胀。
十、压缩指针何时失效
前面说开启-XX:+UseCompressedOops后 Klass Pointer 压成 4 字节。但有个硬限制:压缩指针用「基址 + 偏移×8」寻址,能覆盖的最大堆是32G 左右(4G × 8 = 32G,实际约 32G 出头)。
一旦你设-Xmx=40g,JVM自动关闭压缩指针,Klass Pointer 变回 8 字节,每个对象头多 4 字节。看似只多 4 字节,但在十亿级对象的内存里,这就是几个 G 的差距——而且大堆本身 GC 更慢。
结论:除非真需要,别轻易把堆推过 32G。超过这条线,往往不如「两个 30G 实例」来得划算。
十一、对象头与锁升级的伏笔
本篇提到 Mark Word 在不同锁状态下存不同内容(偏向锁/轻量/重量)。这其实是后面「并发与锁」的入口:当多个线程竞争同一个对象,JVM 会让它的 Mark Word 在几种状态间升级,升级过程就记录在对象头那 8 字节里。
今天我们只要记住:对象头不是死数据,它动态记录着对象的运行时状态(锁、哈希、年龄)。理解它,才能理解后面为什么synchronized有时快、有时慢。
总结
一个对象 =8 字节 Mark Word(锁/哈希/年龄)+ 4 字节 Klass 指针(压缩后)+ 实例数据 + 对齐填充。指针压缩让我们在 <32G 堆下省一半头开销,分代年龄被锁死在 15,数组多一个长度字段。下次有人问「这个对象多大」,别只加字段——把 12 字节固定头和对齐算进去,才靠谱。