JVM 内存模型详解(运行时数据区 + Java Memory Model)
在中文语境里,“JVM 内存模型”有两种常见指代:
1)JVM 运行时数据区(HotSpot 里的堆、栈、元空间等),偏“内存结构”;
2)Java Memory Model(JMM)(可见性/有序性/原子性规则),偏“并发语义”。
这份文档把两者都讲清楚,并给出排障与调优落地方法。
1. 一张图先建立整体视角
┌─────────────── 线程私有 ────────────────┐
Java 线程 ───▶ │ 程序计数器 PC │ 虚拟机栈 │ 本地方法栈 │
└────────────────────────────────────────┘
│
▼
┌────────────── 线程共享 ───────────────┐
│ 堆 Heap │
│ (新生代/老年代/对象分配/GC 等) │
└───────────────────────────────────────┘
│
▼
┌────────────── 线程共享 ───────────────┐
│ 方法区 / 元空间 Metaspace │
│ (类元数据、常量池、方法字节码等) │
└───────────────────────────────────────┘
另外:直接内存 Direct Memory(NIO/堆外),不属于运行时数据区但非常重要。
2. JVM 运行时数据区(HotSpot 视角)
2.1 程序计数器(PC Register)——线程私有
- 作用:记录当前线程执行到哪一条字节码指令(解释器/即时编译器都需要)。
- 特点:
- 线程私有(每个线程一份),切换线程后能恢复到正确位置。
- 几乎是 JVM 中唯一不会 OOM 的区域。
- 注意:执行 Native 方法时,PC 的值是未定义(不指向字节码)。
2.2 虚拟机栈(Java Virtual Machine Stack)——线程私有
- 组成单位:栈帧(Stack Frame),每次方法调用入栈,返回出栈。
- 栈帧主要包含:
- 局部变量表(Local Variables)
- 操作数栈(Operand Stack)
- 动态链接(指向运行时常量池中的符号引用解析结果)
- 方法返回地址等
- 常见问题:
StackOverflowError:递归太深/栈帧过大导致栈空间耗尽。OutOfMemoryError: unable to create new native thread:线程太多或单个线程栈太大导致无法再创建线程(本质是 OS 资源/地址空间不足)。
- 调参:
-Xss控制每个线程栈大小- 栈大:单线程递归更深、但线程数上限变低
- 栈小:线程数上限高、但更易 SOE
实战经验:线上“线程爆炸”时,盲目把
-Xss调太大很容易把问题放大(因为每个线程占用更多内存)。
2.3 本地方法栈(Native Method Stack)——线程私有
- 作用:执行 JNI/Native 方法时使用的栈。
- 问题类型:同样可能
StackOverflowError或 OOM(不同 JVM 实现表现略有差异)。
2.4 堆(Heap)——线程共享
堆是 GC 主要工作区域,也是对象的主要分配地。
2.4.1 分代结构(经典 HotSpot)
- 新生代 Young:Eden + S0 + S1(Survivor)
- 老年代 Old:存放存活时间长/体积大/晋升的对象
- 大对象/特殊对象:可能直接进入老年代(取决于收集器和配置)
JDK 21+(或不同 GC)分代实现细节有差异,但“短命对象多、长命对象少”的假设仍然成立。
2.4.2 对象分配的典型路径
- 绝大多数对象先分配在 Eden
- Minor GC 后存活对象进入 Survivor,并增加“年龄”
- 年龄达到阈值或 Survivor 放不下 → 晋升到 Old
- Old 不够 → Full GC / Mixed GC / 触发 OOM(取决于 GC)
2.4.3 TLAB(线程本地分配缓冲)
- 为了减少多线程在堆上分配对象时的锁竞争,JVM 给每个线程划一小块 TLAB。
- 大多数小对象在 TLAB 内“指针碰撞”即可分配,速度非常快。
2.5 方法区 / 元空间(Method Area / Metaspace)——线程共享
- 方法区是 JVM 规范概念;HotSpot 在 JDK 8 之后用 **元空间(Metaspace)**实现。
- 主要内容:
- 类元数据(Class Metadata)
- 运行时常量池(Runtime Constant Pool)
- 方法字节码、字段信息等
- JDK 7/8 时代对比:
- JDK 7 及以前:HotSpot 有 永久代 PermGen(在堆里的一块区域),常见
OutOfMemoryError: PermGen space - JDK 8+:移除 PermGen,改为 Metaspace(使用本地内存),常见
OutOfMemoryError: Metaspace
- JDK 7 及以前:HotSpot 有 永久代 PermGen(在堆里的一块区域),常见
- 常见 OOM 场景:
- 动态生成大量类(CGLIB、Javassist、ByteBuddy、脚本引擎等)且类卸载条件不满足
- 调参:
-XX:MaxMetaspaceSize(上限)-XX:MetaspaceSize(触发 GC 的阈值之一)
2.6 直接内存(Direct Memory / Off-Heap)
- 不属于 JVM 规范的运行时数据区,但在 HotSpot 中非常关键。
- 典型来源:
- NIO
ByteBuffer.allocateDirect - Netty 堆外内存
- mmap 文件映射等
- NIO
- 风险:
- 堆看起来不大,但进程 RSS 飙升,最终被 OS 杀死或出现
OutOfMemoryError: Direct buffer memory
- 堆看起来不大,但进程 RSS 飙升,最终被 OS 杀死或出现
- 相关参数:
-XX:MaxDirectMemorySize(若未设置,通常与-Xmx相关联,具体行为依 JVM 实现而定)
3. 对象在内存中的样子(理解 GC 与锁很有用)
3.1 对象的基本布局(HotSpot 常见)
- 对象头:
- Mark Word(哈希、锁状态、GC 年龄等)
- Klass Pointer(指向类元数据)
- 实例数据:字段内容
- 对齐填充:按 8 字节对齐(常见)
这也是为什么“加一个 boolean 字段不一定只多 1 字节”的原因:对齐与对象头占比会影响最终大小。
3.2 引用类型(强/软/弱/虚)
- 强引用:默认引用,GC 不会回收
- 软引用:内存紧张时回收(缓存场景)
- 弱引用:下一次 GC 就可能回收
- 虚引用:配合引用队列做资源回收通知
4. 垃圾回收(GC)你至少需要知道这些
4.1 何为可达性分析(GC Roots)
常见 GC Roots:
- 线程栈中的局部变量引用
- 静态字段引用(类变量)
- JNI 引用
- 活跃线程、锁对象等
对象从 Roots 可达 → 存活;不可达 → 可回收(可能经历一次 finalize 复活,但不建议依赖)。
4.2 常见 GC 事件(概念层)
- Minor GC:主要回收新生代
- Major/Old GC:回收老年代(不同收集器定义略不同)
- Full GC:通常指全堆 + 方法区/元空间相关回收(代价高)
4.3 你会在日志里看到什么
- 吞吐量(Throughput):应用时间 / 总时间
- 停顿时间(Pause):STW 时长(用户更敏感)
- 晋升失败、并发失败、空间不足等关键字
建议:生产环境至少打开 GC 日志,并把日志输出到文件(避免 STDOUT 影响容器/日志采集)。
5. Java Memory Model(JMM)——并发的“内存规则”
JMM 解决的问题不是“内存怎么分区”,而是:
- 一个线程写入的变量,另一个线程什么时候能看见?(可见性)
- 指令会不会乱序导致诡异结果?(有序性)
- 某些操作是不是不可分割?(原子性)
5.1 主内存与工作内存(抽象模型)
- 主内存:所有线程共享的变量存储
- 工作内存:每个线程对共享变量的副本(寄存器/缓存/编译器优化的抽象)
这解释了为什么“你在一个线程里改了变量,另一个线程不一定马上看到”。
5.2 三大核心性质
- 原子性
- 单次读/写(如
int赋值)通常是原子的 i++不是原子操作(读-改-写三步)
- 单次读/写(如
- 可见性
volatile、synchronized、final(正确发布)可以提供可见性保障
- 有序性
- 编译器/CPU 可能重排序,只要不改变单线程语义
- 但多线程下可能出现“先看见结果,后看见原因”的诡异现象
5.3 happens-before 规则(非常重要)
理解为:如果 A happens-before B,那么 A 的结果对 B 可见,且 A 的执行顺序排在 B 之前(在 JMM 意义上)。
常用规则:
- 程序顺序规则:同一线程内,前面的操作 hb 后面的操作
- 监视器锁规则:解锁 hb 之后对同一锁的加锁
- volatile 变量规则:对 volatile 的写 hb 之后对它的读
- 线程启动/终止规则:
Thread.start()hb 线程内动作;线程内动作 hbThread.join()返回 - 传递性:A hb B 且 B hb C ⇒ A hb C
5.4 volatile:轻量但“不是万能”
volatile 提供:
- 对该变量的读写可见性
- 对 volatile 写-读建立 happens-before
- 禁止某些重排序(插入内存屏障)
volatile 不提供:
- 复合操作的原子性(
count++仍然不安全)
适用场景:
- 状态标记(如停止标志)
- 单例双重检查(DCL)中配合
volatile(避免重排序导致半初始化对象可见)
5.5 synchronized / Lock
- synchronized:
- 进入/退出监视器带来内存语义(可见性 + 有序性)
- 同时提供互斥(原子性)
java.util.concurrent.locks:- 同样有 happens-before 保障(基于 AQS/volatile/CAS)
6. 把两者串起来:为什么“并发 Bug”经常像“内存问题”
一个经典例子:发布逸出(unsafe publication)
- 线程 A new 了对象,但对象内部字段还没完全写完
- 由于重排序/缓存,线程 B 可能拿到“非 null 引用”,但字段仍是默认值
解决:
- 正确的发布方式:
final字段、静态初始化、volatile 引用、加锁发布等。
7. 线上排障速查(非常实用)
7.1 判断是“堆”还是“非堆/堆外”
- 堆 OOM:
OutOfMemoryError: Java heap space - 元空间 OOM:
OutOfMemoryError: Metaspace - 直接内存 OOM:
OutOfMemoryError: Direct buffer memory - 线程创建失败:
unable to create new native thread
7.2 常用工具链(按“上手快”排序)
jcmd <pid> VM.flags/VM.system_propertiesjcmd <pid> GC.heap_info/GC.class_histogramjstat -gcutil <pid> 1sjmap -dump:format=b,file=heap.hprof <pid>(大堆会卡顿,慎用)jstack <pid>(线程死锁/阻塞/线程爆炸)- JFR(Java Flight Recorder):低开销、强烈建议
7.3 一套“先不动代码”的定位流程
- 看错误类型(heap/metaspace/direct/native thread)
- 看 GC 日志:是否频繁 Full GC、晋升失败、停顿是否异常
- 拉一次类直方图(class histogram):是不是某类对象激增
- 若怀疑泄漏:dump heap → MAT / VisualVM 分析 dominator tree、引用链
- 若怀疑堆外:看进程 RSS 与堆大小差异、排查 direct buffer/Netty/ mmap
8. 参数与实践建议(别迷信“调大内存”)
- 先明确目标:低延迟还是高吞吐
- 先收集证据:GC 日志 + 指标(停顿、吞吐、分配速率、Old 占用趋势)
- 再做改变:一次只改一组参数,并记录效果
- 容器环境要特别小心:
- 确认 JVM 是否正确识别 cgroup 限制
- 关注“堆外 + 元空间 + 线程栈 + 代码缓存”总和,避免 OOMKilled
9. 面试/工作里经常被问的点(快速复习)
- 堆、栈、方法区分别存什么?为什么栈线程私有?
i++为什么不是原子?- volatile 的语义是什么?为什么不能保证
count++? - happens-before 有哪些规则?举例说明
- Metaspace OOM 常见原因?如何避免动态类泄漏?
- Direct Memory 为什么会把你“阴死”?如何限制与观测?
10. 参考阅读(建议)
- 《Java 虚拟机规范》运行时数据区章节
- JLS(Java Language Specification)关于内存模型章节
- OpenJDK/HotSpot 源码与 JEP(了解不同 GC 的演进)
1370

被折叠的 条评论
为什么被折叠?



