主题
Chapter 20 · JVM 内存模型与垃圾回收
程序"自动管理内存"的秘密 — Java 工程师的内功。
🎯 本章目标
- 理解 JVM 运行时数据区(堆 / 栈 / 方法区 ...)
- 区分 栈和堆 存什么
- 理解 垃圾回收(GC) 基本原理
- 知道常见 GC 算法(标记-清除 / 复制 / 标记-整理)
- 了解 JVM 调优 的常见参数
1. JVM 是什么?
Java Virtual Machine — 运行 .class 文件的虚拟计算机。
.java → javac → .class → JVM → 真实 CPUJVM 屏蔽了 OS 和 CPU 的差异,是 Java 跨平台的核心。
2. JVM 运行时数据区
┌─────────────────────────────────────────────────┐
│ 方法区(Method Area) │ ← 存类信息、常量、静态变量
│ JDK 8+ 改名"元空间 Metaspace" │ 线程共享
├─────────────────────────────────────────────────┤
│ 堆(Heap) │ ← 存对象实例
│ 新生代 + 老年代 + 永久代 │ 线程共享,GC 主战场
├─────────────────────────────────────────────────┤
│ 栈(Stack) │ ← 存方法的局部变量
│ 每个线程一个栈,栈帧记录方法调用 │ 线程私有
├─────────────────────────────────────────────────┤
│ 程序计数器(PC Register) │ ← 当前线程执行的字节码行号
├─────────────────────────────────────────────────┤
│ 本地方法栈(Native Stack) │ ← native 方法(JNI)
└─────────────────────────────────────────────────┘各区域详解
| 区域 | 线程 | 存什么 | 异常 |
|---|---|---|---|
| 堆(Heap) ⭐ | 共享 | 对象实例、数组 | OutOfMemoryError |
| 方法区/Metaspace | 共享 | 类元数据、常量池、静态字段 | OutOfMemoryError |
| 虚拟机栈(Stack) | 私有 | 栈帧(局部变量、操作数、引用) | StackOverflowError |
| 程序计数器(PC) | 私有 | 当前指令地址 | 唯一不会 OOM |
| 本地方法栈 | 私有 | native 方法栈 | StackOverflowError |
3. 栈与堆:经典误解
java
public void demo() {
int x = 10; // 栈:基本类型直接存值
String s = "hello"; // 栈:引用 堆:String 对象
Person p = new Person(); // 栈:引用 堆:Person 对象
int[] arr = {1, 2, 3}; // 栈:引用 堆:数组对象
}💡 关键:
- 基本类型局部变量 在栈上
- 对象 一律在堆上
- 栈上只有"对象的地址(引用)"
栈帧(Stack Frame)
每次方法调用 → 压一个新栈帧;方法结束 → 弹出。栈帧含:
- 局部变量表
- 操作数栈
- 动态链接
- 返回地址
java
void a() { b(); } // a 的栈帧上面叠 b 的栈帧
void b() { c(); } // b 上面叠 c栈过深 → StackOverflowError(无限递归经典错误)。
4. 堆的分代结构
Heap
┌──────────────────────────────┐
│ 新生代 (Young Gen) 1/3 │
│ ┌──────┬──────┬──────┐ │
│ │ Eden │ S0 │ S1 │ │ (8:1:1)
│ └──────┴──────┴──────┘ │
├──────────────────────────────┤
│ 老年代 (Old Gen) 2/3 │
└──────────────────────────────┘对象的一生
- new 时分配在 Eden 区
- Eden 满 → 触发 Minor GC:
- 存活对象进入 Survivor 区(S0/S1 来回拷贝)
- 每熬过一次 GC,年龄 +1
- 年龄到 15 岁 → 进入 老年代
- 老年代满 → 触发 Full GC("Stop The World",慢且讨厌)
💡 设计哲学:大部分对象都是"朝生夕死",只有少数会活到老年代。
5. 垃圾回收:怎么判断垃圾?
5.1 引用计数法(Java 不用)
每个对象一个计数器,引用 +1,失效 -1,归 0 → 垃圾。 问题:循环引用搞不定(A → B → A)。
5.2 可达性分析(Java 用)⭐
从 GC Roots 出发,能到达的对象都活着,到不了的就是垃圾。
GC Roots 包括:
- 栈帧中的局部变量
- 方法区中的静态字段
- 方法区中的常量
- 本地方法栈中的引用
6. 三种核心 GC 算法
6.1 标记-清除(Mark-Sweep)
- 标记所有可达对象
- 清除未标记的
- 缺点:产生内存碎片
6.2 复制(Copying)
- 把存活对象复制到另一半
- 适合存活率低的区域(新生代)
- 缺点:浪费一半空间
6.3 标记-整理(Mark-Compact)
- 标记后把存活对象往一端移动
- 适合存活率高的区域(老年代)
- 缺点:移动开销
| 算法 | 速度 | 空间 | 碎片 | 用于 |
|---|---|---|---|---|
| 标记-清除 | 中 | 不浪费 | 有 | CMS |
| 复制 | 快 | 浪费 | 无 | 新生代 |
| 标记-整理 | 慢 | 不浪费 | 无 | 老年代 |
7. 主流垃圾回收器(简表)
| 收集器 | 适用场景 | 特点 |
|---|---|---|
| Serial | 单线程客户端 | 简单,STW 长 |
| Parallel | 注重吞吐量 | 多线程,JDK 8 默认 |
| CMS | 关注延迟(已废弃) | 老年代并发标记清除 |
| G1 ⭐ | JDK 9+ 默认 | 分区,低延迟,可预测 |
| ZGC | JDK 11+ | 超低延迟(< 10ms) |
| Shenandoah | RH OpenJDK | 与 ZGC 类似 |
8. 常见 OOM 错误
| 错误 | 原因 | 排查 |
|---|---|---|
java.lang.OutOfMemoryError: Java heap space | 堆溢出 | 内存泄漏 / 堆太小 |
OutOfMemoryError: Metaspace | 元空间溢出 | 类太多(动态生成代理) |
StackOverflowError | 栈溢出 | 递归过深 |
OutOfMemoryError: Direct buffer memory | 直接内存 | NIO 用太多 |
OutOfMemoryError: GC overhead limit exceeded | GC 占 98% 时间没回收 2% 内存 | 内存严重不足 |
9. JVM 调优常用参数
bash
java \
-Xms512m \ # 初始堆大小
-Xmx2g \ # 最大堆大小
-Xmn512m \ # 新生代大小
-Xss256k \ # 每个线程栈大小
-XX:MetaspaceSize=128m \
-XX:MaxMetaspaceSize=256m \
-XX:+UseG1GC \ # 用 G1 收集器
-XX:MaxGCPauseMillis=200 \
-XX:+PrintGCDetails \ # 打印 GC 日志
-XX:+HeapDumpOnOutOfMemoryError \ # OOM 时 dump
-XX:HeapDumpPath=/tmp/heap.hprof \
-jar app.jar💡 一般生产环境
-Xms = -Xmx,避免动态扩展抖动。
10. 排查工具
| 工具 | 用途 |
|---|---|
jps | 列出 Java 进程 |
jstat -gc <pid> | 看 GC 统计 |
jstack <pid> | 看线程栈(死锁排查) |
jmap -dump:format=b,file=h.hprof <pid> | 堆 dump |
| VisualVM / JConsole | 图形化监控 |
| Arthas ⭐ | 阿里开源,在线诊断神器 |
| MAT (Eclipse Memory Analyzer) | 分析 hprof |
11. 实战练习
| 文件 | 内容 |
|---|---|
StackOverflowDemo.java | 制造 StackOverflowError |
HeapOOMDemo.java | 制造 Java heap space OOM |
MemoryLeakDemo.java | 内存泄漏经典场景 |
GCDemo.java | 观察 Minor GC / Full GC |
⚠️ 这些 demo 可能崩溃!适合当场看,不要在生产跑。
12. 浏览器演示
打开 demo.html:
- JVM 内存结构交互图
- 对象的一生动画
- GC Roots 可达性分析
13. 面试可能会问什么?
Q1: JVM 内存结构?
5 大区域:堆、方法区、虚拟机栈、本地方法栈、程序计数器。 线程共享:堆 + 方法区 线程私有:虚拟机栈 + 本地方法栈 + PC
Q2: 栈和堆什么区别?
| 栈 | 堆 | |
|---|---|---|
| 存什么 | 局部变量、引用 | 对象实例 |
| 分配 | 自动 | new |
| 速度 | 快 | 慢 |
| 线程 | 私有 | 共享 |
| 生命周期 | 跟随方法 | GC 决定 |
Q3: 怎么判断对象是垃圾?
可达性分析:从 GC Roots 出发能到达的对象活着,否则是垃圾。
GC Roots 主要是:栈中局部变量、静态字段、常量。
Q4: 为什么不用引用计数?
循环引用问题:A.field = B; B.field = A;,但谁也不引用 A、B → 计数永远不为 0,无法回收。
Q5: Minor GC、Major GC、Full GC 区别?
- Minor GC:清理新生代,频率高,速度快
- Major GC:清理老年代
- Full GC:清理整个堆 + 方法区,慢且 STW,要尽量避免
Q6: 什么是 STW?
Stop The World — GC 时所有应用线程被暂停。 应用对延迟敏感时(如交易系统),STW 长就是灾难。 现代 GC(G1 / ZGC)大量并发执行,把 STW 降到几毫秒。
Q7: 老年代为什么不用复制算法?
老年代对象存活率高,复制开销大,浪费一半空间也亏。 所以用 标记-清除 或 标记-整理。
Q8: 字符串常量池在哪?
- JDK 6 及以前:方法区(永久代)
- JDK 7+:堆中
- JDK 8:方法区 → 元空间(在本地内存里),但常量池仍在堆
Q9: 怎么排查 OOM?
- 加
-XX:+HeapDumpOnOutOfMemoryError自动 dump - 用 MAT / JProfiler 打开 .hprof 看
- 找"占内存最多的对象 + 引用链"
- 常见:缓存无限增长、ThreadLocal 没 remove、大对象未释放
Q10: G1 和 CMS 区别?
| CMS | G1 | |
|---|---|---|
| 分代 | 是 | 不严格分代 |
| 区域 | 连续 | 划分成多个 Region |
| 算法 | 标记-清除(碎片) | 复制 + 标记-整理(无碎片) |
| 延迟控制 | 不可控 | 可设 -XX:MaxGCPauseMillis |
| 状态 | JDK 9 废弃 | JDK 9+ 默认 |
🎁 本章小结
✅ JVM 5 大区域:堆 / 方法区 / 栈 / PC / 本地栈
✅ 对象在堆,引用在栈
✅ 堆分代:新生代(Eden + S0 + S1)+ 老年代
✅ GC 算法:标记-清除 / 复制 / 标记-整理
✅ 可达性分析判断垃圾,从 GC Roots 出发
✅ G1 是现代默认,ZGC 是低延迟之王
✅ 调优看场景:吞吐量 vs 延迟🎓 通关恭喜!
恭喜你完成了 20 章 Java 学习之旅!🎉
接下来推荐:
- JDK 源码阅读:HashMap、ConcurrentHashMap、ThreadPoolExecutor
- Spring 框架:Spring Boot、Spring Cloud
- 数据库:MySQL + 索引原理 + 事务
- 中间件:Redis、Kafka、RabbitMQ
- 分布式系统:CAP、一致性算法、微服务
🔗 导航
- ⬅️ 上一章:Chapter 19 · 反射与注解
- 🏠 回到首页