JVM 内存结构

JVM 内存结构的本质,不在"有哪些区域",而在"为何这样划分"—— 它是 字节码执行模型与多线程并发模型 的工程投影。

设计起点:从需求到结构

理解 JVM 内存结构,不应从"有哪些区域"入手,而应回答一个前置问题:

为什么 JVM 需要一套内存结构?它由什么力量塑形?

三股独立驱动力,收敛出同一套结构。

驱动力一·执行模型:决定"要存什么"

JVM 的运行本质是执行字节码——方法调用与返回、操作数计算、对象创建。这套执行机制反向要求四类运行时状态必须有处可存:

执行机制 要物化的状态
指令顺序推进、可跳转 执行位置
方法调用与返回 调用上下文
操作数计算 计算暂存
对象创建与类型引用 对象数据 + 类型元数据

执行模型只回答"要存什么",不回答"怎么摆"。后者由另两股力量决定。

驱动力二·并发 + 驱动力三·GC:从两个方向收敛到同一刀

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 对应不同的区、不同的根因、不同的排查路径。而这些区,远不止堆。

进程内存全景:Xmx 只管一行

承接前文"规范 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 要治理的对象

故理解内存结构的入口,是"为什么需要这些区域",而非"有哪些区域"——分区图是推导的结果,不是起点。

关联内容(自动生成)