
学JVM学到这里很多人会卡在对象这一章。平时我们天天new对象但new出来之后这个对象在堆里到底怎么摆放的、一个引用变量拿在手里又是怎么定位到真实对象的绝大多数人说不清楚。第七章正好把这几个问题串成了一条完整的链路对象实例化、内存布局、访问定位。这篇文章我就按照自己的学习笔记来拆配合实际调试工具和踩过的坑尽量把每一步背后的“为什么”也讲明白。这篇笔记能帮你解决三件事第一理解一条new指令从字节码到堆内存的完整执行链路搞清楚哪些步骤是JVM帮你做的、哪些是你的构造函数在做的第二能自己估算一个对象在内存里到底占多少字节知道对象头里存了哪些“元信息”第三搞明白栈上的引用是怎么一步步找到堆里的对象和类型信息的面试最常问的“句柄访问和直接指针访问的区别”也会有落地答案。适合正在啃JVM的进阶开发、准备面试的候选人以及被OOM和GC调优折腾过的人。1. 对象实例化从字节码指令到堆内存的完整链路1.1 字节码层面的三条指令先用一个最普通的例子开场Object obj new Object();javap -c反编译这段代码你会看到下面这几条关键指令0: new #2 // class java/lang/Object 3: dup 4: invokespecial #1 // Method java/lang/Object.init:()V 7: astore_1很多人以为new一条指令就完成了全部创建逻辑其实它只负责“分配内存并建立对象外壳”。后续的dup负责复制一份引用因为invokespecial调用构造器会“消耗”栈顶的引用但赋值语句astore_1还需要一份引用所以必须先复制一份备用。最后astore_1把引用存入局部变量表这才让obj这个变量真正可用。这里有一个新手常犯的误解new指令执行完对象里的实例字段全是默认值零值你的构造函数还没来得及跑。如果构造器抛异常这个对象引用虽然拿不到但堆里的内存已经分配过之后会靠GC回收。所以“new 分配内存”和“构造器初始化”是两件独立的事。1.2 类加载检查与初始化触发时机JVM 执行new指令时第一步不是分配内存而是检查常量池里这个类的符号引用是否能被解析。如果类还没有被加载、连接或初始化会先触发类加载流程加载、验证、准备、解析、初始化。其中“准备”阶段会为静态变量分配内存并设置零值“初始化”阶段才会执行clinit方法设置静态变量的显式值。判断一个类是否已经完成初始化最直观的办法是加参数-XX:TraceClassLoading跑一次能看到每个类是谁在什么时机加载的。我在排查“奇怪的空指针”时用过这个参数发现往往是某个静态工具类的初始化被延迟到了第一次使用时而那个使用点恰好被并发线程抢占了属于典型的类初始化顺序问题。这里顺带澄清一个被反复混淆的概念JVM 本身是 JRE 的一部分负责字节码执行而类加载这把“钥匙”掌握在类加载器手里引导类加载器、扩展类加载器、应用类加载器这些框架代码都在JRE的类库里。所以“JVM 加载类”这个说法并不准确更精确地说是 JVM 向 JRE 里的类加载器框架发出请求再由类加载器去文件系统或网络上找.class文件。1.3 内存分配策略指针碰撞、空闲列表、TLAB类加载完成之后JVM 已经知道了这个类的实例需要多大内存下一步就是给对象分配空间。分配方式取决于堆内存是否规整指针碰撞如果堆中已分配的内存集中在一边空闲内存在另一边中间有一个边界指针。分配时只需要把这个指针向空闲区移动对象大小的距离即可非常高效。使用带压缩整理能力的收集器时Serial、Parallel等堆处于规整状态走这条路。空闲列表如果堆内存被分配得七零八落JVM 需要维护一张表记录哪些区块是空闲的分配时找一块足够大的区块更新列表记录。CMS这类不整理内存的收集器就依赖这种方式。分配策略适用场景核心开销备注指针碰撞堆内存规整仅移动指针需要同步控制并发空闲列表堆内存碎片化查找合适的空闲块需要维护空闲块链表但是这里有个并发问题多个线程同时在堆上分配对象如果不加控制光“移动指针”这一步就会互相覆盖。JVM 采用了两种手段CAS 失败重试直接对指针做原子更新冲突了就重试。这种方式简单但高并发下CPU开销大。TLABThread Local Allocation Buffer每个线程在 Eden 区预先申请一小块私有空间自己往里装对象时不需要加锁。只有当这块私有空间用完了才需要重新向 Eden 区申请新空间这个申请动作才会和其他线程竞争。TLAB 默认是开启的-XX:UseTLAB大小可以用-XX:TLABSize调整也可以让 JVM 基于线程分配速率动态调整-XX:ResizeTLAB。实际调优时我很少手动指定 TLAB 大小更常见的是观察 GC 日志里的TLAB totals和refills统计如果 refill 频率特别高说明 TLAB 开小了才去考虑手动放大。有一点要注意TLAB 只是“分配空间”的优化不改变对象的生命周期。对象填进 TLAB 之后依然受 GC 管辖Minor GC 时该搬走搬走、该回收回收不要误以为 TLAB 里的对象永远留在 Eden。1.4 零值初始化、对象头设置与构造器执行内存分配完成后JVM 会把这块内存的内容全部置零。这意味着在构造函数执行之前所有实例字段都已经有了确定的默认值引用类型是nullint是0boolean是false。这一步保证了即使构造函数中途抛出异常对象内的字段也不会留有无意义的随机值这是 Java 语言安全性的底层保障之一。接着 JVM 设置对象头Object Header。对象头里要放哈希码、GC 分代年龄、锁状态等信息还要写入指向类元数据的类型指针。具体内容下一章拆解这里先记住结论对象头是 JVM 管理对象的核心数据在分配时就要初始化。最后一步JVM 执行init方法也就是你的构造函数。这里有个很容易忽略的细节构造器里的this是在分配阶段就确定的它在调用父类构造函数、给实例字段赋值、执行构造器代码块的过程中始终指向同一个对象。你在构造器里调用的this.getClass()、this.hashCode()走的都是对象头里已经写好的类型指针和哈希值。除了new指令还有两种常见的创建方式值得注意Class.newInstance()和Constructor.newInstance()。前者只能调用 public 无参构造器JDK 9 之后标记为废弃后者可以调用任意可见性的构造器还可以传参。而更底层的Unsafe.allocateInstance()可以完全跳过构造器直接创建对象反序列化框架和某些 ORM 就用这种方式绕过构造器但代价是字段的显式初始化逻辑全部失效业务方得自己负责赋值。2. 对象内存布局一个对象在堆里的真实模样2.1 对象头的详细拆解对象在堆里的完整结构由三部分组成对象头Header、实例数据Instance Data、对齐填充Padding。其中对象头又是最关键的部分它通常包含Mark Word64位 JVM 下占 8 字节用于存储对象的运行时元数据。内容包括无锁状态下的对象哈希码、GC 分代年龄、偏向锁标记、锁状态标志等。Mark Word 的设计非常巧妙它会根据锁状态复用存储空间锁状态存储内容64位布局概览无锁未使用位 hashCode 分代年龄 偏向锁标记0 锁标志01偏向锁线程ID epoch 分代年龄 偏向锁标记1 锁标志01轻量级锁指向栈中锁记录的指针重量级锁指向管程 Monitor 的指针GC标记转发指针用于对象移动后的访问不同 JDK 版本对位段的划分略有差异但核心思想一致同一块内存在不同状态下被复用来存储不同信息最大化利用空间。Klass Pointer类型指针指向方法区元空间里的类元数据JVM 靠它确认这个对象到底是哪个类。开启压缩指针时占 4 字节关闭时占 8 字节。数组长度如果对象是数组对象头里还会多一块 4 字节的长度字段用来记录数组大小。普通对象没有这个字段。以最常见的 64 位 JVM 开启压缩指针为例一个普通对象的对象头是 12 字节Mark Word 8 字节 Klass Pointer 4 字节数组对象是 16 字节再加长度 4 字节。2.2 实例数据与字段重排实例数据存放对象真正的字段值。但字段的存放顺序并不是按你源码里声明顺序来的HotSpot 会做“字段重排”。核心思路是把占用空间大的字段尽量往前放例如先排列 long/double再排 int/float再排 short/char接着是 byte/boolean引用类型则放在对应区段。HotSpot 通过-XX:FieldsAllocationStyle参数控制具体的排列风格。为什么 JVM 要费劲重排字段因为 CPU 在读取内存时对齐的数据效率更高。一个 8 字节的 long 如果恰好跨在两个缓存行/内存对齐单位上读取一次要访问两次内存代价非常大。JVM 通过把大字段对齐排列尽可能避免这种“撕裂读”。我在读 JOL 输出时发现一个有意思的现象父类的字段会排在子类字段之前同一个类里相同宽度的字段会凑在一起。这意味着如果你在子类里声明了一个 long 类型变量它可能不会紧接着父类最后一个字段而是和父类的 long 字段放在同一区域。这直接影响你用反射或 Unsafe 计算字段偏移量时的正确性依赖字段顺序的代码必须谨慎。2.3 对齐填充与对象大小计算对象的大小必须是 8 字节的整数倍默认-XX:ObjectAlignmentInBytes8。如果对象头和实例数据加起来不是 8 的倍数JVM 会在末尾补上对齐填充。对齐填充没有任何业务含义纯粹是为了满足内存对齐要求。举个例子假设你定义了一个类public class User { private long id; // 8字节 private int age; // 4字节 private boolean flag; // 1字节 private String name; // 引用类型开启压缩指针时4字节 }计算过程如下对象头12 字节压缩指针场景实例数据经字段重排后约 8 4 1 4 17 字节总大小 12 17 29 字节向上对齐到 8 的倍数实际占用 32 字节多出 3 字节填充不要小看这几字节的填充。如果系统里有几百万个这样的对象每多填充 1 字节就是几 MB 的堆空间。这也是为什么字段声明顺序会影响内存占用——一个把 boolean 和引用交替声明得乱七八糟的类对齐填充往往更多。想要精确看到对象布局强烈推荐 JOLJava Object Layout工具一行代码就能输出实例的内存布局java -jar jol-cli.jar internalsSystem.out.println(ClassLayout.parseClass(User.class).toPrintable()); System.out.println(GraphLayout.parseInstance(user).toFootprint());parseClass查看类布局parseInstance查看指定对象的完整内存足迹。我做内存优化时先用parseClass看字段排列是否合理再用parseInstance统计一个集合里所有对象加起来的实际占用比靠猜靠谱得多。2.4 压缩指针与对齐参数的联合影响64 位 JVM 里如果直接用 8 字节指针引用对象光指针就吃掉了大量堆空间。于是 HotSpot 引入了压缩指针-XX:UseCompressedOops用 4 字节的“偏移量”代替 8 字节“地址”。它的核心思想是既然所有对象都按 8 字节对齐那么对象地址的低 3 位一定是 0这 3 位信息可以省掉把真实地址除以 8 后存进 4 字节里。这样 4 字节最多能寻址 4G × 8 32GB 的堆空间。默认情况下堆小于等于 32GB 时压缩指针自动开启超过 32GB 自动关闭。这时对象头里的 Klass Pointer 从 4 字节变成 8 字节所有引用类型字段从 4 字节变成 8 字节相同堆大小能放下的对象数量明显减少。如果你既想要 4 字节的压缩指针又想要大于 32GB 的堆可以把对象对齐值从 8 提升到 16-XX:ObjectAlignmentInBytes16。这样 4 字节可以寻址 4G × 16 64GB。代价是对象填充浪费更严重因为大小必须对齐到 16 字节的整数倍。这是一个典型的空间换空间博弈我在实际项目中见过有人为了省指针空间把对齐改成 16结果对象填充造成的浪费比省下的指针还多得不偿失。堆大小CompressedOopsKlass Pointer引用类型字段≤32GB默认开启4字节4字节32GB默认8字节对齐关闭8字节8字节配合16字节对齐可支持到64GB4字节4字节但填充变多3. 对象访问定位从栈上引用到堆中目标3.1 两种主流实现方式对比对象创建出来了引用变量也存进了栈帧的局部变量表。但引用本身不是对象地址本身而是一个通往对象的路标。怎么通过这个路标找到对象业界有两种实现句柄访问和直接指针访问。访问方式引用指向访问实例数据的次数优点缺点句柄访问句柄池中的句柄先找句柄再找对象两次寻址对象被GC移动时只需更新句柄池访问速度慢直接指针访问对象在堆中的实际地址一次寻址访问速度最快GC移动对象时需要更新所有引用点3.2 句柄访问的利与弊句柄访问的逻辑是这样的JVM 在堆外维护一个句柄池栈上的引用先指向句柄池里的某个句柄句柄里存放两块指针一块指向对象实例数据另一块指向对象的类型数据。它最大的优势在于 GC 移动对象时的成本。假设这次 Minor GC 把一个年轻代对象挪到了老年代对象地址变了。如果是句柄访问只需要修改句柄池里那一个句柄的实例数据指针栈、堆里所有指向这个句柄的引用都不用动。对 GC 来说省掉了一次全堆扫描更新的开销。代价也很明显每次通过引用访问字段或调用方法都要先拿引用找到句柄再通过句柄拿实例数据地址等于绕了一圈。在getfield、invokevirtual这种最频繁的字节码操作里多一次指针解引用会让性能打折。3.3 直接指针访问在 HotSpot 中的落地HotSpot 虚拟机选的是直接指针访问。引用直接保存对象的堆地址对象本身的对象头里带着 Klass Pointer你要拿实例数据就直接按引用偏移去读要拿类型信息就顺着 Klass Pointer 去元空间找。JVM 选直接指针而不是句柄本质上是拿“GC 移动对象时需要更新引用”的代价去换“所有字段访问和方法调用都快一步”的收益。在一次完整的程序运行里字段访问和方法调用的次数远远超过 GC 移动对象的次数所以优先保证高频操作的速度是合理的。那 GC 移动对象时引用全变了怎么兜底HotSpot 的做法是配合各种屏障技术比如 G1 的 SATB 快照屏障、ZGC 的读屏障以及对象移动时在对象头 Mark Word 里留下的转发指针Forwarding Pointer。这也是我在调优时才真正体会到的访问定位不是孤立的设计它和 GC 算法是耦合在一起的理解这个耦合才是完整的对象访问链路。面试里经常问“为什么 HotSpot 不用句柄”如果你只背一句“因为句柄慢”就浅了。更好的回答是直接指针把开销花在 GC 移动时的引用更新上但换来了日常访问的超低延迟在 HotSpot 这种追求吞吐和响应的生产级虚拟机上这笔账是划算的。而句柄访问适合 GC 移动极其频繁、日常访问量相对小的场景两种方案没有绝对优劣只是取舍不同。4. 对象与运行时数据区的联动机制4.1 从方法区到堆的元数据绑定一个对象在 JVM 里从来不是孤立存在的。栈帧的局部变量表里有引用堆里有实例数据和类型指针元空间里有类元数据。这三者串起来才构成一个完整可用的“对象”。当你在代码里写obj.method()时JVM 的执行过程是从局部变量表取出引用解析到堆对象通过对象头的 Klass Pointer 进入元空间在类元数据的方法表和虚方法表中查找目标方法找到后开始执行。所以类型指针不仅是“身份标识”更是方法调度的入口。这里顺带厘清一个高频混淆点我们常说的“JVM内存模型”其实有两套完全不同的含义。一个是 JMMJava Memory Model讨论的是 volatile、synchronized、happens-before 这些并发可见性和有序性规则另一个是 JVM 运行时数据区讨论的是堆、栈、元空间这些内存区域的划分和职责。对象实例化、内存布局、访问定位属于后者不要和前者的并发语义混在一起。4.2 逃逸分析、栈上分配与标量替换学完对象头很多人会问对象一定在堆里吗答案是不一定。HotSpot 的 JIT 编译器在编译热点代码时会做逃逸分析Escape Analysis判断方法里的对象是否会被外部引用。如果对象没有逃出方法作用域JVM 可能做两个优化栈上分配把原本要在堆上创建的对象直接分配到栈帧里方法结束栈帧销毁对象自动消失完全不触发 GC。标量替换更激进。既然对象不逃逸干脆不创建对象把对象的字段拆散成独立的局部变量甚至直接塞进寄存器。-XX:DoEscapeAnalysis和-XX:EliminateAllocations默认开启。看一下这个代码double distance(Point a, Point b) { Point delta new Point(a.x - b.x, a.y - b.y); return Math.sqrt(delta.x * delta.x delta.y * delta.y); }delta对象全程只在方法内使用没有泄漏到外部经过逃逸分析后大概率不会在堆上真实分配。这也是为什么你测试“创建100万个临时对象”的微基准时结果可能出乎意料地快——很多对象根本没进堆。顺带回应一个学习过程中绕不开的问题JVM 的编译器到底有几种。HotSpot 常见的是解释器、C1客户端编译器编译快但优化少、C2服务端编译器优化强但启动慢JDK 11 之后还能用 Graal 替代 C2。逃逸分析和标量替换主要发生在 C2 和 Graal 这种重优化编译器上这也能解释为什么同一个代码段在小循环里没被优化、跑到一定热度之后性能突然跳变——JIT 火力全开了。在代码里手动关闭逃逸分析或者用反射强行保留对临时对象的引用会直接破坏这些优化。所以我做性能测试时特别小心基准测试代码一定要有真实的逃逸场景否则测出来的结果和线上行为差异巨大。4.3 对象创建后的代际流转对象分配在 Eden 区或者 TLAB 子空间之后并不一定一直留在那里。第一次 Minor GC存活对象会被移到 Survivor 区同时分代年龄加 1。每经历一次 Minor GC 且存活年龄加 1。当年龄达到-XX:MaxTenuringThreshold设定的阈值默认 15或者存活对象达到 Survivor 区的一半时对象会晋升到老年代。有一种对象更特殊大对象。如果对象大小超过-XX:PretenureSizeThreshold设置的阈值在 Serial 和 ParNew 收集器下会直接进入老年代目的是避免大对象在年轻代反复复制带来的开销。但注意这个参数在 Parallel Scavenge 和 G1 下效果并不保证。G1 走的是另一套逻辑对象超过 Region 大小的 50% 会被视为 Humongous Object直接分配在连续的一块或多块 Region 里连直接进入老年代这个说法都不完全准确。调优的时候别只盯着一个参数要结合收集器一起看。5. 实战排查与参数调优5.1 影响对象分配的关键JVM参数下面这张表是我排查和调优时最常用的对象相关参数整理出来供你参考参数作用备注-Xms/-Xmx设置初始堆和最大堆生产环境通常设为相同值避免动态扩容-Xmn年轻代大小太小会导致频繁Minor GC-XX:SurvivorRatioEden与Survivor比例默认8即Eden占年轻代的8/10-XX:UseTLAB开启线程本地分配缓冲默认开启-XX:TLABSize手动设置TLAB大小一般让JVM动态调整-XX:PretenureSizeThreshold大对象直接进老年代阈值主要对Serial/ParNew生效-XX:MaxTenuringThreshold晋升老年代的分代年龄阈值默认15-XX:UseCompressedOops开启压缩指针堆≤32GB时默认开启-XX:ObjectAlignmentInBytes对象对齐字节数默认8可调16-XX:DoEscapeAnalysis开启逃逸分析默认开启-XX:PrintGCDetails打印GC详细日志排查分配问题必备一个常见的调优场景是年轻代频繁 GC。我遇到过 Eden 区 2GB但每秒分配速率高达十几 GB 的情况Minor GC 几乎每一两秒就来一次。排查后发现是上游接口把大量一次性对象存进了静态缓存对象逃逸导致逃逸分析失效调整了缓存策略后分配速率才降下来。这说明对象分配问题往往先看业务代码再看 JVM 参数。5.2 用JOL观测对象布局的完整步骤想看对象到底占多少字节、字段怎么排最直接的工具是 JOL。步骤很简单在项目里引入依赖dependency groupIdorg.openjdk.jol/groupId artifactIdjol-core/artifactId version0.17/version /dependency写一段测试代码输出布局import org.openjdk.jol.info.ClassLayout; import org.openjdk.jol.info.GraphLayout; public class JolDemo { static class User { long id; int age; boolean flag; String name; } public static void main(String[] args) { User user new User(); System.out.println(ClassLayout.parseInstance(user).toPrintable()); System.out.println(---); System.out.println(GraphLayout.parseInstance(user).toFootprint()); } }运行时应加参数-XX:-UseCompressedOops对照看布局变化验证压缩指针的影响。我实际测试时发现同一个 User 对象开启压缩指针对比关闭实例占用从 32 字节变成 40 字节差异非常直观。想观察数组就用ClassLayout.parseClass(byte[].class)可以看到对象头里多出的 4 字节长度字段。这个工具最大的价值在于帮你建立“内存直觉”。比如你知道一个空的HashMap对象其实要占几十字节就不会在高并发场景下动不动 new 出几百万个 Map 了。5.3 常见问题排查实录我把自己在实际项目里排查过的几个典型问题整理成速查表方便你对照现象可能原因排查方向Minor GC 过于频繁Eden 区偏小或对象分配速率过高jstat -gcutil观察年轻代使用率用 JFR 定位分配热点老年代持续增长但晋升年龄不大存在大量大对象直接进老年代用 jmap dump 后用 MAT 查大对象检查业务代码中的大数组、大集合堆内存接近上限才创建的对象特别多压缩指针关闭或对象头变大检查堆是否超过32GB对比-XX:UseCompressedOops前后的对象大小对象访问偶发停顿GC 移动对象配合引用更新的屏障开销观察 GC 日志中的 pause 时间考虑改用低延迟收集器同一种对象大量出现且无法回收逃逸分析失效对象被缓存检查静态集合、ThreadLocal 中是否有长期引用排查工具链我一般这样用先用jstat -gcutil看整体 GC 状况再用jmap -histo:live看堆里数量最多的类型最后用jmap -dump:live,formatb,fileheap.hprof导出堆快照交给 MAT 或 JProfiler 分析。如果要看对象分配速率和分配点JFR 的 TLAB Allocation 事件是最佳选择能直接定位到具体方法。还有一个容易被忽略的坑-XX:HeapDumpOnOutOfMemoryError这个参数一定要在测试环境就加上否则生产 OOM 时没有堆快照事后只能靠猜。我吃过亏线上报 OOM 后才发现快照参数没开连复现都无法复现后来所有服务默认都会加上这个参数并指定 dump 路径。学习这一章内容我最大的体会是对象实例化、内存布局和访问定位这三件事表面上是三个独立知识点实际上是一条链。new分配内存时决定了对象头怎么初始化对象头里的类型指针决定了访问定位走哪条路内存布局的字段重排和压缩指针又直接决定了对象占用多少内存。你在任何一环上的取舍都会波及其他环节。最后分享一个小技巧平时写完业务代码顺手用 JOL 输出几个核心对象的内存布局再结合jmap -histo看看线上的实际情况。只要坚持一段时间你对“一个对象值多少钱”会有非常具象的概念后面做缓存设计、批量处理、并发模型的取舍时都会比以前从容得多。