← 返回博客
2026-08-25 08:00:02

JVM 内存模型与对象创建学习笔记:对象到底在堆里怎么“住”进去的

JVM 内存模型与对象创建学习笔记:对象到底在堆里怎么“住”进去的

你写一行 new User(),JVM 背后干了多少事?很多人背得出“堆、栈、方法区”,但一问“对象头里到底存了什么”“TLAB 是干嘛的”就卡壳。这篇把对象从“类”变成“堆里一块内存”的完整过程拆开讲。本文按 JDK 8 讲述,你本机若是 JDK 11+,个别命令、方法名、字节数会不一样,以你本机实测为准。

JVM 运行时数据区:先看清对象住进哪块地

new 之前,先认识 JVM 把内存分成的这几块(JDK 8 规范):

JVM 运行时数据区:线程私有 vs 共享,栈引用→堆对象→方法区

记法:栈、堆、方法区构成引用三角——栈里的局部变量是引用,指向堆里的对象;堆对象对象头里有 Klass 指针,指向方法区的类元数据。下面整个对象创建链路都在讲“这个对象在堆里怎么落成一块内存”,这个三角先立住。

手把手实操:先跑起来看内存长什么样

先装好 JDK 8 或 11,写个最简单的类:

public class User {
    private int id;
    private String name;
    // 构造器、getter/setter 省略
}

再写个测试类:

public class MemoryTest {
    public static void main(String[] args) throws InterruptedException {
        User user = new User();  // 断点打在这行
        Thread.sleep(60000);     // 让程序别退出,方便你观察
    }
}

先介绍一个能在 IDEA 里直接看对象内存的工具:jol(Java Object Layout,OpenJDK 官方工具)。Maven 项目加一行依赖:

<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.17</version>
</dependency>

写个 main 方法,直接 Run,结果打在 IDEA 控制台里:

import org.openjdk.jol.info.ClassLayout;
import org.openjdk.jol.vm.VM;

public class JolTest {
    public static void main(String[] args) {
        System.out.println(VM.current().details());   // 先确认压缩指针开没开
        System.out.println(ClassLayout.parseInstance(new User()).toPrintable());
    }
}

输出会把 User 对象的每个字节拆开:

com.example.User object internals:
OFF  SZ               TYPE DESCRIPTION               VALUE
  0   8                    (object header: mark)     0x0000000000000001 (non-biasable; age: 0)
  8   4                    (object header: class)    0x010ce0c8
 12   4                int User.id                   0
 16   4   java.lang.String User.name                 null
 20   4                    (object alignment gap)
Instance size: 24 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

前 12 字节是对象头(mark 8 字节 + class 4 字节),接着 8 字节实例数据,末尾 4 字节对齐填充——jol 直接报了总数:Instance size: 24 bytes。mark 的值 0x...001 表示无锁状态(低 3 位是 001),class 那行的 0x010ce0c8 就是指向方法区的 Klass 指针。注意:mark 显示 non-biasable 是因为较新 JDK 默认关了偏向锁,JDK 8 下新对象会显示 0x...005(偏向锁位为 1)。旧版 jol(0.16)输出是另一种排版,数字含义一样。

24 bytes 是关键。一个 User 只有 int id(4字节)和 String name(引用,4字节,压缩指针下),实例数据 8 字节。但对象总大小 24 字节,多出来的 16 字节是对象头 + 对齐填充:对象头 12 字节(Mark Word 8 + Klass 指针 4),尾部再补 4 字节凑成 8 的倍数。下面这张图把 24 字节拆开看:

对象内存布局:user 引用 → 24 字节对象 → 方法区

为什么对象头是 12 字节而不是 8?Mark Word 固定 8 字节,Klass 指针在压缩指针开启时只要 4 字节。为什么 4 字节够用?对象按 8 字节对齐,32 位指针左移 3 位就能表示 2³² × 8 = 32GB 的地址空间,所以默认开启压缩指针(-XX:+UseCompressedOops-XX:+UseCompressedClassPointers)时,堆不超过 32GB,引用和类指针都是 4 字节。堆一超过 32GB,引用压缩自动关闭、变回 8 字节。但对象头会不会跟着变 16 字节,要看 JDK 版本:JDK 14 及以前,类指针压缩跟 oops 绑在一起,会一起关掉,对象头变成 16 字节(Mark 8 + Klass 8);JDK 15 起两者解耦(JDK-8241825),oops 关了类指针仍保持 4 字节,对象头还是 12 字节——堆开大了单个对象变费内存,主要是引用字段从 4 字节变 8 字节,不一定是对象头变大。这也是“堆开得越大、单个对象越费内存”的底层原因。

