首页 / Java 学习笔记 / 25

JAVA · Vol.III · DAY 25 · JVM 原理与调优

对象创建全过程:类加载检查 → TLAB → 内存分配 → 初始化

高级极高#JVM#内存
第 1 站

new 指令到对象落地的五阶段完整链路

面试官喜欢用一个"入门级"问题开场,恰恰是因为它危险——人人都会答两句,而他要的是你能答出两句以上:

"写一句 new Person("Alice", 18),从 JVM 执行它到对象可用,中间到底发生了什么?"

大多数候选人的回答是"在堆上分配内存,然后调用构造方法"。这句话没错,但就像把去医院描述成"看个病、拿个药"——省掉了分诊、挂号、检查、出报告的全过程。这篇文章把这"一次就诊"拆成五个阶段,逐个讲清楚每个阶段在做什么、为什么必须做、面试官会怎么追问。

先给结论。一条 new 指令从字节码到对象落地,必须经历五个阶段

对象创建五阶段时间线(从 new 指令到对象可用) JVM 执行 new 指令(字节码) 1 类加载检查 类加载了没? 链接、初始化做了没? 没做 → 先补上 2 内存分配 堆里找一块空间 指针碰撞 / 空闲列表 通常落在线程 TLAB 3 零值初始化 字段 → 0 / null / false 构造器里能读到 零值,不是脏数据 4 设置对象头 写入 Mark Word 写入类指针 数组再写长度 5 执行 init super() → 字段初始化 初始化块 → 构造体 唯一执行用户逻辑的阶段 ②③④ 在分配内存的那一刻一并完成(JVM 内部工作,用户不可见) ⑤ 才执行用户可见的构造器逻辑——后面六站,一站拆一个阶段
图 1 对象创建五阶段:类加载检查 → 内存分配 → 零值初始化 → 设置对象头 → 执行构造器;前四步都是 JVM 的自动动作
阶段做什么发生在哪由谁完成
① 类加载检查检查目标类是否已加载 / 链接 / 初始化,没有则先走类加载流程方法区(类元信息)/ 堆(Class 对象)JVM,由 new 指令自动触发
② 内存分配按对象大小在堆上划出一块空间(指针碰撞 / 空闲列表,TLAB 优化并发)堆,通常是 Eden 里该线程的 TLABJVM 分配逻辑
③ 零值初始化所有实例字段赋默认零值:0 / null / false / 0.0堆上刚分配的那块空间JVM,与分配同一瞬间
④ 设置对象头写入 Mark Word、类指针;数组对象再写入长度堆上那块空间的头部JVM,与分配同一瞬间
⑤ 执行 <init>执行构造器字节码:super() → 字段初始化 → 构造器方法体栈帧(执行)+ 堆(字段写回)解释器 / JIT + 构造器字节码

注意一个关键区分:阶段 ②③④ 都是"JVM 内部工作",在分配内存的那一刻一并完成;只有阶段 ⑤ 执行用户可见的构造器逻辑。很多面试陷阱的根源都在这——比如"为什么构造器里能读到字段的零值""为什么构造器抛异常的对象不会泄漏",答案都藏在"②③④ 先于 ⑤"这个顺序里。

易错点

阶段顺序容易记混:有资料把"设置对象头"写在"零值初始化"前面。HotSpot 实际的分配流程是先零值初始化、再设置对象头——先把整块内存(对象头部分除外)清零,再往头部写入 Mark Word 和类指针。两步发生在同一次分配里,中间没有任何业务逻辑。面试时按"先零值、后对象头"的口述是标准答案。

为什么 JVM 要把"一个 new"拆成五个阶段?因为每个阶段解决的是一个独立的问题:① 解决"类存不存在",② 解决"放在哪",③④ 解决"这块内存有没有确定状态",⑤ 解决"对象值是什么"。后面六站一站拆一个阶段:

  • 第 2 站:类加载检查——为什么是"检查"而不是"加载";Class 对象其实住在堆里,不在方法区。
  • 第 3~4 站:内存分配——指针碰撞 vs 空闲列表怎么选;多线程同时分配怎么不打架,TLAB 如何把竞争变成偶发事件。
  • 第 5 站:零值初始化——为什么 Java 字段总有默认值而 C/C++ 没有;boolean 字段其实是 1 个字节。
  • 第 6 站:对象头——Mark Word 的位级布局与五种锁状态、压缩指针、字段布局规则、用 JOL 算出任意对象的真实大小。
  • 第 7 站:执行构造器——new → dup → invokespecial 的字节码真相,<init> 三部分的执行顺序。
  • 第 8 站:JIT 限定词——"new 出来的对象一定在堆里"?不完全是。

发车前,把三个问题揣在兜里,边走边验证:

  • 多个线程同时 new 对象,谁保证两个对象不会"撞"到同一块地址?
  • TLAB 是固定大小吗?TLAB 申请失败之后会走哪条路?
  • 8 字节的 Mark Word 凭什么同时装得下 hashcode、GC 年龄和锁状态?
第 2 站

阶段一:类加载检查——类准备好了吗?

面试官的第一个追问,通常就打在这个最不起眼的阶段上:

"你说第一步是类加载检查——检查什么?类到这时候不是早加载完了吗?为什么还要再查一遍?"

答"检查类加载了没有,没加载就加载"——80 分。想拿 100 分,要再说出两件事:"准备好"具体指哪三件事(加载 / 链接 / 初始化),以及类的"档案"到底存在哪(方法区 vs 堆)

检查什么:三个"就绪"状态

