JVM 内存模型
一、Java 内存模型(JMM,Java Memory Model)
关注"多线程下变量如何可见、有序",是语言规范层面的概念,和硬件内存模型有关,但不是 JVM 内部数据结构。
1. 核心问题
-
多线程共享变量时,一个线程的写操作,何时对其他线程可见?
-
编译器和处理器为了性能会做重排序,如何保证程序语义不变?
2. 抽象结构
-
主内存(Main Memory):所有线程共享,存放实例字段、静态字段等。
-
工作内存(Working Memory):每个线程私有,保存该线程使用的变量的副本。
-
线程不能直接读写主内存中的变量,必须通过工作内存:
-
读:主内存 → 工作内存
-
写:工作内存 → 主内存
这里的"主内存 / 工作内存"是逻辑概念,不等同于堆 / 栈。
3. 关键机制
-
happens-before 原则:定义操作之间的可见性顺序,常见规则:
-
程序顺序规则
-
监视器锁规则(解锁先于后续加锁)
-
volatile 变量规则
-
线程启动 / 终止规则
-
volatile:
-
可见性:写立即刷新到主内存,读从主内存加载
-
禁止部分重排序(通过内存屏障)
-
synchronized / Lock:保证原子性 + 可见性 + 有序性
-
final:正确构造的对象,final 字段对其他线程可见
二、JVM 运行时数据区(Runtime Data Areas)
关注"JVM 进程在运行 Java 程序时,内存如何划分",是 JVM 实现层面的结构。
以下以 HotSpot + Java 8+ 为主说明。
1. 线程私有区域
(1)程序计数器(PC Register)
-
当前线程所执行字节码的行号指示器
-
线程切换后能恢复到正确执行位置
-
唯一不会 OOM 的区域
(2)Java 虚拟机栈(JVM Stack)
每个方法调用对应一个栈帧(Stack Frame)
栈帧包含:
-
局部变量表
-
操作数栈
-
动态链接
-
方法返回地址
出现异常:
-
"StackOverflowError":栈深度过大
-
"OutOfMemoryError":栈扩展失败
(3)本地方法栈(Native Method Stack)
-
为 Native 方法(JNI)服务
-
HotSpot 中常与虚拟机栈合二为一
2. 线程共享区域
(4)Java 堆(Heap)
-
最大的一块内存区域
-
存放对象实例和数组
-
GC 的主要区域
-
可细分为:
-
新生代(Eden、Survivor)
-
老年代
-
异常:
"OutOfMemoryError: Java heap space"
(5)方法区(Method Area)
存储:
-
类信息(类名、字段、方法、字节码等)
-
常量
-
静态变量
-
JIT 编译后的代码
-
Java 8 之前:永久代(PermGen)
-
Java 8+:元空间(Metaspace),使用本地内存
异常:
"OutOfMemoryError: Metaspace"
(6)运行时常量池(Runtime Constant Pool)
-
方法区的一部分
-
存放编译期生成的字面量和符号引用
-
运行时也可放入新常量(如
"String.intern()")
3. 直接内存(Direct Memory)
-
不属于 JVM 运行时数据区
-
通过 "ByteBuffer.allocateDirect()" 分配
-
受本机内存限制
异常:
"OutOfMemoryError"
三、总结
-
JMM:解决"多线程下变量怎么看得到、不乱序"
-
JVM 内存结构:解决"Java 程序运行时的内存怎么分、放哪"
垃圾回收机制
一、是什么
JVM 垃圾回收(GC)的核心就一句话:自动找到不再使用的对象,回收它们占用的内存。
二、怎么判断对象"死了"?
- 引用计数法(不用)
给每个对象记一个计数器,被引用就 +1,引用失效就 -1,变成 0 就回收。
❌ 缺点:两个对象互相引用就永远回收不了(循环引用问题) - 可达性分析(JVM 实际用的)
从一组 "GC Roots" 出发,顺着引用链往下找:
能找到的对象 → 存活
打个比方:GC Roots 是树根,顺着根能摸到的都是活的,摸不到的就是"垃圾"。
找不到的对象 → 可回收
GC Roots是判断对象是否存活的起点。从这些根节点开始,能通过引用链访问到的对象就是"活的",反之就是"垃圾"
有哪些
虚拟机栈中引用的对象(当前方法中的局部变量)
本地方法栈中JNI引用的对象
方法区中静态属性引用的对象(static变量)
方法区中常量引用的对象(final常量)
JVM内部的引用(如Class对象、异常对象、类加载器等)
一句话总结:GC Roots是那些"绝对不会被回收"的对象的集合,是垃圾回收的起点。
打个比方:GC Roots 是树根,顺着根能摸到的都是活的,摸不到的就是"垃圾"。
三、垃圾怎么清?
- 标记-清除(Mark-Sweep)
先标记垃圾,再统一清除
❌ 缺点:产生大量内存碎片 - 复制算法(Copying)
把内存分成两半,只用一半
存活对象复制到另一半,然后清空这一半
✅ 没有碎片
❌ 缺点:只能用一半内存 - 标记-整理(Mark-Compact)
标记后,把存活对象往一端挪,然后清理边界外的内存
✅ 没有碎片,空间利用率高
❌ 缺点:移动对象成本高 - 分代收集(实际 JVM 用的)
核心思想:大部分对象活不长。
把堆分成几块,不同区域用不同策略
四、对象在内存里怎么放的?
新生代:新创建的对象都放这,大多数对象"朝生夕死"
Eden(伊甸园)+ 两个 Survivor 区
老年代:在新生代活过几轮 GC 还没死的对象,搬到这里
为什么分代?因为大部分对象活不长,可以针对性地频繁清理新生代,减少全堆扫描的开销。
对象的一生:
新对象先放 Eden
Eden 满了 → 触发 Minor GC,存活的放到 S0(先放到S1,然后回收eden和S0,S0和S1互换)
下次 GC,Eden + S0 存活的复制到 S1,清空 Eden + S0
每熬过一次 GC,年龄 +1
年龄到阈值(默认 15)→ 晋升 老年代
老年代也满了 → 触发 Full GC(全局回收,最慢)
五、垃圾回收器(简单了解)
- Serial:单线程,简单但会"Stop The World"(暂停所有用户线程)
- Parallel / Parallel Old:多线程并行,吞吐量优先
- CMS:并发标记清除,停顿短,但已废弃
- G1(主流):把堆分成很多小 Region,可预测停顿时间,平衡吞吐和延迟
- ZGC / Shenandoah:超低延迟(停顿 < 10ms),适合超大堆
总结
JVM GC 基于可达性分析识别垃圾,按分代采用不同回收算法(新生代复制、老年代整理),并由各类垃圾回收器(如 G1、ZGC)负责具体执行。