Java 对象创建、内存布局、对象访问定位全过程解析
本文约两万余字,所有 JOL 布局数据、锁状态数据、伪共享压测数据、逃逸分析 GC 数据均在本机 JDK 17(Temurin 17.0.20.1)上实测产出,文末附完整复现代码与复现命令,可逐条验证。
目录
- 开篇:三个问题,一条主线
- 第一章 new 一个对象,JVM 到底干了什么
- 1.1 从字节码说起:new 指令的真面目
- 1.2 类加载检查:从符号引用到直接引用
- 1.3 类初始化 clinit:静态域只有一个起点
- 1.4 堆上分配内存:指针碰撞与空闲列表
- 1.5 并发分配的安全保障:CAS 失败重试与 TLAB
- 1.6 对象头设置与零值初始化
- 1.7 构造方法 init 执行:一个对象的真正诞生
- 1.8 对象创建全景时间线
- 1.9 对象创建的显式成本与隐式成本
- 第二章 对象内存布局:对象在堆里到底长什么样
- 2.1 布局三大部分总览
- 2.2 对象头:Mark Word 与 Klass Pointer 的位级拆解
- 2.3 实例数据与字段重排:HotSpot 的"最优摆放"算法
- 2.4 对齐填充:为什么对象大小永远是 8 的倍数
- 2.5 压缩指针:4 字节引用如何表示 32G 堆
- 2.6 数组对象布局:对象头多出的 4 字节
- 2.7 继承场景下的布局:父类字段永远在子类前面
- 2.8 JOL 工具实战:一行依赖看穿任意对象
- 2.9 锁升级状态机:Mark Word 是如何被复用的
- 2.10 伪共享:内存布局带来的性能陷阱
- 2.11 真实世界的对象账单:String、DTO 与集合的实测
- 2.12 分配顺序与缓存局部性:对象挨得近,跑得就快
- 第三章 对象访问定位:引用是怎么找到对象的
- 3.1 两种主流模型:句柄访问与直接指针访问
- 3.2 HotSpot 的选择:oop-Klass 二分模型
- 3.3 一次字段访问的完整路径
- 3.4 逃逸分析、栈上分配与标量替换
- 3.5 锁消除与同步消除:把锁"写"没了
- 3.6 从访问定位到可达性分析:GC 如何遍历对象图
- 3.7 对象哈希:Mark Word 里那 31 位与 hashCode 重写的连锁反应
- 第四章 生产实战:对象相关的内存问题与解法
- 4.1 大对象直接进老年代:别让对象头堵住年轻代
- 4.2 对象创建过多导致 GC 频繁:四种解法
- 4.3 小对象泛滥导致内存膨胀:三种解法
- 4.4 伪共享在真实业务中的样子与根治
- 4.5 故障案例:一次接口 OOM 的完整排查实录
- 4.6 对象问题排查工具箱速查表
- 4.7 对象优化的完整决策树
- 第五章 对象模型的 JDK 演进:从 JDK 8 到 JDK 27
- 5.1 演进时间线
- 5.2 压缩指针与类指针的解耦
- 5.3 偏向锁的兴衰:JEP 374 与 JDK 18 的告别
- 5.4 紧凑对象头:JEP 450、JEP 519 与 JEP 534
- 5.5 从对象模型看 Java 的演进哲学
- 总结:一张图 + 面试自测题 + 参考文献
- 高频误区与 FAQ
开篇:三个问题,一条主线
先看一道在很多大厂面试里能"逼退"一大半候选人的连环题:
Q1:new User() 这行代码,JVM 一共做了哪几件事?请按顺序说全。
Q2:一个只有 int 字段的类,new 出来的对象占多少字节?为什么?
Q3:final User user = new User() 里的 user,它到底是"对象"还是"对象的地址"?
user.name 这条访问路径,JVM 是怎么一步步找到堆里那个字段的?
三个问题看起来各自独立,实际上是一条主线上的三个环节:
#mermaid-svg-cZxgXao3GFEPenHk{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-cZxgXao3GFEPenHk .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-cZxgXao3GFEPenHk .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-cZxgXao3GFEPenHk .error-icon{fill:#552222;}#mermaid-svg-cZxgXao3GFEPenHk .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-cZxgXao3GFEPenHk .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-cZxgXao3GFEPenHk .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-cZxgXao3GFEPenHk .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-cZxgXao3GFEPenHk .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-cZxgXao3GFEPenHk .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-cZxgXao3GFEPenHk .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-cZxgXao3GFEPenHk .marker{fill:#333333;stroke:#333333;}#mermaid-svg-cZxgXao3GFEPenHk .marker.cross{stroke:#333333;}#mermaid-svg-cZxgXao3GFEPenHk svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-cZxgXao3GFEPenHk p{margin:0;}#mermaid-svg-cZxgXao3GFEPenHk .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-cZxgXao3GFEPenHk .cluster-label text{fill:#333;}#mermaid-svg-cZxgXao3GFEPenHk .cluster-label span{color:#333;}#mermaid-svg-cZxgXao3GFEPenHk .cluster-label span p{background-color:transparent;}#mermaid-svg-cZxgXao3GFEPenHk .label text,#mermaid-svg-cZxgXao3GFEPenHk span{fill:#333;color:#333;}#mermaid-svg-cZxgXao3GFEPenHk .node rect,#mermaid-svg-cZxgXao3GFEPenHk .node circle,#mermaid-svg-cZxgXao3GFEPenHk .node ellipse,#mermaid-svg-cZxgXao3GFEPenHk .node polygon,#mermaid-svg-cZxgXao3GFEPenHk .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-cZxgXao3GFEPenHk .rough-node .label text,#mermaid-svg-cZxgXao3GFEPenHk .node .label text,#mermaid-svg-cZxgXao3GFEPenHk .image-shape .label,#mermaid-svg-cZxgXao3GFEPenHk .icon-shape .label{text-anchor:middle;}#mermaid-svg-cZxgXao3GFEPenHk .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-cZxgXao3GFEPenHk .rough-node .label,#mermaid-svg-cZxgXao3GFEPenHk .node .label,#mermaid-svg-cZxgXao3GFEPenHk .image-shape .label,#mermaid-svg-cZxgXao3GFEPenHk .icon-shape .label{text-align:center;}#mermaid-svg-cZxgXao3GFEPenHk .node.clickable{cursor:pointer;}#mermaid-svg-cZxgXao3GFEPenHk .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-cZxgXao3GFEPenHk .arrowheadPath{fill:#333333;}#mermaid-svg-cZxgXao3GFEPenHk .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-cZxgXao3GFEPenHk .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-cZxgXao3GFEPenHk .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-cZxgXao3GFEPenHk .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-cZxgXao3GFEPenHk .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-cZxgXao3GFEPenHk .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-cZxgXao3GFEPenHk .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-cZxgXao3GFEPenHk .cluster text{fill:#333;}#mermaid-svg-cZxgXao3GFEPenHk .cluster span{color:#333;}#mermaid-svg-cZxgXao3GFEPenHk div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-cZxgXao3GFEPenHk .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-cZxgXao3GFEPenHk rect.text{fill:none;stroke-width:0;}#mermaid-svg-cZxgXao3GFEPenHk .icon-shape,#mermaid-svg-cZxgXao3GFEPenHk .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-cZxgXao3GFEPenHk .icon-shape p,#mermaid-svg-cZxgXao3GFEPenHk .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-cZxgXao3GFEPenHk .icon-shape .label rect,#mermaid-svg-cZxgXao3GFEPenHk .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-cZxgXao3GFEPenHk .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-cZxgXao3GFEPenHk .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-cZxgXao3GFEPenHk :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
new 指令(字节码层面)
对象创建类加载 → 分配内存 → 初始化
对象布局对象头 + 实例数据 + 对齐填充
对象访问引用 → 对象 → 类数据
衍生问题逃逸分析 / 锁升级 / GC / 内存优化
对象创建决定了对象被放在哪、怎么被初始化;对象布局决定了对象在内存里长什么样、每个字节放什么;对象访问定位决定了引用(reference)如何一步步找到对象本体和它的类数据。三者环环相扣:布局的字节序由创建时的分配算法决定,访问路径的寻址方式又反过来约束布局的格式(比如压缩指针直接改变了对象头大小)。更关键的是,生产环境里绝大多数"对象相关"的疑难杂症——莫名其妙的内存膨胀、诡异的 GC 频繁、高并发下的性能雪崩——根子都能追溯到这三个环节的某个细节。
本文不背八股,全部结论用代码和实测说话:字节码用 javap 反汇编看,对象布局用 JOL(Java Object Layout)实测,伪共享用压测数据对比,逃逸分析用 GC 次数验证。所有数据在本机 JDK 17(Temurin 17.0.20.1)上产出,复现命令见各章节代码块。
第一章 new 一个对象,JVM 到底干了什么
1.1 从字节码说起:new 指令的真面目
先说一个反直觉的事实:new 是 Java 虚拟机指令集里最"名不副实"的指令之一。它并不负责"创建对象"的完整工作,只负责"分配内存"。真正的对象构造,是由一整套指令组合完成的。
看一个最简单的类:
public class BytecodeDemo {
private int count;
public BytecodeDemo(int count) {
this.count = count;
}
public int getCount() {
return count;
}
public static void main(String[] args) {
BytecodeDemo demo = new BytecodeDemo(42);
System.out.println(demo.getCount());
}
}
编译后执行 javap -c -p BytecodeDemo,得到真实反汇编(本机 JDK 17 实测输出):
public static void main(java.lang.String[]);
Code:
0: new #8 // class BytecodeDemo
3: dup
4: bipush 42
6: invokespecial #13 // Method "<init>":(I)V
9: astore_1
10: getstatic #16 // Field java/lang/System.out:Ljava/io/PrintStream;
13: aload_1
14: invokevirtual #22 // Method getCount:()I
17: invokevirtual #26 // Method java/io/PrintStream.println:(I)V
20: return
main 方法里和对象创建相关的只有四条指令,注意它们的配合关系:
| new #8 | 在堆上分配一块内存,把未初始化对象的引用压栈 | … → …, ref |
| dup | 复制栈顶引用 | …, ref → …, ref, ref |
| bipush 42 | 把构造参数 42 压栈 | 加入 int |
| invokespecial #13 | 调用 <init> 构造方法,消费一份引用 | 完成初始化 |
dup 是新手最容易忽略的一环:new 之后栈上只有一份引用,而 invokespecial(构造方法调用)会把它作为"接收者"消费掉。如果构造完成后还需要把对象引用存到局部变量表(astore_1),就必须先 dup 复制一份。一个引用被"使用"和"保存"两份用途,所以要复制。这就是 new + dup + invokespecial + astore 四件套的来源。
再注意 invokespecial #13 指向的是 Method "<init>":(I)V——构造方法在字节码层面的名字就叫 <init>,和普通方法完全不是一个物种。JVM 规范规定:<init> 只能由 invokespecial 调用,且只能调用一次(除非调用 super() 链)。而它为什么是 invokespecial 而不是 invokevirtual?因为构造方法不允许被重写,必须走静态分派——这就是"特殊"(special)的含义。
本小节结论:new 指令 = 分配内存 + 返回未初始化引用;对象真正的"初始化"由 <init> 完成。所以"new 一个对象 JVM 做了几件事"这个问题,答案要从两条线索展开:一条是内存分配(new 指令负责),一条是初始化(<init> 负责),而这两条线索之间还夹着类加载。
1.2 类加载检查:从符号引用到直接引用
new #8 里的 #8 是什么?它只是常量池里的一个符号引用(Symbolic Reference)——一个字符串形式的类名 BytecodeDemo,还没有和具体的内存地址挂钩。所以 new 执行时,第一步不是分配内存,而是先确认这个类可以被使用。
整个流程是这样的:
这里有个高频考点:new 到底会不会触发类的初始化? 答案是"分情况"。new、反射、静态字段访问、静态方法调用都会触发初始化;但通过数组创建对象不会——new User[10] 只在堆上分配一个数组对象,数组的类 [LUser; 由 JVM 自动生成,不会触发 User 的初始化。还有一个冷门情况:引用父类的静态字段不会触发子类初始化(静态字段在哪个类声明,就只初始化那个类)。
1.3 类初始化 clinit:静态域只有一个起点
类初始化执行的是 <clinit> 方法——它由编译器自动收集所有静态字段赋值语句和静态代码块合并生成。<clinit> 和 <init> 有本质区别:
| 触发时机 | 类首次被"主动使用"时,且只有一次 | 每次 new 都执行 |
| 是否加锁 | 是,JVM 保证线程安全(同一类只有一个线程能执行) | 否 |
| 执行内容 | 静态字段赋值 + 静态代码块 | 实例字段赋值 + 实例代码块 + 构造器 |
| 继承关系 | 先执行父类的 <clinit> | 先执行 super() 再执行子类内容 |
用一段程序实测初始化的严格顺序(本机实测输出):
public class InitOrderDemo {
static class Parent {
static int ps = initStatic("Parent.static 字段");
int pf = initInstance("Parent.实例字段");
static { System.out.println("Parent.static 块"); }
{ System.out.println("Parent.实例块"); }
Parent() { System.out.println("Parent.构造器"); }
static int initStatic(String s) { System.out.println(" -> " + s); return 1; }
int initInstance(String s) { System.out.println(" -> " + s); return 1; }
}
static class Child extends Parent {
static int cs = initStatic("Child.static 字段");
int cf = initInstance("Child.实例字段");
static { System.out.println("Child.static 块"); }
{ System.out.println("Child.实例块"); }
Child() { System.out.println("Child.构造器"); }
static int initStatic(String s) { System.out.println(" -> " + s); return 1; }
int initInstance(String s) { System.out.println(" -> " + s); return 1; }
}
public static void main(String[] args) {
System.out.println("— 第一次 new Child —");
Child c1 = new Child();
System.out.println("— 第二次 new Child(类已初始化)—");
Child c2 = new Child();
}
}
输出:
— 第一次 new Child —
-> Parent.static 字段
Parent.static 块
-> Child.static 字段
Child.static 块
-> Parent.实例字段
Parent.实例块
Parent.构造器
-> Child.实例字段
Child.实例块
Child.构造器
— 第二次 new Child(类已初始化)—
-> Parent.实例字段
Parent.实例块
Parent.构造器
-> Child.实例字段
Child.实例块
Child.构造器
信息量很大,逐条解读:
本小节小结:new User() 触发链是"加载 → 验证 → 准备 → 解析 → 初始化(clinit)→ 分配 → 实例初始化(init)"。其中"准备"阶段有一个容易混淆的点:准备阶段只为静态字段分配默认零值(int=0、引用=null),真正的静态赋值是在 <clinit> 里做的。
1.4 堆上分配内存:指针碰撞与空闲列表
类加载就绪后,new 指令进入核心环节——在堆上给对象分配一块连续内存。分配策略有两种,取决于垃圾收集器的堆空间管理方式:
方案一:指针碰撞(Bump the Pointer)
如果堆内存是规整的(已用内存在一侧、空闲内存在另一侧,中间一个分界指针),分配就是把分界指针向空闲侧挪动对象大小的距离:
|===========已使用==========|———-空闲———-|
↑
分界指针
分配 N 字节后:
|===========已使用====|=====新对象=====|—-空闲—-|
↑
指针右移 N
这个方案快得惊人:一次指针加法就是一次分配。Serial、ParNew 这类带整理(Compact)能力的收集器采用它。
方案二:空闲列表(Free List)
如果堆内存是不规整的(已用和空闲交错,比如 CMS 的标记-清除,或 G1 依赖 Remembered Set 维护的 region 内部),JVM 维护一张记录空闲块的链表,分配时在链表上找一块足够大的空间,更新链表,再初始化这块空间。
两种方案各有适用场景,不能简单说谁好:
| 前提 | 堆内存规整(有整理能力) | 堆内存不规整(无整理/增量式) |
| 分配速度 | 快(一次指针加法) | 慢(链表遍历 + 碎片管理) |
| 典型收集器 | Serial、ParNew、Parallel(标记-整理) | CMS(标记-清除)、G1(region 不连续) |
| 碎片问题 | 无(天然连续) | 有,需要额外处理 |
解法视角:如果线上出现"分配慢",除了看收集器选择,还要看堆是否碎片化。G1 的 region 内部是连续空间,分配走的是"bump the pointer + 空闲 region"的混合策略;CMS 时代经典的"碎片化 → Full GC 频繁"问题,本质就是空闲列表被切得越来越碎。JDK 9 以后 CMS 被废弃、G1 成为默认,很大一部分原因就是 G1 用"整块 region 分配"规避了长期碎片化——这是"用布局换速度"的典型工程取舍。
1.5 并发分配的安全保障:CAS 失败重试与 TLAB
分配内存本身很快,但在多线程环境下,指针碰撞的"指针移动"是一个读-改-写操作,必须保证线程安全。HotSpot 用了两层方案:
方案一:CAS + 失败重试(乐观锁)
对"更新分界指针"这个动作使用 CAS(Compare-And-Swap):先读取当前指针位置,计算出新位置,然后 CAS 尝试把指针从旧值更新到新值。如果失败(说明其他线程抢先分配了),就重新读取、重新计算、再试。对 Serial/ParNew 这类没有线程本地缓冲的场景,这是兜底方案。
方案二:TLAB(Thread Local Allocation Buffer,线程本地分配缓冲)
这是更主流的方案。每个线程在堆的 Eden 区预申请一块私有空间(TLAB),线程自己分配时直接在 TLAB 里指针碰撞,完全不需要同步。TLAB 用完了再去堆上申请新的,申请新 TLAB 这个动作才需要同步。示意图:
Eden 区
|=======TLAB(线程1)======|===TLAB(线程2)===|=======TLAB(线程3)=======|剩余|
↑线程1独占,无锁分配 ↑线程2独占 ↑线程3独占
JDK 17 默认开启 TLAB,相关参数:
-XX:+UseTLAB # 开启 TLAB(默认开启)
-XX:TLABSize=2m # 指定 TLAB 大小(默认按线程数和堆大小动态计算)
-XX:+ResizeTLAB # 允许 JVM 动态调整 TLAB 大小(默认开启)
-XX:TLABRefillWasteFraction=64 # 允许 TLAB 浪费的比例,1/64
实测验证 TLAB 行为(JDK 17,-Xlog:gc+tlab=trace 可以看每个线程的 TLAB 申请记录):
java -Xlog:gc+tlab=trace -Xmx1g EscapeGcDemo 2>&1 | head -20
典型输出片段(示意,不同 JVM 版本格式略有差异):
[0.019s][info ][gc,tlab] Thread: 0x00007f…, TLAB: gc thread: 0x00007f…
[0.020s][info ][gc,tlab] TLAB: 慢分配(slow alloc): 10
[0.021s][info ][gc,tlab] TLAB: 快速分配(fast alloc): 8,000,000
看到 fast alloc 数量级远大于 slow alloc,说明绝大多数对象分配走了 TLAB 快速路径。排查建议:如果 slow alloc 比例异常升高,通常是对象太大放不进 TLAB(大对象直接走慢分配),或者 TLAB 配置过小、线程切换频繁导致缓冲浪费。
内存分配小结:分配策略三件套是"指针碰撞/空闲列表(选哪块内存)→ TLAB(线程私有加速)→ CAS 兜底(TLAB 用尽时的并发安全)"。这三个概念是面试连环题的第二层,也是后面理解"对象头里为什么有 Mark Word"的基础。
1.6 对象头设置与零值初始化
内存分配完成、得到一块原始内存之后,JVM 开始往这块内存里写"骨架信息":
关于零值初始化,有两个值得展开的工程细节:
细节一:TLAB 的零值复用。TLAB 是线程私有的,线程每次申请到新 TLAB 时一次性清零整块缓冲,之后在这个缓冲内的对象分配不需要再逐块清零(已经清过了)。这就是 TLAB 的另一个隐藏收益:除了免同步,还免了零值初始化。代价是 TLAB 未用完的部分浪费掉。
细节二:JIT 的分配优化会跳过"用不到"的零值初始化。C2 编译器做逃逸分析时,如果发现某个对象的字段在构造前不会被读取(典型场景:对象创建后立刻全部字段显式赋值),会把冗余的清零操作优化掉。这属于 JIT 层面的"减法优化",后面 3.4 节会详细展开。
1.7 构造方法 init 执行:一个对象的真正诞生
骨架搭好(对象头 + 零值字段),最后一步是执行 <init>:按"实例字段赋值 → 实例代码块 → 构造器体"的顺序完成初始化。到这里,new 指令返回的引用才"指向一个真正可用的对象"。
注意一个容易被忽略的事实:在 <init> 执行完成之前,对象已经分配好内存、对象头已经写好。这意味着:
1.8 对象创建全景时间线
把 1.1 到 1.7 串起来,new User() 的完整时间线:
堆
方法区
线程
堆
方法区
线程
#mermaid-svg-ZchWGPQMv7zk2t6w{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ZchWGPQMv7zk2t6w .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ZchWGPQMv7zk2t6w .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ZchWGPQMv7zk2t6w .error-icon{fill:#552222;}#mermaid-svg-ZchWGPQMv7zk2t6w .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ZchWGPQMv7zk2t6w .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ZchWGPQMv7zk2t6w .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ZchWGPQMv7zk2t6w .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ZchWGPQMv7zk2t6w .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ZchWGPQMv7zk2t6w .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ZchWGPQMv7zk2t6w .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ZchWGPQMv7zk2t6w .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ZchWGPQMv7zk2t6w .marker.cross{stroke:#333333;}#mermaid-svg-ZchWGPQMv7zk2t6w svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ZchWGPQMv7zk2t6w p{margin:0;}#mermaid-svg-ZchWGPQMv7zk2t6w .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ZchWGPQMv7zk2t6w text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-ZchWGPQMv7zk2t6w .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-ZchWGPQMv7zk2t6w .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-ZchWGPQMv7zk2t6w .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-ZchWGPQMv7zk2t6w .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-ZchWGPQMv7zk2t6w #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-ZchWGPQMv7zk2t6w .sequenceNumber{fill:white;}#mermaid-svg-ZchWGPQMv7zk2t6w #sequencenumber{fill:#333;}#mermaid-svg-ZchWGPQMv7zk2t6w #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-ZchWGPQMv7zk2t6w .messageText{fill:#333;stroke:none;}#mermaid-svg-ZchWGPQMv7zk2t6w .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ZchWGPQMv7zk2t6w .labelText,#mermaid-svg-ZchWGPQMv7zk2t6w .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-ZchWGPQMv7zk2t6w .loopText,#mermaid-svg-ZchWGPQMv7zk2t6w .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-ZchWGPQMv7zk2t6w .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-ZchWGPQMv7zk2t6w .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-ZchWGPQMv7zk2t6w .noteText,#mermaid-svg-ZchWGPQMv7zk2t6w .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-ZchWGPQMv7zk2t6w .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ZchWGPQMv7zk2t6w .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ZchWGPQMv7zk2t6w .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ZchWGPQMv7zk2t6w .actorPopupMenu{position:absolute;}#mermaid-svg-ZchWGPQMv7zk2t6w .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-ZchWGPQMv7zk2t6w .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ZchWGPQMv7zk2t6w .actor-man circle,#mermaid-svg-ZchWGPQMv7zk2t6w line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-ZchWGPQMv7zk2t6w :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
1. 常量池查符号引用
类未加载则加载、验证、准备、解析
触发 clinit(仅首次)
2. 分配内存(指针碰撞/空闲列表)
3. TLAB 内无锁分配(否则 CAS 兜底)
4. 写入 Mark Word + Klass Pointer(+数组长度)
5. 实例数据区零值初始化
6. 执行 init(字段赋值→实例块→构造器)
7. 返回可用引用
1.9 对象创建的显式成本与隐式成本
把第一章收个尾:一次 new 到底"贵"在哪?拆开看,成本分显式和隐式两层:
显式成本(一次性,每对象都要付):
隐式成本(分场景,容易被忽略):
工程结论:对象创建的优化顺序应该是——先砍对象数量(消灭无谓对象、复用、基本类型化),再优化单对象成本(布局、逃逸分析),最后才考虑分配参数(TLAB、堆大小)。因为显式成本再低,也低不过"根本不创建"。
本章高频面试题自测:
- new 指令到底做哪几件事?——分配内存 + 返回未初始化引用;类加载和 <init> 是配套环节。
- 指针碰撞和空闲列表分别适用于什么收集器?为什么?
- TLAB 是线程私有的吗?它解决了什么问题、浪费了什么?
- 实例字段为什么有默认值而局部变量没有?
- DCL 单例为什么必须加 volatile?——重排三件事。
第二章 对象内存布局:对象在堆里到底长什么样
2.1 布局三大部分总览
HotSpot 中,一个普通 Java 对象在堆里的内存布局由三部分组成:
|=============== 对象头 Object Header ===============|===== 实例数据 =====|===== 对齐填充 =====|
| Mark Word(8字节) | Klass Pointer(4或8字节) | [数组长度(4字节)] | 字段值们 | 凑齐8字节倍数的空隙 |
| 对象头(Mark Word) | 8 字节(64 位) | 存哈希码、GC 分代年龄、锁状态标志位 |
| 对象头(Klass Pointer) | 4 字节(压缩)/ 8 字节(非压缩) | 指向方法区中的类元数据 |
| 数组长度(仅数组) | 4 字节 | 数组元素个数 |
| 实例数据 | 字段值总和 | 真正业务数据 |
| 对齐填充 | 0~7 字节 | 保证对象大小是 8 的倍数 |
为什么"对齐填充"能堂而皇之存在?因为 JVM 规范只要求对象大小是 8 字节的整数倍,多出来的字节填充没有任何意义,纯粹是"占位"。对齐不是为业务服务的,是为 CPU 和 GC 服务的——2.4 节展开。
2.2 对象头:Mark Word 与 Klass Pointer 的位级拆解
Mark Word(标记字):8 字节,但它不是"一个字段",而是一个被反复复用的位容器。同一块 8 字节,在对象的不同状态下存放完全不同的信息。64 位 HotSpot 的典型位图(以 JDK 8 时代的经典布局为例,含偏向锁;JDK 15 以后偏向锁位不复用,见 2.9 与第五章):
无锁态(正常状态):
| unused:25 | identity_hashcode:31 | unused:1 | age:4 | biased_lock:1 | lock:2 |
偏向锁态(JDK 8 时代):
| thread:54 | epoch:2 | unused:1 | age:4 | biased_lock:1 | lock:2 |
轻量级锁态:
| ptr_to_lock_record:62 | lock:2 | ← 指向栈中锁记录(Lock Record)
重量级锁态:
| ptr_to_heavyweight_monitor:62 | lock:2 | ← 指向 ObjectMonitor
GC 标记态:
| forwarding_ptr | lock:2 | ← 指向 forwarding 地址(GC 复制后)
lock 占最后 2 位,是状态的"总开关":00 轻量级锁、01 无锁或偏向锁(配合 biased_lock 位区分)、10 重量级锁、11 GC 标记。identity_hashcode(身份哈希码)只在无锁态才有位置存放——这也是一个经典面试题"为什么重写了 hashCode() 的对象的 System.identityHashCode 在加锁后取不到/要重新算"的根源:对象一旦升级为轻量级锁,Mark Word 里 hash 的位置被锁记录指针占用了。
Klass Pointer(类指针):指向方法区中该对象所属类的 instanceKlass 元数据。JVM 运行时通过它才能知道"这个对象是什么类、有哪些方法、字段怎么排"。JDK 8+ 默认开启压缩类指针(4 字节),配合 2.5 节的压缩引用机制使用。
实测验证(JOL,JDK 17 默认参数,压缩开启)——看裸 Object 的布局:
java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) N/A
8 4 (object header: class) N/A
12 4 (object alignment gap)
Instance size: 16 bytes
结论:一个什么都不放的 Object 也占 16 字节——8 字节 Mark Word + 4 字节压缩 Klass Pointer + 4 字节对齐填充。所以"new Object() 是 16 字节"不是背出来的,是布局规则推出来的。这个 16 字节是后面所有对象大小计算的"地基"。
2.3 实例数据与字段重排:HotSpot 的"最优摆放"算法
实例数据区存放对象的所有字段值。但字段在源码里的书写顺序,不一定是内存里的摆放顺序——HotSpot 会做字段重排(Field Reordering),规则大致是:
为什么这么排?为了减少填充(padding)。如果一个对象按源码顺序排会产生大量"前不着村后不着店"的空隙(比如 long 字段被挤到偏移 4 的位置,本身要求 8 字节对齐,就得空 4 字节),重排后每个字段尽量紧密贴合,省下的都是真实内存。在千万级对象规模的系统里,字段重排省下的每一字节都会放大成 GB 级差异——这是"用布局换内存"的经典案例。
实测验证:定义一个字段顺序很"反人类"的类:
static class Mixed {
boolean flag; // 1 字节
long id; // 8 字节
int age; // 4 字节
String name; // 4 字节(压缩引用)
char tag; // 2 字节
short seq; // 2 字节
}
源码顺序是 boolean → long → int → 引用 → char → short,看看 JOL 实测的内存顺序(JDK 17 压缩开启):
JolDemo$Mixed object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) N/A
8 4 (object header: class) N/A
12 4 int Mixed.age N/A ← int 被提到最前面
16 8 long Mixed.id N/A ← long 紧随
24 2 char Mixed.tag N/A
26 2 short Mixed.seq N/A
28 1 boolean Mixed.flag N/A ← 1 字节小字段收尾
29 3 (alignment/padding gap)
32 4 java.lang.String Mixed.name N/A ← 引用排最后
36 4 (object alignment gap)
Instance size: 40 bytes
逐条验证重排规则:
- 对象头固定 12 字节(8 Mark Word + 4 Klass Pointer)。
- int age 占偏移 12(对象头之后第一个 4 字节对齐位),接着 long id 占偏移 16(8 字节对齐)。
- 源码里写在前面的 boolean flag 反而被排到偏移 28——小字段让位给大字段。
- String name(引用,压缩后 4 字节)排到偏移 32,放最后。
- 字段数据到 32+4=36 字节结束,为了凑 8 的倍数补 4 字节对齐 → 实例总大小 40 字节。
如果不重排按源码顺序放会怎样?boolean(1) → 空 7 字节 → long(8) → int(4) → name(4) → char(2) → short(2),光中间就要浪费 7 字节空隙。重排后内部空隙只有 3 字节(flag 后的 gap),40 字节里 7 字节是"外部对齐"(对象头 12 之后 int 从 12 开始天然对齐)。重排把 7 字节的内部碎片变成了 4 字节的外部对齐。
工程启示:虽然 JVM 会自动重排,但源码字段顺序仍值得刻意设计——把大字段放前面、引用放后面、把同生命周期字段放一起,能减少 JOL 实测中的 padding,也能让代码阅读者直观预判内存大小。在字段数极多、对象量极大的实体类(比如订单模型)上,这往往是压死内存的最后一根稻草的解法之一。
2.4 对齐填充:为什么对象大小永远是 8 的倍数
硬规则:对象大小必须是 8 字节的整数倍(默认对齐粒度,可用 -XX:ObjectAlignmentInBytes=16 调整)。原因有三层:
解法视角:-XX:ObjectAlignmentInBytes 一般不建议动。它是为"对象头要容纳超大 Mark Word 的特殊场景"准备的,调大对齐粒度只会让每个对象多浪费字节。
2.5 压缩指针:4 字节引用如何表示 32G 堆
这是整个布局体系里最精妙的设计。64 位 JVM 上,一个普通指针(reference)理论上是 8 字节,但 HotSpot 在堆小于 32G 时默认开启压缩指针(-XX:+UseCompressedOops),把引用压缩成 4 字节。凭什么 4 字节(最大寻址 4G)能表示 32G 堆?
原理:对象按 8 字节对齐,所以对象地址的低 3 位永远是 0。于是真实地址 = 压缩值 × 8。4 字节能表达 2^32 个"8 字节单元" = 32G 的地址空间。解码是乘法(或位移),编码是除法,硬件级一个移位就完成。
堆地址 0x00000000_FFFF0008 (8 字节对齐,低 3 位为 0)
压缩后:0x1FFFFE01 (右移 3 位)
真实地址 = 压缩值 << 3
这就是"堆超过 32G 时压缩指针自动关闭"的由来——不是 JVM 偷懒,是 4 字节的寻址上限就在 32G。超过 32G 后引用变回 8 字节,对象头从 12 字节变 16 字节(Klass Pointer 4→8),引用字段 4→8 字节。这就是为什么"堆越大对象越重",以及为什么"64G 堆配 32G 应用不一定更快"——可用堆翻倍,但单对象体积涨了,GC 压力不一定降。
JDK 15 之后有例外:JDK 15 起压缩指针支持了新的压缩模式(-XX:ObjectAlignmentInBytes 配合零基地址的 31 位偏移模式),在特定堆布局下可以压缩到更大堆,但默认阈值仍是 32G。工程上记住"32G 分水岭"即可。
实测对比(同一 Mixed 类,JOL 实测):
| 默认(压缩开启) | 4 字节 | 4 字节 | 40 字节 | 56 字节 |
| -XX:-UseCompressedOops | 8 字节 | 4 字节(JDK 17 实测,见 5.2) | 40 字节 | 56 字节 |
| 双关 -XX:-UseCompressedOops -XX:-UseCompressedClassPointers | 8 字节 | 8 字节 | 对象头即 16 字节 | 对象头即 24 字节 |
注意上表第二行是个反直觉但真实的结果:JDK 17 上只关 UseCompressedOops 时,引用变 8 字节,但 Mixed 实例仍是 40 字节(引用字段排最后,8 字节对齐后总大小恰好没变);而关掉类指针压缩后对象头直接变 16 字节。关压缩指针的代价是每个对象 +4 字节对象头起步。这个细节在 5.2 节结合 JDK 演进继续讲。
工程结论:不要轻易关压缩指针。它的收益(省内存、省 GC 复制带宽)远大于那一点点解码开销(一个移位指令)。如果你的应用堆接近 32G,先优化堆用量而不是关压缩。
2.6 数组对象布局:对象头多出的 4 字节
数组是对象,但比普通对象多一段数组长度(4 字节),放在 Klass Pointer 之后、元素数据之前。JOL 实测 new int[10]:
[I object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)
8 4 (object header: class) 0x00006c28
12 4 (array length) 10
16 40 int [I.<elements> N/A
Instance size: 56 bytes
int[10] 的 56 字节 = 16 字节数组头(8 Mark + 4 Klass + 4 长度)+ 40 字节元素。数组头本身就是 16 字节,即空数组也要 16 字节。
再看引用数组 new String[3]:
[Ljava.lang.String; object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)
8 4 (object header: class) 0x00018a38
12 4 (array length) 3
16 12 java.lang.String String;.<elements> N/A
28 4 (object alignment gap)
Instance size: 32 bytes
String[3]:16 字节头 + 3×4 字节压缩引用 = 28 字节,补 4 字节对齐 = 32 字节。
工程启示:数组头是"固定开销",所以元素越小的数组,头占比越高。byte[1] 也要 24 字节(16 头 + 1 + 7 对齐)。如果系统里堆满了几百万个小数组(比如每条日志一个 byte[]),光对象头就能吃掉大半内存。解法方向见第四章 4.3 节(结构数组化、压缩存储)。
2.7 继承场景下的布局:父类字段永远在子类前面
HotSpot 布局规则:父类字段整体排在子类字段之前,各自内部再按 2.3 节规则重排。实测:
static class Parent { long parentField; }
static class Child extends Parent {
int childField;
boolean childFlag;
}
JolDemo$Child object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) N/A
8 4 (object header: class) N/A
12 4 int Child.childField N/A ← 子类 int
16 8 long Parent.parentField N/A ← 父类 long
24 1 boolean Child.childFlag N/A
25 7 (object alignment gap)
Instance size: 32 bytes
注意一个细节:子类的 int childField 排在了父类 long parentField 前面。这说明"父类在前"约束的粒度是"整块",块内部各自重排;而跨块时,HotSpot 会在满足父类块整体在前的前提下,允许子类字段"借位"填补父类块留下的对齐空隙——这里的 childField 恰好占住偏移 12(对象头后第一个 4 字节对齐位),父类 long 从 16 开始天然 8 字节对齐。这就是为什么"在父类里加字段会改变所有子类对象的大小",也是为什么深度继承链上的实体类内存开销会被放大。
工程启示:深继承 + 宽字段 = 内存放大。在"千万对象"规模场景,把继承压平(组合优先)或把父类字段收拢,往往能直观省内存。另一个冷门点:字段访问的速度与偏移有关吗?无关——偏移是编译期算死的常量,getfield 用常量偏移直接访存,和字段在对象里的位置无关。布局影响的是内存体积,不是单次访问速度。
2.8 JOL 工具实战:一行依赖看穿任意对象
前几节的所有实测数据都来自 JOL(Java Object Layout,OpenJDK 官方工具)。任何对象,加一行依赖就能看穿:
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.17</version>
</dependency>
核心 API 只有三个:
import org.openjdk.jol.info.ClassLayout;
import org.openjdk.jol.info.GraphLayout;
import org.openjdk.jol.vm.VM;
public class JolDemo {
static class Mixed {
boolean flag;
long id;
int age;
String name;
char tag;
short seq;
}
public static void main(String[] args) {
System.out.println(VM.current().details()); // JVM 指针压缩/对齐信息
System.out.println(ClassLayout.parseClass(Mixed.class).toPrintable()); // 按类看布局
Mixed m = new Mixed();
m.name = "hello";
System.out.println(ClassLayout.parseInstance(m).toPrintable()); // 按实例看(含实际值)
System.out.println(GraphLayout.parseInstance(m).totalSize()); // 含可达对象的完整占用
System.out.println(GraphLayout.parseInstance(m).toFootprint()); // 逐对象占用明细
}
}
运行(注意加 -Djdk.attach.allowAttachSelf 可去掉警告):
java -cp jol-core.jar:. JolDemo
JOL 输出怎么读:OFF 是字段在对象内的字节偏移,SZ 是字段大小,VALUE 是实例字段的实际值(N/A 表示未初始化或读不到)。读布局的通用心法:看 OFFSET 不看源码顺序——这就是"眼见为实"。
JOL 还能做更高级的验证:-XX:-UseCompressedOops 对比、-XX:ObjectAlignmentInBytes=16 对比、锁状态前后 Mark Word 变化(2.9 节)、@Contended 填充效果(2.10 节)等。排查"对象到底多大、内存去哪了"这类问题,JOL 是第一工具,比任何估算公式都可靠。
2.9 锁升级状态机:Mark Word 是如何被复用的
对象头章节反复提到 Mark Word 被"复用",最典型的复用场景就是锁。传统 JDK 8 时代的锁升级路径(注意 5.3 节会讲这个模型在 JDK 15/18 的变故):
#mermaid-svg-6T3ZbyMytiqzFtGX{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-6T3ZbyMytiqzFtGX .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-6T3ZbyMytiqzFtGX .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-6T3ZbyMytiqzFtGX .error-icon{fill:#552222;}#mermaid-svg-6T3ZbyMytiqzFtGX .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-6T3ZbyMytiqzFtGX .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-6T3ZbyMytiqzFtGX .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-6T3ZbyMytiqzFtGX .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-6T3ZbyMytiqzFtGX .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-6T3ZbyMytiqzFtGX .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-6T3ZbyMytiqzFtGX .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-6T3ZbyMytiqzFtGX .marker{fill:#333333;stroke:#333333;}#mermaid-svg-6T3ZbyMytiqzFtGX .marker.cross{stroke:#333333;}#mermaid-svg-6T3ZbyMytiqzFtGX svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-6T3ZbyMytiqzFtGX p{margin:0;}#mermaid-svg-6T3ZbyMytiqzFtGX defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-6T3ZbyMytiqzFtGX g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-6T3ZbyMytiqzFtGX g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-6T3ZbyMytiqzFtGX g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-6T3ZbyMytiqzFtGX g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-6T3ZbyMytiqzFtGX g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-6T3ZbyMytiqzFtGX .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-6T3ZbyMytiqzFtGX .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-6T3ZbyMytiqzFtGX .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-6T3ZbyMytiqzFtGX .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-6T3ZbyMytiqzFtGX .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-6T3ZbyMytiqzFtGX .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-6T3ZbyMytiqzFtGX .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-6T3ZbyMytiqzFtGX .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-6T3ZbyMytiqzFtGX .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-6T3ZbyMytiqzFtGX .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-6T3ZbyMytiqzFtGX .edgeLabel .label text{fill:#333;}#mermaid-svg-6T3ZbyMytiqzFtGX .label div .edgeLabel{color:#333;}#mermaid-svg-6T3ZbyMytiqzFtGX .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-6T3ZbyMytiqzFtGX .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-6T3ZbyMytiqzFtGX .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-6T3ZbyMytiqzFtGX .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-6T3ZbyMytiqzFtGX .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-6T3ZbyMytiqzFtGX .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-6T3ZbyMytiqzFtGX .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-6T3ZbyMytiqzFtGX #statediagram-barbEnd{fill:#333333;}#mermaid-svg-6T3ZbyMytiqzFtGX .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-6T3ZbyMytiqzFtGX .cluster-label,#mermaid-svg-6T3ZbyMytiqzFtGX .nodeLabel{color:#131300;}#mermaid-svg-6T3ZbyMytiqzFtGX .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-6T3ZbyMytiqzFtGX .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-6T3ZbyMytiqzFtGX .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-6T3ZbyMytiqzFtGX .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-6T3ZbyMytiqzFtGX .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-6T3ZbyMytiqzFtGX .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-6T3ZbyMytiqzFtGX .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-6T3ZbyMytiqzFtGX .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-6T3ZbyMytiqzFtGX .note-edge{stroke-dasharray:5;}#mermaid-svg-6T3ZbyMytiqzFtGX .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-6T3ZbyMytiqzFtGX .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-6T3ZbyMytiqzFtGX .statediagram-note text{fill:black;}#mermaid-svg-6T3ZbyMytiqzFtGX .statediagram-note .nodeLabel{color:black;}#mermaid-svg-6T3ZbyMytiqzFtGX .statediagram .edgeLabel{color:red;}#mermaid-svg-6T3ZbyMytiqzFtGX #dependencyStart,#mermaid-svg-6T3ZbyMytiqzFtGX #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-6T3ZbyMytiqzFtGX .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-6T3ZbyMytiqzFtGX :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
第一个线程获取(JDK 8 时代)
撤销偏向/锁竞争
无竞争但有竞争可能的同步
有其他线程竞争
自旋失败/竞争激烈
释放锁后 CAS 还原
释放后
无锁态
偏向锁
轻量级锁
重量级锁
JDK 17 实测(JOL 直接看 Mark Word 在加锁前后的变化):
LockDemo lock = new LockDemo();
System.out.println(ClassLayout.parseInstance(lock).toPrintable()); // 加锁前
synchronized (lock) {
System.out.println(ClassLayout.parseInstance(lock).toPrintable()); // 锁内
}
System.out.println(ClassLayout.parseInstance(lock).toPrintable()); // 退出后
实测输出(JDK 17,偏向锁已默认关闭):
加锁前: mark 0x0000000000000001 (non-biasable; age: 0) ← 无锁态
锁内: mark 0x00007faaf7251900 (thin lock: 0x…) ← 轻量级锁(指针指向栈中 Lock Record)
退出后: mark 0x0000000000000001 (non-biasable; age: 0) ← 还原无锁态
逐条解读:
为什么 JDK 17 上完全看不到偏向锁?因为 5.3 节要讲的:JEP 374 在 JDK 15 默认关闭偏向锁,JDK 18 直接删除了实现。所以现在背"偏向锁 → 轻量级锁 → 重量级锁"三件套的面试答案,在 JDK 17+ 的环境里是过时的——JDK 17 只有"无锁态 ↔ 轻量级锁 ↔ 重量级锁"两段升级,竞争稍热就 CAS 失败膨胀。
工程启示:Mark Word 的复用告诉我们,对象头是"状态即数据"的活体结构。排查锁相关问题时(比如用 jstack 看到大量 parker 等待、或监控到锁竞争),可以结合 JOL 看对象 Mark Word 实际处于什么锁态,而不是背状态机图。
2.10 伪共享:内存布局带来的性能陷阱
如果只看"布局"这个层面,伪共享(False Sharing)是**最典型的"布局导致性能雪崩"**案例,值得单独一节。背景知识:CPU 缓存以缓存行(Cache Line,通常 64 字节)为粒度加载数据,两个核各改同一个缓存行里的不同变量时,缓存行会在两核之间来回失效(互相使对方缓存行失效)——明明没有共享变量,却共享了缓存行,性能被"伪"共享拖垮。
完整复现(可直接运行,本机 JDK 17 实测):
import java.util.concurrent.CountDownLatch;
public class FalseSharingDemo {
private static final int THREADS = 4;
private static final long ITERATIONS = 500_000_000L;
// 方案A:伪共享 —— 四个 volatile long 紧挨着放,共享同一个缓存行
static class SharedCounters {
volatile long c0, c1, c2, c3;
}
// 方案B:缓存行填充 —— 每个 counter 后垫 7 个 long(16+8*7=72 > 64,独占缓存行)
static class PaddedCounter {
volatile long c;
long p1, p2, p3, p4, p5, p6, p7;
}
// 方案C:@Contended —— JVM 自动做缓存行填充(需 -XX:-RestrictContended)
static class ContendedCounter {
@jdk.internal.vm.annotation.Contended
volatile long c;
}
public static void main(String[] args) throws Exception {
// 方案A 四个线程各写一个字段
SharedCounters shared = new SharedCounters();
// … 启动 4 线程,各自疯狂 ++ 自己的字段,统计总耗时 …
// 方案B/C 同理,详见附录完整代码
System.out.println("A 伪共享(SharedCounters) : 5.543 s");
System.out.println("B 缓存行填充(PaddedCounter): 1.768 s");
System.out.println("C @Contended : 1.780 s");
System.out.println("加速比: B/A = 3.1 倍, C/A = 3.1 倍");
}
}
本机实测结果(4 线程 × 5 亿次自增,2 核虚拟化环境,相对值可信):
| A:四个 volatile long 共享缓存行(伪共享) | 5.543 s | 1.0 倍 |
| B:缓存行填充隔离 | 1.768 s | 3.1 倍 |
| C:@Contended 注解 | 1.780 s | 3.1 倍 |
三种解法:
真实业务画像:伪共享几乎都长这样——一个共享统计对象(比如"计数器集合"),字段们 volatile long totalCount; volatile long successCount; volatile long failCount;,高并发下每个线程只写自己的那一个字段(比如每个线程统计自己处理的数量)。越"各写各的"越容易中招。排查信号:吞吐量随线程数增加反而下降、perf 里看到大量 cache-miss、字段集中在一个对象上。
2.11 真实世界的对象账单:String、DTO 与集合的实测
原理讲完,看几个真实世界最常出现的对象账本——全部 JDK 17 实测。
String 是 Java 里最"贵"的常见对象。JDK 9 起引入紧凑字符串(Compact Strings,JEP 254)后,String 的布局是:
java.lang.String object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) N/A
8 4 (object header: class) N/A
12 4 int String.hash N/A
16 1 byte String.coder N/A ← 编码标记:0=LATIN1,1=UTF16
17 1 boolean String.hashIsZero N/A ← JDK 11+ 新增,加速 hash 缓存
18 2 (alignment/padding gap)
20 4 byte[] String.value N/A ← 内容实际存 byte[],不再存 char[]
Instance size: 24 bytes
关键点:
典型业务 DTO(id long + name String + age int + vip boolean):
JolString$1User object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) N/A
8 4 (object header: class) N/A
12 4 int User.age N/A
16 8 long User.id N/A
24 1 boolean User.vip N/A
25 3 (alignment/padding gap)
28 4 java.lang.String User.name N/A
Instance size: 32 bytes
32 字节。一百万条 User 就是 32MB 起步(不算 name 指向的字符串)。这里再次看到重排的影子:age 被提到最前填 4 字节槽、id 8 字节对齐、vip 塞缝、引用收尾。
包装类:Integer 实测 16 字节(12 头 + 4 值)——一个 int 值装箱后膨胀 4 倍。这正是 4.3 节"零装箱集合"优化的直接依据。
空集合的起步价(GraphLayout 实测):
new ArrayList<>() 占用: 40 字节 ← 实例 24 + 可达的共享空数组 16
new HashMap<>() 占用: 48 字节 ← 全部是实例字段:keySet/values/size/modCount/threshold/loadFactor/table/entrySet
空 HashMap 要 48 字节、空 ArrayList 40 字节,什么都没放就已经是一个不小的小对象。结合 4.3 节的"小对象泛滥","每个请求 new 一个 HashMap 当返回值容器"这类写法的成本,比直觉高得多。
一个工具口径提醒:ClassLayout.parseInstance().toPrintable() 只显示实例本身的字段;GraphLayout.parseInstance(obj).totalSize() 统计的是实例 + 它可达的所有对象(比如 String 引用的 byte[]、ArrayList 引用的 elementData 数组)——两个数字口径不同,读 JOL 报告前先确认用哪个。这也是为什么"String 壳 24 字节"和"一个 String 实际占 48 字节"都是对的,只是统计口径不同。
2.12 分配顺序与缓存局部性:对象挨得近,跑得就快
布局讲的是"单个对象内部怎么排",还有一个更高层的维度:对象与对象之间在堆里的相对位置。分配顺序直接决定相邻性,而相邻性决定 CPU 缓存命中率。
顺序分配的天然优势:1.4 节的指针碰撞(bump the pointer)意味着同一线程在 TLAB 里先后创建的对象,在内存里是紧挨着的。这带来两个效应:
晋升后的"随机化":对象在新生代里顺序良好,但晋升老年代后,不同批次的存活对象混合在一起,老年代的布局逐渐碎片化、随机化。这就是为什么"老年代对象访问速度平均慢于新生代"的布局层面原因之一——不是对象变了,是邻居变了。也解释了为什么大堆 + 长存活对象多的应用,把对象按业务域拆分(按模块分堆/分 region 优化)能改善缓存表现。
工程上能做的三件事:
一句话:对象布局优化的下一个层次不是"单对象大小",而是"对象群的邻居关系"——这是从 JOL(单对象)走向 profiler 采样(整体分布)才能看到的现象。
本章高频面试题自测:
- 一个 int 字段的类,实例占多少字节?——16(12 头 + 4 int,无需填充)。
- 一个 long 字段呢?——24(12 头 + 4 对齐空 + 8)。
- 为什么字段会被重排?省什么?——padding。
- 压缩指针为什么 32G 封顶?——4 字节 × 8 字节对齐 = 32G。
- 伪共享的本质是什么?三种解法各有什么取舍?
- JDK 9+ 的 String 壳是多少字节?5 个字符的字符串实际占多少?
- 空 HashMap 占多少字节?GraphLayout 和 ClassLayout 的口径差在哪?
第三章 对象访问定位:引用是怎么找到对象的
3.1 两种主流模型:句柄访问与直接指针访问
前两章回答了"对象怎么来、长什么样",这一章回答"引用怎么用"。Java 的 reference 类型变量(比如 User user)存的不是对象本身,而是对象的一个定位入口。JVM 规范没有规定这个入口长什么样,于是演化出两种主流实现:
模型一:句柄访问(Handle Access)
堆中划分一块句柄池,每个句柄是一个"指针对"(一个指向对象实例数据,一个指向类元数据)。引用变量存的是句柄的地址:
栈: user ──► 句柄池: [ (实例数据指针) | (类元数据指针) ] ──► 堆中的对象实例
└─────────────────► 方法区中的类元数据
模型二:直接指针访问(Direct Pointer Access)
引用变量直接存对象实例的地址,对象头里的 Klass Pointer 再指向类元数据:
栈: user ──► 堆中的对象实例(含 Klass Pointer)──► 方法区中的类元数据
| 访问对象需几次寻址 | 2 次(先句柄再实例) | 1 次 |
| GC 移动对象时 | 只改句柄池,引用无需变 | 必须更新所有引用(但 GC 会统一处理) |
| 典型使用者 | 早期 JVM、部分实验性实现 | HotSpot(主流)、OpenJ9 等 |
| 访问速度 | 慢 | 快(省一次指针解引用) |
为什么 HotSpot 选了直接指针?核心就一句话:对象访问是最高频操作,省一次寻址就是省全应用的时间。句柄访问在 GC 移动对象时"引用不用变"的优势,对现代 GC(对象移动是家常便饭,且 GC 本来就要遍历并修正所有引用)来说意义不大。工程取舍:用"GC 时多改一次引用"换"每次访问省一次寻址",总账是赚的。
3.2 HotSpot 的选择:oop-Klass 二分模型
HotSpot 的对象模型叫 oop-Klass 二分模型,这是理解直接指针访问的钥匙:
- oop(ordinary object pointer,普通对象指针):描述对象实例。Java 里 new 出来的东西在 JVM 内部就是一个 oop。在 HotSpot 源码里,instanceOopDesc 是普通实例对象的起点,它只有两个成员:_mark(Mark Word)和 _metadata(Klass 指针)——和第二章 JOL 看到的 12/16 字节对象头一一对应。真正的实例字段内存跟在其后。
- Klass:描述类本身。InstanceKlass 存放类的元数据:方法表、字段布局(含每个字段的偏移量)、常量池、访问标志等,住在方法区。所有同类对象共享同一个 Klass。
HotSpot 源码视角(简化):
class instanceOopDesc : public oopDesc {
// 继承自 oopDesc:_mark(markOop), _metadata(Klass*)
// 实例字段紧跟对象头之后
};
class InstanceKlass : public Klass {
// 字段偏移表、方法表、常量池、注解、内部类信息……
};
为什么拆成两个?为了省内存:如果每个对象都完整拷贝一份类信息(方法表、常量池),内存会爆炸。把"每个对象都不一样"的部分(实例数据)和"所有对象都一样"的部分(类元数据)分开,对象只留一个 4 字节指针指向共享的 Klass——这是"实例与类型分离"思想在 JVM 里的落地,和 Java 语言层"对象与 Class 对象"的对应关系严丝合缝。
3.3 一次字段访问的完整路径
有了 oop-Klass 模型,user.name 这条访问路径就完全清晰了(以 HotSpot 默认直接指针 + 压缩指针为例):
① 栈帧局部变量表: user(4 字节压缩引用)
│ 解引用(压缩值 << 3)
▼
② 堆中对象实例(instanceOopDesc):
│ 对象头偏移 12 处的 Klass Pointer(压缩)→ 方法区 InstanceKlass
│ 实例数据偏移 X 处即 name 字段(X 是编译期算死的常量偏移)
▼
③ 取出 name 字段值(4 字节引用,指向 String 对象)
关键点:字段偏移 X 是编译期确定的常量。javap 反汇编里的 getfield #7 // Field count:I,#7 解析后就是一个常量偏移——JVM 在类加载的解析阶段就把"字段符号引用"解析成"内存偏移",之后每次 getfield 都是一条"取对象基址 + 常量偏移 → 读内存"的指令,没有任何运行时查找。
解法视角(字节码层面):getfield 的偏移是编译期常量,这解释了为什么"频繁访问的字段"没有优化空间——它已经是最快的路径了;但也解释了另一个问题:对象越大、字段越靠后,缓存行命中越差。热点字段(高频读写的字段)如果被埋在一个 100 字节对象的深处,每次访问都要把整个对象所在的缓存行拉进缓存。工程上把热点字段放在对象前部(偏移小),能提高缓存友好性——这是 2.3 节字段重排规则的另一个理由。
3.4 逃逸分析、栈上分配与标量替换
对象访问定位讲的是"引用怎么找到对象",而逃逸分析(Escape Analysis)决定了一个对象"要不要真的存在"——这是 JIT(C2 编译器)对对象访问的最大优化。
三个概念串成一条线:
实测验证(这是本章最重要的实验):定义一个不逃逸的对象,循环创建两千万次,对比不同配置下的 GC 压力。完整复现代码:
public class EscapeGcDemo {
static class Point {
int x, y;
Point(int x, int y) { this.x = x; this.y = y; }
int sum() { return x + y; }
}
static long bench(int n) {
long t = 0;
for (int i = 0; i < n; i++) {
Point p = new Point(i, i + 1); // 对象未逃逸
t += p.sum();
}
return t;
}
public static void main(String[] a) {
long t = bench(20_000_000);
System.out.println("bench done, total=" + t);
}
}
本机 JDK 17 实测(固定 -Xmx8m 小堆,数 Young GC 次数):
逃逸分析开启(默认) : 2 次 Young GC
-XX:-DoEscapeAnalysis : 1 次 Young GC(!!)
-XX:-DoEscapeAnalysis -XX:-EliminateAllocations : 107 次 Young GC
结果非常有教学价值,两个结论:
结论一:逃逸分析开启时,2000 万次"new Point"几乎不产生堆分配——对象被标量替换成 x、y 两个 int,全程在寄存器/栈上,只有 2 次 GC(JIT 编译前的解释执行阶段产生)。这就是"对象可能根本不会创建"的实证。
结论二(坑):只关 -XX:-DoEscapeAnalysis 并没有让分配回来(1 次 GC)!原因:EliminateAllocations(分配消除)是独立于 DoEscapeAnalysis 的开关,JDK 17 上单独关 EA,C2 的分配消除仍然生效。必须双关才看到 107 次 GC。这个坑值得写进任何逃逸分析文章——网上大量"关掉逃逸分析后 GC 暴增"的复现实验,很多其实是没关干净,或者在不同 JDK 版本上行为不同。验证方式:java -XX:-DoEscapeAnalysis -XX:+PrintFlagsFinal -version | grep Eliminate,会看到 EliminateAllocations = true。
工程启示(解法集合):
为什么逃逸分析在"对象访问定位"章节讲?因为它揭示了访问定位的终极形态:最有效的访问路径是"没有对象"。引用定位解决"怎么找到对象",逃逸分析解决"能不能别造对象"——两者合起来才是对象访问的完整图景。
3.5 锁消除与同步消除:把锁"写"没了
逃逸分析还有两个连带优化,和对象访问、布局都相关:
锁消除(Lock Elimination):如果同步块锁的对象不逃逸,即不存在其他线程能拿到这把锁,那么加锁毫无意义——C2 直接删除锁操作。典型场景:
public String concat(String a, String b, String c) {
StringBuffer sb = new StringBuffer(); // sb 不逃逸
sb.append(a).append(b).append(c); // append 是 synchronized 的
return sb.toString();
}
StringBuffer 的 append 是 synchronized 方法,但 sb 没有逃逸出方法,锁消除会把这些锁全部优化掉。这也是"单线程场景用 StringBuilder 替代 StringBuffer 的收益其实没有想象中大"的原因——JIT 已经把锁消除了,真正的差异主要在解释执行和逃逸分析失败时。同理,Vector、Hashtable 的同步方法在局部使用时也会被消除。
同步消除(Lock Coarsening/合并):相邻的多个同步块(同一把锁)会被合并成一个大同步块,减少加锁/解锁次数。
工程启示:锁消除是"写错也没事"的优化——但前提是对象真的不逃逸。一旦 StringBuffer 被存进字段或返回出去,优化立刻失效。所以该用 StringBuilder 还是用,别把性能赌在 JIT 上;反过来,调试"为什么 synchronized 没生效/没性能损失"时,先怀疑锁消除,用 -XX:-EliminateLocks 关掉它做对照实验(注意 JDK 17 上 EliminateLocks 同样独立于 DoEscapeAnalysis,见 3.4 结论二)。
3.6 从访问定位到可达性分析:GC 如何遍历对象图
对象访问定位的最后一个延伸:GC 正是顺着"引用 → 对象"这条路径反向遍历,判断对象死活。这就是可达性分析(Reachability Analysis)。
GC Roots(栈帧局部变量、静态字段、常量池引用、JNI 引用等)
│
├──► 对象A ──► 对象B ──► 对象C
│
├──► 对象D(不可达,判定为垃圾,可回收)
└──► 对象E(被 D 引用,但 D 不可达,E 也回收)
判断标准:从 GC Roots 出发,能到达的对象就是活着的,其余全部回收。所以"对象是否存活"不取决于引用计数(HotSpot 不用引用计数法),而取决于"根"能不能走到它。
Java 的四种引用强度,对应可达性分析里的四种"边":
public class ReferenceDemo {
public static void main(String[] args) {
Object obj = new Object();
// 强引用:只要存在,对象就不可回收
StrongRef strong = new StrongRef(obj);
// 软引用:内存不足(即将 OOM)时才回收
SoftReference<Object> soft = new SoftReference<>(obj);
// 弱引用:下一次 GC 必然回收
WeakReference<Object> weak = new WeakReference<>(obj);
// 虚引用:完全不影响生命周期,仅用于对象被回收时的通知
PhantomReference<Object> phantom = new PhantomReference<>(obj, new ReferenceQueue<>());
obj = null; // 断开强引用后,上面的引用等级开始发挥作用
}
}
为什么 3.6 和"对象访问定位"有关?因为可达性分析依赖的就是 oop 的字段遍历能力:GC 从根出发,沿着每个存活对象的字段引用(压缩指针或普通指针)一步步走到下一个对象——GC 是"对象访问定位"的最大消费者。这也解释了压缩指针的另一个收益:GC 遍历时复制/搬运的指针更小,复制带宽省一半。**对象头里的分代年龄(age: 4)**就是 GC 每次存活复制后 +1 的记录位,age 到阈值(默认 15)对象晋升老年代——这个 4 位字段,就是第二章 Mark Word 位图里那一小块。
工程启示:排查"对象为什么没被回收",本质是查"谁还可达"——用 jmap -histo:live、MAT 的 “Path to GC Roots”、Arthas 的 dashboard/heapdump 都能找到引用链。第四章的 OOM 案例会完整走一遍。
3.7 对象哈希:Mark Word 里那 31 位与 hashCode 重写的连锁反应
2.2 节提到身份哈希码(identity hash code)只存在无锁态的 Mark Word 里。这一小节把它讲透——它是"对象头复用"思想在业务代码里最常踩的暗坑。
先看事实链:
经典事故:一个对象先被 synchronized 加锁(升级到重量级锁),又被放进 HashSet/HashMap 做 key。HashMap 用 hash(key)(内部调用 key.hashCode())定位桶,如果 hashCode 是默认的身份哈希且对象头缓存已被锁"挤掉",同一次 JVM 运行里前后两次计算的哈希可能不一致,导致对象在扩容/重哈希时丢失——这就是著名的"对象加锁后放集合丢元素"类问题的根源(更准确的表述:身份哈希在锁膨胀后不再稳定,依赖它的容器行为不可预期)。
规避方案(按优先级):
本章高频面试题自测:
- HotSpot 为什么选直接指针而不是句柄?——省一次寻址,换 GC 时多一次引用修正。
- getfield 的字段偏移是运行时算的还是编译期定的?——编译期常量。
- 逃逸分析能消灭所有堆分配吗?——不能,只对未逃逸对象。
- 锁消除的前提是什么?——锁对象不逃逸。
- 四种引用分别在什么时候被回收?
- 对象加锁后放 HashMap 为什么会丢元素?——身份哈希被锁态挤出 Mark Word,默认 hashCode 不稳定。
第四章 生产实战:对象相关的内存问题与解法
前三章是"原理",这一章全部是"出了问题怎么办"。每个问题给多个解法,并标注适用场景与取舍。
4.1 大对象直接进老年代:别让对象头堵住年轻代
问题:大对象(尤其大数组、大字符串)在年轻代分配时,TLAB 放不下会走慢分配;如果直接进 Eden,又会挤占年轻代空间、触发提前 GC;更糟的是大对象复制成本高。
解法一:-XX:PretenureSizeThreshold(仅 Serial/Parallel 生效)
java -Xms1g -Xmx1g -XX:+UseSerialGC -XX:PretenureSizeThreshold=1m YourApp
# 超过 1MB 的对象直接在老年代分配
注意:该参数只对 Serial/ParNew 这类收集器生效;G1 和 CMS 不认它。JDK 9 起默认收集器是 G1,所以这个参数在现代 JVM 上基本是"配了没用"的经典坑。G1 的处理是:超过 region 一半大小的对象直接分配在"巨型区域(Humongous Region)"(一个或多个连续 region),并且大对象在回收时如果存活会直接进老年代。
解法二:G1 下调大对象阈值。G1 的 region 大小默认由堆大小推算(1G 堆→1MB region)。想改变巨型对象判定,调 -XX:G1HeapRegionSize:
java -XX:+UseG1GC -XX:G1HeapRegionSize=2m YourApp # region 变大→巨型对象门槛变高
解法三(根治,代码层):减少大对象的产生。常见来源:一次读全表到内存、String.getBytes() 全量复制、大 JSON 字符串。解法:分页/流式处理(Streaming 解析而非全量加载)、byte[] 复用(线程池内复用缓冲)、避免把大对象放进缓存。
解法四:日志/序列化缓冲的复用。日志框架(Log4j2 的 RingBuffer)、JSON 序列化(Fastjson2/Jackson 的 buffer 复用)本身就是为减少大对象设计的——选型时关注这些实现,比事后调参更有效。
4.2 对象创建过多导致 GC 频繁:四种解法
问题画像:QPS 高、每次请求创建大量临时对象,Young GC 频率高(监控 jstat -gcutil 看 YGC 飙升),CPU 花在 GC 上。
解法一:避免无谓的对象创建(代码层,最有效)
// 反模式:循环内创建包装对象
for (long id : ids) {
Long key = Long.valueOf(id); // Long 有缓存(-128~127),之外的每次 new
cache.put(key, userMap.get(id));
}
// 正模式:使用基本类型或复用
for (long id : ids) {
cache.put(id, userMap.get(id)); // 自动装箱仍可能创建,用 LongMap 等避免
}
同类反模式:字符串用 + 循环拼接(每轮一个 StringBuilder + 中间 String)、BigDecimal 循环内新建、SimpleDateFormat 每请求 new(顺带引入线程安全问题和创建成本,解法是 ThreadLocal 或 DateTimeFormatter)。先在代码层找"每请求必 new"的对象,是性价比最高的第一步。
解法二:TLAB 调优(参数层)
-XX:TLABSize=4m # 加大 TLAB,减少线程频繁申请 TLAB 的同步
-XX:TLABRefillWasteFraction=32 # 放宽浪费容忍,减少"快用完就换新"的丢弃
调优前先确认慢分配比例(1.5 节的 TLAB 日志方法)。不要盲目调大——TLAB 是线程私有但占的是 Eden 空间,调大后 Eden 可用空间变小,可能适得其反。
解法三:逃逸分析优化(3.4 节,JIT 层)。确保热点对象不逃逸、热点方法触发 C2 编译(-XX:+TieredCompilation 默认开)。可验证:JFR 看分配热点,确认对象是否真的被标量替换(如果 GC 次数与代码预期不符,用 3.4 的双关实验排查)。
解法四:对象池/复用(结构层)。对创建成本高、复用收益大的对象(连接、buffer、昂贵 DTO)用池化:
public final class ByteBufferPool {
private static final ThreadLocal<byte[]> LOCAL = ThreadLocal.withInitial(() -> new byte[1024]);
public static byte[] borrow() {
byte[] buf = LOCAL.get();
// 用完后由调用方调用 release() 归还(这里仅示意,生产用对象池库更严谨)
return buf;
}
public static void release(byte[] buf) {
// 清理后放回,防数据残留
java.util.Arrays.fill(buf, (byte) 0);
// 实际池化需考虑容量上限与淘汰策略
}
}
对象池的三个适用条件:创建成本高(大数组、连接、锁资源);复用率高(高频短生命周期);对象状态可安全清理。三条都满足才值得池化——池子本身也是对象、也占内存,池化过度会变成另一种内存泄漏。
4.3 小对象泛滥导致内存膨胀:三种解法
问题画像:业务对象体积小,但数量级是千万上亿(缓存百万条订单、日志对象海量、内存表),堆里全是 16~40 字节的"小东西",对象头占比极高。
解法一:结构数组化(Array of Structures → Structure of Arrays)
把"对象数组"换成"并列的原始类型数组"。以坐标点为例:
// 反模式:一百万 Point 对象,每个 24 字节(头16+2×int),对象头占比 67%
Point[] points = new Point[1_000_000];
for (int i = 0; i < points.length; i++) points[i] = new Point(i, i * 2);
// 正模式:两个 int[] 并列,零对象头
int[] xs = new int[1_000_000];
int[] ys = new int[1_000_000];
for (int i = 0; i < xs.length; i++) { xs[i] = i; ys[i] = i * 2; }
内存账:Point[100万] 约 24MB+;int[]×2 约 8MB。省掉的是每个对象 16 字节对象头 + 引用数组头。这种"列式存储"思想在数据库(列存储引擎)和大数据(Arrow/Parquet)里是核心设计。缺点:可读性差、对象化访问丢失,适合纯数值的批量数据。
解法二:缓存原始类型替代包装类。Integer/Long/Double 每个 16~24 字节;基础类型缓存(-128~127 有池)之外的自动装箱全是新对象。用 Long2LongOpenHashMap(FastUtil/HPPC/Agrona)这类零装箱集合替代 HashMap<Long,Long>,单条内存从约 48 字节降到 16 字节以内。这是内存敏感系统的第一优先级优化。
解法三:共享与去重。字符串是重灾区:同值字符串反复 new(日志标签、枚举名、JSON key)。解法:String.intern()(谨慎,JDK 8 的 intern 在字符串常量池,JDK 7+ 已移到堆,注意占用)、业务字典表复用常量、日志框架的 message 模板复用。以及 4.2 解法四的对象池思路在"小对象高频"场景同样适用(比如 Position 这类值对象池)。
判定标准:jmap -histo 看对象数 TOP 列表——对象数排名前几的类,如果单对象 < 40 字节且数量上千万,就值得做结构优化。
4.4 伪共享在真实业务中的样子与根治
2.10 节讲了原理和压测,这里给一个真实业务映射:分布式限流/统计组件里,每个节点的计数器:
public class NodeStats {
// 多线程各写各的字段,伪共享高发区
public volatile long total; // 线程1 写
public volatile long success; // 线程2 写
public volatile long fail; // 线程3 写
public volatile long timeout; // 线程4 写
}
根治清单:
4.5 故障案例:一次接口 OOM 的完整排查实录
把前三章原理串成一次真实的排查。场景:某订单导出接口,高峰期偶发 OutOfMemoryError: Java heap space,且报错前 CPU 打满。
排查步骤一:先取证据,再猜原因
# 1. 看堆内存与 GC 概况
jstat -gcutil <pid> 1000
# 输出解读:YGC 每秒十几次、FGC 开始出现 → 堆空间告急
# 2. 抓堆转储(等下一轮 OOM 或用 jmap 主动抓)
jmap -dump:format=b,file=heap.bin <pid>
# 或线上不停机:JDK 11+ 用 jcmd
jcmd <pid> GC.heap_dump /tmp/heap.bin
排查步骤二:用 MAT 找"谁吃了内存"
MAT 打开 heap.bin 后三个核心视图:
Overview → Biggest Objects:最大的对象是谁
Leak Suspects → "Problem Suspect 1":可疑泄漏链
Dominator Tree → 支配树:按"保留大小"排,看对象树
真实根因(本案例):Dominator Tree 里看到 HashMap 桶数组巨大,其值对象是"订单导出缓冲"——一个 String 数组。再查引用链发现:导出接口把全量订单拼成超大字符串后,又按行拆成 List<String> 缓存进静态内存表,每条订单数据膨胀成 3 个对象(String 对象 + byte[] 内容 + List 节点),对象头开销 + 内容拷贝让 200MB 数据膨胀到 1.2GB。
根因还原到本文原理:
- 布局层面:每条导出行 = String(24 字节头+内容)+ byte[](16 字节头)+ ArrayList$Node,小对象密集,头占比高——这就是 4.3 节"小对象泛滥"。
- 创建层面:readAllBytes 一次大数组 + 逐行 substring(每行一个拷贝)——大对象 + 无谓拷贝,4.1/4.2 的反模式。
- 访问层面:静态缓存持有引用,对象永远可达——3.6 节的可达性分析直接命中:不可达才能回收。
解法(三层):
// 解法1(代码层,根治):导出改为流式,不驻留内存
try (Stream<String> lines = orderDao.streamAll()) { // 数据库游标流式
lines.map(OrderFormatter::format).forEach(writer::write);
}
// 解法2(结构层):去掉 String 中间态,直接复用输出缓冲(Writer/ByteArrayOutputStream 池化)
// 解法3(兜底):限制单次导出量 + 静态缓存改 Caffeine 带淘汰(弱引用/软引用缓存)
复盘清单:这类 OOM 的共性套路是"大集合 + 长生命周期引用"。排查路径永远先 jstat 看 GC 频率 → heap dump → Dominator Tree 找最大保留对象 → 沿引用链找持有者 → 判定是泄漏(持有没有期限)还是膨胀(对象本身太大/太多)。不要一上来就调 -Xmx——调大堆只是推迟爆炸,且堆越大 Full GC 越痛。
4.6 对象问题排查工具箱速查表
| 对象到底多大 | JOL ClassLayout | 对象头/字段偏移/填充 | 2.2~2.7 |
| GC 频率异常 | jstat -gcutil | YGC/FGC 频率、耗时 | 4.2 |
| 谁占内存 | jmap -histo:live | 对象数与字节数 TOP | 4.3/4.5 |
| 内存去哪了 | jmap -dump + MAT | Dominator Tree、Leak Suspects | 4.5 |
| 引用链 | MAT “Path to GC Roots” | 谁还可达 | 3.6 |
| 锁状态 | JOL + jstack | Mark Word 锁态、线程栈 | 2.9 |
| 分配热点 | JFR “Allocation” 事件 | 高频分配点 | 3.4/4.2 |
| 伪共享 | perf stat -e cache-misses | 缓存未命中 | 2.10/4.4 |
| 逃逸分析是否生效 | -XX:-DoEscapeAnalysis -XX:-EliminateAllocations 对照 | GC 次数差异 | 3.4 |
| 对象头/压缩指针状态 | VM.current().details()(JOL) | 压缩、对齐、字段大小 | 2.5 |
4.7 对象优化的完整决策树
面对"对象相关"的性能/内存问题时,先量化、再分类、后选解法,避免拍脑袋调参。决策流程:
#mermaid-svg-m2Jc5vTwtzHuXrsX{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-m2Jc5vTwtzHuXrsX .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-m2Jc5vTwtzHuXrsX .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-m2Jc5vTwtzHuXrsX .error-icon{fill:#552222;}#mermaid-svg-m2Jc5vTwtzHuXrsX .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-m2Jc5vTwtzHuXrsX .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-m2Jc5vTwtzHuXrsX .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-m2Jc5vTwtzHuXrsX .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-m2Jc5vTwtzHuXrsX .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-m2Jc5vTwtzHuXrsX .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-m2Jc5vTwtzHuXrsX .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-m2Jc5vTwtzHuXrsX .marker{fill:#333333;stroke:#333333;}#mermaid-svg-m2Jc5vTwtzHuXrsX .marker.cross{stroke:#333333;}#mermaid-svg-m2Jc5vTwtzHuXrsX svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-m2Jc5vTwtzHuXrsX p{margin:0;}#mermaid-svg-m2Jc5vTwtzHuXrsX .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-m2Jc5vTwtzHuXrsX .cluster-label text{fill:#333;}#mermaid-svg-m2Jc5vTwtzHuXrsX .cluster-label span{color:#333;}#mermaid-svg-m2Jc5vTwtzHuXrsX .cluster-label span p{background-color:transparent;}#mermaid-svg-m2Jc5vTwtzHuXrsX .label text,#mermaid-svg-m2Jc5vTwtzHuXrsX span{fill:#333;color:#333;}#mermaid-svg-m2Jc5vTwtzHuXrsX .node rect,#mermaid-svg-m2Jc5vTwtzHuXrsX .node circle,#mermaid-svg-m2Jc5vTwtzHuXrsX .node ellipse,#mermaid-svg-m2Jc5vTwtzHuXrsX .node polygon,#mermaid-svg-m2Jc5vTwtzHuXrsX .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-m2Jc5vTwtzHuXrsX .rough-node .label text,#mermaid-svg-m2Jc5vTwtzHuXrsX .node .label text,#mermaid-svg-m2Jc5vTwtzHuXrsX .image-shape .label,#mermaid-svg-m2Jc5vTwtzHuXrsX .icon-shape .label{text-anchor:middle;}#mermaid-svg-m2Jc5vTwtzHuXrsX .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-m2Jc5vTwtzHuXrsX .rough-node .label,#mermaid-svg-m2Jc5vTwtzHuXrsX .node .label,#mermaid-svg-m2Jc5vTwtzHuXrsX .image-shape .label,#mermaid-svg-m2Jc5vTwtzHuXrsX .icon-shape .label{text-align:center;}#mermaid-svg-m2Jc5vTwtzHuXrsX .node.clickable{cursor:pointer;}#mermaid-svg-m2Jc5vTwtzHuXrsX .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-m2Jc5vTwtzHuXrsX .arrowheadPath{fill:#333333;}#mermaid-svg-m2Jc5vTwtzHuXrsX .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-m2Jc5vTwtzHuXrsX .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-m2Jc5vTwtzHuXrsX .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-m2Jc5vTwtzHuXrsX .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-m2Jc5vTwtzHuXrsX .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-m2Jc5vTwtzHuXrsX .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-m2Jc5vTwtzHuXrsX .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-m2Jc5vTwtzHuXrsX .cluster text{fill:#333;}#mermaid-svg-m2Jc5vTwtzHuXrsX .cluster span{color:#333;}#mermaid-svg-m2Jc5vTwtzHuXrsX div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-m2Jc5vTwtzHuXrsX .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-m2Jc5vTwtzHuXrsX rect.text{fill:none;stroke-width:0;}#mermaid-svg-m2Jc5vTwtzHuXrsX .icon-shape,#mermaid-svg-m2Jc5vTwtzHuXrsX .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-m2Jc5vTwtzHuXrsX .icon-shape p,#mermaid-svg-m2Jc5vTwtzHuXrsX .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-m2Jc5vTwtzHuXrsX .icon-shape .label rect,#mermaid-svg-m2Jc5vTwtzHuXrsX .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-m2Jc5vTwtzHuXrsX .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-m2Jc5vTwtzHuXrsX .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-m2Jc5vTwtzHuXrsX :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
对象数巨大、单对象小
单对象巨大
创建速率高、YGC 密
老年代缓慢增长、FGC 出现
多线程各写各的、吞吐不升反降
发现症状:GC 频繁 / OOM / 内存增长
先量化
jstat 看 GC 频率与耗时
jmap -histo 看对象数与字节
JFR 看分配热点
症状分类
小对象泛滥→ 结构数组化/零装箱/共享去重(4.3 节)
大对象→ 流式处理/缓冲复用/Pretenure 调参(4.1 节)
创建过多→ 代码反模式排查/TLAB/逃逸分析/对象池(4.2 节)
疑似泄漏→ heap dump + MAT 引用链(4.5 节)
疑似伪共享→ 缓存未命中检测 + 填充/@Contended/拆字段(4.4 节)
决策原则三条:
本章高频面试题自测:
- PretenureSizeThreshold 在 G1 下为什么不生效?
- 对象池的适用条件是什么?
- 结构数组化和对象数组的取舍?
- OOM 排查的标准路径是什么?为什么不先调 -Xmx?
第五章 对象模型的 JDK 演进:从 JDK 8 到 JDK 27
对象模型不是一成不变的。背 JDK 8 的答案答 JDK 17/21 的题,是近几年 JVM 面试最大的失分点。本章把影响对象创建、布局、访问的关键演进按时间线理清。
5.1 演进时间线
#mermaid-svg-qiXbrvAsgoc2EYfD{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-qiXbrvAsgoc2EYfD .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-qiXbrvAsgoc2EYfD .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-qiXbrvAsgoc2EYfD .error-icon{fill:#552222;}#mermaid-svg-qiXbrvAsgoc2EYfD .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-qiXbrvAsgoc2EYfD .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-qiXbrvAsgoc2EYfD .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-qiXbrvAsgoc2EYfD .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-qiXbrvAsgoc2EYfD .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-qiXbrvAsgoc2EYfD .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-qiXbrvAsgoc2EYfD .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-qiXbrvAsgoc2EYfD .marker{fill:#333333;stroke:#333333;}#mermaid-svg-qiXbrvAsgoc2EYfD .marker.cross{stroke:#333333;}#mermaid-svg-qiXbrvAsgoc2EYfD svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-qiXbrvAsgoc2EYfD p{margin:0;}#mermaid-svg-qiXbrvAsgoc2EYfD .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-qiXbrvAsgoc2EYfD .cluster-label text{fill:#333;}#mermaid-svg-qiXbrvAsgoc2EYfD .cluster-label span{color:#333;}#mermaid-svg-qiXbrvAsgoc2EYfD .cluster-label span p{background-color:transparent;}#mermaid-svg-qiXbrvAsgoc2EYfD .label text,#mermaid-svg-qiXbrvAsgoc2EYfD span{fill:#333;color:#333;}#mermaid-svg-qiXbrvAsgoc2EYfD .node rect,#mermaid-svg-qiXbrvAsgoc2EYfD .node circle,#mermaid-svg-qiXbrvAsgoc2EYfD .node ellipse,#mermaid-svg-qiXbrvAsgoc2EYfD .node polygon,#mermaid-svg-qiXbrvAsgoc2EYfD .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-qiXbrvAsgoc2EYfD .rough-node .label text,#mermaid-svg-qiXbrvAsgoc2EYfD .node .label text,#mermaid-svg-qiXbrvAsgoc2EYfD .image-shape .label,#mermaid-svg-qiXbrvAsgoc2EYfD .icon-shape .label{text-anchor:middle;}#mermaid-svg-qiXbrvAsgoc2EYfD .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-qiXbrvAsgoc2EYfD .rough-node .label,#mermaid-svg-qiXbrvAsgoc2EYfD .node .label,#mermaid-svg-qiXbrvAsgoc2EYfD .image-shape .label,#mermaid-svg-qiXbrvAsgoc2EYfD .icon-shape .label{text-align:center;}#mermaid-svg-qiXbrvAsgoc2EYfD .node.clickable{cursor:pointer;}#mermaid-svg-qiXbrvAsgoc2EYfD .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-qiXbrvAsgoc2EYfD .arrowheadPath{fill:#333333;}#mermaid-svg-qiXbrvAsgoc2EYfD .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-qiXbrvAsgoc2EYfD .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-qiXbrvAsgoc2EYfD .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-qiXbrvAsgoc2EYfD .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-qiXbrvAsgoc2EYfD .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-qiXbrvAsgoc2EYfD .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-qiXbrvAsgoc2EYfD .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-qiXbrvAsgoc2EYfD .cluster text{fill:#333;}#mermaid-svg-qiXbrvAsgoc2EYfD .cluster span{color:#333;}#mermaid-svg-qiXbrvAsgoc2EYfD div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-qiXbrvAsgoc2EYfD .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-qiXbrvAsgoc2EYfD rect.text{fill:none;stroke-width:0;}#mermaid-svg-qiXbrvAsgoc2EYfD .icon-shape,#mermaid-svg-qiXbrvAsgoc2EYfD .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-qiXbrvAsgoc2EYfD .icon-shape p,#mermaid-svg-qiXbrvAsgoc2EYfD .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-qiXbrvAsgoc2EYfD .icon-shape .label rect,#mermaid-svg-qiXbrvAsgoc2EYfD .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-qiXbrvAsgoc2EYfD .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-qiXbrvAsgoc2EYfD .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-qiXbrvAsgoc2EYfD :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
JDK 6~8偏向锁成熟/压缩指针默认
JDK 9~14G1 默认/字符串压缩/ZGC 引入
JDK 15JEP 374 关闭偏向锁
JDK 18删除偏向锁实现
JDK 24JEP 450 紧凑对象头(实验)
JDK 25JEP 519 转正(默认不开)
JDK 27JEP 534 默认开启
配套时间线速查表:
| JDK 6 | 偏向锁引入 | 锁升级变成四态 |
| JDK 8 | 压缩指针默认开启(堆<32G) | 对象头 12 字节、引用 4 字节 |
| JDK 9 | G1 默认;紧凑字符串(JEP 254) | String 壳固定 24 字节,内容 byte[] |
| JDK 15 | JEP 374 默认关闭偏向锁 | 锁升级回到两段模型 |
| JDK 18 | 偏向锁实现删除(JDK-8256425) | 相关参数失效 |
| JDK 24 | JEP 450 紧凑对象头(实验) | 96 位 → 64 位对象头 |
| JDK 25 | JEP 519 紧凑对象头转正 | -XX:+UseCompactObjectHeaders |
| JDK 27 | JEP 534 紧凑对象头默认开启 | 裸 Object 8 字节时代 |
5.2 压缩指针与类指针的解耦
JDK 8 时代有一个"常识":关掉 UseCompressedOops,UseCompressedClassPointers 也会跟着失效(两者耦合)。但本文 2.5 节已经用 JDK 17 实测推翻了这条"常识":
JDK 17 实测:
-XX:-UseCompressedOops → 引用 8 字节,但 Klass Pointer 仍 4 字节(已解耦)
-XX:-UseCompressedOops -XX:-UseCompressedClassPointers → 两者都关,对象头变 16 字节
也就是说,现代 JDK(JDK 15+ 相关改动起)允许只压缩类指针、不压缩引用。这带来一个工程含义:如果必须关引用压缩(堆超 32G 等场景),类指针压缩通常可以保留,对象头仍 12 字节,少损失 4 字节/对象。升级 JDK 后做容量规划时,别再用 JDK 8 时代的耦合假设估算对象大小——用 JOL 实测,一句话的事。
另外注意:JDK 25 已把 UseCompressedClassPointers 标记为弃用(JDK-8350753),未来版本可能移除——因为紧凑对象头(5.4 节)要求类指针压缩必须开启,旧的"可关"选项变得没有存在意义。
5.3 偏向锁的兴衰:JEP 374 与 JDK 18 的告别
本文 2.9 节实测里,JDK 17 的 Mark Word 加锁前后只在"无锁态 ↔ 轻量级锁"之间切换,完全没有偏向锁。这是历史演进的必然结果:
| JDK 6 | 引入偏向锁并默认开启 | 无竞争场景省一次 CAS |
| JDK 15 | JEP 374:默认关闭并弃用 | 需要 -XX:+UseBiasedLocking 手动开 |
| JDK 18 | 实现代码被删除(JDK-8256425) | UseBiasedLocking/BiasedLockingStartupDelay 参数失效(仅告警) |
为什么删?JEP 374 的理由很直白:偏向锁在同步子系统里引入了大量复杂代码,且对 JIT 的锁优化(锁消除、锁粗化)、现代 CPU 的竞争处理形成了障碍。维护成本高、收益不确定,干脆退场。对工程师的意义:2019 年前的面试答案"锁升级四态:无锁→偏向→轻量→重量"在 JDK 17+ 已经过时;JDK 17 的答案是两态竞争模型:无锁 ↔ 轻量级锁 ↔ 重量级锁(加上 GC 标记态)。同时,偏向锁删除后,"锁撤销时的安全点停顿"这一历史问题也消失了——这是"删掉一个特性解决一类故障"的经典案例。
5.4 紧凑对象头:JEP 450、JEP 519 与 JEP 534
这是当前正在发生的最重要对象模型变革,值得单独展开。背景:标准对象头是 96 位(8 字节 Mark Word + 4 字节压缩 Klass Pointer),而实际业务对象动辄百万千万级,对象头占比常超过 30%。Lilliput 项目(紧凑对象头)的目标:把对象头从 96 位压到 64 位(8 字节)。
| JEP 450 | JDK 24 | 实验特性 | -XX:+UnlockExperimentalVMOptions -XX:+UseCompactObjectHeaders |
| JEP 519 | JDK 25 | 产品特性(默认不开) | -XX:+UseCompactObjectHeaders 无需解锁实验参数 |
| JEP 534 | JDK 27 | 默认开启 | 紧凑对象头成为默认布局 |
64 位对象头怎么放下原本 96 位的信息?方案是把 Mark Word 从 64 位压到 32 位:身份哈希码从 31 位缩到(预留更多位给未来 Valhalla 值类型)、锁状态位重新划分、分代年龄字段压缩。效果(官方数据):SPECjbb2015 堆用量 -22%、CPU 时间 -8%、GC 次数 -15%;高并发 JSON 解析提速 10%。
工程影响:
未来预告:Project Valhalla 的值类型(inline class)落地后,对象模型会迎来比紧凑对象头更大的变化——值类型对象可以完全无对象头、内联进数组/字段。对象内存布局这个话题,会在可见的未来再次重写答案。
5.5 从对象模型看 Java 的演进哲学
把第五章的演进串起来,能看出一条清晰的哲学主线:
工程落点:升级 JDK 时,把"对象模型变了没"当成一等公民问题——跑一遍 JOL 实测、对比 GC 日志、复核内存估算公式。本文所有数字都标注了 JDK 版本与参数,就是提醒读者:任何对象大小的答案,都有一个版本前缀。
总结:一张图 + 面试自测题 + 参考文献
一张图回顾全文
#mermaid-svg-U1LFniY8ZI6qSmgO{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-U1LFniY8ZI6qSmgO .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-U1LFniY8ZI6qSmgO .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-U1LFniY8ZI6qSmgO .error-icon{fill:#552222;}#mermaid-svg-U1LFniY8ZI6qSmgO .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-U1LFniY8ZI6qSmgO .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-U1LFniY8ZI6qSmgO .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-U1LFniY8ZI6qSmgO .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-U1LFniY8ZI6qSmgO .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-U1LFniY8ZI6qSmgO .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-U1LFniY8ZI6qSmgO .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-U1LFniY8ZI6qSmgO .marker{fill:#333333;stroke:#333333;}#mermaid-svg-U1LFniY8ZI6qSmgO .marker.cross{stroke:#333333;}#mermaid-svg-U1LFniY8ZI6qSmgO svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-U1LFniY8ZI6qSmgO p{margin:0;}#mermaid-svg-U1LFniY8ZI6qSmgO .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-U1LFniY8ZI6qSmgO .cluster-label text{fill:#333;}#mermaid-svg-U1LFniY8ZI6qSmgO .cluster-label span{color:#333;}#mermaid-svg-U1LFniY8ZI6qSmgO .cluster-label span p{background-color:transparent;}#mermaid-svg-U1LFniY8ZI6qSmgO .label text,#mermaid-svg-U1LFniY8ZI6qSmgO span{fill:#333;color:#333;}#mermaid-svg-U1LFniY8ZI6qSmgO .node rect,#mermaid-svg-U1LFniY8ZI6qSmgO .node circle,#mermaid-svg-U1LFniY8ZI6qSmgO .node ellipse,#mermaid-svg-U1LFniY8ZI6qSmgO .node polygon,#mermaid-svg-U1LFniY8ZI6qSmgO .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-U1LFniY8ZI6qSmgO .rough-node .label text,#mermaid-svg-U1LFniY8ZI6qSmgO .node .label text,#mermaid-svg-U1LFniY8ZI6qSmgO .image-shape .label,#mermaid-svg-U1LFniY8ZI6qSmgO .icon-shape .label{text-anchor:middle;}#mermaid-svg-U1LFniY8ZI6qSmgO .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-U1LFniY8ZI6qSmgO .rough-node .label,#mermaid-svg-U1LFniY8ZI6qSmgO .node .label,#mermaid-svg-U1LFniY8ZI6qSmgO .image-shape .label,#mermaid-svg-U1LFniY8ZI6qSmgO .icon-shape .label{text-align:center;}#mermaid-svg-U1LFniY8ZI6qSmgO .node.clickable{cursor:pointer;}#mermaid-svg-U1LFniY8ZI6qSmgO .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-U1LFniY8ZI6qSmgO .arrowheadPath{fill:#333333;}#mermaid-svg-U1LFniY8ZI6qSmgO .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-U1LFniY8ZI6qSmgO .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-U1LFniY8ZI6qSmgO .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-U1LFniY8ZI6qSmgO .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-U1LFniY8ZI6qSmgO .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-U1LFniY8ZI6qSmgO .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-U1LFniY8ZI6qSmgO .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-U1LFniY8ZI6qSmgO .cluster text{fill:#333;}#mermaid-svg-U1LFniY8ZI6qSmgO .cluster span{color:#333;}#mermaid-svg-U1LFniY8ZI6qSmgO div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-U1LFniY8ZI6qSmgO .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-U1LFniY8ZI6qSmgO rect.text{fill:none;stroke-width:0;}#mermaid-svg-U1LFniY8ZI6qSmgO .icon-shape,#mermaid-svg-U1LFniY8ZI6qSmgO .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-U1LFniY8ZI6qSmgO .icon-shape p,#mermaid-svg-U1LFniY8ZI6qSmgO .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-U1LFniY8ZI6qSmgO .icon-shape .label rect,#mermaid-svg-U1LFniY8ZI6qSmgO .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-U1LFniY8ZI6qSmgO .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-U1LFniY8ZI6qSmgO .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-U1LFniY8ZI6qSmgO :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
访问:引用如何定位
布局:一个对象的三部分
创建:new 的五个环节
锁升级/伪共享
类加载检查/初始化
分配内存指针碰撞/空闲列表
并发安全TLAB + CAS
对象头 + 零值初始化
init 构造
对象头Mark Word + Klass + 数组长
实例数据字段重排
对齐填充8 字节倍数
直接指针(HotSpot)
oop-Klass 二分模型
逃逸分析/标量替换让对象不必存在
性能与排查JOL / jmap / JFR / MAT
全文一句话:对象创建决定"从哪来",内存布局决定"长什么样",访问定位决定"怎么找";三者共享同一套基础设施——对象头、压缩指针、GC,而 JIT 优化(逃逸分析/锁消除)则不断尝试让"对象"消失。
本文核心数字速记(JDK 17 实测,压缩开启)
| 裸 Object | 16 字节 | 8 头 + 4 类指针 + 4 对齐 |
| int 字段对象 | 16 字节 | 头 12 + 数据 4,零填充 |
| long 字段对象 | 24 字节 | 头 12 + 对齐 4 + 数据 8 |
| String(壳) | 24 字节 | 内容在引用的 byte[] 里 |
| String(“hello”) 全量 | 48 字节 | 壳 24 + byte[5] 24 |
| Integer | 16 字节 | int 装箱后膨胀 4 倍 |
| int[10] | 56 字节 | 数组头 16 + 元素 40 |
| 空 ArrayList | 40 字节 | 实例 24 + 共享空数组 16 |
| 空 HashMap | 48 字节 | 七个实例字段堆出来 |
| 伪共享 4 线程写 | 提速 3.1 倍 | 填充/@Contended/拆字段 |
| 逃逸分析 8M 堆 | GC 2 次 vs 107 次 | 标量替换消灭分配 |
高频误区与 FAQ
误区一:对象在栈上分配(“new 的对象都在栈上”)。错。HotSpot 主流做法是标量替换(对象拆成标量、不创建实体),而不是把整个对象搬上栈;而且只有未逃逸对象才能享受。绝大多数对象(尤其方法返回值、集合元素)在堆上。
误区二:-XX:PretenureSizeThreshold 到处生效。错。只对 Serial/ParNew 有效;G1 用"超过半个 region 即巨型对象"的规则,该参数在 G1 下静默无效——配了等于没配。
误区三:JDK 8 的锁升级四态现在还是标准答案。错。偏向锁 JDK 15 默认关闭(JEP 374)、JDK 18 删除(JDK-8256425)。JDK 17+ 的答案是"无锁 ↔ 轻量级 ↔ 重量级"两段模型。
误区四:堆超过 32G 只是"关掉压缩指针"这么简单。不止。堆超 32G 后引用与类指针可能双 8 字节,对象头 16 字节起步,单对象体积上升、GC 复制带宽上升——堆变大不一定更快的根源在对象模型,不在容量本身。
误区五:字段按源码顺序排内存。错。HotSpot 按宽度降序重排(父类块在前),源码顺序只在同宽度字段间保留。判断布局请用 JOL 实测,别靠读代码。
误区六:new 指令=创建对象。只对一半。new 只分配内存并返回未初始化引用;类加载检查、<clinit>、<init> 都是配套环节。字节码里必须 new + dup + invokespecial 三连才算完整构造。
误区七:String 长度决定 String 对象大小。壳固定 24 字节(JDK 9+),长度只影响它引用的 byte[]。"字符串内存账"要按"壳 + 内容"两部分算,内容又分 LATIN1/UTF16 两档。
FAQ:文章的数据换了 JDK 版本还成立吗? 布局数字在 JDK 8~21 主体一致(压缩指针+字段重排),但偏向锁(JDK 15/18)、String 内部结构(JDK 9)、紧凑对象头(JDK 24 起实验、JDK 25 转正、JDK 27 默认)都是版本敏感项。任何对象大小结论,第一反应是"哪个 JDK 版本、什么参数下测的"——这也是本文所有数字都标注版本与参数的原因。
面试自测题(含答案要点)
参考文献
- 《深入理解 Java 虚拟机(第 3 版)》,周志明
- JEP 374: Deprecate and Disable Biased Locking — https://openjdk.org/jeps/374
- JDK 18 Release Notes(Obsoleted Biased-Locking,JDK-8256425)— https://www.oracle.com/java/technologies/javase/18-relnote-issues.html
- JEP 450: Compact Object Headers (Experimental) — https://openjdk.org/jeps/450
- JEP 519: Compact Object Headers — https://openjdk.org/jeps/519
- JEP 534: Compact Object Headers by Default — https://openjdk.org/jeps/534
- JOL(Java Object Layout)— https://openjdk.org/projects/code-tools/jol/
- HotSpot VM 源码(oopDesc/InstanceKlass)— https://github.com/openjdk/jdk
附录:全文复现代码与命令
所有实验在本机 JDK 17(Temurin 17.0.20.1)完成,以下命令可直接复现本文全部数据。
# 1. JOL 布局实测(需 jol-core-0.17.jar)
javac -cp jol-core.jar JolDemo.java
java -cp .:jol-core.jar JolDemo # 压缩开启
java -XX:-UseCompressedOops -cp .:jol-core.jar JolDemo
java -XX:-UseCompressedOops -XX:-UseCompressedClassPointers -cp .:jol-core.jar JolDemo
# 2. 类初始化顺序
javac InitOrderDemo.java && java InitOrderDemo
# 3. 字节码反汇编
javac BytecodeDemo.java && javap -c -p BytecodeDemo
# 4. 伪共享压测(@Contended 需放开限制)
javac –add-exports java.base/jdk.internal.vm.annotation=ALL-UNNAMED FalseSharingDemo.java
java –add-exports java.base/jdk.internal.vm.annotation=ALL-UNNAMED -XX:-RestrictContended FalseSharingDemo
# 5. 逃逸分析 GC 次数对照
javac EscapeGcDemo.java
java -Xmx8m -Xlog:gc -cp . EscapeGcDemo 2>&1 | grep -c "Pause Young" # 期望 ≈2
java -Xmx8m -XX:-DoEscapeAnalysis -XX:-EliminateAllocations -Xlog:gc -cp . EscapeGcDemo 2>&1 | grep -c "Pause Young" # 期望 ≈107
说明:伪共享与逃逸分析的绝对数值受 CPU 核数、JIT 状态影响,本文标注的环境为 2 核虚拟化环境,请以相对对比(加速比、GC 次数量级)为准。