new 指令执行时,目标类在字节码里以类常量引用的形式出现(常量池索引,比如 #2)。JVM 在使用这个引用之前,必须确认它指向的类处于"可用"状态。一个类从出生到可用要经历加载 → 链接(验证 / 准备 / 解析)→ 初始化,new 指令要求这三件事至少都已完成:

  1. 已加载:类的字节码流(来自 .class 文件、jar、网络、运行时生成)已被读取,堆里生成了 java.lang.Class 对象,方法区里建好了类的元信息。
  2. 已链接:验证(字节码符合规范)、准备(给静态变量分配内存并赋零值)、解析(符号引用转成直接引用)都已完成。
  3. 已初始化:执行过类的 <clinit>(静态变量赋值和静态代码块编译后的字节码)——类的"静态世界"真正生效的那一刻。

重点来了:这一步是"检查",不是"加载"。绝大多数场景下,new 指令执行时类早已加载、初始化完毕——类在一个类加载器里只会被加载一次,业务类的"第一次使用"往往远早于此刻这个 new。new 指令做的只是确认"它在";只有确认"它不在",才会真正走完整的加载流程(委派父加载器、读字节码、验证、执行 <clinit>)。加载链接的完整流程见《类加载机制:双亲委派模型与三种破坏场景》。

为什么 new 能触发类初始化

有候选人会问:"类加载时不就一起初始化了吗,规范为什么还要单独强调 new 能触发类初始化?"因为加载、链接、初始化不保证一口气做完——初始化会一直延迟到第一次"主动使用"才发生。JVM 规范列举了几种必须触发类初始化的主动引用场景,其中"创建该类的实例(new,但 new 数组除外)"是最直接的一条;其余还包括:读写静态字段(非编译期常量)、调用静态方法、初始化子类(父类必须先行初始化)、JVM 直接执行的主类等。

所以 new 指令里的"检查"在极端情况下会"触发连锁反应":类还没初始化,JVM 先把完整的初始化流程(含静态块)走完,再继续分配内存。这也是线上"第一次请求特别慢"的微观解释之一——服务刚启动时的首个调用,常常撞上类加载 + 初始化 + JIT 冷启动,耗时比稳态大一个数量级很正常。

经典陷阱题

"Class 对象在哪?"——高频陷阱题。答案要一分为二:类的元信息(Klass、虚方法表、方法表、字段描述)在方法区(JDK 8 起是 Metaspace,位于堆外本地内存);Class 对象本身是一个普通的 Java 对象,分配在堆里。第 6 站讲的"类指针"指向方法区,是为了找到元信息;而你在代码里 .getClass() 拿到的,是堆里那个 Class 对象。两个区域的分工见《JVM 运行时数据区全景:堆、栈、方法区、程序计数器》。

特殊对象:数组

new int[100] 时容易忽略:int[] 本身也是一个"类"。但数组类不来自任何 .class 文件,它由 JVM 在运行时按需创建——数组类的元信息(元素类型、长度字段)是 JVM 当场组装的,初始化也在创建时一并完成。所以数组对象的"类加载检查"依然成立,只是这个类的"出生证明"是 JVM 自己开的,不走读文件的流程。数组对象的对象头结构(多一个长度字段)在第 6 站展开。

本站要点

new 执行前,JVM 做的是"检查"而非"加载":确认类已加载、已链接、已初始化。绝大多数时候类早已就绪;只有首次使用时才走完整类加载流程(《类加载机制:双亲委派模型与三种破坏场景》)。记住位置分工:类元信息在方法区(Metaspace),Class 对象在堆里;数组类是 JVM 运行时生成的,没有类文件。

第 3 站

阶段二:指针碰撞 vs 空闲列表

阶段 ② 的核心问题只有一个:在堆上找到一块足够大的连续空间。听起来简单,但"找"的方式完全取决于一个前提——

"分配之前,JVM 先要判断什么?"

答案是:当前堆内存是否"规整"——已用内存和空闲内存是否各占一侧、边界清晰。规整,就"碰指针";不规整,就"翻列表"。这两种方式叫指针碰撞(Bump the Pointer)空闲列表(Free List),是 JVM 分配策略的两条基本路线。注意:这是"策略"层面的二选一,和"哪个线程来分配"(第 4 站的 TLAB)是两个正交的问题。

指针碰撞:一根指针顶完

当内存规整时,JVM 只需维护一根分配指针(Allocation Pointer),指向"已用内存"和"空闲内存"的分界线。分配一个 size 字节的对象:把指针往前挪 size 个字节,对象就放进了挪出来的那块空间。整个过程一次加法、一次指针更新,O(1),不需要遍历任何东西

指针碰撞能用,靠的是"每次 GC 之后内存依然规整"。用复制算法的年轻代天然满足:每次 Minor GC 把存活对象紧凑复制到 To 区,Eden 和 From 区"指针拨回底部即清空",内存永远是"已用在前、空闲在后"的整齐形态。用标记-整理的区域同理——整理完成后对象紧凑排列,指针碰撞照样成立。

空闲列表:碎片化堆的无奈之选

当内存不规整——已用和空闲内存交错成碎片——指针碰撞就失效了:指针往前挪得到的空间里可能插着别的对象。这时 JVM 必须维护一张空闲列表:把每块空闲内存的(起始地址,大小)记下来,分配时遍历列表,找到第一块放得下的空闲块(first fit 一类策略)。对象放进去后,这块空闲内存被切掉,列表更新。

成本显而易见:分配从 O(1) 变成"查表 + 可能切块 + 更新列表",还要付出维护列表本身的 CPU 与内存开销。但这是标记-清除算法的必然代价——CMS 老年代用标记-清除回收(不整理),GC 之后堆就是碎片的,只能靠空闲列表分配。碎片越积越多时,一个稍大的对象可能"明明有空闲总量却找不到连续块",CMS 就会在 Full GC 时退而求其次做一次整理(Serial Old 单线程兜底,很慢)。各收集器用什么算法,见《垃圾收集器全览:Serial → Parallel → CMS → G1 → ZGC》。

两种分配策略:内存规整碰指针,内存碎片翻列表 指针碰撞(内存规整) 已用对象 空闲内存 新对象 分配指针 指针前移 = 分配完成(O(1)) 适用:复制算法 / 标记-整理之后的区域 Serial、ParNew、Parallel 的年轻代,Serial Old,G1 Region 内 空闲列表(内存碎片) 32B 64B 24B 挑放得下的块 空闲列表(JVM 维护:地址 + 大小) [ 32B @ 0x488 ] [ 64B @ 0x580 ] [ 24B @ 0x684 ] 分配时遍历找块,放入后切块、更新列表 适用:CMS 老年代(标记-清除不整理,天然碎片) ZGC 因并发转移,在 Region 内用块级(chunk)空闲列表分配 关键:内存"规整不规整"由收集算法决定——复制 / 标记-整理 → 规整 → 指针碰撞;标记-清除 → 碎片 → 空闲列表 碎片化严重时"空闲总量够、连续块不够",是 CMS 大对象分配失败、被迫 Full GC 整理的原因
图 2 指针碰撞与空闲列表:左图规整内存一次指针前移完成分配;右图碎片化堆靠空闲列表查找空闲块

一张表说清选型

策略前提分配成本典型场景
指针碰撞内存规整(已用/空闲各占一侧)O(1),一次指针前移年轻代(复制算法后)、Serial Old(整理后)、G1 Region 内
空闲列表内存碎片化(已用/空闲交错)查表 + 切块 + 更新列表,慢CMS 老年代(标记-清除后);ZGC 的 Region 内块级分配
"那对象放不下的时候怎么办?"

分配决策是一条向后的退路链:对象 ≤ Eden 剩余空间 → 在 Eden 分配(实际落在线程的 TLAB,第 4 站);Eden 剩余空间装不下(注意:是"剩余空间",不是 Eden 总大小)→ 直接在老年代分配;老年代也装不下 → 先触发 Full GC 腾空间再试;仍失败 → OutOfMemoryError: Java heap space。大对象直通老年代、G1 的 Humongous 对象这些细则,见《JVM 堆内存:年轻代三区与老年代的生命周期》第 3 站;OOM 本身的识别与排查,见《OOM 排查实战:六种 OutOfMemoryError 全解》。

本站要点

分配策略看"内存规整不规整":规整 → 指针碰撞(O(1)),碎片 → 空闲列表(查表切块)。规整与否由收集算法决定——复制和标记-整理保持规整,标记-清除制造碎片。面试时把"策略(怎么找空间)"和"并发(谁来分配)"拆成两层说,层次感立刻不一样。

第 4 站

并发安全:CAS 重试与 TLAB 私有缓冲区

第 3 站解决了"怎么找空间",这一站解决"谁来分配"——多线程同时 new 对象时,如何保证两个线程不会把对象写到同一块地址。这是"对象创建全过程"里工程含量最高的一个知识点。

"Eden 里那根分配指针是共享的,100 个线程每秒各分配几万次对象,直接锁不是要死吗?JVM 怎么办?"

两个方案:CAS + 失败重试(无锁,原子地改指针)和 TLAB(给每个线程一块"私房钱")。先说 CAS 为什么不够,再说 TLAB 怎么把高频竞争变成偶发事件。

方案一:CAS + 失败重试

把"分配 size 字节"实现成对分配指针的原子操作:CAS(Compare-And-Swap)期望指针还在 old 位置,就改成 old + size;失败了说明别的线程抢先挪了指针,重试。低并发下这很高效,完全无锁。

但高并发下它有三个硬伤:① 所有线程挤在同一根指针、同一条缓存行上竞争,缓存行在核心间反复弹跳(cache line bouncing),CPU 大量耗在等内存一致性协议上;② 分配是每秒百万次级别的高频操作,竞争次数与分配频率成正比,自旋重试的开销直接线性放大;③ 重试还可能放大尾延迟——一个线程可能被连续"截胡"好几轮。

方案二:TLAB——线程私有分配缓冲区

TLAB(Thread Local Allocation Buffer,线程本地分配缓冲区)的思路是:既然大家抢同一根指针很贵,那就预先在 Eden 里给每个线程划一块私有缓冲区,线程在自己 TLAB 里分配对象时,动的是一根"私有指针"——不需要任何同步,还是指针碰撞,只是指针不再共享。只有当 TLAB 用完,才需要回 Eden "申请下一块",这时才发生一次 CAS 竞争。

本质是一笔交易:用"少量内存浪费"换"高频竞争的消失"。每次 TLAB 用完时,里面没用的剩余空间直接废弃(计入浪费);但换来的是 TLAB 装满之前(通常几百到几千个对象)该线程的每次分配都是零同步的。竞争从"每个对象一次"降频为"每个 TLAB 一次",频率差几个数量级。

TLAB 分配决策:对象装不下 TLAB 时走哪条路? TLAB 速记 -XX:+UseTLAB:Server 模式默认 true 大小:JVM 自适应,不是固定值 -XX:TLABSize 可手动固定(字节) -XX:TLABWasteTarget 默认 1% 1% 是浪费目标,不是 Eden 占比 G1 下 TLAB 取自当前 Eden Region 待分配对象(大小 S) S ≤ TLAB 剩余空间? 在 TLAB 内分配 私有指针前移,零同步(快路径) 绝大多数对象走这条路 S ≤ 新 TLAB 的大小? (JVM 计划申请的下一块大小) refill:申请新 TLAB CAS 竞争 Eden 分配指针 旧 TLAB 剩余空间 = 浪费 新对象放进新 TLAB 否(比一块新 TLAB 还大) 绕过 TLAB CAS 直接在 Eden 分配 Eden 剩余空间也不够时 直接分配在老年代(大对象路径) 见《JVM 堆内存:年轻代三区与老年代的生命周期》 老年代也放不下 → Full GC 再试 → 仍失败 → OOM: Java heap space
图 3 TLAB 分配决策树:TLAB 装得下 → 零同步分配;装不下但够开新 TLAB → refill;比新 TLAB 还大 → 绕过 TLAB 直接 Eden;Eden 也不够 → 老年代

TLAB 的大小:自适应,不是固定值

这是本题最大的陷阱区。三个参数各管一件事,面试时别串位:

  • -XX:+UseTLAB:开关,Server 模式默认开启(生产 JVM 都是 Server 模式,所以默认就有 TLAB)。
  • -XX:TLABSize:手动固定 TLAB 大小(字节)。默认不固定——JVM 会观察每个线程的对象大小分布,动态调整 TLAB 大小:分配大对象的线程给大 TLAB,分配小对象的线程给小 TLAB,初始大小与线程近期分配的平均对象大小相关。
  • -XX:TLABWasteTarget:默认 1%,含义是"TLAB 浪费目标"——JVM 希望"用不完就废弃的剩余空间"不超过 TLAB 大小的 1%。它约束的是浪费比例,不是 TLAB 占 Eden 的比例。
高频陷阱

"TLAB 是 Eden 的 1%"或"TLAB 默认 32KB"——都是错的。TLAB 与 Eden 大小没有固定比例关系,也没有一个写死的默认大小:它是每线程独立、动态自适应的。1% 是 TLABWasteTarget(浪费目标)。面试被问"TLAB 多大",标准答法是:"默认自适应,JVM 按线程的对象分配模式动态调整;可用 TLABSize 手动固定,用 TLABWasteTarget(默认 1%)控制可接受的浪费比例。"

观察 TLAB:参数与日志

TLAB 相关 JVM 参数 · 开关 / 大小 / 浪费目标 / 日志
# 开关:Server 模式默认 true(生产 JVM 默认就有 TLAB)
-XX:+UseTLAB

# 手动固定 TLAB 大小(字节)。默认不设:JVM 按线程分配模式自适应
-XX:TLABSize=262144

# 浪费目标:TLAB 用完时废弃的剩余空间 ≤ TLAB 大小 × 1%(默认 1%)
# 注意:这是"浪费目标",不是"TLAB 占 Eden 的比例"
-XX:TLABWasteTarget=1

# JDK 8:GC 时打印各线程 TLAB 统计(示例日志,字段名以实际版本为准)
-XX:+PrintTLAB
#   Thread 0x00007f8a1c008800  tlab_size=262144  fills=12  waste=8192  slowAllocs=3
#   fills      = TLAB 用完后 refill(重新申请)的次数
#   waste      = 被废弃的剩余空间字节数(refill 时计入)
#   slowAllocs = TLAB 路径失败、回退到慢速路径分配的次数

# JDK 9+:统一日志框架输出 TLAB 分配信息(gc 日志 tlab 标签,具体输出随版本变化)
-Xlog:gc+tlab=trace

读这三个数字的方法:fills 很高说明 TLAB 偏小(频繁 refill,竞争回来了);waste 占比远超 1% 说明 TLAB 偏大(浪费内存);slowAllocs 持续非零说明对象经常大到绕过 TLAB(大对象多,去查分配源头)。G1 下 TLAB 取自当前 Eden Region,每次 Young GC 后旧 TLAB 作废、从新 Region 重新申请——所以 TLAB 统计和 GC 日志放在一起看才有意义。

本站要点

CAS + 重试无锁但高频竞争下缓存行弹跳 + 自旋开销大;TLAB 给每个线程一块 Eden 里的私有缓冲区,线程内分配零同步,竞争降频为"每个 TLAB 一次"的 refill。大小自适应(TLABSize 可手动固定),TLABWasteTarget(默认 1%)是浪费目标而非 Eden 占比。装不下的对象:能开新 TLAB 就 refill,比新 TLAB 还大就绕过 TLAB 直接 Eden,Eden 也不够就进老年代。

第 5 站

阶段三:零值初始化——字段为什么总有默认值

内存分配完成的那一刻,JVM 顺手做了一件"看起来多余"的事:把刚划出来的那块空间(对象头部分除外)全部写成零值。于是 new 出来一个对象,即使你一行字段赋值都没写,它的字段也"有值":

ZeroInit.java · 构造器第一行就读字段(可运行)
public class ZeroInit {
    int a;
    long b;
    boolean flag;
    String name;

    public ZeroInit() {
        // 构造器还没做任何赋值,字段已经是确定的零值
        System.out.println(a);      // 输出: 0
        System.out.println(b);      // 输出: 0
        System.out.println(flag);   // 输出: false
        System.out.println(name);   // 输出: null
    }
}
字段类型默认零值底层位模式实例字段占用(HotSpot)
int / short / byte0全 0 位4B / 2B / 1B
char'\u0000'全 0 位2B
long0L64 位全 08B
float / double0.0f / 0.0dIEEE 754 正零4B / 8B
booleanfalse01B
引用类型null全 0 位4B(压缩指针)/ 8B

为什么必须做:三条理由

理由一:堆内存是复用的,上面可能留着"尸体"。这块空间上一任住户可能是另一个已回收的对象,里面残留着它字段的字节。如果不清零,你 new 出来的对象读到的就是前任的"脏数据"——一个 int 字段可能"恰好"是上轮循环的计数值。清零是唯一能保证"字段有确定值"的手段。

理由二:Java 的语言契约允许"不赋值就使用"。实例字段不显式初始化就读取,语言是放行的(编译不报错),那么 JVM 就必须为这种放行兜底——保证任何字段在任何时刻都有确定值。对比一下局部变量:不赋值就使用是编译错误,因为局部变量的活跃区间短、编译器可以静态检查;字段会被跨方法、跨时间访问,编译器管不住,只能由 JVM 在分配时统一兜底。

理由三:对比 C/C++ 就能看出设计取舍。malloc 出来的内存不初始化,读到未初始化值是未定义行为。Java 选择在运行时统一清零,牺牲一点 CPU(每次分配多一次写零),换来"永远不会读到脏内存"的强保证——这是托管语言的典型做法:把安全做进运行时,而不是留给程序员自律

纠错:boolean 字段是 1 个字节

不少资料(包括一些老版面试书)写"boolean 在 JVM 里按 int(4 字节)存储"——对实例字段不成立。HotSpot 中实例字段的 boolean 占 1 个字节(用 JOL 一看便知,第 6 站有工具)。"boolean 按 int 处理"说的是操作数栈上的行为:字节码栈里 boolean 以 32 位整型值存在(1 为 true,0 为 false),那是栈的粒度问题,和字段在对象里占几个字节是两回事。两个语境别混。

"零值初始化为什么不放进构造器里做?反正构造器也要给字段赋值。"

三个原因:① 构造器是用户代码,可能抛异常、可能被继承体系改写顺序——JVM 必须在用户代码开始执行之前就把内存置成"安全状态":即使 <init> 第一行就抛异常,字段依然是零值而不是脏值,任何拿到这个半成品对象的代码(比如父类构造器里)都不会读到垃圾。② 父类构造器可能读到子类字段——此刻子类的 <init> 还没执行,子类字段如果没被清零,父类代码读到的就是脏数据。③ 职责分层:零值初始化是"内存层"契约(JVM 负责,保证有值),构造器赋值是"逻辑层"行为(程序员负责,保证值正确)。零值是安全网,不是最终值——构造器随后会把它覆盖成真正的业务值,这次"重复劳动"正是分层的代价,也是分层的好处。

实现层面:HotSpot 在分配时就完成清零。小对象按字(8B/16B 粒度)批量写零;大对象(典型如大数组)用 memset 一类的高效率块清零指令。注意堆内存是"复用内存",不能依赖"新内存本来就是零"——OS 刚提交的新页常常是零页,但堆里循环使用的空间不是,所以 JVM 必须显式清零。

本站要点

零值初始化发生在分配同一瞬间、构造器执行之前:字段默认 0 / null / false,boolean 实例字段占 1 字节。动机:堆内存复用会有脏数据 + 语言契约允许字段不赋值就读。零值是"内存层安全网",构造器赋值是"逻辑层真值",后者覆盖前者——所以"字段被赋值两次"不是 bug,是分层。

第 6 站

阶段四:对象头与内存布局

零值写完后,JVM 往这块内存的头部写元信息。对象头是 JVM 的"户口本"——GC 靠它判断对象死活与年龄,锁靠它判断对象有没有人占,方法调用靠它找到类的元信息。这一站是全文信息密度最高的一站。

对象内存布局:头、数据、对齐三件套

HotSpot 里每个 Java 对象的内存布局由三部分组成:对象头(Header)实例数据(Instance Data)对齐填充(Padding)。HotSpot 要求对象总大小是 -XX:ObjectAlignmentInBytes默认 8 字节)的整数倍,不够就补 Padding——所以对象的最小尺寸就是 16 字节(2 个对齐单元),new Object() 的真实大小就是 16B。