再验证栈上的引用和堆里的对象不是一回事。在 MemoryTest 里加一行:

System.out.println(user);  // 打印的是 com.example.User@1b6d3586

@ 后面的十六进制是对象的 hashCode,存在对象头里。你打印两次,同一个对象 hashCode 不变,但每次 new 出来的对象 hashCode 都不同——这就是堆里不同内存块的标识。

上面 jol 看的是单个对象的内存。想看整个堆的内存分布——堆里有哪些类的实例、各占多少字节——还有两条路:

 num     #instances         #bytes  class name
   1:             1             24  com.example.User

看源码及解析:对象创建的五步链路

对象创建入口在 HotSpotbytecodeInterpreter 或 JIT 编译后的代码里,核心逻辑在 InterpreterRuntime::_newInstanceKlass::allocate_instance。整个链路可以拆成五步:

第一步:类加载检查。 new 指令执行时,JVM 先查方法区的常量池,确认这个类已经被加载、解析、初始化过。没加载就触发 ClassLoader.loadClass。这一步解决的是“类还没准备好,不能造对象”。

第二步:分配内存。 这是核心。对象需要的总大小在类加载时就算好了,存在 InstanceKlass 里。分配方式有两种:

第三步:TLAB 分配。 多线程同时 new 对象,如果都在堆上抢同一块内存,得加锁。HotSpot 的解决方案是 TLAB(Thread Local Allocation Buffer):每个线程在 Eden 区划一块私有小空间。TLAB 大小是自适应的(按 Eden 大小和线程数估期望尺寸,再随分配速率调整),-XX:TLABWasteTargetPercent(默认 1)控制的是允许浪费的预算——每次换新 TLAB 时剩下的空间按这个比例算浪费,不是 TLAB 固定占 Eden 的 1%。线程在自己的 TLAB 里分配不用锁,TLAB 用完了才去堆上“抢”新的。

关键源码在 ThreadLocalAllocBuffer::allocate,它维护 _top_end 两个指针,分配就是 _top += size,比指针碰撞还快,因为完全无竞争。TLAB 用完了,线程会触发一次 slow_alloc,这时候才走加锁的分配路径。

先分清:第二步的分配和 TLAB 分配,回答的不是同一个问题。 常有人把 TLAB 当成第三种分配算法,其实它是跑在指针碰撞/空闲列表之上的一层优化。区别有三:

一句话记法:指针碰撞/空闲列表决定"去哪拿",TLAB 决定"拿一次能用多久"。 所以 slow_alloc 的本质就是用第二步的算法去堆上重新进货——TLAB 不是替代第二步,而是架在它上面。另外,超过 TLAB 容量的大对象会直接绕过 TLAB 去堆上分配,TLAB 只负责绝大多数小对象。

第四步:对象头初始化。 内存分配完,JVM 把对象头填好。对象头在 64 位 JVM 下默认是 12 字节(压缩指针开启时),包含:

Mark Word 的 64 位布局:低 3 位锁标志、4 位 age、1 位 cms_free、31 位 hashCode、顶部 25 位未用

上面是 JDK 8 的布局(本文按 JDK 8 讲)。一个常被记错的点:cms_free不是 JDK 9 移除的——JDK 9(JEP 291)只是把 CMS 标记为废弃,这个位一直留到 JDK 15。JDK 15 移除 CMS 时(JDK-8232365),才把它原地改名为 unused_gap,刻意不挪位置,避免打乱整个对象头布局。所以它不是让位给 hashCode:hashCode 从 JDK 8 到 JDK 26 始终是 31 位,unused_gap 只是占着 bit 7 的一个空置位。JDK 24+ 出了实验性的 -XX:+UseCompactObjectHeaders,把对象头从 12 字节压到 8 字节。你本机 JDK 26 默认仍是 12 字节对象头。

第五步:实例数据初始化。 对象头填完,JVM 把实例字段按默认值清零(int 是 0,引用是 null),然后执行 方法,也就是构造器。这时候 new User() 才算完成。

为什么这么设计?把对象头单独拎出来,是为了让 JVM 在 GC 时快速判断对象状态。Mark Word 里的锁标志位让 synchronized 能无锁→偏向锁→轻量级锁→重量级锁逐级升级,不用每次加锁都进内核态。分代年龄让 GC 不用扫描整个堆就知道哪些对象该晋升到老年代。

