JVM内存模型详解

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 对象分配的典型路径
  1. 绝大多数对象先分配在 Eden
  2. Minor GC 后存活对象进入 Survivor,并增加“年龄”
  3. 年龄达到阈值或 Survivor 放不下 → 晋升到 Old
  4. 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
  • 常见 OOM 场景
    • 动态生成大量类(CGLIB、Javassist、ByteBuddy、脚本引擎等)且类卸载条件不满足
  • 调参
    • -XX:MaxMetaspaceSize(上限)
    • -XX:MetaspaceSize(触发 GC 的阈值之一)

2.6 直接内存(Direct Memory / Off-Heap)

  • 不属于 JVM 规范的运行时数据区,但在 HotSpot 中非常关键。
  • 典型来源:
    • NIO ByteBuffer.allocateDirect
    • Netty 堆外内存
    • mmap 文件映射等
  • 风险
    • 堆看起来不大,但进程 RSS 飙升,最终被 OS 杀死或出现 OutOfMemoryError: Direct buffer memory
  • 相关参数
    • -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 三大核心性质

  1. 原子性
    • 单次读/写(如 int 赋值)通常是原子的
    • i++ 不是原子操作(读-改-写三步)
  2. 可见性
    • volatilesynchronizedfinal(正确发布)可以提供可见性保障
  3. 有序性
    • 编译器/CPU 可能重排序,只要不改变单线程语义
    • 但多线程下可能出现“先看见结果,后看见原因”的诡异现象

5.3 happens-before 规则(非常重要)

理解为:如果 A happens-before B,那么 A 的结果对 B 可见,且 A 的执行顺序排在 B 之前(在 JMM 意义上)。

常用规则:

  • 程序顺序规则:同一线程内,前面的操作 hb 后面的操作
  • 监视器锁规则:解锁 hb 之后对同一锁的加锁
  • volatile 变量规则:对 volatile 的写 hb 之后对它的读
  • 线程启动/终止规则:Thread.start() hb 线程内动作;线程内动作 hb Thread.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_properties
  • jcmd <pid> GC.heap_info / GC.class_histogram
  • jstat -gcutil <pid> 1s
  • jmap -dump:format=b,file=heap.hprof <pid>(大堆会卡顿,慎用)
  • jstack <pid>(线程死锁/阻塞/线程爆炸)
  • JFR(Java Flight Recorder):低开销、强烈建议

7.3 一套“先不动代码”的定位流程

  1. 看错误类型(heap/metaspace/direct/native thread)
  2. 看 GC 日志:是否频繁 Full GC、晋升失败、停顿是否异常
  3. 拉一次类直方图(class histogram):是不是某类对象激增
  4. 若怀疑泄漏:dump heap → MAT / VisualVM 分析 dominator tree、引用链
  5. 若怀疑堆外:看进程 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 的演进)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值