JVM 内存区域与对象内存布局|new 对象底层流程,对象头 MarkWord 详解

大家好,这里是后端基础拾光集。

上一篇我们学习了 GC 垃圾回收,GC 保洁员负责清理堆仓库里面废弃对象。今天把镜头拉近,我们继续深挖: 看看仓库里到底怎么划区,以及每一个具体的"食材箱"(对象)内部长什么样? 当我们写下new User()这一行代码,JVM 从检查类、分配内存、写入对象头,一步步在仓库里面把这个对象 "搭建" 出来。 之前讲synchronized锁升级(偏向锁/轻量级锁/重量级锁),锁的全部状态信息,就保存在对象头 Mark Word 里面。这篇就是锁机制的底层地基。

运行时的内存分区,决定了一个对象"住在哪里";对象的内存布局,决定了一个对象"长成什么样"。 把这两层搞清楚,很多面试题(比如 Object o = new Object() 占多少字节、为什么堆超过 32GB 指针压缩会失效)就不再是死记硬背,而是有画面感的推导。

厨房类比回顾:

JVM = 完整厨房

堆 Heap = 厨房公共大仓库(所有 new 出来的食材对象,全部放在这里,线程共享)

虚拟机栈 = 厨师个人操作台(线程私有,存放局部变量,存放对象引用地址,不是对象本身)

方法区 (元空间)= 厨房菜谱档案室,存放类模板信息

一、 后厨的"区域划分":对象到底住在哪?

沿用厨房类比,JVM 的运行时数据区可以分为"每个厨师自己的工具"和"全店共享的场地"。

1. 程序计数器(PC 寄存器)

类比:厨师手上的便签纸条,记录当前做到代码第几行。

  • 线程私有,每个厨师单独一张纸条。
  • JVM 唯一不会发生 OOM 的内存区域。
  • 作用:线程切换之后,切回来可以继续从上次位置执行。

2. Java 虚拟机栈

类比:厨师手边临时操作台,当前这一单的食材清单和操作步骤。

  • 线程私有,一线程一栈。
  • 存放:局部变量、方法调用栈帧、对象引用地址(不是对象实体!对象实体在堆仓库)
  • 每个方法对应一个栈帧,每调用一个方法,压入栈帧;方法执行结束,栈帧直接弹出释放。
  • 异常:递归太深,栈帧不断压入,抛出StackOverflowError栈溢出。

3. 本地方法栈

类比:厨房外接设备操作台(烤箱、蒸箱)。 专门服务 native 本地方法(C/C++ 底层方法),结构和虚拟机栈基本一致。

这里有一个容易混淆的点:虚拟机栈的"溢出"和堆的"溢出"是两回事 。堆 OOM 是仓库爆仓;栈溢出通常是栈帧不断压栈却不出栈 ,比如无终止条件的递归,或者方法调用链过深。可以通过 -Xss 调整栈大小,但治本之道是检查递归终止条件,而不是盲目调大参数。

4. Java 堆 Heap(重中之重)

类比:厨房公共食材仓库+ 操作台,绝大多数 new 对象、数组全部存放于此,所有线程共享。 GC 垃圾回收主要工作区域,堆划分为新生代(Eden、S0、S1)、老年代,上一篇 GC 已经详细讲解。

区分重点:栈里面存的是对象地址,对象本体在堆仓库。

sql 复制代码
User user = new User();
// user 引用变量,放在虚拟机栈(厨师操作台)
// new User() 对象本体,存放在堆仓库

5. 方法区(JDK8 之后叫元空间 Metaspace)

类比:厨房菜谱档案室。存放类 Class 元信息、静态变量、运行时常量池;JDK 8 起由元空间实现,使用本地内存,不再受堆参数限制。

6. 直接内存

类比:厨房外的临时货架。不属于 JVM 运行时数据区规范,但 NIO 等会用到,也可能 OOM。

  • 线程共享。

JDK1.7 及之前:永久代,放在堆里面,容易 OOM。