验证方法:怎么确认你真的懂了

验证对象头:对象头在上文实操里已经用 jol 打印过了(mark 8 字节 + class 4 字节,低 3 位 001 无锁状态)。这里再验证一个点:压缩指针。JolTestVM.current().details() 的输出中,Using compressed oop with 3-bit shift 一行就代表压缩指针开启。给它加 JVM 参数 -XX:-UseCompressedOops 再跑一次 jol,JDK 8 上对象头会从 12 字节变成 16 字节(Klass 指针从 4 字节变回 8 字节),正好验证上文"堆超过 32GB 压缩自动关闭"的说法。但你要是用 JDK 15+,会看到 class 还是 4 字节、对象头还是 12 字节——JDK 15(JDK-8241825)把 UseCompressedClassPointersUseCompressedOops 里解耦了,-XX:-UseCompressedOops 只关引用压缩、不再管类指针。想在 JDK 15+ 上演示对象头变大,改用 -XX:-UseCompressedClassPointers(JDK 25 起标为 deprecated,实测 JDK 26 仍有效),jol 里 class 一行会从 4 字节变 8 字节、对象头变 16 字节;两个一起关,引用字段也回到 8 字节,对象会更大。

验证 TLAB:JDK 8 上加 -XX:+PrintTLAB 跑程序,GC 时会输出每个线程的 TLAB 使用情况,看到 TLAB: gc thread: 0x... 之类的行就说明 TLAB 生效了。JDK 9 起 GC 日志统一收编进 -Xlog 框架(JEP 158/271),一大批 -XX:+Print* 开关被移除,-XX:+PrintTLAB 就是其一——JDK 26 上直接报 Unrecognized VM option。替代写法是 -Xlog:gc+tlab=trace:tag 是单数 tlab,要用 gc+tlab 组合(只写 -Xlog:tlab 会提示你需要加 tag 集),而且这些统计行在 trace 级别才打出来,默认 info 什么也不出。输出就是每线程的 TLAB 填充统计:TLAB: fill thread: 0x... desired_size: 81KB slow allocs: 0 refill waste: 1304B alloc: 0.99993 1024KB refills: 1 waste 0.0%(JDK 8 里开头叫 gc thread,JDK 26 叫 fill thread,字段含义一样)。关掉 TLAB(-XX:-UseTLAB,JDK 26 仍有效)再跑,对比 GC 次数,你会发现开启 TLAB 时 GC 更少,因为分配快、碎片少。

验证对齐填充:把 User 改成只有一个 boolean flag 字段,用 jol 看大小。boolean 占 1 字节,但对象总大小是 16 字节——对象头 12 字节 + 1 字节数据 + 3 字节填充。JVM 要求对象大小是 8 的倍数,所以补到 16。对齐填充的真正原因是配合压缩指针:对象按 8 字节对齐后,32 位压缩指针左移 3 位就能得到真实地址,同时保证 64 位字段能对齐访问。跟“避免跨缓存行”关系不大,那是伪共享的话题。

面试速答

对象创建五步:类加载检查、分配内存(指针碰撞或空闲列表)、TLAB 分配、初始化对象头、执行构造器。对象头存 hashCode、GC 分代年龄、锁状态和类指针。TLAB 是线程私有的 Eden 区小块,避免多线程分配竞争。追问“指针碰撞和 TLAB 什么关系”时,答:指针碰撞/空闲列表决定从堆的哪里找空间,TLAB 是架在其上的线程私有缓存——先从堆整块进货再锁内切对象,决定要不要抢锁,不是第三种分配算法。对齐填充是为了让对象大小是 8 的倍数,配合压缩指针寻址(对象按 8 字节对齐,压缩指针左移 3 位就能还原真实地址)。追问到“为什么 TLAB 不设大一点”时,答:TLAB 太大浪费 Eden 空间,太小频繁去堆上抢内存,默认 1% 是允许浪费的预算,是吞吐和浪费的折中。

核心收获:对象创建不是“new 一下就完事”,是类加载、内存分配、并发控制、GC 协作的完整链路。下一步:用 jol 把你手头项目的核心类都打印一遍,看看哪些对象比你想象的大,再想想为什么。

本文关键词:JVM、内存模型、对象头、TLAB、压缩指针