LESSON 09 · JVM 内存模型 + GC

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 结束)

每个栈帧里有什么?

栈帧组成存什么
局部变量表基本类型 + 对象引用(不存对象本身,对象在堆)
操作数栈方法执行时的临时数据(iaddastore 用它)
动态链接指向运行时常量池的方法引用
方法出口方法正常 / 异常返回时,回到调用者的位置
⭐ 2 个常见错误:
  1. StackOverflowError = 栈帧太多(递归太深)→ 调大 -Xss 或改写递归
  2. 栈帧的生命周期 = 方法的生命周期,方法结束栈帧就弹出,局部变量自动释放
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 步

  1. gc.log:是 Minor GC 后 OOM(年轻代不够)还是 Full GC 后 OOM(老年代不够)?
  2. 如果是老年代 OOM → dump 堆(jmap -dump:format=b,file=heap.hprof <pid>
  3. 用 MAT / VisualVM 打开 heap.hprof,找"占用最大"的对象
  4. 看是 哪个类 + 谁在引用它 → 修代码或调大堆
⭐ 经典面试题(背下来):
  1. Minor GC / Major GC / Full GC 区别?→ Minor = 年轻代;Major = 老年代;Full = 整个堆 + 方法区
  2. 对象什么时候进老年代?→ 年龄 15 / 大对象 / Survivor 放不下
  3. G1 的"分区"是什么?→ 把堆切成 2048 个小块(Region),按需选年轻代 / 老年代
  4. 为什么 Survivor 要分 From 和 To 两块?→ 复制算法需要两块,活的复制到 To,下次交换角色(避免碎片)
  5. Stop-The-World 是什么?→ GC 时所有应用线程暂停,所有 GC 都有 STW,只是长短不同

你的作业(分步走)

  1. 画 JVM 内存图:凭记忆画一张 JVM 运行时数据区图,标出 5 大区域 + 每个区域存什么,对照课件查漏
  2. 对象晋升验证:写代码创建大量临时对象,加 JVM 参数 -Xmx64m -XX:+PrintGCDetails 跑,看 GC 日志找出"对象从年轻代到老年代"的时刻
  3. 强引用 vs 软引用:分别用强引用和 SoftReference 包一个 10MB 字节数组,-Xmx50m 跑,手动触发 GC对比哪个先被回收
  4. 4 种引用类型:写代码分别演示 4 种引用被 GC 时的行为差异(强 / 软 / 弱 / 虚),打印回收时机
  5. 面试题答题:凭记忆答 3 道:① Minor/Major/Full GC 区别 ② 为什么用可达性不用引用计数 ③ G1 为什么"分区"
  6. GC 日志实战:跑个 Spring Boot demo,加 -Xlog:gc*:file=gc.log:time,触发几次 full GC,把日志贴给我看哪行是 STW、哪行是并发