JDK8:为了解决永久代在堆内、大小难调、容易 PermGen space OOM 的问题,移除永久代,替换为元空间,直接使用操作系统本地内存,默认不设上限(受物理内存限制) 。

⚠️ 仍然可以配置-XX:MaxMetaspaceSize限制大小,否则类动态大量加载,会出现元空间 OOM。
区分总结:

✅线程私有(厨师私有的区域):程序计数器、虚拟机栈、本地方法栈

✅线程共享(全店共享的场地):堆、方法区、直接内存

二、new 对象完整 5 步流程

代码:User user = new User();

  1. 类加载检查
    JVM 先去菜谱档案室(元空间),查找 User 类有没有加载。没有加载,先触发类加载流程。
    类比:制作食材前,先确认有没有对应的菜谱,没有就先把菜谱拿进档案室。
  2. 堆内存分配
    在堆仓库,划出一块连续内存,给 User 对象。
    分配两种方式:
  • 指针碰撞:内存规整,一个指针分割已使用 / 未使用区域。
  • 空闲列表:内存碎片化,维护一张空闲内存清单。

多线程并发 new 对象会出现竞争,JVM 使用 TLAB 本地线程分配缓冲,优先在线程私有 TLAB 分配,减少锁竞争。

  1. 内存初始化为零值
    分配好内存之后,先把对象实例字段全部赋予默认零值。
    int=0,boolean=false,引用类型 = null。
    👉这一步完成之后,代码里即使不手动赋值,字段也有默认值。
  2. 设置对象头(核心!面试高频,锁升级的根源)
    JVM 写入对象头信息,记录:哈希码、GC 分代年龄、锁标记、指向类的指针。

synchronized 的锁状态,全部存在 Mark Word 里面,也就是这里!

  1. 执行构造方法<init>
    执行我们写的构造函数,给成员变量赋予我们代码中设置的值。

注意顺序:先零值初始化,再执行构造器赋值!

三、对象内存布局(堆里面一个对象长什么样)

一个 Java 对象在堆里,不是只存了你的业务字段。HotSpot 里,堆中对象由三部分组成:对象头、实例数据、对齐填充

1. 对象头(Object Header) ------箱子的"标签和管理信息"

对象头是整个对象的控制中心,分为两块,数组对象额外增加数组长度:

① Mark Word(8 字节) :这是最"动态"的 8 个字节,会根据对象状态复用 同一块空间

类比:食材外包装标签。动态复用空间,里面存放多种信息:

  • 对象 hashcode 哈希值
  • GC 分代年龄(4bit,最大 15,到龄晋升老年代)
  • 锁状态标记:无锁、偏向锁、轻量级锁、重量级锁(和前面多线程 synchronized 锁升级完美联动!)
  • 偏向锁线程 ID、时间戳

一个很反直觉但面试常考的细节:调用 System.identityHashCode() 会让对象的偏向锁永久失效。因为无锁状态下 Mark Word 需要预留位置存哈希码,而偏向锁状态下这块位置被线程 ID 占了。一旦你主动取了哈希码,JVM 会撤销偏向锁,且后续不再开启。

Mark Word 很巧妙:空间很小,不同状态下存储的信息不一样,动态切换,节省内存。

② Klass Pointer(类型指针,默认 4 字节) : 指向元空间里面这个对象所属 Class 类信息。JVM 通过这个指针,知道这个对象是 User 还是 String,从而完成方法调用和类型校验。

如果是数组对象 ,对象头还会多一个 4 字节的数组长度字段 ,这也是 Java 数组最大长度不能超过 Integer.MAX_VALUE 的底层原因。

2. 实例数据(Instance Data)------你真正关心的"食材"

对象真正的成员属性,我们在类里面定义的字段,包括从父类继承来的字段 。 基础类型、引用类型,按规则存放。但有一个关键规则:字段不是按代码声明顺序排列的,JVM 会重排序,目的是减少内存间隙。

