
写Java的人尤其是搞过性能调优、高并发或者服务排查的迟早会碰上“对象内存布局”这个话题。很多人一开始觉得这是JVM底层的东西跟平时写业务没关系但真到排查线上OOM、分析GC日志、估算缓存容量的时候才发现不懂对象在内存里是怎么摆的很多事情都只能靠猜。HotSpot作为目前应用最广的JVM实现它对Java对象的内存布局有一套非常固定的规则。搞懂这套规则你就能回答一些很实际的问题一个空Object到底占多少字节一个Integer对象比int多占多少内存对象头里的锁状态是怎么变的为什么有时候开压缩指针能省那么多内存这篇文章不打算从零讲一遍JVM规范而是直接把手伸到HotSpot的实现细节里把对象内存布局这件事拆开揉碎讲清楚包括对象头里每个位段的含义、字段重排的规则、对齐填充的底层逻辑再配上用工具实测的数据。无论你是准备面试还是真的要做底层优化这都能当一份现场笔记用。1. 对象在堆里到底长什么样三段式布局先建立一个整体图景。HotSpot中任何一个Java对象这里指普通对象数组对象稍微特殊点在堆内存里都是按“三段式”存放的依次是对象头、实例数据、对齐填充。1.1 三个组成部分各干什么对象头记录对象自身的运行时数据包括哈希码、GC分代年龄、锁状态标志、类型指针指向它的类元数据等。对于数组对象还需要额外记录数组长度。实例数据对象真正存储的字段内容不管是父类继承下来的还是自己定义的都会存放在这里。对齐填充HotSpot要求对象的大小必须是8字节的整数倍如果前面两部分加起来不够8的倍数就用这部分占位补全。这部分本身不存储任何有效数据纯粹是为了对齐而存在。这三部分里对象头最复杂也最关键后面单独开一节细说。实例数据跟字段声明顺序和虚拟机分配策略有关对齐填充则牵涉到JVM的寻址效率和对象访问速度。1.2 为什么不厌其烦地做8字节对齐你可能会问对齐填充看起来纯粹浪费空间JVM为什么要这么干答案跟CPU访问内存的方式有关。绝大多数现代CPU读取内存的最小单位是字wordHotSpot在这里选择按8字节对齐意味着任何对象在内存中的起始地址都是8的倍数。这样CPU在读取对象中的任意字段时都能保证一次总线事务读取完整的数据不会出现“字段跨边界”的情况避免额外的一次内存访问。打个生活化的比方你搬家时如果所有箱子都按统一规格整齐摆好找东西、搬东西的效率最高如果箱子尺寸不规则、东歪西扭每次取东西都得翻找半天。8字节对齐就是让所有对象的“箱子”都按同一个规格摆放代价是箱子里可能留出一些空位换来的是整体的访问效率。另外一个非常实际的意义是对象的起始地址是8的倍数意味着地址的二进制表示里低3位永远是0。HotSpot正是利用这一点把指针压缩技术做到了极致——这个后面讲压缩指针时再展开。1.3 对象访问定位的两种方式对象在堆里这样布局但栈上的引用变量怎么找到这个对象HotSpot用的是直接指针访问栈上存的引用直接指向堆中对象的地址。也就是说你在Java代码里拿到的引用本质上就是一个指向对象头的指针。这种方式的优点是访问速度极快因为定位一次就能拿到对象本身。对比一下早期的SUN Classic VM用句柄池的方式——引用先指向句柄池里的句柄再由句柄指向真实对象虽然GC时移动对象只需更新句柄但每次访问对象都要经历两次指针跳转性能上吃亏。HotSpot选择直接指针也是典型的时间换空间、牺牲一定GC实现复杂度换取运行速度的决策。2. 对象头是真正的“信息中枢”对象头是整个对象内存布局里最值得研究的部分。在64位JVM中对象头通常由两部分组成Mark Word和类型指针Klass Pointer。加上数组对象的长度字段一共是三块信息。2.1 Mark Word一个可以“变脸”的复合结构Mark Word是对象头的核心它的大小跟JVM位数有关。32位JVM上是4字节64位JVM上是8字节。但Mark Word本身只在很小一部分情况下固定含义大多数情况下它存的内容会随着对象状态的不同重用同一块存储空间。64位JVM上Mark Word的位布局是这样的各位的含义取决于锁状态锁状态占用位段64位无锁未用25位 对象哈希码31位 GC分代年龄4位 偏向锁标志1位 锁标志2位偏向锁已撤销后未用54位 GC分代年龄4位 偏向锁标志1位 锁标志2位轻量级锁指向栈中锁记录的指针62位 锁标志2位重量级锁指向重量级锁监视器的指针62位 锁标志2位GC标记空位61位 GC标记2位 锁标志2位这里的锁标志位是理解Mark Word的关键。两位锁标志可以表达四种状态01无锁或偏向锁、00轻量级锁、10重量级锁、11GC标记。对象哈希码存在Mark Word里这个很多人会忽略。它有一个前提——只有在对象第一次调用hashCode()时才会被写进Mark Word。而且注意这里存的是HotSpot通过随机数算法算出的identity hash code不是Object.hashCode()被覆盖后返回的值。如果对象的hashCode()被重写了那Mark Word里就不会存这个哈希码了。一个很重要的实战知识点如果对象已经计算过identity hash code它就没法进入偏向锁状态。因为偏向锁一旦被撤销Mark Word要恢复成无锁状态此时原本存偏向线程ID的位段要重新存放31位的哈希码两者空间冲突干脆就从源头掐断计算过哈希码的对象不再允许进行偏向锁偏向。注意这个现象很多人遇到过但不理解——某对象先执行了hashCode()再进synchronized块锁就莫名其妙膨胀到了轻量级锁。现在原因清楚了Mark Word里哈希码占了位偏向锁没地方记线程ID了。2.2 Klass Pointer对象指向类的“门牌号”对象头的第二部分是类型指针JVM内部叫作Klass Pointer。它指向方法区或者叫元空间取决于JDK版本中的类元数据告诉JVM这个对象是什么类型的实例。在开启压缩指针之前64位JVM上Klass Pointer占8字节开启-XX:UseCompressedClassPointers之后被压缩成4字节。HotSpot会把类元数据放到一个从0地址开始、4GB范围内连续的内存区域里这样4字节无符号整数就足够表示所有类的地址偏移。Klass Pointer和后面要说的引用压缩是两个独立的开关虽然经常一起开启。前者压缩的是对象头里的类指针后者压缩的是实例数据里的引用字段。默认情况下HotSpot会同时开启这两者但如果你显式设置堆内存超过32GB两个压缩开关都会自动关闭——具体原因在第3节细说。2.3 数组对象多出来的4字节如果一个对象是数组对象头还要额外增加一个4字节的_length字段用来记录数组长度。也就是说new int[0]这样长度为0的数组也不是“空”的它至少包含8字节Mark Word 4字节Klass Pointer压缩后 4字节数组长度 16字节再对齐到16字节。这个4字节不是可省的因为JVM在访问数组元素时必须做越界检查而检查就得知道数组长度。一个数组对象的实际内存占用是这个公式对象头16字节 数据区域 对齐填充。3. 压缩指针是怎么省出一半引用的现在64位JVM已经成了绝对主流但当年从32位迁移到64位时遇到了一个尴尬指针变宽了从4字节变成8字节同样大小的堆能容纳的对象数量不变但光是指针占的内存就要翻一倍。压缩指针就是为了解决这个问题诞生的。3.1 对齐带来的低3位红利前面提过所有Java对象都按8字节对齐所以对象的起始地址必然是8的倍数。在二进制表示里一个能被8整除的数其低3位一定是0。那么用4字节存储一个地址的“高32位”信息再加一个位移量右移3位复原理论上是可行的。具体做法是JVM用一个4字节的偏移值替代8字节的完整指针存的是对象地址相对于某个基地址右移3位后的值。使用时把偏移量左移3位再加上基地址就能还原真实地址。这样原本8字节的引用就压缩成了4字节。代价是寻址范围受限4字节无符号整数最大表示4GB左移3位后映射到32GB的地址空间。这就是为什么默认情况下开压缩指针时堆内存上限是32GB。一旦你通过-Xmx把堆设到32GB以上JVM认为压缩指针够不着了就自动关闭压缩所有引用恢复8字节。3.2 压缩指针与压缩类指针的区别很多人把UseCompressedOops和UseCompressedClassPointers混为一谈其实这是两个独立的开关配置项压缩对象默认状态-XX:UseCompressedOops实例数据中指向Java对象的引用字段JDK 6 update 23开始默认开启对64位JVM-XX:UseCompressedClassPointers对象头中的Klass Pointer默认开启与UseCompressedOops联动术语里的Oops全称是“Ordinary Object Pointer”指的是普通对象指针。HotSpot内部把指向Java对象的引用称为Oops压缩后的就叫CompressedOops。这里有个伴随出来的问题如果只压缩引用不压缩Klass Pointer对象头还是12字节8字节Mark Word 4字节压缩类指针那对齐不变如果都不压缩对象头直接变16字节。你会发现很多讲对象布局的资料里空对象的大小有时候报16字节、有时候报12字节就是因为这些配置不同。3.3 压缩指针的踩坑场景压缩指针不是免费的午餐它牺牲了一定的CPU计算量来换取内存节省——每次访问引用都需要做左移和基地址加法。但现代CPU有强大的寻址能力这些移位运算在流水线中几乎不产生额外代价所以整体的收益是压倒性的。实际调优中如果你发现堆内存逼近32GB但实际使用量并没有超过20GB通常建议把-Xmx压回到31GB左右让压缩指针被激活。30GB的堆开着压缩往往比32GB的堆关着压缩表现更好——一个是省了指针内存、能放更多有效对象另一个是直接可用堆大一些但引用体积也让单对象占用暴涨实际能存的对象数反而可能更少。4. 实例数据不是按声明顺序排的介绍完了对象头下面说说实例数据部分。这里有一个看起来反直觉的规则Java源码里字段声明的顺序跟它们在内存中的排列顺序不一定一致。4.1 字段重排规则HotSpot在分配实例数据时会进行字段重排核心原则是相同宽度的字段尽量放在一起父类字段优先于子类字段。具体的排列顺序是这样的按照字段宽度分组8字节的long/double最先接着是4字节的int/float然后是2字节的short/char再是1字节的byte/boolean最后是引用类型开启压缩时4字节、未压缩时8字节。父类中定义的字段会排在子类字段之前。整体上尽量按照“从大到小”的宽度顺序排列。这样做的目的是最大化利用对齐空间减少填充字节。如果严格按照源码声明顺序存放就可能出现8字节的long字段夹在两个引用类型之间导致为了对齐插入大量空洞。一个很经典的例子如果一个类声明了int a; long b; boolean c;如果不重排int a占4字节、long b需要对齐到8字节边界中间会插入4字节空洞boolean c之后又补7字节。重排后long b放最前面int a跟上boolean c放最后整体只在最后补4字节对齐。4.2 继承场景下的内存叠加对象头算完实例数据从父类到子类依次排布。子类对象里不仅有自己的字段还包含父类的所有字段。这就是为什么有人说“继承不止是语义上的内存上也真的是父子叠加”。场景分析有个基类只声明了一个long value子类声明了一个Object ref。内存布局大约是对象头12字节压缩指针开 父类long8字节 子类引用4字节 24字节刚好8的倍数不需要填充。如果子类还有个byte字段那就变成28字节要补4字节填充到32字节。4.3 利用父类字段占位来省空间这个知识可以用在实际开发中如果我们能控制一个类被大量实例化时的内存占用调整字段类型和顺序是一种零成本的优化手段。举个真实场景某个缓存对象同时需要存时间戳和业务标记时间戳精度到秒就够了。用long存占8字节用int存秒数占4字节。一个类里如果有三四个这样可宽可窄的字段全部换成更窄的类型单对象能省下十几字节如果该对象在内存里有上百万实例那就是几十MB的差异。另一个更高级的技巧是把多个布尔标志位组合成一个byte或int用位运算来存取。这在读代码的体验上稍微增加复杂度但对内存的节省是实打实的。5. 用工具实测JOL看对象到底多大前面讲的都是理论规则实际操作中我们怎么验证推荐一个工具JOLJava Object Layout。5.1 JOL快速上手JOL是OpenJDK提供的工具一般用来可视化对象的内存布局。引入依赖之后写几行代码就能打印任意对象的内存布局// Maven依赖 // org.openjdk.jol:jol-core:0.17 import org.openjdk.jol.info.ClassLayout; import org.openjdk.jol.info.GraphLayout; public class JolExample { public static void main(String[] args) { Object obj new Object(); System.out.println(ClassLayout.parseInstance(obj).toPrintable()); } }运行后能看到类似这样的输出java.lang.Object object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) 01 00 00 00 00 00 00 00 8 4 (object alignment gap) (alignment gap) 12 4 (object alignment gap) (alignment gap) Instance size: 16 bytes注意这个输出已经反映了压缩类指针开启的状态Mark Word占8字节这里显示为第一行8字节但后面并没有显示Klass Pointer的大小因为被压进前8字节的字节流里了。如果关闭压缩类指针对象头会变成16字节Mark Word 8 Klass Pointer 8整个空Object就变成16字节。5.2 分析一个有字段的类再看一个含字段的类public class User { int id; String name; boolean active; }通过JOL打印出来的布局大致为OFFSET SIZE TYPE DESCRIPTION VALUE 0 8 (object header: mark) ... 8 4 (object header: class) ... 12 4 int User.id ... 16 4 boolean User.active ... 20 4 (alignment/padding gap) ... 24 4 java.lang.String User.name ... 28 4 (alignment/padding gap) ... Instance size: 32 bytes这正好验证了字段重排boolean active被排到了int id后面而不是按源码声明顺序放最后。因为字段重排之后id和active可以紧挨着放在4字节的边界内不产生空洞。name是引用类型重排后被放在靠后的位置。实例大小是32字节而实际有效数据不到28字节说明有4字节填充。如果不重排让active放最后它后面只有1字节的空间对齐填充会更多。这就是HotSpot做字段重排的原因所在。5.3 用GraphLayout估算对象图总大小除了单个对象JOL的GraphLayout还能算整个对象图的内存占用包括引用指向的外部对象。public class User { String name new String(jack); // 16字节字符串对象 4字节引用 int id; } System.out.println(GraphLayout.parseInstance(user).totalSize());在模拟估算缓存大小、或者分析一个复杂对象的真实内存占用时这个接口特别有用。比如你要缓存100万个用户对象先跑一个totalSize()乘以100万就能精确估算缓存需要的内存避免上线后OOM。实测提示JOL对对象头状态的还原非常准确但前提是JVM参数别乱调。如果你想看完整16字节对象头启动参数里加上-XX:-UseCompressedOops再跑一遍同一个类对比两种布局差异对理解压缩指针的帮助特别直观。6. 常见问题与排查技巧实录最后整理几个实际排查中经常遇到的问题对应“理论”和“现场”之间的差距。6.1 空对象到底是12字节还是16字节这可能是网上争论最多的问题正确答案是“取决于配置”。64位JVM默认开启压缩类指针时空Object是12字节8字节Mark Word 4字节Klass Pointer但HotSpot要求对齐到8字节所以实际实例大小按16字节计。把压缩类指针关掉之后对象头变16字节空Object实例大小就是16字节。“理论上12字节实际上算16字节”这个差异经常让人困惑。记住一点对齐填充是对象大小的一部分不能用“有效数据”的思维去算对象占用。6.2 synchronized后为什么锁会“升级”很多人看文章说无锁、偏向锁、轻量级锁、重量级锁就以为所有对象一定按这个顺序升级。实际上偏向锁在JDK 15之后已经被标记为废弃并默认关闭了。如果对象先执行了hashCode()它会直接跳过偏向锁阶段。而轻量级锁的膨胀重点在于同一个对象被多个线程竞争。Mark Word里的锁标志位从01变到00再变到10这个过程在对象内存布局上是可以直接看到的synchronized (obj) { // 此时Mark Word已变成轻量级锁标志00 System.out.println(ClassLayout.parseInstance(obj).toPrintable()); }用这段代码打印你能在锁状态一栏看到锁标志位的变化。这对理解锁膨胀非常有帮助也是把“JVM内存布局”和“并发编程”串起来的一个很好的切入点。6.3 算内存占用的一个完整示例假设我们要开发一个简单的在线用户列表用户对象如下public class OnlineUser { long userId; // 8字节 long lastSeenTs; // 8字节 int score; // 4字节 boolean online; // 1字节 short level; // 2字节 }按字段重排规则布局依次是对象头12字节 两个long共16字节 score 4字节 level 2字节 online 1字节 35字节对齐到40字节。如果你装载1000万在线用户光这一层的对象内存就是约400MB。如果把userId改成int、lastSeenTs改成int秒级时间戳、level改成byte单对象可以降到12 8 4 1 1 26字节对齐到32字节。1000万个对象直接省掉80MB内存。这就是对象内存布局知识在容量评估中的直接价值。6.4 认识锁标志位的位段分区最后一个实用技巧查看线上对象状态时Mark Word里判断锁状态是依靠最后3位。最后2位是锁标志倒数第3位是偏向锁标志。两个标志位的组合是101偏向锁000轻量级锁注意这不是00前面全是0而是最后两位为00010重量级锁001无锁GC标记状态时是011在JOL里这些值会直接以十六进制形式打印出来。看清这些位段的含义之后再去分析一些JVM日志、甚至原生内存转储文件时你就有能力从字节层面判断对象的真实状态了。我个人在实际项目里的感受是理解对象内存布局短期看是一种“知识储备”长期看是一种“决策能力”。做技术选型时会自然去估算内存、评估并发锁成本、预判GC压力。尤其是当你面对线上内存暴涨却无从下手时能把“每个对象到底占多大”这件事算清楚的人和只会看监控曲线的人解决问题的速度完全不在一个量级。