对象内存布局(64 位 HotSpot,压缩指针开启) 一个普通对象(以 LayoutDemo { int i; long l; } 为例) Mark Word(8 字节) hashcode | GC 年龄 | 锁状态——五种状态复用同一块 8B 类指针(4B 压缩) → 方法区的类元信息 对齐填充(4B) 凑满 16 字节头部 头部合计 8 + 4 + 4 = 16B 实例数据(字段值) long l(8B,排前面) int i(4B) 字段顺序由 JVM 决定 (long 优先于 int, 与声明顺序无关) 对齐填充(0B,本例 16+12 已凑成 24 的倍数,无需再补) Instance size: 24 bytes Mark Word 五种状态(64 位) 无锁 hashcode(31b) + age(4b) + 偏向标志(1b) 01 偏向锁 偏向线程ID(54b) + epoch(2b) + age(4b) 01 轻量级锁 指向线程栈中锁记录的指针(62b) 00 重量级锁 指向堆中 Monitor(ObjectMonitor)的指针(62b) 10 GC 标记 GC 期间的临时标志位 11 末 2 位 = 锁标志位;状态切换时内容整体改写 偏向锁:JDK 15 起默认禁用,JDK 17 起移除相关选项 数组对象:对象头 = Mark Word + 类指针 + 长度(4B) Mark Word 8B 类指针 4B 长度 4B 元素数据(int[8] = 32B) int[8] = 16 + 32 = 48B
图 4 64 位、压缩指针开启时:普通对象头 16B(Mark Word 8B + 类指针 4B + 填充 4B);数组头再多一个 4B 长度字段;Mark Word 用 8 字节复用五种状态