排列优先级大致是:long/double(8 字节)→ int/float(4 字节)→ short/char(2 字节)→ byte/boolean(1 字节)→ 引用类型(开启压缩 4 字节)。父类字段永远排在子类字段之前,父类部分结束位置会对齐到 4 字节边界,再放子类字段。

3. 对齐填充(Padding)

不是必须存在。JVM 要求对象起始地址必须是 8 字节的整数倍。 如果前面对象头 + 实例数据,总长度不是 8 倍数,就补几个空字节凑齐。单纯内存对齐,没有任何业务含义。

用一个最经典的面试题收束这一节:

Object o = new Object() 占多少字节?

开启指针压缩的 64 位 HotSpot 下:Mark Word 8 字节 + Klass Pointer 4 字节 = 12 字节 ,但总大小必须是 8 的倍数,所以补 4 字节 Padding,最终 16 字节。如果你能画出这个推导过程,这道题就不需要背答案了。

⚠️一个影响全局的开关:指针压缩

刚才反复提到"开启指针压缩",它到底是什么?

64 位 JVM 里,一个引用(指针)原本要占 8 字节。但 HotSpot 默认开启 Compressed Oops ,把引用压缩成 4 字节的偏移量 ,配合堆基址和 8 字节对齐来还原真实地址。对象头里的 Klass Pointer 也有对应的压缩开关 -XX:+UseCompressedClassPointers,默认同样开启,把 8 字节压到 4 字节。

但压缩指针有一个硬边界:堆内存大约 32GB。 因为 4 字节最多表示 2³² 个偏移量,按 8 字节对齐,最大可寻址约 32GB。堆一旦超过 32GB,压缩指针失效,引用退回 8 字节,同样的对象会占更多内存,缓存局部性变差,GC 压力反而可能上升 。这就是为什么堆不是越大越好,以及为什么有些团队会把 -Xmx 设在 31g 左右,而不是无脑往上加。

四、实战代码:JOL 工具查看对象内存布局

引入依赖

xml 复制代码
<!-- JOL 查看对象内存布局 -->
<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.17</version>
</dependency>

Java 代码

arduino 复制代码
import org.openjdk.jol.info.ClassLayout;

public class JOLDemo {
    public static void main(String[] args) {
        class User{
            private int id;
            private String name;
        }
        User user = new User();
        // 打印对象内存布局
        System.out.println(ClassLayout.parseInstance(user).toPrintable());
    }
}

运行结果,可以直接看到 Mark Word、Klass 指针、实例数据、对齐填充,直观看到对象占用多少字节。

五、两种对象访问定位方式

我们写user.id,JVM 怎么通过栈里面的引用找到堆对象?

  1. 句柄访问 堆开辟句柄池,引用指向句柄,句柄再指向对象实例 + 类元信息。
    优点:对象移动(GC 复制整理),引用地址不用修改;
    缺点:多一次访问,速度慢。
  2. 直接指针(HotSpot 默认) 栈里面引用地址,直接指向堆对象。速度更快,HotSpot 虚拟机使用。

⚠️开发高频踩坑实录

坑 1:误以为局部变量放在堆里面。

✅纠正:方法内普通局部变量、引用地址在虚拟机栈;只有 new 出来的对象本体在堆。

坑 2:不清楚对象头 Mark Word,看不懂 synchronized 锁升级原理。

✅纠正:锁状态全部保存在 Mark Word,这就是锁升级底层载体,前后知识点打通。

坑 3:大量创建短生命周期对象,Eden 快速填满,频繁 MinorGC。

✅优化:减少循环内不停 new 对象,复用对象。

坑 4:忽略元空间 OOM。动态生成大量 Class(ASM、CGLIB 动态代理),元空间持续增长。

✅解决:配置-XX:MaxMetaspaceSize限制元空间上限,防止系统内存耗尽。

坑 5:计算对象内存大小忘记对齐填充,估算内存不准。

✅使用 JOL 工具直接查看真实占用。

