JVM 内存模型
与垃圾回收
这节啃 Java 后端最有深度的一块:JVM 怎么分配内存、怎么回收垃圾。大厂一面 100% 会问,是 Java 后端工程师"知其然也知其所以然"的分水岭。
学完这节:你能画出 JVM 运行时数据区、说清
new Object() 走过的每一步、解释 Minor GC / Major GC / Full GC 的区别、看懂 GC 日志、知道线上 OOM 怎么排查。
1
JVM 整体结构:5 大内存区域
💡 动机:先看清"全景图",再学单个区域不迷路
JVM 运行时内存分 5 块,按"线程私有 / 共享"分两组:
| 区域 | 线程 | 存什么 | 大小 |
|---|---|---|---|
| 堆(Heap) | ⭐ 共享 | 所有 new 出来的对象 | ⭐ 最大(-Xmx 默认 1/4 物理内存) |
| 虚拟机栈(Stack) | 私有 | 方法的栈帧(局部变量 / 操作数栈) | 小(-Xss 默认 512KB~1MB) |
| 本地方法栈 | 私有 | Native 方法(很少用) | 小 |
| 程序计数器 | 私有 | 当前线程执行的字节码行号 | ⭐ 最小(几乎不占内存) |
| 方法区(Method Area) | ⭐ 共享 | 类信息 / 常量池 / 静态变量 | JDK 8+ 改名叫"元空间",用本地内存 |
小白记法(按访问频率):
- 堆 = 仓库(存所有货物)→ 90% 的 OOM 发生在这里
- 栈 = 工人手上的活儿(每个方法一个工位)→ StackOverflow 是这的事
- 方法区 = 设计图(类的元信息)→ 静态变量在这
- 程序计数器 = 工人当前位置(在哪一行代码)
2
堆(Heap):对象的"仓库" ⭐ 面试重点
💡 动机:所有对象都活在这,OOM 也基本都发生在这
堆按"对象年龄"再细分成年轻代 + 老年代(JDK 8 默认比例 1:2):
堆 (Heap)
├── 年轻代 (Young Generation) 新对象出生地
│ ├── Eden 区 90% 的对象在这"死"
│ └── Survivor 区(From + To) 活下来的暂存
│
└── 老年代 (Old Generation) 熬过多次 GC 的"老人"
└── 大对象直接进(-XX:PretenureSizeThreshold)
默认比例(JDK 8+):
- 年轻代 : 老年代 = 1 : 2
- Eden : Survivor From : Survivor To = 8 : 1 : 1
- 对象年龄计数器:每次 GC 还活着就 +1,默认 15 岁 晋升老年代
对象的一生(点击下面按钮看流程)
// 1. new Object():对象诞生在 Eden 区
Object obj = new Object();
// 2. Eden 满了 → 触发 Minor GC → 还活着的去 Survivor To
// (年龄 +1)
// 3. 反复 Minor GC → 年龄到 15 → 晋升老年代
// (或者 Survivor 不够放时也提前晋升)
// 4. 老年代满了 → 触发 Major GC(Full GC)→ 回收老年代
// (Full GC 慢,会"Stop The World")
3
栈(Stack):每个方法一个"工位"
💡 动机:方法怎么调用的?局部变量存在哪?
public static void main(String[] args) { // ← 栈帧 1(main)
int a = 10; // 局部变量 a 在栈帧 1 里
sayHi("张三"); // ← 栈帧 2(sayHi)
} // 栈帧 2 弹栈(方法结束)
// 栈帧 1 弹栈(main 结束)
每个栈帧里有什么?
| 栈帧组成 | 存什么 |
|---|---|
| 局部变量表 | 基本类型 + 对象引用(不存对象本身,对象在堆) |
| 操作数栈 | 方法执行时的临时数据(iadd、astore 用它) |
| 动态链接 | 指向运行时常量池的方法引用 |
| 方法出口 | 方法正常 / 异常返回时,回到调用者的位置 |
⭐ 2 个常见错误:
StackOverflowError= 栈帧太多(递归太深)→ 调大-Xss或改写递归- 栈帧的生命周期 = 方法的生命周期,方法结束栈帧就弹出,局部变量自动释放
4
GC 判定:什么样的对象"该死"? ⭐ 面试必问
💡 动机:GC 怎么知道哪些对象没用了?
4 种引用类型(按强度递减)
| 引用类型 | 特征 | GC 时机 |
|---|---|---|
| 强引用 | Object o = new Object()(最常见) |
永不回收(即使 OOM 也不收) |
| 软引用 | SoftReference<T> |
内存不够时回收(做缓存) |
| 弱引用 | WeakReference<T> |
下次 GC 必回收(ThreadLocal key 用这个) |
| 虚引用 | PhantomReference<T> |
任何时候都可能被回收,只用于跟踪对象被回收的事件 |
⭐⭐ 可达性分析(GC Roots)
JVM 不用"引用计数"(有循环引用问题),用可达性分析:
// 思路:从 "GC Roots" 出发,能被引用链到的就"活",否则"死"
//
// GC Roots 包括:
// 1. 虚拟机栈里的引用(方法的局部变量)
// 2. 方法区里的类静态属性引用
// 3. 方法区里的常量引用(如 "abc" 字符串)
// 4. 本地方法栈里的 JNI 引用
//
// 不可达 ≠ 立即死
// 1. 第一次标记:判定"不可达"
// 2. 筛选:有没有必要执行 finalize()?必要时执行
// 3. 第二次标记:finalize() 也没救活 → 真的死
⭐ 面试题:为什么 Java GC 用可达性,不用引用计数?
引用计数解决不了循环引用——A 引用 B、B 引用 A,引用计数都是 1,但实际都不可能被访问。可达性分析从 GC Roots 出发,循环引用不可达,会被回收,干净利落。
5
GC 算法:3 大经典 + 分代
💡 动机:3 种基础算法的 trade-off,所有 GC 收集器都基于它们
| 算法 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 标记-清除 | 两遍扫:先标"死",再清 | 简单 | ⚠️ 产生内存碎片 |
| 标记-复制 | 内存分两半,活的复制到另一半 | ⭐ 无碎片 | ⚠️ 浪费一半空间(但实际只用 10%) |
| 标记-整理 | 先标"死",再把活的往一头挪 | ⭐ 无碎片、不浪费 | ⚠️ 移动对象的成本高 |
⭐ 分代收集(实战怎么用):
- 年轻代:对象"朝生暮死",用标记-复制(快,浪费 10% 可接受)
- 老年代:对象"长寿",用标记-整理(少移动,碎片多可接受)
- 混合:现代收集器(G1、ZGC)分得更细,但基础还是这 3 种
6
垃圾收集器:G1 / ZGC 选型 ⭐
💡 动机:JDK 9+ 默认 G1,啥时候用啥收集器?
| 收集器 | 算法 | 特点 | 适用场景 |
|---|---|---|---|
| Serial | 复制 | 单线程、Stop-The-World | ⭐ 单核机器 / 客户端小程序 |
| Parallel | 复制 | 多线程、追求吞吐量 | ⭐ 后台批处理(不在意延迟) |
| CMS | 标记-清除 | ⭐ 追求低停顿(已废弃) | 已不推荐(碎片多、并发失败) |
| ⭐ G1 | 分区 + 复制/整理 | ⭐ JDK 9+ 默认,可控停顿 | ⭐⭐ 90% 后端服务用它 |
| ZGC / Shenandoah | 染色指针 / 并发整理 | ⭐⭐ 亚毫秒级停顿 | 超大堆(>100GB)、低延迟场景 |
⭐ 选型口诀:
- 堆 < 4GB → 默认就好(G1)
- 堆 4-32GB → G1 + 调优
-XX:MaxGCPauseMillis=200 - 堆 > 32GB 追求低延迟 → ZGC(
-XX:+UseZGC) - 别用 CMS(已废弃,JDK 14 移除)
7
调优实战 + 常见面试题 ⭐⭐
💡 动机:线上 OOM 怎么排查?GC 日志怎么看?
4 个必背的 JVM 参数
-Xms512m // 堆初始大小(建议和 -Xmx 一样,避免扩容抖动)
-Xmx2g // 堆最大大小
-Xmn1g // 年轻代大小(G1 下用 -XX:G1NewSizePercent)
-Xss512k // 每个线程栈大小
-XX:+UseG1GC // 用 G1
-XX:MaxGCPauseMillis=200 // 目标停顿时间(G1 用)
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heap.hprof
// OOM 时自动 dump 堆(救命参数)
-XX:+PrintGCDetails -Xlog:gc*:file=gc.log
// 打印 GC 日志
⭐ 线上 OOM 排查 4 步
- 看
gc.log:是 Minor GC 后 OOM(年轻代不够)还是 Full GC 后 OOM(老年代不够)? - 如果是老年代 OOM → dump 堆(
jmap -dump:format=b,file=heap.hprof <pid>) - 用 MAT / VisualVM 打开 heap.hprof,找"占用最大"的对象
- 看是
哪个类+谁在引用它→ 修代码或调大堆
⭐ 经典面试题(背下来):
- Minor GC / Major GC / Full GC 区别?→ Minor = 年轻代;Major = 老年代;Full = 整个堆 + 方法区
- 对象什么时候进老年代?→ 年龄 15 / 大对象 / Survivor 放不下
- G1 的"分区"是什么?→ 把堆切成 2048 个小块(Region),按需选年轻代 / 老年代
- 为什么 Survivor 要分 From 和 To 两块?→ 复制算法需要两块,活的复制到 To,下次交换角色(避免碎片)
- Stop-The-World 是什么?→ GC 时所有应用线程暂停,所有 GC 都有 STW,只是长短不同
你的作业(分步走)
- 画 JVM 内存图:凭记忆画一张 JVM 运行时数据区图,标出 5 大区域 + 每个区域存什么,对照课件查漏
- 对象晋升验证:写代码创建大量临时对象,加 JVM 参数
-Xmx64m -XX:+PrintGCDetails跑,看 GC 日志找出"对象从年轻代到老年代"的时刻 - 强引用 vs 软引用:分别用强引用和
SoftReference包一个 10MB 字节数组,-Xmx50m跑,手动触发 GC对比哪个先被回收 - 4 种引用类型:写代码分别演示 4 种引用被 GC 时的行为差异(强 / 软 / 弱 / 虚),打印回收时机
- 面试题答题:凭记忆答 3 道:① Minor/Major/Full GC 区别 ② 为什么用可达性不用引用计数 ③ G1 为什么"分区"
- GC 日志实战:跑个 Spring Boot demo,加
-Xlog:gc*:file=gc.log:time,触发几次 full GC,把日志贴给我看哪行是 STW、哪行是并发