很多人把"JVM 内存模型"和"Java 内存模型(JMM)"混为一谈,其实是两回事。前者讲的是运行时数据区——堆、栈、方法区这些物理上的内存划分;后者讲的是多线程下变量的可见性与有序性。本文只谈前者,以及一个对象在堆里到底长什么样。理解这套结构,排查 OutOfMemoryError、StackOverflowError、对象膨胀导致的内存浪费时,你才知道问题落在哪一块。
场景:一次 OOM,到底是哪块内存爆了
线上抛出 java.lang.OutOfMemoryError,后面跟的提示决定了排查方向完全不同:Java heap space 是堆满了,Metaspace 是元空间满了,unable to create new native thread 其实是线程栈耗尽了系统资源。如果你脑子里没有运行时数据区的分区图,就只能盲目调大 -Xmx 碰运气。
机制:运行时数据区的分区
JVM 规范把运行时内存分成几块,按"线程私有"还是"线程共享"来分:
线程私有(随线程生灭):
- 程序计数器(PC Register):记录当前线程执行字节码的行号指示器。它是唯一不会抛 OOM 的区域。
- 虚拟机栈(JVM Stack):每个方法调用对应一个栈帧,栈帧里装着局部变量表、操作数栈、动态链接、返回地址。方法调进调出就是栈帧的入栈出栈。
StackOverflowError就是递归太深、栈帧压太多导致的。 - 本地方法栈:为 native 方法服务,逻辑同上。
线程共享:
- 堆(Heap):对象实例几乎都分配在这。GC 主要工作区,也是
Java heap spaceOOM 的来源。 - 方法区(Method Area):存类的元信息、运行时常量池、静态变量。在 HotSpot 中,Java 8 之前由"永久代(PermGen)"实现,Java 8 起改用元空间(Metaspace),放到了本地内存而非堆里。
一个关键变化要记牢:Java 8 用 Metaspace 替代了 PermGen。PermGen 时代字符串常量池和类元数据混在堆的永久代里,容易因为大量动态生成类(比如某些 ORM、动态代理)而触发 PermGen space OOM,且 -XX:MaxPermSize 不好估算。Metaspace 默认使用本地内存,上限由 -XX:MaxMetaspaceSize 控制,不设则受物理内存限制——这也意味着失控的动态类生成会吃光机器内存。
对象布局:一个对象在堆里的样子
在 HotSpot 中,一个普通对象在堆里由三部分组成:
1 | +------------------+ |
对象头分两部分:
- Mark Word:存对象的运行时数据——哈希码、GC 分代年龄、锁状态标志、偏向线程 ID 等。64 位机器上占 8 字节。注意 Mark Word 是复用的:同一块空间在无锁、偏向锁、轻量级锁、重量级锁、GC 标记等不同状态下含义完全不同,靠末尾几位标志位区分。这就是
synchronized锁升级能"无中生有"地把锁信息塞进对象的原因。 - Klass Pointer:指向方法区里该对象的类元数据,用来确定"我是哪个类的实例"。开启压缩指针(-XX:+UseCompressedOops) 后,这个指针从 8 字节压到 4 字节。
如果是数组对象,对象头里还会多一个记录数组长度的字段。
对齐填充:HotSpot 要求对象起始地址是 8 字节的整数倍,所以末尾会补 padding。这看似浪费,却是压缩指针能用更小位宽寻址更大堆的前提。
可以用 OpenJDK 的 JOL(Java Object Layout)工具实测:
1 | // 引入 org.openjdk.jol:jol-core |
输出会清楚列出每个字段的偏移、大小,以及对象头和 padding 占了多少字节。一个 new Object() 在开启压缩指针时通常是 16 字节(12 字节头 + 4 字节对齐)。
工程权衡:压缩指针与字段重排
压缩指针的 32GB 边界。对象按 8 字节对齐,意味着对象地址末 3 位恒为 0,可以省掉不存。于是 32 位的压缩 oop 实际能寻址 2^32 * 8 = 32GB 的堆。这就是为什么堆大小尽量别越过 32GB:一旦超过,压缩指针失效,所有引用退回 8 字节,内存占用反而暴涨,可能 33GB 堆装的对象还不如 31GB 堆多。这是 JVM 调优里一条反直觉但极重要的红线。
字段重排序。JVM 不会按你源码里声明的字段顺序排列实例数据,而是按类型宽度从大到小排(long/double → int/float → short/char → byte/boolean → 引用),目的是减少 padding、提高对齐效率。所以你无法假设字段在内存里的物理顺序。
常见误区
- “对象一定分配在堆上”:不绝对。开启逃逸分析后,未逃逸的对象可能被标量替换,字段直接散落在栈上,根本不创建堆对象——这也是为什么有些场景下大量临时对象并不产生 GC 压力。
- “调大 -Xmx 就能解决 OOM”:如果是 Metaspace 或线程栈的 OOM,加
-Xmx毫无用处,甚至堆越大、留给元空间和线程栈的本地内存越少。 - “字符串常量池在方法区”:Java 7 起字符串常量池已移到堆中,只有类的运行时常量池仍在方法区/元空间。
小结
JVM 运行时内存分线程私有(PC、虚拟机栈、本地方法栈)和线程共享(堆、方法区)两类,不同 OOM 提示精确对应不同区域。对象在堆里由对象头(Mark Word + Klass Pointer)、实例数据、对齐填充组成,Mark Word 的状态复用支撑了锁升级,压缩指针把堆的"甜点区"卡在 32GB 以内。把这张图刻进脑子,排查内存问题时你就有了坐标系。