JVM 内存结构的本质,不在"有哪些区域",而在"为何这样划分"—— 它是 字节码执行模型与多线程并发模型 的工程投影。
理解 JVM 内存结构,不应从"有哪些区域"入手,而应回答一个前置问题:
为什么 JVM 需要一套内存结构?它由什么力量塑形?
三股独立驱动力,收敛出同一套结构。
JVM 的运行本质是执行字节码——方法调用与返回、操作数计算、对象创建。这套执行机制反向要求四类运行时状态必须有处可存:
| 执行机制 | 要物化的状态 |
|---|---|
| 指令顺序推进、可跳转 | 执行位置 |
| 方法调用与返回 | 调用上下文 |
| 操作数计算 | 计算暂存 |
| 对象创建与类型引用 | 对象数据 + 类型元数据 |
执行模型只回答"要存什么",不回答"怎么摆"。后者由另两股力量决定。
JVM 天然是多线程执行环境,同时又要托管自动内存管理。二者出发点不同,却指向同一条分界线:
两股力量,一把刀:
执行上下文 → 私有;对象数据 → 共享。 并发从"安全"出发、GC 从"可回收"出发,独立推理却落在同一处切分——这是内存结构最稳定的骨架。
于是所有运行时数据归为两类,自然形成两大区域:
| 数据类型 | 本质 | 归属 |
|---|---|---|
| 执行上下文 | 描述"代码执行到哪里" | 线程私有 |
| 对象数据 | 描述"程序处理的数据" | 线程共享 |
JVM 内存结构(HotSpot)
├── 线程私有区(执行上下文)
│ ├── 程序计数器(PC)
│ ├── 虚拟机栈(JVM Stack)
│ └── 本地方法栈(Native Stack)
│
└── 线程共享区(数据存储)
├── 堆(Heap)
├── 方法区(Method Area)
├── 运行时常量池
└── 直接内存
《Java 虚拟机规范》锁定的只是行为契约——"有一个存放类元数据的逻辑职责""方法调用需要栈帧"。而这些职责落在内存的哪里、是否连续、是否被 GC、是否与相邻区合并,规范一概不管:
运行时数据区的内存布局、所用的垃圾回收算法、以及虚拟机指令的内部优化(如翻译为机器码),全部"留给实现者自行裁量"。
规范唯一、实现多样,实现间差异是工程权衡而非理论分歧(见 JVM "JVM 实现的多样性"一节)。内存结构是这一判断在"数据区"层的投影。
行为锁定、布局放开,直接把所有运行时概念劈成两层——一层随规范恒定,一层随实现浮动:
| 层次 | 属性 | 成员 | 是否随实现 / 版本变 |
|---|---|---|---|
| 逻辑概念 | 公共、抽象、规范锁定 | 方法区、运行时常量池、字符串常量池、栈帧职责 | 否——稳定原理层 |
| 物理概念 | 私有、具体、实现裁量 | Heap、Metaspace、PermGen、直接内存的物理落地 | 是——可替换实现层 |
类比:方法区之于 Metaspace,如同接口之于实现类。 规范定义"方法区"这个接口(存类元数据的职责),PermGen(JDK7 及以前)与 Metaspace(JDK8+)是两个实现类。
规范每一处"未规定 / 允许 / 不属于",都是留给实现的填空题;填法由工程权衡(省内存、简化、性能)决定,于是同一逻辑区在不同实现与版本间落点各异。
方法区,规范原文称它"逻辑上属于堆"(logically part of the heap),HotSpot 却在 JDK8 后把它搬到了堆外、乃至 JVM 托管内存之外的 Metaspace。规范说"属于堆"、实现搬出堆——位置、归属、边界全可被实现推翻;不可推翻的只有"逻辑职责"这一契约。
线程私有区的本质是:描述“一个线程的执行现场——它进行到哪、调用链如何、当前在算什么”
程序计数器的本质是线程级的执行状态指针——记录当前线程执行到哪条字节码指令。
为何需要它:JVM 多线程靠时间片轮转、抢占式调度切换执行。线程被切走再切回时,得知道"上次执行到哪"才能续上,PC 就是这个断点,它保证了单线程执行的连续性。
唯一不会 OOM 的区域:PC 只存一个指令地址,容量恒定、不随程序增长——是 JVM 中唯一没有 OOM 可能的内存区。
两者是同一模型的两个实例:线程私有、后进先出,一次方法激活对应一个栈帧,入栈即调用、出栈即返回——栈结构本身就是"方法调用链"的运行时建模。
为何是栈:方法调用天然嵌套,后调用的先返回,调用间需保存、返回时需恢复现场——这正是 LIFO 语义。
栈帧结构(虚拟机栈,规范定义):
栈帧
├── 局部变量表 —— 方法的私有数据空间
├── 操作数栈 —— 字节码指令的计算暂存区
├── 动态连接 —— 与运行时常量池的绑定
└── 返回地址 —— 调用链的恢复点
唯一的分叉——服务哪个世界:
| 虚拟机栈 | 本地方法栈 | |
|---|---|---|
| 服务对象 | Java(字节码)方法 | Native 方法 |
| 栈帧内部 | 上表四件套,规范锁定 | 由 native 实现自定,规范不管 |
本地方法栈是同一栈模型在 Java / Native 执行边界另一侧的落地。HotSpot 直接把二者合二为一。
异常语义(两栈共有):容量有限——调用过深抛 StackOverflowError,栈扩展时申请不到内存抛 OutOfMemoryError。
线程共享区的本质是:存储程序的状态数据——被多线程处理、需跨方法调用存活的对象;区别于私有区那些随方法执行产生、又随退栈消亡的过程数据。
堆的本质是对象生命周期管理区——存储所有对象实例与数组,并承载 GC。
为何需要独立的堆:三个条件同时成立,栈便无法承载,堆成为默认解——
| 条件 | 栈为何不行 | 若该条件不成立,可换的解 |
|---|---|---|
| 生命周期编译期不定 | 栈帧随方法退出即销毁,装不下活得更久的对象 | 编译期能定生死 → 所有权 + 确定性析构,无需堆 |
| 多线程需共享对象 | 栈是线程私有,别的线程看不见 | 不共享内存、只传消息 → 每线程/进程私有内存 |
| 需统一自动回收 | 栈无法处理乱序存活的对象 | 手动释放 / 整块释放 → Arena 区域分配 |
堆是 "生命周期不定 + 需共享 + 要自动回收"三者叠加时的一个工程默认解。
堆的分代:
按对象的某种统计规律分区,对不同区施加匹配该规律的策略——用分类换取整体效率。
| 分区维度 | 派生模型 | 依赖的假说 |
|---|---|---|
| 年龄 | 分代 | 多数对象朝生夕死 |
| 空间(等大 Region) | 分区(G1) | 可按回收收益挑区,而非整代 |
| 大小 | 大对象区 | 大对象少、移动贵、常偏长寿 |
| 引用拓扑 / 可变性 / NUMA 位置 | 连通性 GC 等 | 各自的局部性 / 存活规律 |
TLAB:堆是共享区,多线程同时分配会争抢同一个分配指针。TLAB 让每个线程持有一段私有分配区,把分配热路径从共享退化为私有、由争锁变为无锁——用空间换并发。它是"共享区在热点上局部私有化"的典型手法,也是前文"私有 / 共享"分界在堆内部的一次回响。
方法区的本质是 JVM 的类型信息仓库——存储类结构信息、常量、静态变量、以及 JIT 编译后的代码。
演进:PermGen → Metaspace(JDK8 分界)。根因是将类元数据与 Java 堆解耦:脱离 -Xmx 与堆 GC 后,Metaspace 默认无上限、按需扩展,解决了 PermGen "大小难调"之痛——但溢出并未消除,只是从"堆内固定小区易撑爆"变为"无节制增长可能触发进程级 OOM"。
运行时常量池是常量的运行时表示,属方法区的逻辑组成。
动态性是其关键:它不止装编译期常量,String.intern() 可在运行期向池中注入新常量——体现常量池的运行时扩展能力,而非一份静态只读表。
直接内存的本质是进程地址空间里的 Native Heap——经 NIO / Unsafe 在 JVM 堆外分配,不受 -Xmx 管辖,但会触发 OOM。
动机是消除拷贝:NIO 的 Zero Copy 让 I/O 直接读写这块 native 内存,省去"Java 堆 ↔ Native 堆"的来回复制。代价是脱离 GC 托管,需手动或借 Cleaner 释放。
前面讲的"区"是容器,对象是容器里真正被操作的单元。它的三个面——怎么生、怎么排、怎么被找到——各沉淀一条可迁移的权衡。贯穿它们的是一个原则:对象是自描述单元,自带类型与运行时状态元数据,故执行引擎(多态)、GC(追踪)、并发(加锁)三方能协同操作它,无需外部登记表。
Object obj = new Object() 编译为 new → dup → invokespecial → astore,其稳定内核不在这几条指令,而在它们揭示的两阶段:
分配内存 + 零值填充 → 执行 <init> 构造
(对象已存在) (对象才完备)
指令只是该协议的投影:
new只做"分配 + 压引用",dup留一份给构造消耗、一份给赋值——字节码层面就把"分配"与"初始化"劈开了。
对象
├── 对象头
│ ├── 类型指针 —— 指向类元数据:对象"是什么",多态分派与 GC 的依据
│ └── Mark Word —— 运行时状态:锁 / GC 年龄 / hashCode
├── 实例数据 —— 字段值本身
└── 对齐填充 —— 补齐到 8 字节整数倍
对象头是"自描述"的固定成本(64 位压缩指针下约 12 字节)。两条稳定权衡:
Mark Word——定长空间的位复用,是这里最本质的模式:
| 位复用 | 收益 | 代价 |
|---|---|---|
| 锁 / GC 年龄 / hashCode 共用同一片固定位 | 元信息内嵌对象头、就近可取、零额外指针 | 彼此互斥,须状态机分时占用 |
这是一切"元数据内联定长槽"的通用权衡——指针压缩、tagged pointer、NaN-boxing 同理。锁升级不过是这片位在"无竞争 → 交替 → 真竞争"三种假设下的状态机(机制属并发领域,见 JAVA并发编程
引用如何找到对象,是"要不要引入一层间接"的通用抉择:
| 方式 | 机制 | 得 | 失 |
|---|---|---|---|
| 句柄 | 引用 → 句柄池 → 对象 | 对象移动只改句柄,引用稳定 | 多一次解引用 |
| 直接指针 | 引用 → 对象 | 少一次访存 | 对象移动须改所有引用 |
HotSpot 选直接指针——把"GC 移动时更新引用"的成本,换访问热路径的速度。
这是架构中反复出现的 introduce-indirection 模式:加一层间接换灵活 / 稳定(OS 虚拟地址、DNS、Handle),去一层换性能。定位之争只是它在对象访问上的落地。
OOM 不是一种错误,而是一族。每个 detail message 对应不同的区、不同的根因、不同的排查路径。而这些区,远不止堆。
承接前文"规范 vs 实现":分区树画的是规范数据区;进程实际吃多少内存是另一维度。JVM 进程内存只分两类——
| 类别 | 成员 | -Xmx 管辖? |
|---|---|---|
| 堆内 | Java 堆 | 是 |
| 堆外(native) | Metaspace、线程栈、直接内存、Code Cache、GC 结构… | 否 |
堆内只有堆一项,堆外是一批。每个堆外区各有自己的上限与 OOM 信号(如 Metaspace 撑爆、线程栈耗尽 native 内存),撑爆任一区都是 OOM,但都不受
-Xmx管辖。 故进程 RSS ≈ 堆 + Σ 堆外区;观测堆外全景靠 NMT,而非看堆的工具。
内存约束来自两个彼此独立、互不知情的执行者:
正因堆外区逃逸
-Xmx,外层天花板可能在堆远未满时先触发——这就是"堆没满进程却被杀"的根源,也是容器额度必须比-Xmx留出堆外余量的原因。诊断的第一步永远是分清:这次是谁、撞了哪道天花板。
现象 → 区域定位 → 根因
读 detail message,判归属
├─ 堆内(heap space / GC overhead)→ heap dump + MAT,看 dominator tree / 引用链
├─ 堆外(Metaspace / native thread / Direct buffer …)
│ → NMT baseline → 增长后 summary.diff,看哪个分类持续 +committed
└─ 无 message 但退出 137 → 非 JVM OOME,核对进程 RSS vs 容器 limit
JVM 内存结构不是随意划分,而是"字节码执行 + 多线程并发"两条约束推导出的必然产物:
字节码执行模型 → 需物化执行现场 → PC / 栈
多线程并发 → 需隔离与共享 → 私有区 / 共享区
└─ 共享区的存在,反过来定义了 GC 与 JMM 要治理的对象
故理解内存结构的入口,是"为什么需要这些区域",而非"有哪些区域"——分区图是推导的结果,不是起点。