🧪面试高频考点

Q1:JVM 运行时五大内存区域,哪些线程私有,哪些共享?哪些会 OOM?

A: 线程私有:程序计数器、虚拟机栈、本地方法栈。程序计数器无 OOM;虚拟机栈、本地方法栈会栈溢出。 线程共享:堆、元空间(方法区),这两块会发生 OOM;堆是最常见 OOM 区域。

Q2:new 对象的完整过程?

A:类加载检查 → 分配堆内存 → 初始化零值 → 设置对象头 → 执行构造方法。

Q3:Java 对象在堆的内存布局由哪三部分组成?Mark Word 里面存什么?

A:对象头、实例数据、对齐填充。Mark Word 存放 hashcode、GC 年龄、锁状态、偏向锁信息。

Q4:对象头里面 Mark Word,和 synchronized 锁升级是什么关系?

A:synchronized 的锁状态(无锁、偏向锁、轻量级锁、重量级锁)全部记录在 Mark Word,锁升级本质就是修改 Mark Word 里面标记位。

Q5:句柄访问和直接指针访问对象的区别?HotSpot 使用哪一种?

A:句柄访问中间多一层句柄,对象移动不需要修改引用;直接指针速度更快,HotSpot 默认直接指针。

Q6: Object o = new Object() 占多少字节?

A:开启指针压缩的 64 位 HotSpot:8(Mark Word)+ 4(Klass Pointer)+ 4(Padding)= 16 字节。

✅总结避坑清单

  1. 引用变量存在栈,对象本体在堆,不要混淆。
  2. Mark Word 是理解 synchronized 锁升级的底层基础,前后文章联动。
  3. 元空间可以发生 OOM,动态生成类场景一定要设置上限。
  4. 对象存在对齐填充,估算内存不能只计算属性。
  5. TLAB 是为了并发创建对象,减少分配内存时的锁竞争。

用厨房类比收个尾:

  • 内存区域回答的是"食材放在后厨的哪个位置":手边清单(栈)、大仓库(堆)、菜谱柜(元空间)、外部货架(直接内存),各司其职;
  • 内存布局回答的是"一个食材箱内部怎么码放":先贴标签和管理信息(对象头),再放真正的食材(实例数据),最后用泡沫补到整齐(对齐填充)。

把这两层搞清楚,再回头看上一篇的 GC:GC 清理的是堆仓库里不可达的对象,而对象的大小、引用指针的宽度、能不能逃逸到栈上,直接决定了仓库的吞吐和保洁的频率。 面试和线上排查,说到底都是在"区域---对象---回收"这条链上找答案。

📝本文属于专栏【Java 后端基础拾光集】,生活化讲解 Java 基础,持续更新面试知识点。 公众号【Java后端程序媛手记】同步更新 Java 底层原理、后端线上踩坑实录,系列文章整理成合集,方便系统学习。

相关推荐
用户094248568031 小时前
第2章:Windows、WSL2 与 Docker 环境搭建Redis
java·jvm
linyb极客之路2 小时前
Spring Boot 2升3太痛苦?用OpenRewrite批量搞定,少走90%弯路!
spring boot
小物学长2 小时前
HJ212-2017 对接与环境在线监测平台设计:报文、CRC、应答、预警和传输有效率
java
171320330计算机毕设编程2 小时前
计算机毕业设计 基于springboot+vue的在线观鸟视频播放网站 LW PPT
java·大数据·vue.js·spring boot·算法·课程设计
IT枫斗者枫哥2 小时前
JDBC事务实测:操作日志写入失败时,让客户修改一起回滚
java
OxYGC2 小时前
Spring 面经系列 · 第 1 篇:Bean 生命周期、单例语义、依赖注入与线程安全
java·安全·spring
小物学长2 小时前
Java 程序员的 IoT 协议入门(一):物联网平台到底在做什么
java
const_qiu2 小时前
第四章 07:4.7 本章小结
java·运维·服务器·人工智能