欢迎光临
我们一直在努力

Java对象创建、内存布局、对象访问定位全过程解析

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 执行时,第一步不是分配内存,而是先确认这个类可以被使用。

整个流程是这样的:

  • 类加载检查:JVM 在常量池中查找 #8 对应的符号引用,检查这个类是否已加载、解析、初始化过。没有的话,走类加载器(ClassLoader)的加载流程——这背后是双亲委派模型:先让父加载器尝试加载,层层向上,最后由 Bootstrap ClassLoader 兜底。加载成功的类会以 instanceKlass 的形式存在方法区(JDK 8 以后是元空间 Metaspace),堆里所有该类的对象,对象头里的 Klass Pointer 都指向这块类元数据。
  • 解析(Resolution):把符号引用替换为直接引用。类、字段、方法的符号引用都会被解析成内存中的实际地址或偏移量。注意:HotSpot 是惰性解析的——很多符号引用(比如方法调用)直到第一次执行才解析,这是为了减少启动开销。
  • 分配内存:类加载就绪后,才轮到 new 指令真正在堆上分配。
  • 类初始化(clinit):如果类还没有执行过静态初始化,需要先触发 <clinit>。
  • 这里有个高频考点:new 到底会不会触发类的初始化? 答案是"分情况"。new、反射、静态字段访问、静态方法调用都会触发初始化;但通过数组创建对象不会——new User[10] 只在堆上分配一个数组对象,数组的类 [LUser; 由 JVM 自动生成,不会触发 User 的初始化。还有一个冷门情况:引用父类的静态字段不会触发子类初始化(静态字段在哪个类声明,就只初始化那个类)。

    1.3 类初始化 clinit:静态域只有一个起点

    类初始化执行的是 <clinit> 方法——它由编译器自动收集所有静态字段赋值语句和静态代码块合并生成。<clinit> 和 <init> 有本质区别:

    对比项<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.构造器

    信息量很大,逐条解读:

  • 静态初始化顺序:父类 → 子类,字段和代码块按书写顺序。Parent.static 字段 先于 Parent.static 块,因为字段赋值语句写在代码块前面。
  • 第二次 new 不再执行任何静态代码——<clinit> 整个生命周期只执行一次。
  • 实例初始化顺序:父类实例块 → 父类构造器 → 子类实例块 → 子类构造器。实例字段赋值语句和实例块按书写顺序,统一在父类构造器返回后、子类构造器体执行前完成。
  • JVM 规范保证 <clinit> 的线程安全:多个线程同时触发同一个类的初始化,只有一个线程能执行,其余线程阻塞等待。但要注意——这是"初始化完成前阻塞",不是"锁重入",如果 <clinit> 里发生死锁(比如互相等待对方类初始化),会直接抛 ExceptionInInitializerError,而等待的线程会得到 NoClassDefFoundError。这是一个非常经典的生产事故元凶(见第四章案例)。
  • 本小节小结: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 开始往这块内存里写"骨架信息":

  • 设置对象头(Object Header):写入 Mark Word(存哈希码、GC 分代年龄、锁状态标志位)和 Klass Pointer(指向方法区的类元数据)。如果对象是数组,还要在对象头里写数组长度。
  • 零值初始化(Zeroing):把实例数据区域全部置零。注意:这一步在构造方法执行之前。所以 Java 里"实例字段没被显式赋值时默认为 0 / null / false"这个语言特性,本质上是 JVM 在构造前做的一次内存清零——而不是"编译器帮你赋了默认值"。这也直接解释了为什么 Java 的局部变量没有默认值而实例字段有:实例字段的内存由 JVM 统一清零,局部变量在栈上,JVM 不做清零。
  • 关于零值初始化,有两个值得展开的工程细节:

    细节一:TLAB 的零值复用。TLAB 是线程私有的,线程每次申请到新 TLAB 时一次性清零整块缓冲,之后在这个缓冲内的对象分配不需要再逐块清零(已经清过了)。这就是 TLAB 的另一个隐藏收益:除了免同步,还免了零值初始化。代价是 TLAB 未用完的部分浪费掉。

    细节二:JIT 的分配优化会跳过"用不到"的零值初始化。C2 编译器做逃逸分析时,如果发现某个对象的字段在构造前不会被读取(典型场景:对象创建后立刻全部字段显式赋值),会把冗余的清零操作优化掉。这属于 JIT 层面的"减法优化",后面 3.4 节会详细展开。

    1.7 构造方法 init 执行:一个对象的真正诞生

    骨架搭好(对象头 + 零值字段),最后一步是执行 <init>:按"实例字段赋值 → 实例代码块 → 构造器体"的顺序完成初始化。到这里,new 指令返回的引用才"指向一个真正可用的对象"。

    注意一个容易被忽略的事实:在 <init> 执行完成之前,对象已经分配好内存、对象头已经写好。这意味着:

  • 如果 <init> 里抛异常(比如业务校验失败),这个对象会变成"分配了但没用上"的对象,交给 GC 回收——对象的创建成本已经付了。高并发下构造器里做重活(IO、锁、远程调用)不仅拖慢创建线程,还会放大 GC 压力。
  • 指令重排的经典陷阱:对象头写入是 JVM 内部保证的,但对 Java 代码来说,“构造函数里赋值"和"对象发布"是两个不同步骤。著名的 DCL(双重检查锁)单例问题就出在这里——new Singleton() 的三件事(分配内存、设置对象头、执行 <init>)在 CPU 和 JIT 层面可能乱序,导致另一个线程拿到"对象头已写、字段还是零值"的半成品。解法:字段加 volatile(禁重排),或者用静态内部类/枚举/AtomicReference。这段是本章与第三章"访问定位"的交界处:对象什么时候"可见”,取决于内存模型而不只是分配顺序。
  • 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 内一次指针移动(纳秒级);TLAB 用完后的慢分配走同步路径(微秒级)。
  • 对象头初始化:Mark Word、Klass Pointer 写入(如果 TLAB 已清零,零值初始化省掉)。
  • <init> 执行:字段赋值 + 构造器体。这部分才是大头——构造器里每做一次 IO、一次加锁、一次远程调用,成本都是分配的成千上万倍。
  • 隐式成本(分场景,容易被忽略):

  • GC 分摊:对象迟早要回收。年轻代对象被复制、扫描;活到老年代的对象还要被老年代 GC 扫描。“创建快"不等于"免费”——每个对象都是给 GC 挂的一笔债。单次分配纳秒级,但它未来被 GC 处理的成本(复制带宽、暂停)会在系统层面放大。
  • TLAB 浪费:每个线程的 TLAB 末段可能剩余不足新对象大小而丢弃(RefillWasteFraction 允许的浪费比例)。线程多、对象大小波动大时,这块浪费不可忽视。
  • JIT 波动:对象创建路径在 C2 编译前后行为完全不同(解释执行会真分配、C2 可能标量替换)。性能测试必须预热,否则测的是解释执行的成本。
  • 缓存效应:新对象在缓存里是"热"的,构造器对它的写入快;但批量创建后马上被丢给 GC,缓存行白白被污染——这也是 2.12 节"分配局部性"的另一面。
  • 工程结论:对象创建的优化顺序应该是——先砍对象数量(消灭无谓对象、复用、基本类型化),再优化单对象成本(布局、逃逸分析),最后才考虑分配参数(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),规则大致是:

  • 按字段宽度从大到小排:long/double(8)→ int/float(4)→ char/short(2)→ byte/boolean(1)→ 引用类型(4 或 8,取决于压缩)。
  • 父类字段整体放在子类字段之前(2.7 节验证)。
  • 相同宽度的字段保持源码顺序。
  • 引用类型在 HotSpot 默认布局策略里通常排到最后(或按策略不同靠前),最终以实测为准。
  • 为什么这么排?为了减少填充(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 调整)。原因有三层:

  • CPU 读取效率:64 位 CPU 读一个 long(8 字节)如果跨越两个缓存行/字边界,需要两次访存。对齐让"一个对象从对齐地址开始",内部字段更容易落在单次访存内。
  • GC 的指针移动:GC 在复制对象(新生代复制算法)时按对齐后的步长移动指针,对齐后的对象边界就是 GC 遍历的天然步长。
  • 压缩指针的位运算:2.5 节会看到,压缩指针的"除以 8"解码依赖 8 字节对齐——对齐是压缩指针能成立的前提。
  • 解法视角:-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 实测):

    配置引用大小Klass PointerMixed 实例大小int[10] 大小
    默认(压缩开启) 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) ← 还原无锁态

    逐条解读:

  • 加锁前:Mark Word 低 3 位 001,无锁态,age: 0 表示 GC 分代年龄为 0(没有被回收过)。
  • 锁内:thin lock 即轻量级锁——Mark Word 里存的是指向当前线程栈中 Lock Record 的指针(62 位),lock=00。轻量级锁的哲学:锁竞争不激烈时,用 CAS 把 Mark Word 换成锁记录指针,比内核级互斥快得多;如果 CAS 失败(真竞争),升级。
  • 退出后:CAS 还原为无锁态。注意轻量级锁的 Mark Word 只在加锁瞬间被改写,锁内字段值本身不受影响——Mark Word 和实例数据是两个独立区域。
  • 为什么 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 倍

    三种解法:

  • 手动缓存行填充:在每个热点字段前后补足 64 字节。经典案例是 JDK 8 之前 LongAdder/Striped64 的 Cell 用 @sun.misc.Contended 或手动填充避免 Cell 数组元素互相干扰。缺点:手工数缓存行易错(64 字节是主流,但也要考虑对象头 16 字节的占位),且不同架构缓存行大小不同(有的 128 字节),硬编码不可移植。
  • @Contended 注解(JDK 8+,jdk.internal.vm.annotation.Contended):让 JVM 在布局时自动给注解字段前后加填充。需 -XX:-RestrictContended 放开限制(否则只对 JDK 内部类生效)。优点:可移植、语义清晰;缺点:依赖具体 JVM 实现。
  • 拆分热点字段:把"高频各自写"的字段拆到不同对象里(比如不同线程各持有一个自己的计数对象),从根源上让它们天然不在同一缓存行。LongAdder 的设计就是"每个线程一个 Cell",这也是"用对象切分替代填充"的更优雅解法。
  • 真实业务画像:伪共享几乎都长这样——一个共享统计对象(比如"计数器集合"),字段们 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

    关键点:

  • String 实例本身固定 24 字节(12 对象头 + hash 4 + coder 1 + hashIsZero 1 + 2 填充 + value 引用 4)——不管字符串多长,String 壳都是 24 字节,内容在它引用的 byte[] 里。
  • "hello"(5 个 ASCII 字符)实测总占用 48 字节 = String 24 + byte[5] 24(16 数组头 + 5 内容,对齐到 24)。一个 5 字符的字符串,内容只占 5 字节,外壳占 43 字节——外壳是内容的 8 倍多。这就是"日志、标签、Key 字符串海量"场景内存爆炸的根因。
  • JDK 8 时代 String 内部是 char[] value(每个字符 2 字节),“hello” 要 10 字节内容;JDK 9 的紧凑字符串让纯 ASCII 内容省一半——同一个"字符串对象多少钱"的答案,JDK 8 和 JDK 9+ 是不一样的,这也是 5.1 节"对象模型随版本演进"的一个具体例子。
  • 典型业务 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 里先后创建的对象,在内存里是紧挨着的。这带来两个效应:

  • 遍历友好:一批"同批创建、同批使用"的对象(比如一次请求里的 DTO 们)往往在同一个 TLAB 段里连续分布。循环遍历这批对象时,CPU 预取(prefetch)能高效工作——第一次访问某对象时,缓存行已经把旁边的对象一起拉进来了。
  • GC 友好:新生代 GC 按顺序扫描、按顺序复制,连续对象复制后仍然连续,老年代里也尽量保持簇状。
  • 晋升后的"随机化":对象在新生代里顺序良好,但晋升老年代后,不同批次的存活对象混合在一起,老年代的布局逐渐碎片化、随机化。这就是为什么"老年代对象访问速度平均慢于新生代"的布局层面原因之一——不是对象变了,是邻居变了。也解释了为什么大堆 + 长存活对象多的应用,把对象按业务域拆分(按模块分堆/分 region 优化)能改善缓存表现。

    工程上能做的三件事:

  • 批量创建代替零星创建:一次请求要用的对象尽量在同一代码路径、同一时刻创建(比如集中构建一个 DTO 列表再统一处理),而不是边处理边 new——让它们住在同一片 TLAB。
  • 遍历顺序与创建顺序一致:for (DTO d : list) 遍历时,list 里的对象创建顺序越接近遍历顺序,缓存命中越好。反向遍历、跳跃遍历会打乱预取。
  • -XX:+AlwaysPreTouch 与堆预留:启动时预触达堆内存(提前 commit 并清零),避免运行时边分配边触页的缺页开销;对超低延迟服务有实际收益,代价是启动变慢。
  • 一句话:对象布局优化的下一个层次不是"单对象大小",而是"对象群的邻居关系"——这是从 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 编译器)对对象访问的最大优化。

    三个概念串成一条线:

  • 逃逸分析:C2 分析一个对象的作用域,判断它是否逃逸出当前方法/线程(被返回、被存入全局、被其他线程拿到)。没逃逸的对象,可以进一步优化。
  • 栈上分配(Stack Allocation):没逃逸的对象不分配到堆上,分配到线程栈帧上,方法结束自动释放,零 GC 压力。HotSpot 历史上实现过栈上分配,但现代 C2 很少真做"栈上分配",更多走下面这条。
  • 标量替换(Scalar Replacement):把对象的每个字段拆成独立标量(int、long、引用等),直接放进寄存器或栈槽,连对象本身都不创建了。这是"最彻底的栈上分配"。
  • 实测验证(这是本章最重要的实验):定义一个不逃逸的对象,循环创建两千万次,对比不同配置下的 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。

    工程启示(解法集合):

  • 能写"不可变+局部使用"的临时对象就尽量让对象不逃逸——别把临时对象塞进返回结构、集合或静态缓存。比如 StringBuilder 在方法内拼接后 toString(),如果 StringBuilder 本身不逃逸(只返回它的 toString 结果),它可能被标量替换。
  • 不要在循环里构造"观察者"性质的包装对象。经典反模式:循环内 new BigDecimal("0.1").add(…)——BigDecimal 结构大且方法调用链可能逃逸分析失败。解法:循环外提取常量、复用实例、或用原始类型方案。
  • 逃逸分析不是银弹:对象一旦逃逸(存进集合、被方法返回、被多线程共享),上述优化全部失效,回到堆分配。判断"要不要优化"先看对象会不会逃逸。
  • 排查工具:JDK 17 可用 -XX:+PrintEscapeAnalysis(诊断参数)看分析结果,生产环境则用 JFR 的 Allocation 事件看对象分配热点。
  • 为什么逃逸分析在"对象访问定位"章节讲?因为它揭示了访问定位的终极形态:最有效的访问路径是"没有对象"。引用定位解决"怎么找到对象",逃逸分析解决"能不能别造对象"——两者合起来才是对象访问的完整图景。

    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 里。这一小节把它讲透——它是"对象头复用"思想在业务代码里最常踩的暗坑。

    先看事实链:

  • System.identityHashCode(obj) 在对象第一次被调用时计算,结果缓存进 Mark Word 的高 31 位(无锁态时)。之后只要对象还在无锁态,每次调用都直接读缓存,不会重算。
  • 一旦对象进入轻量级锁/重量级锁,Mark Word 的高 31 位被锁记录指针/监视器指针占用,哈希码"无处存放"。轻量级锁释放后还能还原;但重量级锁对象在释放锁后,哈希码缓存的位置已被占用过——JVM 的处理是让对象在需要哈希时重新计算(不能保证稳定)或直接丢弃缓存。
  • 重写 hashCode() 后,Object.hashCode() 这个 native 方法不再被调用(业务 hashCode 完全绕过对象头),但 System.identityHashCode 仍然走对象头——两个"哈希"从此各走各的路。
  • 经典事故:一个对象先被 synchronized 加锁(升级到重量级锁),又被放进 HashSet/HashMap 做 key。HashMap 用 hash(key)(内部调用 key.hashCode())定位桶,如果 hashCode 是默认的身份哈希且对象头缓存已被锁"挤掉",同一次 JVM 运行里前后两次计算的哈希可能不一致,导致对象在扩容/重哈希时丢失——这就是著名的"对象加锁后放集合丢元素"类问题的根源(更准确的表述:身份哈希在锁膨胀后不再稳定,依赖它的容器行为不可预期)。

    规避方案(按优先级):

  • 业务对象(尤其要进 HashMap/HashSet 的)必须重写 hashCode() 与 equals(),让哈希来自字段而不是对象头——这是最彻底的解法。
  • 只依赖 System.identityHashCode 的场景(比如用对象做身份标识的内存表),不要同时让对象经历重量级锁;换 IdentityHashMap 或用弱引用包装。
  • 排查时先确认对象是否进过锁(JOL 看 Mark Word 锁态),再判断哈希行为是否符合预期——这正好是"对象头布局"反哺排障的实例。
  • 本章高频面试题自测:

    • 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 写
    }

    根治清单:

  • 先用 perf stat -e cache-misses / JFR 确认伪共享(缓存未命中异常高)。
  • 解法 A:@Contended 注解(-XX:-RestrictContended),一行解决,可移植性好。
  • 解法 B:手动填充(注意 64 字节缓存行 + 16 字节对象头,要填 7 个 long),JDK 内部 LongAdder$Cell 的历史实现就是如此。
  • 解法 C(架构级):把"各写各的"字段拆到各线程自己的对象里,最后再聚合——这正是 LongAdder 的设计哲学,也是"别让热点字段同居一个对象"的通用原则。
  • 演进注意:JDK 15+ 的 JEP 374 关闭偏向锁、JDK 18 移除后,锁竞争处理更直接,伪共享对"锁内字段"的影响模型变了;另外 @Contended 只在 HotSpot 有效,跨 JVM 移植性差。
  • 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 节)

    决策原则三条:

  • 永远先量化再优化。没有 jstat/jmap/JFR 数据支撑的调优,都是猜。JOL 负责"单个对象多大",jmap -histo 负责"哪些类最多",JFR 负责"在哪里分配"——三件套把问题钉死。
  • 代码层解法优先于参数层解法。参数(TLAB 大小、region 大小、堆大小)只改变分配的位置和节奏,不改变"要分配这么多对象"这个事实。先消灭无谓对象,再考虑挪位置。
  • 一个指标一个动作。改完参数后回到第一步重新量化,确认目标指标(GC 次数/GC 耗时/堆占用)确实改善,且没有引入新问题(比如 TLAB 调大挤占 Eden、池化引入滞留内存)。
  • 本章高频面试题自测:

    • 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版本状态内容
    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%。

    工程影响:

  • 对象大小重算:JDK 27 后裸 Object 从 16 字节降到 8 字节,int 字段对象从 16 降到 12(8 头 + 4 数据)——所有"对象占多少字节"的老答案都要+版本限定语。
  • 前提约束:紧凑对象头要求压缩类指针开启(这正是 5.2 节弃用"可关闭类指针压缩"选项的原因),且与旧的栈锁(legacy stack locking)等机制冲突。
  • 排障习惯:升级 JDK 时,先跑一遍 JOL 实测确认对象尺寸变化,再做容量与 GC 参数复核——别把旧版本的字节数当常数背。
  • 未来预告:Project Valhalla 的值类型(inline class)落地后,对象模型会迎来比紧凑对象头更大的变化——值类型对象可以完全无对象头、内联进数组/字段。对象内存布局这个话题,会在可见的未来再次重写答案。

    5.5 从对象模型看 Java 的演进哲学

    把第五章的演进串起来,能看出一条清晰的哲学主线:

  • 减法比加法更值钱:偏向锁被删(JDK 18)、字符串从 char[] 减到 byte[]+coder(JDK 9)、对象头从 96 位减到 64 位(JDK 24~27)——JVM 历史上最成功的优化,大多是"删掉冗余",而不是"增加机制"。对工程师的启发:优化一个系统,先找可以删除的部分。
  • 通用机制向专用机制让步:旧的"一锁通吃"(偏向/轻量/重量三级)被更简单的两段模型替代;GC 从 CMS 的"大而全"走向 G1/ZGC 的分区与着色——复杂度是维护成本,能省的复杂度一定要省。
  • 布局决定性能的上限:压缩指针、字段重排、对齐、紧凑对象头,全是"在字节层面做文章"。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 版本、什么参数下测的"——这也是本文所有数字都标注版本与参数的原因。

    面试自测题(含答案要点)

  • new 指令的职责边界?——只分配内存;类加载与 <init> 是配套流程。
  • 对象创建五步是什么?——类加载检查→分配内存→并发安全(TLAB/CAS)→对象头+零值→<init>。
  • 一个 int 字段的类在 JDK 17 压缩开启下占多少字节?——16(12 头 + 4 数据)。
  • 为什么字段会被重排?——减少 padding、紧凑布局、缓存友好。
  • 压缩指针为何 32G 封顶?——4 字节 × 8 对齐 = 32G。
  • 句柄访问 vs 直接指针?HotSpot 选谁、为什么?
  • 逃逸分析后对象去哪了?——标量替换,字段拆进寄存器/栈槽。
  • JDK 17 的锁升级路径?——无锁 ↔ 轻量级 ↔ 重量级;偏向锁 JDK 15 关闭、JDK 18 删除。
  • 伪共享的根因和解法?——缓存行共享;填充/@Contended/字段拆分。
  • OOM 排查路径?——jstat → dump → MAT Dominator Tree → 引用链 → 区分泄漏与膨胀。
  • JDK 27 后对象头变多少?——64 位紧凑对象头(JEP 534)。
  • 参考文献

    • 《深入理解 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 次数量级)为准。

    赞(0)
    未经允许不得转载:171主机测评 » Java对象创建、内存布局、对象访问定位全过程解析
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址