大家好,这里是后端基础拾光集。
上一篇我们学习了 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 spaceOOM 的问题,移除永久代,替换为元空间,直接使用操作系统本地内存,默认不设上限(受物理内存限制) 。⚠️ 仍然可以配置
-XX:MaxMetaspaceSize限制大小,否则类动态大量加载,会出现元空间 OOM。
区分总结:✅线程私有(厨师私有的区域):程序计数器、虚拟机栈、本地方法栈
✅线程共享(全店共享的场地):堆、方法区、直接内存
二、new 对象完整 5 步流程
代码:User user = new User();
- 类加载检查
JVM 先去菜谱档案室(元空间),查找 User 类有没有加载。没有加载,先触发类加载流程。
类比:制作食材前,先确认有没有对应的菜谱,没有就先把菜谱拿进档案室。 - 堆内存分配
在堆仓库,划出一块连续内存,给 User 对象。
分配两种方式:
- 指针碰撞:内存规整,一个指针分割已使用 / 未使用区域。
- 空闲列表:内存碎片化,维护一张空闲内存清单。
多线程并发 new 对象会出现竞争,JVM 使用 TLAB 本地线程分配缓冲,优先在线程私有 TLAB 分配,减少锁竞争。
- 内存初始化为零值
分配好内存之后,先把对象实例字段全部赋予默认零值。
int=0,boolean=false,引用类型 = null。
👉这一步完成之后,代码里即使不手动赋值,字段也有默认值。 - 设置对象头(核心!面试高频,锁升级的根源)
JVM 写入对象头信息,记录:哈希码、GC 分代年龄、锁标记、指向类的指针。
synchronized 的锁状态,全部存在 Mark Word 里面,也就是这里!
- 执行构造方法
<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 怎么通过栈里面的引用找到堆对象?
- 句柄访问 堆开辟句柄池,引用指向句柄,句柄再指向对象实例 + 类元信息。
优点:对象移动(GC 复制整理),引用地址不用修改;
缺点:多一次访问,速度慢。 - 直接指针(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 字节。
✅总结避坑清单
- 引用变量存在栈,对象本体在堆,不要混淆。
- Mark Word 是理解 synchronized 锁升级的底层基础,前后文章联动。
- 元空间可以发生 OOM,动态生成类场景一定要设置上限。
- 对象存在对齐填充,估算内存不能只计算属性。
- TLAB 是为了并发创建对象,减少分配内存时的锁竞争。
用厨房类比收个尾:
- 内存区域回答的是"食材放在后厨的哪个位置":手边清单(栈)、大仓库(堆)、菜谱柜(元空间)、外部货架(直接内存),各司其职;
- 内存布局回答的是"一个食材箱内部怎么码放":先贴标签和管理信息(对象头),再放真正的食材(实例数据),最后用泡沫补到整齐(对齐填充)。
把这两层搞清楚,再回头看上一篇的 GC:GC 清理的是堆仓库里不可达的对象,而对象的大小、引用指针的宽度、能不能逃逸到栈上,直接决定了仓库的吞吐和保洁的频率。 面试和线上排查,说到底都是在"区域---对象---回收"这条链上找答案。
📝本文属于专栏【Java 后端基础拾光集】,生活化讲解 Java 基础,持续更新面试知识点。 公众号【Java后端程序媛手记】同步更新 Java 底层原理、后端线上踩坑实录,系列文章整理成合集,方便系统学习。