Mark Word:8 字节的"复用账本"

Mark Word 在 64 位 JVM 下固定 8 字节,但它的"户口内容"随对象状态变化——五种状态各存各的信息,靠最低 2 位的锁标志位区分:

状态8 字节里存了什么(64 位)锁标志(低 2 位)
无锁hashcode(31 位,惰性计算)+ GC 分代年龄(4 位)+ 偏向标志(1 位),其余位未用01
偏向锁偏向线程 ID(54 位)+ epoch(2 位)+ GC 年龄(4 位)01
轻量级锁指向线程栈中锁记录(Lock Record)的指针(62 位)00
重量级锁指向堆中 Monitor(ObjectMonitor)的指针(62 位)10
GC 标记GC 期间的临时标志(标记/清除过程使用)11

三个必须说清的细节:

  • hashcode 是惰性计算的:无锁态里预留了 31 位 hashcode 空间,但直到第一次调用 hashCode()(或 System.identityHashCode())才真正计算并写入。刚 new 出来的对象,这块是空的。
  • age(GC 分代年龄):每熬过一次 Minor GC 存活下来就 +1,达到晋升阈值(-XX:MaxTenuringThreshold默认 15)晋升老年代——age 就存在 Mark Word 这 4 个位里,晋升机制详见《JVM 堆内存:年轻代三区与老年代的生命周期》。
  • 版本差异(必背):偏向锁从 JDK 15 起默认禁用(高竞争场景下偏向锁的撤销成本大于收益),从 JDK 17 起偏向锁相关选项被移除——所以现代 JDK 上实际存在的锁升级路径是:无锁 → 轻量级锁 → 重量级锁。
面试追问:"8 个字节的 Mark Word,凭什么装得下 hashcode、GC 年龄、锁状态?它们不同时都需要吗?"

答:因为它们是"复用"关系,不是"并列"关系。同一块 8 字节,在不同时刻被不同状态解读:无锁/偏向前期存 hash + age;一旦上锁,整块改写成"指向锁结构的指针"(此时 hash 暂时不存放在 Mark Word 里,需要时 JVM 实时计算,解锁后再按需写回);GC 期间临时改写为标记位。8 字节是"按当前使用者整块改写"的账本,不是"每个字段各占固定槽位"的表格——这正是对象头能做到 8 字节的原因。追问再深一层:锁升级路径(无锁 → 轻量级 → 重量级,含 CAS 重试与 Monitor 的互斥量/等待队列)见并发卷的《synchronized 锁升级:偏向锁 → 轻量级锁 → 重量级锁》。

类指针:对象的"户籍地"

对象头的第二块是类指针(Klass Pointer),指向方法区里该类的元信息(Klass:常量池、虚方法表、方法表、字段描述符)。它回答"这个对象是哪个类的实例",用途很多:虚方法分派(invokevirtual 靠它找到 vtable)、instanceof 判断、反射拿到字段/方法列表。压缩指针开启时它占 4 字节(未压缩 8 字节)。

压缩指针:4 字节的引用怎么撑起 32GB 堆

64 位 JVM 下对象引用"名义上"该占 8 字节,但 HotSpot 默认开启压缩指针(Compressed Oops,-XX:+UseCompressedOops 默认 true):堆小于 32GB 时,对象引用只占 4 字节。原理值得在面试里完整推一遍:

  1. 引用存的是相对于堆起始地址的偏移,不是完整 64 位地址——堆在内存中的具体位置由 OS 决定,VM 自己记录基址,省掉高位。
  2. 对象按 8 字节对齐(-XX:ObjectAlignmentInBytes 默认 8),偏移量的低 3 位恒为 0,不需要存——实际存的是 偏移 ÷ 8。
  3. 于是 32 位能表示 232 个不同的 8 字节对齐位置,寻址空间 = 232 × 8B = 32GB——"32GB 上限"就是这么来的。
  4. 读引用时做一次左移(shift 3)恢复完整地址;若 -XX:ObjectAlignmentInBytes=16,低 4 位恒 0,shift 4,寻址空间减半为 16GB。
压缩指针寻址上限 = 232 × 对齐字节数(8B 对齐 → 32GB;16B 对齐 → 16GB)
收益:对象头类指针 4B(头部 16B 而非 24B)+ 所有引用字段 4B——对象普遍变小,同堆容量装更多对象,GC 扫描的数据量也降下来

这是"堆为什么默认不超过 32GB"的底层原因之一:堆到 32GB,压缩指针自动关闭,所有引用翻倍,内存占用和 GC 压力同时跳档——所以很多大堆服务会刻意把堆压在 31G 左右。堆与引用占用的全景见《JVM 运行时数据区全景:堆、栈、方法区、程序计数器》。

对象访问定位:直接指针 vs 句柄池

引用"怎么找到对象",JVM 规范允许两种方式:

  • 直接指针(HotSpot 采用):对象引用直接指向对象头。优点:访问快,少一层间接;代价:GC 移动对象后要更新所有指向它的引用(靠根扫描 + 写屏障处理)。
  • 句柄池(Handle Pool):引用指向一张句柄表,表里再存对象指针。优点:对象移动时只需改表,引用不变;代价:每次访问多一次间接寻址,还要维护这张表。JVM 规范允许,但 HotSpot 没有采用。
面试提一句"规范允许两种,HotSpot 用直接指针,因为访问频率远高于 GC 移动频率"就够了,不用展开。

实例数据的字段布局:顺序由 JVM 决定,不是声明顺序

字段在"实例数据"里怎么排?JVM 规范(JVMS 2.13)只规定了一条:同宽度的字段要分配给更宽字段留下的空位(long/double 不会和 int 挤在一起浪费空间)。具体顺序由 JVM 实现自己定,HotSpot 的策略是:

  1. 按字段宽度分组:先放 8 字节宽字段(long/double),再放 4 字节(int/float/压缩后的引用),最后 1~2 字节(short/char/byte/boolean);
  2. 同宽度内:父类字段优先于子类字段,同一类里按声明顺序(LongSize 策略——宽字段优先布局,减少空洞)。

经典反直觉例子:即使 int i 声明在前,long l 也会排在前面——因为 long 要 8 字节对齐,放后面反而多出一个 4B 空洞。用 JOL(JOL 全称 Java Object Layout,org.openjdk.jol:jol)看一眼就记住了:

JOL 对象布局实测 · ClassLayout.parseInstance(obj).toLayout()(示例输出)
// 代码:class LayoutDemo { int i; long l; }   (int 声明在前)
ClassLayout.parseInstance(new LayoutDemo()).toLayout()

// 示例输出(64 位,压缩指针开启):
LayoutDemo instance layout:
offset  size  type         description
---------------------------
 0       8     (object header) 1 word
 8       4     (loss of alignment)
 8       8     long         LayoutDemo.l        // long 排在前面!
16       4     int          LayoutDemo.i
20       4     (4 bytes of padding)
Instance size: 24 bytes
Space wasted: 0 bytes (0%)

// 对照:数组 int[8] = 16B 头(含长度)+ 32B 元素 = 48 bytes
// 空对象 new Object() = 16 bytes(对象头即最小尺寸)

JOL 的用法:Maven 引入 org.openjdk.jol:jol(0.16 / 0.17 等版本),对任意对象调用 ClassLayout.parseInstance(obj).toLayout() 即可看到逐字节的布局——算对象大小永远以 JOL 输出为准,别手算(字段布局规则、压缩指针开关、数组长度字段,一个细节算错就差 4~8 字节)。三个常用结论:new Object() = 16B;int[8] = 48B;{int i; long l} = 24B。

面试加分

"能不能通过调整字段声明顺序省内存?"——能,但收益有限。因为 HotSpot 按宽度分组布局,把 long/double 集中声明、引用和 int 放一起,确实可能减少 1~2 处 4B 空洞(对海量小对象有 5%~10% 量级的影响,通常)。但现代代码不建议为这点空间扭曲可读性——真要抠对象大小,先查"对象该不该存在"(能不能复用、能不能基本类型化),而不是字段排列。

本站要点

对象 = 对象头 + 实例数据 + 对齐填充(8B 对齐,最小 16B)。头部:Mark Word 8B(五种状态复用,末 2 位锁标志:01 无锁/偏向、00 轻量级、10 重量级、11 GC 标记;hash 惰性计算;age 默认 15 晋升;偏向锁 JDK 15 默认禁用、JDK 17 移除选项)+ 类指针 4B(压缩指针,堆 < 32GB)+ 填充。数组头再多 4B 长度。字段布局:宽字段优先、父先于子、同宽按声明——用 JOL 实测为准。

第 7 站

阶段五:执行 <init>——构造器的字节码真相

走到最后一步,JVM 终于要执行"用户代码"了。先回到字节码层面,看一条 new 到底被编译成了什么:

javap -c 输出 · new Object() 的字节码(示例)
// 源码:Object obj = new Object();
// javap -c 反编译 main 方法:
 0: new           #2   // class java/lang/Object
 3: dup
 4: invokespecial #3   // Method java/lang/Object.<init>:()V
 7: astore_1               // 对象引用存入局部变量 obj
 8: return

三条核心指令,各司其职:

  • new:触发前四个阶段——类加载检查、内存分配、零值初始化、设置对象头,然后把新对象的引用压上操作数栈。注意:new 执行完,对象在堆里已经"存在"了,只是字段全是零值、构造器还没跑。
  • dup:复制栈顶的引用。为什么要复制?因为下一条 invokespecial消费栈顶引用(把它当构造器的 this 参数),如果不复制,构造器执行完栈上就什么都没了,后面没法赋给局部变量。
  • invokespecial:调用对象的 <init> 方法(构造器)。构造器执行期间,this 指向的是一块"已经分配、已经清零、已经写好对象头"的完整内存。

换成带字段的类,看看构造器内部长什么样:

javap -p -c 输出 · Person 构造器字节码(示例)
// 源码:class Person { String name; int age;
//         Person(String name, int age) { this.name = name; this.age = age; } }
public Person(String, int);
  // 构造器 <init> 的字节码:
 0: aload_0                // 加载 this
 1: invokespecial #1     // ① super() → Object.<init>(隐式插入,必须是第一句)
 4: aload_0
 5: aload_1
 6: putfield      #2     // ② this.name = name
 9: aload_0
10: iload_2
11: putfield      #3     // ③ this.age = age
14: return

<init> 的三段式结构

编译器生成的 <init> 永远是三段,顺序雷打不动:

  1. super()——调用父类构造器(字节码里第一条 invokespecial)。你不写它,编译器也会在第一行隐式插入 super()
  2. 字段初始化器 + 实例初始化块——按源码中出现顺序交织编译成 putfield(不是"先全部字段、再全部块")。
  3. 构造器方法体——你自己写的代码。

为什么 super() 必须排第一?Java 语言规范(JLS §8.8.7.1)强制规定:构造器的第一条语句必须是 super(...) 或 this(...)。原因是"先壳后瓤"——父类构造器可能初始化子类会依赖的字段,如果允许子类代码先跑,就可能读到尚未构造的父类状态,语义直接失控。

这也解释了一个经典面试题:父类构造器里读子类字段,读到什么?——零值。因为执行到父类 <init> 时,子类 <init> 里的字段初始化器还没跑,而第 5 站的零值初始化早已完成,所以子类字段此刻是 0 / null,不是"未定义"。

"构造器里抛异常,堆里这个对象怎么办?会泄漏吗?"

不会"泄漏",但会留下半成品。执行 invokespecial <init> 时,对象已经完整走完分配 + 零值 + 对象头(第 1 站的 ②③ 先于 ⑤),它实实在在占着堆内存。<init> 抛异常后:① 异常沿调用栈传播,局部变量不会被赋值astore 没执行到);② 堆里这个对象没有任何引用指向它,成了垃圾,等待 GC 回收;③ 但构造器里已经执行过的副作用是真实的——资源已打开、事件已注册、静态计数器已加一。所以"构造器抛异常不泄漏对象"的完整说法是:对象会被 GC 回收,但副作用不会自动回滚,这是资源管理要格外小心的地方。

this 逸出(点到为止)

构造器还没执行完,this 就已经是一个"合法的引用"了——所以你在构造器里把 this 交给别人(注册监听器、赋值给静态字段、启动线程),等于把一个字段还是零值/未赋值完的半成品对象暴露给了别的线程。别的线程可能在你构造完成之前读到"半初始化"状态。正确姿势:构造器里不做任何发布动作,构造完成后再注册。锁与线程安全的细节见并发卷的《synchronized 锁升级:偏向锁 → 轻量级锁 → 重量级锁》和《JMM 内存模型:happens-before 八条规则全解》。

最后把"字段被赋值两次"这件事钉死:第 5 站的零值初始化(JVM,内存层,保证"有值")→ 本阶段的 putfield(构造器,逻辑层,保证"值对")。零值是安全网,构造器赋值是覆盖——两次写入是分层设计的代价,不是冗余 bug。

本站要点

new 的字节码真相:new(完成分配+零值+对象头,压引用)→ dup(复制引用,一份给 this、一份留给赋值)→ invokespecial(执行 <init>)。<init> 三段式:super()(必须第一句,JLS 8.8.7.1)→ 字段初始化器 + 初始化块(按源码顺序交织)→ 构造体。构造器抛异常:对象变垃圾被回收,但副作用不回滚;构造器里暴露 this = this 逸出,半成品对象泄露给其他线程。

第 8 站

限定词:"new 的对象一定在堆里"吗?

五个阶段讲完,该给"对象在堆里"这个认知打补丁了。面试官如果听你一路说"new 在堆上分配",最后一定会补一刀:

"逃逸分析命中的对象还在堆里吗?'new 出来的对象一定在堆里'这句话严谨吗?"

不严谨——这句话需要限定词。默认情况下(解释执行、C1 编译)确实如此:对象在堆(TLAB/Eden)分配。但 C2 编译器开启逃逸分析后,如果判定对象"不逃逸"(方法内创建、没被返回、没被存到静态字段/被其他线程访问),可以做两个优化:

  • 标量替换(Scalar Replacement):把对象的字段拆成独立的标量变量(本地变量),"对象"不再作为一个整体被组装出来——堆上分配被彻底消除,GC 也没有目标。HotSpot 所谓"栈上分配"的效果,本质就是标量替换:不是把对象搬到栈上,而是让这个对象不再以对象形态存在
  • 锁消除(Lock Elision):对象是线程私有的(不逃逸),对它加的锁永远不会被竞争,编译器直接把锁去掉。

所以严谨的说法是:从 Java 语言和默认运行时视角看,new 出来的对象在堆里分配内存;但在 JIT(C2)+ 逃逸分析下,不逃逸的对象可以连堆分配都省掉(标量替换),锁也可以消除。面试时这句话的完整版本是:"对象默认分配在堆,这是语言语义的底线;JIT 可以在不改变语义的前提下,把不逃逸的对象'化整为零',消除堆分配——优化的是实现,不变的是语义。"

两个说法别踩

① 别说"JVM 会把对象分配到栈上"——HotSpot 没有真正的栈上分配,机制是标量替换(字段变成局部变量),说"栈上分配"会被追问到墙角。② 别把逃逸分析说成"一定生效"——它是 C2 的热方法优化,方法不热、对象逃逸(被 return、被存储、被线程共享)时都不生效,而且版本间优化策略会变。验证手段:JDK 8 可用 -XX:+UnlockDiagnosticVMOptions-XX:+PrintEscapeAnalysis 打印分析结果(JDK 9+ 走统一 JIT 日志);标量替换与逃逸分析的完整机制见《JIT 编译优化:逃逸分析与标量替换》。

本站要点

"new 一定在堆里"要加限定词:默认运行时 = 堆;C2 + 逃逸分析 + 对象不逃逸 = 标量替换消除堆分配、锁消除。机制是"化整为零",不是"搬到栈上"。这句话是区分"背过对象创建"和"理解对象创建"的分水岭。

第 9 站

总结:知识地图与面试模板

知识地图

本文全部知识点的结构(能默画出来就算内化):

  • 五阶段链路:类加载检查 → 内存分配 → 零值初始化 → 设置对象头 → 执行 <init>。②③ 在分配同一瞬间完成(JVM 内部),⑤ 才执行用户逻辑;顺序是"先零值、后对象头"。
  • 类加载检查:是"检查"不是"加载"——确认类已加载/链接/初始化,首次使用才走完整流程;new(非数组)是触发类初始化的主动引用;类元信息在方法区(Metaspace),Class 对象在堆里;数组类运行时生成。
  • 分配策略:内存规整 → 指针碰撞(O(1));碎片 → 空闲列表(查表切块)。规整与否由收集算法决定(复制/整理 → 规整;标记-清除 → 碎片)。Eden 剩余不够 → 老年代 → Full GC → OOM。
  • TLAB:每线程 Eden 私有缓冲区,线程内分配零同步;refill 时 CAS 竞争 + 旧 TLAB 剩余 = 浪费;大小自适应(TLABSize 手动固定),TLABWasteTarget 默认 1% 是浪费目标不是 Eden 占比;决策树:TLAB 够 → 用;不够但够开新 TLAB → refill;比新 TLAB 大 → 直接 Eden;Eden 不够 → 老年代。
  • 零值初始化:字段 → 0 / null / false(boolean 实例字段 1B);动机:复用内存有脏数据 + 语言契约;零值是内存层安全网,构造器是逻辑层覆盖。
  • 对象头:头 16B = Mark Word 8B + 类指针 4B + 填充 4B(压缩指针,堆 < 32GB,232×8B 上限);数组头 +4B 长度;Mark Word 五状态复用 8B(01 无锁/偏向、00 轻量级、10 重量级、11 GC 标记),hash 惰性计算,age 默认 15 晋升,偏向锁 JDK 15 默认禁用 / JDK 17 移除选项;字段布局宽字段优先、父先于子、同宽按声明,JOL 实测为准(Object()=16B,int[8]=48B)。
  • <init>:new → dup → invokespecial;三段式 super()(第一句)→ 字段初始化器 + 初始化块(源码顺序)→ 构造体;构造器抛异常对象变垃圾但副作用不回滚;this 逸出 = 暴露半成品对象。
  • JIT 限定词:C2 + 逃逸分析 + 不逃逸 → 标量替换消除堆分配 + 锁消除;不是真正的栈上分配。

面试回答模板

30 秒版(先说结论)

"new 一个对象,JVM 走五个阶段:类加载检查——确认类已加载、链接、初始化,通常早就就绪;内存分配——内存规整用指针碰撞、碎片用空闲列表,多线程靠 TLAB 把竞争降频;零值初始化——字段先填 0 / null / false;设置对象头——Mark Word 加类指针,64 位压缩指针下头 16 字节;最后执行构造器——super 先行、字段初始化、构造体。前四步 JVM 自动完成,构造器才跑用户逻辑。"

3 分钟版(追问三层展开)

30 秒版 + 三个"为什么":① 为什么 TLAB——CAS 重试无锁,但高频分配下所有线程挤同一根指针、缓存行反复弹跳;TLAB 给每线程一块 Eden 私有缓冲区,线程内零同步,竞争降频为"每个 TLAB 一次"的 refill,代价是可控浪费(TLABWasteTarget 默认 1%,注意这是浪费目标不是 Eden 占比);TLAB 大小自适应,不是固定值。② 为什么 Mark Word 只有 8 字节——五种状态(无锁/偏向/轻量级/重量级/GC 标记)复用同一块 8B,末 2 位锁标志区分;无锁态存 hash(惰性计算)+ 年龄,上锁后整块改成指向锁结构的指针——是"复用账本"不是"固定槽位"。③ 为什么字段先零值再赋值——堆内存复用会有脏数据,语言契约允许字段不赋值就读,JVM 必须在构造器(用户代码)之前保证内存处于安全状态;零值是安全网,构造器是覆盖。收尾加限定词:"默认对象分配在堆,但 C2 逃逸分析可以对不逃逸对象标量替换、彻底消除堆分配——机制是化整为零,不是栈上分配。"

参数速查

参数默认值作用
-XX:+UseTLABServer 模式 true启用线程本地分配缓冲区(生产 JVM 默认就有)
-XX:TLABSize不设(自适应)手动固定 TLAB 大小(字节);默认由 JVM 按线程分配模式动态调整
-XX:TLABWasteTarget1TLAB 浪费目标:废弃剩余空间 ≤ TLAB 大小 × 1%(是浪费目标,不是 Eden 占比)
-XX:ObjectAlignmentInBytes8对象对齐粒度(字节);对象总大小必须是它的倍数,决定最小对象尺寸 16B
-XX:+UseCompressedOopstrue压缩对象引用为 4B;堆 < 32GB 时生效(232 × 8B 对齐)
-XX:MaxTenuringThreshold15晋升老年代的 GC 年龄阈值(age 存在 Mark Word 的 4 个位里)
-XX:PretenureSizeThreshold0(关闭)超过该大小的对象直接分配老年代(仅 Serial / ParNew 生效)
-XX:+PrintTLABfalseJDK 8:GC 时打印 TLAB 统计(fills / waste / slowAllocs);JDK 9+ 用 -Xlog:gc+tlab

高频追问速答

追问答案要点
new 出来的对象一定在堆里吗?默认运行时是;C2 + 逃逸分析下,不逃逸对象可被标量替换——拆成标量变量,堆分配彻底消除(不是"搬到栈上"),锁也可消除。优化的是实现,不变的是语义
TLAB 多大?是固定的吗?不固定。默认自适应:JVM 按线程的对象大小分布动态调整;-XX:TLABSize 可手动固定;-XX:TLABWasteTarget 默认 1% 是"浪费目标"(废弃剩余 ≤ 1%),常被误说成"TLAB 是 Eden 的 1%"——错
为什么 Mark Word 只有 8 字节?状态复用:无锁/偏向存 hash(31b) + age(4b),轻量级/重量级整块改成指向锁结构的指针(62b),GC 时临时标记;末 2 位锁标志(01/00/10/11)区分状态。同一块 8B 按"当前使用者"整块改写,不是各字段固定槽位
两个对象 new 出来内存一定连续吗?不一定。同一线程 TLAB 内按分配顺序连续;跨线程各用各的 TLAB,地址取决于各自 TLAB 的位置;TLAB refill 后还换了区域。"连续"只在"同线程、同一 TLAB、先后分配"时成立
压缩指针怎么工作?32GB 上限怎么来的?引用存"堆内偏移 ÷ 对齐字节数"(8B 对齐 → 低 3 位恒 0 不存),32 位可表 232 个位置 × 8B = 32GB;读时左移 3 位恢复。对齐改 16B → 上限减半 16GB。这也是大堆服务把堆压在 31G 附近的原因
对象大小怎么算?对象头(普通对象 16B;数组 16B 含长度字段)+ 实例数据(字段按宽度分组布局:long/double 优先,父类先于子类,同宽按声明)+ 对齐填充(8B 倍数)。别手算——JOL 的 ClassLayout.parseInstance().toLayout() 实测为准:Object()=16B,int[8]=48B,{int;long}=24B
核心记忆

一个 new = 五个阶段:查类 → 分空间 → 填零值 → 写头部 → 跑构造器。前四步是 JVM 的"出厂设置"(分配即安全:有确定的零值、有完整的对象头、有正确的类归属),第五步才是"用户装修"。掌握这条链路,对象在堆里的一生就看清了出生这一刻——下一篇我们接着看它的另一头:它怎么被判定为"死了"——引用计数为什么不行、可达性分析怎么标活对象,见《对象存活判定:引用计数 vs 可达性分析》。

Comments · 评论