From 329166fdf7471a46456705eba1cdc74419d7af2f Mon Sep 17 00:00:00 2001 From: wonder Date: Thu, 3 Sep 2026 12:00:06 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E6=B7=BB=E5=8A=A0=20JVM=20=E7=AB=A0?= =?UTF-8?q?=E8=8A=82=20-=20=E5=9E=83=E5=9C=BE=E5=9B=9E=E6=94=B6=E7=B3=BB?= =?UTF-8?q?=E5=88=97=EF=BC=885=E7=AF=87=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/jvm/garbage-collection.md | 158 ++++++++++++++++++++ docs/jvm/gc-collectors.md | 246 +++++++++++++++++++++++++++++++ docs/jvm/generational-gc.md | 155 +++++++++++++++++++ docs/jvm/index.md | 13 ++ docs/jvm/memory-fragmentation.md | 159 ++++++++++++++++++++ docs/jvm/tri-color-marking.md | 203 +++++++++++++++++++++++++ mkdocs.yml | 7 + 7 files changed, 941 insertions(+) create mode 100644 docs/jvm/garbage-collection.md create mode 100644 docs/jvm/gc-collectors.md create mode 100644 docs/jvm/generational-gc.md create mode 100644 docs/jvm/index.md create mode 100644 docs/jvm/memory-fragmentation.md create mode 100644 docs/jvm/tri-color-marking.md diff --git a/docs/jvm/garbage-collection.md b/docs/jvm/garbage-collection.md new file mode 100644 index 0000000..fff92cc --- /dev/null +++ b/docs/jvm/garbage-collection.md @@ -0,0 +1,158 @@ +# 垃圾回收基础 + +!!! note "垃圾回收是编程语言运行时自动管理内存的机制,核心思想:找出程序不再使用的内存并释放。" + +--- + +## 什么是垃圾回收 + +垃圾回收(Garbage Collection,简称 GC)是编程语言运行时(Runtime)自动管理内存的一种机制。基本判断逻辑: + +- **引用计数** — 每个对象记录有多少东西引用它,归零就回收。Python 用这种。简单高效,但解决不了循环引用。 +- **可达性分析** — 从根对象出发,遍历能走到的所有对象,走不到的就是垃圾。Java、Go、JavaScript 都用这个。 + +## 没有 GC 的语言 + +没有垃圾回收的语言,内存管理靠程序员手动完成: + +### 手动管理 + +| 语言 | 方式 | +|------|------| +| **C** | `malloc` / `free` | +| **C++** | `new` / `delete`,以及 RAII(作用域结束自动析构) | +| **Assembly** | 直接操作内存,没有抽象层 | +| **Fortran** | 传统上手动管理 | + +### 所有权 / 借用检查(零成本自动回收,但不是 GC) + +| 语言 | 机制 | +|------|------| +| **Rust** | 所有权 + 值离开作用域自动释放,编译期检查,无运行时开销 | +| **Zig** | 手动 alloc/free,但提供了 allocator 抽象 | + +### 其他方式 + +- **D** — 可选 GC,也支持手动管理 +- **Swift** — ARC(引用计数),严格来说不算传统 GC +- **Objective-C / Swift** — ARC 机制 + +### 对比理解 + +``` +GC 语言(Java、Go、Python) + → 运行时有个"管家"定期打扫 + → 方便,但有 STW 延迟和内存开销 + +手动管理(C、C++) + → 自己打扫,扫不干净(泄漏)或扫过头(野指针)都可能出问题 + +所有权系统(Rust) + → 编译期就帮你规划好谁负责清理 + → 零运行时开销,但学习曲线陡 +``` + +## GC 的代价 + +垃圾回收并非免费午餐,主要代价体现在: + +### 1. Stop-The-World(STW)暂停 + +GC 在某些阶段必须暂停程序执行,所有业务线程停住,等 GC 干完活。 + +- 短暂停(几毫秒)对普通应用无所谓 +- 但在高频交易、游戏渲染、实时系统里,几毫秒的抖动就是灾难 +- Java 的 ZGC、Go 的并发 GC 都在拼命缩短这个暂停时间 + +### 2. 额外内存开销 + +GC 需要额外空间来做标记和跟踪: + +- **引用计数**:每个对象多一个计数器 +- **三色标记**:需要额外的标记位图或栈 +- **分代收集**:需要维护多个内存区域和存活/死亡对象的拷贝 +- **写屏障(Write Barrier)**:每次对象引用变更都要额外记录 + +实际经验:**开销大约在 5%~20% 的额外内存**。 + +### 3. CPU 时间消耗 + +GC 线程本身要吃 CPU: + +- 遍历对象图做可达性分析 +- 拷贝存活对象 +- 整理碎片 +- 这些时间本来可以用来跑业务逻辑 + +Go 的并发 GC 把大部分工作放到后台线程做,但还是要占 CPU。 + +### 4. 吞吐量 vs 延迟的矛盾 + +- **追求高吞吐**:少触发 GC,攒多了一次性清 → 单次暂停更长 +- **追求低延迟**:频繁触发 GC → 暂停短但总 CPU 开销更高 + +这是 GC 调优里永恒的权衡: + +| 收集器 | 侧重 | 特点 | +|--------|------|------| +| Parallel GC | 高吞吐 | 暂停长,适合后台批处理 | +| ZGC | 超低延迟 | 暂停 <1ms,吞吐略低 | +| G1 | 折中 | 可预测暂停 | + +### 5. 内存碎片 + +不同 GC 策略表现不同: + +| 策略 | 碎片情况 | +|------|----------| +| 标记-清除 | 有碎片,长时间运行后分配变慢 | +| 标记-压缩 | 无碎片,但压缩过程要移动对象,暂停更长 | +| 复制算法 | 无碎片,但浪费一半内存空间 | + +碎片多了之后,本来有足够总空闲内存,却分配不出连续的大对象。 + +### 6. 不可预测性 + +你无法精确控制何时释放: + +```python +# 你以为这里释放了 +obj = None +# 但 GC 可能根本不急着回收 +# 文件句柄、数据库连接这种稀缺资源就悬着 +``` + +所以 GC 语言里经常需要 `try-with-resources`、`with` 语句、析构器配合等机制来手动管理资源释放时机。 + +### 7. 维护复杂度 + +GC 的实现本身很复杂——分代阈值怎么设?并发标记和业务线程怎么协调?大对象走哪个分配路径?调优参数一堆,出了问题排查困难。 + +--- + +## 一句话总结 + +> **GC 用 CPU、内存、延迟三样东西,换程序员的脑子。** 值不值取决于场景——绝大多数业务应用完全值得;极端性能敏感的场景(嵌入式、高频交易、游戏引擎核心)就可能不值得。 + +## 练习题 + +??? question "题目一:哪些语言有 GC?哪些没有?" + ??? success "答案" + 有 GC:Java、Go、Python、JavaScript、C#、Ruby、Kotlin、Swift(ARC) + + 无 GC(手动管理):C、C++ + + 无 GC(所有权系统):Rust + + 可选 GC:D、Swift + +??? question "题目二:引用计数和可达性分析有什么区别?各有什么优缺点?" + ??? success "答案" + 引用计数:每个对象维护引用计数器,归零即回收。优点是实现简单、回收及时;缺点是无法处理循环引用(A→B→A),且计数器维护有性能开销。Python 用这种。 + + 可达性分析:从 GC Root 出发遍历对象图,不可达即为垃圾。优点是能处理循环引用;缺点是遍历耗时,可能需要 STW。Java、Go、JavaScript 用这种。 + +## 相关链接 + +- [维基百科:Garbage collection (computer science)](https://en.wikipedia.org/wiki/Garbage_collection_(computer_science)) +- [深入理解 Java 虚拟机 — 周志明](https://book.douban.com/subject/34907498/) diff --git a/docs/jvm/gc-collectors.md b/docs/jvm/gc-collectors.md new file mode 100644 index 0000000..9e5668f --- /dev/null +++ b/docs/jvm/gc-collectors.md @@ -0,0 +1,246 @@ +# GC 回收器对比 + +!!! note "从 Serial 到 ZGC,各回收器的核心差异在于吞吐量与延迟的取舍;G1 取代 CMS 成为主流,因为解决了碎片和可预测暂停问题。" + +--- + +## 一句话比喻 + +把 Java 堆想象成一栋写字楼: + +``` +🏢 Serial GC = 一个保安自己扫 +🏢 Parallel GC = 一群保安一起扫 +🏢 CMS GC = 保安边上班边偷偷扫 +🏢 G1 GC = 按楼层分区,哪脏扫哪 +🏢 ZGC GC = 超级保安,几乎感觉不到 +``` + +## 各回收器定位 + +| 回收器 | JDK 版本 | 作用范围 | 核心特点 | +|--------|---------|---------|---------| +| **Serial** | 1.0+ | 全堆 | 单线程,STW,最简单 | +| **Parallel** | 1.2+ | 全堆 | 多线程 STW,追求吞吐量 | +| **ParNew** | 1.4+ | 年轻代 | 多线程版 Serial,配合 CMS 用 | +| **CMS** | 1.5+ | 老年代 | 尽量并发,追求低延迟 | +| **G1** | 1.7+ | 全堆 | 分 Region,可预测暂停 | +| **ZGC** | 15+ | 全堆 | 暂停 <1ms,超大堆 | +| **Shenandoah** | 12+ | 全堆 | 并发压缩,低延迟 | + +--- + +## CMS 回收器(Concurrent Mark Sweep) + +**核心思想:老年代的垃圾,尽量让 GC 线程和业务线程同时跑。** + +### 四个阶段 + +```mermaid +sequenceDiagram + participant B as 业务线程 + participant G as GC 线程 + + Note over B: 正常运行 + B->>B: 正常运行 + + Note over G: ① 初始标记(极短 STW) + G->>G: 标记 GC Roots 直接引用 + + Note over B,G: ② 并发标记(不暂停业务) + B->>B: 正常运行 + G->>G: 遍历对象图(耗时最长) + + Note over G: ③ 重新标记(短 STW) + G->>G: 修正并发阶段变动 + + Note over B,G: ④ 并发清除(不暂停业务) + B->>B: 正常运行 + G->>G: 清除标记为垃圾的对象 +``` + +### 每个阶段详解 + +#### ① 初始标记(Initial Mark)⏱ 极短,STW + +只标记 GC Roots 直接引用的对象,不做全图遍历,所以很快。 + +#### ② 并发标记(Concurrent Mark)⏱ 最长,但不暂停业务 + +从 GC Root 出发,遍历整个对象图。GC 线程和业务线程同时运行。⚠️ 这阶段业务可能产生新的垃圾,标记不到。 + +#### ③ 重新标记(Remark)⏱ 短,STW + +修正并发阶段产生的变动,用了 Incremental Update 算法。 + +#### ④ 并发清除(Concurrent Sweep)⏱ 长,但不暂停业务 + +清除标记为垃圾的对象,GC 线程和业务线程同时运行。 + +### CMS 的四个致命问题 + +#### 问题 1:浮动垃圾(Floating Garbage) + +并发阶段产生的新垃圾,重新标记阶段才发现不了,只能等下次 GC 才能回收。需要保守预留更多老年代空间。 + +#### 问题 2:内存碎片 + +CMS 用的是标记-清除,不压缩。久了之后老年代全是碎片: + +``` +┌──┬░░┬──┬░░┬──┬░░┬░░┬──┬░░┬──┐ +│▓▓│ │▓▓│ │▓▓│ │ │▓▓│ │▓▓│ +└──┴░░┴──┴░░┴──┴░░┴░░┴──┴░░┴──┘ +``` + +想分配一个大对象?放不下 → 触发 Full GC(退化为 Serial 的标记-压缩)。**这是最致命的。** + +#### 问题 3:Concurrent Mode Failure + +GC 还没清完,老年代就满了 → 被迫 STW 做一次 Full GC → 延迟暴增,CMS 的初衷白费了。 + +#### 问题 4:CPU 敏感 + +并发阶段抢占 CPU 资源。默认 GC 线程数 = (CPU 核数 + 3) / 4。4 核机器上 GC 吃掉 25% CPU。 + +--- + +## G1 回收器(Garbage-First) + +**核心思想:把堆切成很多小 Region,哪里垃圾最多先扫哪里。** + +### 堆的结构 + +传统 GC 把堆分成连续的年轻代/老年代,G1 则把堆切成 Region: + +```mermaid +graph TD + subgraph 传统布局 + A[Young Gen] --- B[Old Gen] + end + subgraph G1 布局 + C[Region 1: E] --- D[Region 2: E] --- E[Region 3: S] --- F[Region 4: O] --- G[Region 5: H] --- H[Region 6: free] + end +``` + +``` +G1 布局示例(每个 Region 1~32MB,堆被切成约 2048 个): +┌────┬────┬────┬────┬────┬────┬────┬────┐ +│ E │ E │ E │ S │ O │ O │ H │free│ +├────┼────┼────┼────┼────┼────┼────┼────┤ +│ O │free│ E │ E │ O │free│ O │ O │ +├────┼────┼────┼────┼────┼────┼────┼────┤ +│free│ E │ O │free│free│ S │ E │free│ +└────┴────┴────┴────┴────┴────┴────┴────┘ + +E = Eden S = Survivor O = Old H = Humongous(大对象) free = 空闲 +``` + +### G1 的回收策略 + +"Garbage-First" = 优先回收垃圾最多的 Region。 + +每个 Region 的角色是**动态分配**的: + +``` +Region #5 角色变化: + │ + ├─→ 这轮是 Eden ← 新对象分配在这里 + ├─→ 下轮变成 free ← GC 后清空 + ├─→ 再下轮变成 Old ← 有对象晋升过来 + └─→ 之后变成 free ← 老年代 GC 后释放 +``` + +### G1 的核心优势:可预测暂停 + +``` +调优命令:-XX:MaxGCPauseTime=200ms + +G1 自动计算:"我有 200ms 时间,哪些 Region 垃圾最多又能在 200ms 内清完?" +→ 挑一组 Region 收割 +→ 保证暂停在 200ms 以内 + +时间轴: + ══╪════════╪═══════╪═══════╪═════════ + 180ms 150ms 160ms 170ms ← 每次都在目标内 +``` + +### G1 的工作阶段 + +| 阶段 | 描述 | STW | +|------|------|-----| +| 年轻代 GC | Eden 满 → 存活对象复制到 Survivor Region | ✅ 短 | +| 初始标记 | 标记 GC Roots(搭年轻代 GC 的便车) | ✅ 短 | +| 并发标记 | 和业务线程并行遍历对象图 | ❌ 不暂停 | +| 重新标记 | 用 SATB 修正并发阶段变动 | ✅ 短 | +| 清除 | 清理空 Region,为 Mixed GC 做准备 | ✅ 短 | +| Mixed GC | 回收年轻代 + 选一批垃圾多的老年代 Region | ✅ 可控 | + +### G1 动态调整代大小 + +``` +默认: + -XX:G1NewSizePercent=5 年轻代最小 5% + -XX:G1MaxNewSizePercent=60 年轻代最大 60% + +场景 1:大量短命对象 → G1 自动扩大年轻代 +┌──────────────────────────────────┐ +│ E E E E E E E E E E E │ O O O O │ +│ 年轻代 (80%) │老年代20% │ +└──────────────────────────────────┘ + +场景 2:大量长命对象 → G1 自动缩小年轻代 +┌──────────────┬──────────────────────────┐ +│ E E E │ O O O O O O O O O O O O O O O O│ +│ 10% │ 老年代 90% │ +└───────┴────────────────────────────────┘ +``` + +--- + +## CMS vs G1 正面对比 + +| 维度 | CMS | G1 | +|------|-----|-----| +| 作用范围 | 仅老年代 | 全堆(年轻代 + 老年代) | +| 算法 | 标记-清除(不压缩) | 标记-复制 + 标记-压缩 | +| 内存碎片 | ❌ 有,严重 | ✅ 无(Region 间整理) | +| 暂停时间 | 不可控 | 可预测(设目标值) | +| 浮动垃圾 | 有,需预留空间 | 有,但 Mixed GC 兜底 | +| 大堆表现 | 差(碎片 + 并发慢) | 好(Region 分治) | +| 小堆表现 | 还行 | 开销略高(Region 管理) | +| 并发阶段 | 并发标记 + 并发清除 | 并发标记(清除是 STW) | +| JDK 默认 | JDK 8 可选 | **JDK 9+ 默认** | + +### 为什么 CMS 被 G1 取代 + +``` +CMS 的致命伤: + 1. 碎片 → 大对象分配失败 → Full GC → 延迟暴增 + 2. Concurrent Mode Failure → Full GC → 延迟暴增 + 3. 不压缩 → 这两个问题无解 + 4. JDK 9 开始标记为废弃 + +G1 的解决方式: + 1. Region 本身就是压缩的 → 无碎片 + 2. Mixed GC 主动回收老年代 → 不会等到老年代爆满 + 3. 可控暂停 → 不会出现意外的长暂停 + 4. JDK 9+ 默认 GC,生产环境广泛验证 +``` + +--- + +## 练习题 + +??? question "题目一:CMS 的"标记-清除"有什么缺点?为什么 G1 要改成"标记-压缩"?" + ??? success "答案" + 标记-清除不移动对象,释放后留下碎片。长时间运行后老年代全是碎片,大对象分配失败,被迫触发 Full GC。G1 在 Region 级别做标记-压缩,存活对象复制到新 Region,旧 Region 直接回收,天然无碎片。 + +??? question "题目二:G1 的"可预测暂停"是怎么实现的?" + ??? success "答案" + G1 在并发标记结束后,会计算每个 Region 的垃圾比例和回收收益。然后根据用户设定的目标暂停时间(-XX:MaxGCPauseTime),贪心地选择一组 Region 进行 Mixed GC,确保选中的 Region 能在目标时间内清完。 + +## 相关链接 + +- [Oracle 官方文档 — G1 Garbage Collector](https://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/g1_gc.html) +- [Oracle 官方文档 — Concurrent Mark Sweep (CMS) Collector](https://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/cms.html) diff --git a/docs/jvm/generational-gc.md b/docs/jvm/generational-gc.md new file mode 100644 index 0000000..29a73af --- /dev/null +++ b/docs/jvm/generational-gc.md @@ -0,0 +1,155 @@ +# Java 分代 GC + +!!! note "分代 GC 的核心思想:不同生命周期的对象用不同策略回收——短命的快扫,长寿的慢扫。" + +--- + +## 为什么要分代 + +研究表明,绝大多数对象都是"朝生夕灭"的(比如方法里的临时变量、请求处理的中间对象),只有少数对象能活很久。分代 GC 就是利用这个规律。 + +--- + +## 堆的分代结构 + +``` +┌─────────────────────────────────────────────────────────┐ +│ Java 堆(Heap) │ +│ │ +│ ┌──────────────────────────┐ ┌─────────────────────┐ │ +│ │ 年轻代 (Young Gen) │ │ 老年代 (Old Gen) │ │ +│ │ │ │ │ │ +│ │ ┌─────┬─────┬────────┐ │ │ │ │ +│ │ │ Eden │ S0 │ S1 │ │ │ │ │ +│ │ │ 80% │ 10% │ 10% │ │ │ Tenured Space │ │ +│ │ └─────┴─────┴────────┘ │ │ (70%) │ │ +│ │ (默认 1/3 堆) │ │ (默认 2/3 堆) │ │ +│ └──────────────────────────┘ └─────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ 元空间 (Metaspace) │ │ +│ │ 存类的元数据(JDK 8+ 移到堆外,不归 GC 管) │ │ +│ └──────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────┘ +``` + +## 年轻代(Young Generation)—— 细分三块 + +### Eden 区(80%) + +新对象都在这里出生。绝大多数对象第一次 GC 就死了。 + +### Survivor 0 / Survivor 1(各 10%) + +也叫 S0、S1,交替使用。活过一次 GC 的对象先搬到这里暂住。 + +### 运作流程 + +```mermaid +graph TD + A[新对象分配到 Eden] --> B{Eden 满了?} + B -->|否| A + B -->|是| C[触发 Minor GC] + C --> D[扫描 Eden + S0,标记存活] + D --> E[存活对象复制到 S1] + E --> F[Eden 和 S0 全部清空] + F --> G{对象年龄 ≥ 阈值?} + G -->|否| H[留在 Survivor,年龄+1] + G -->|是| I[晋升到老年代] + H --> A +``` + +流程演示: + +``` +第 1 步:新对象分配到 Eden +┌────────┬──────┬──────┐ +│ Eden │ S0 │ S1 │ +│▓▓▓▓▓▓▓│ │ │ +│▓▓▓▓▓▓▓│ │ │ +└────────┴──────┴──────┘ + ▓ = 新对象 + +第 2 步:Eden 满了,触发 Minor GC + - 扫描 Eden + S0,标记存活对象 + - 存活对象复制到 S1 + - Eden 和 S0 全部清空 + +┌────────┬──────┬──────┐ +│ Eden │ S0 │ S1 │ +│ │ │▓▓ │ +└────────┴──────┴──────┘ + S1 里是活下来的对象 + +第 3 步:下次 Minor GC + - 扫描 Eden + S1,存活对象复制到 S0 + - S0 和 Eden 清空 + - S0 ↔ S1 角色互换 + +第 4 步:对象每活过一轮 GC,年龄 +1 + - 年龄达到阈值(默认 15)→ 晋升到老年代 +``` + +## 老年代(Old Generation) + +存放长期存活的对象: + +- 年龄达标的对象晋升过来 +- 大对象直接分配在这里(避免在年轻代来回复制) +- 回收频率低,用标记-清除或标记-压缩 + +## 触发 GC 的条件 + +| GC 类型 | 触发时机 | 回收范围 | 算法 | 特点 | +|---------|---------|---------|------|------| +| Minor GC | Eden 区满 | Eden + 活跃 Survivor | 复制算法 | 快(几毫秒),频繁 | +| Major GC / Full GC | 老年代空间不足 | 整个堆(甚至方法区) | 标记-整理/清除 | 慢(几百毫秒~秒级) | +| Mixed GC | G1 专用,老年代占阈值 | 年轻代 + 部分老年代 Region | 混合 | 折中,可控暂停 | + +## 对象晋升流程 + +```mermaid +graph TD + A[new 对象] --> B[Eden 区] + B --> C{Minor GC} + C -->|死亡| D[直接回收] + C -->|存活| E[进入 Survivor] + E --> F{年龄 ≥ 15?} + F -->|否| E + F -->|是| G[晋升到老年代] + G --> H{老年代满?} + H -->|否| I[继续存活] + H -->|是| J[触发 Major GC / Full GC] +``` + +## 为什么这样设计有效 + +``` +场景:一个 Web 请求处理 +├─ 请求进来了,创建一堆临时对象 → Eden +├─ 处理完,这些对象没人引用了 +├─ Minor GC 一扫,99% 都是垃圾,直接清掉 +└─ 真正活得久的(连接池、缓存)慢慢晋升到老年代 + +效率:只扫描 Eden(小区域),速度极快 + 真正长寿的对象不频繁被扫描 +``` + +## GC 收集器演进 + +| JDK 版本 | 年轻代收集器 | 老年代收集器 | 默认 | +|---------|-------------|-------------|------| +| 1.x ~ 7 | Serial / Parallel / ParNew | Serial Old / CMS / Parallel Old | Parallel | +| 8 | Parallel | Parallel Old | Parallel | +| 9+ | G1(全堆) | G1(全堆) | **G1** | +| 15+ | ZGC / Shenandoah | ZGC / Shenandoah | 可选 | + +## 练习题 + +??? question "题目一:为什么 Survivor 区要分成 S0 和 S1 两个?" + ??? success "答案" + 因为复制算法需要一块空闲空间来存放存活对象。如果只有一个 Survivor 区,就没地方复制了。两个 Survivor 区交替使用:Minor GC 时,把 Eden 和当前 Survivor 中存活的对象复制到另一个 Survivor,然后清空前者。这样每次都有一块干净的空间可用。 + +??? question "题目二:对象年龄阈值默认是 15,为什么不能设太小?" + ??? success "答案" + 如果阈值太小,很多对象还没来得及死亡就被晋升到老年代,导致老年代快速增长,触发频繁的 Major GC。Major GC 比 Minor GC 慢得多。默认 15 是经验值,大多数短命对象在年轻代就被回收了,少数真正长寿的对象才晋升。年龄存储在对象头的 Mark Word 中,占 4 位,所以最大值就是 15。 diff --git a/docs/jvm/index.md b/docs/jvm/index.md new file mode 100644 index 0000000..e8aadb7 --- /dev/null +++ b/docs/jvm/index.md @@ -0,0 +1,13 @@ +# JVM 与垃圾回收 + +!!! note "深入理解 Java 虚拟机的内存管理机制" + +本章系统整理了垃圾回收(GC)的核心知识,从基础概念到高级算法,涵盖: + +| 文档 | 主题 | 关键词 | +|------|------|--------| +| [垃圾回收基础](garbage-collection.md) | GC 概念、无 GC 的语言、GC 的代价 | 自动内存管理 | +| [内存碎片与整理](memory-fragmentation.md) | 碎片产生原因、标记-清除/压缩/复制三种整理方式 | 碎片、Mark-Sweep、Mark-Compact | +| [分代 GC](generational-gc.md) | Java 分代结构、Eden/Survivor/老年代、对象晋升流程 | 分代、Minor GC、Major GC | +| [GC 回收器对比](gc-collectors.md) | Serial/Parallel/CMS/G1/ZGC 对比,重点 CMS 与 G1 | CMS、G1、可预测暂停 | +| [GC Roots 与三色标记](tri-color-marking.md) | GC Root 类型、可达性分析、三色标记法及并发问题 | GC Root、漏标、SATB | diff --git a/docs/jvm/memory-fragmentation.md b/docs/jvm/memory-fragmentation.md new file mode 100644 index 0000000..f0c4224 --- /dev/null +++ b/docs/jvm/memory-fragmentation.md @@ -0,0 +1,159 @@ +# 内存碎片与整理方式 + +!!! note "内存碎片是对象随机分配/释放后产生的空洞,主流 GC 用标记-清除、标记-压缩、复制算法三种方式应对。" + +--- + +## 什么是内存碎片 + +程序运行时,内存就像一排格子,每次 `new` 对象就占几个格子。释放后留下空洞,如果空洞分散,就会出现**总空闲空间够用,但放不下一个大对象**的情况。 + +### 碎片产生过程 + +初始分配: + +``` +┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐ +│A │A │B │B │C │C │C │D │D │D │E │E │F │F │F │G │ +└──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘ +``` + +释放 B、D、F 后: + +``` +┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐ +│A │A │░░│░░│C │C │C │░░│░░│░░│E │E │░░│░░│░░│G │ +└──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘ + ░░ = 空闲碎片 +``` + +总空闲 8 格,但没有一块连续。想分配一个 4 格的对象?**放不下。** 这就是内存碎片。 + +--- + +## 三种主流整理方式 + +### 1. 标记-清除(Mark-Sweep) + +不做整理,只标记死对象然后就地释放。 + +```mermaid +graph LR + A[标记阶段] -->|从 GC Root 遍历| B[标记所有存活对象] + B --> C[清除阶段] + C -->|释放未标记对象| D[就地释放] + D --> E[产生碎片] +``` + +流程演示: + +``` +标记阶段:标记哪些还活着 +┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐ +│A │A │B │B │C │C │C │D │D │D │E │E │F │F │F │G │ +│✓ │✓ │ │ │✓ │✓ │✓ │ │ │ │✓ │✓ │ │ │ │✓ │ +└──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘ + +清除后: +┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐ +│A │A │░░│░░│C │C │C │░░│░░│░░│E │E │░░│░░│░░│G │ +└──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘ +``` + +✅ 速度最快,只扫描一遍 +❌ **碎片原封不动,碎片越来越多** + +### 2. 标记-压缩(Mark-Compact) + +标记后把所有活对象**挤到一端**,消除空洞。 + +```mermaid +graph LR + A[标记阶段] --> B[标记存活对象] + B --> C[压缩阶段] + C -->|活对象向一端移动| D[消除碎片] + D --> E[更新引用] +``` + +流程演示: + +``` +标记完成: +┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐ +│A │A │░░│░░│C │C │C │░░│░░│░░│E │E │░░│░░│░░│G │ +│✓ │✓ │ │ │✓ │✓ │✓ │ │ │ │✓ │✓ │ │ │✓ │ +└──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘ + +压缩 — 活对象向左移动: + A → 移到 [0] + C → 紧跟 A 放到 [2] + E → 紧跟 C 放到 [5] + G → 紧跟 E 放到 [7] + +整理后: +┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐ +│A │A │C │C │C │E │E │G │░░│░░│░░│░░│░░│░░│░░│░░│ +└──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘ + 已用(紧凑) 空闲(连续一大块) +``` + +✅ **碎片彻底消除** +❌ 要移动对象,更新所有指向它的引用,**暂停时间长** + +### 3. 复制算法(Copying) + +把内存切成两半(From / To),每次只用一半,GC 时把活对象复制到另一半。 + +```mermaid +graph LR + A[From 区使用中] --> B[GC 触发] + B --> C[扫描存活对象] + C --> D[复制到 To 区] + D --> E[From/To 角色互换] + E --> A +``` + +流程演示: + +``` +From 区(当前使用) To 区(空闲备用) +┌──┬──┬──┬──┬──┬──┬──┐ ┌──┬──┬──┬──┬──┬──┬──┐ +│A │A │░░│C │C │░░│E │ │ │ │ │ │ │ │ │ +│✓ │✓ │ │✓ │✓ │ │✓ │ │ │ │ │ │ │ │ │ +└──┴──┴──┴──┴──┴──┴──┘ └──┴──┴──┴──┴──┴──┴──┘ +``` + +GC 时,把活对象按顺序复制到 To 区: + +``` +From 区(废弃) To 区(新的活跃区) +┌──┬──┬──┬──┬──┬──┬──┐ ┌──┬──┬──┬──┬──┬──┬──┐ +│A │A │░░│C │C │░░│E │ │A │A │C │C │E │░░│░░│ +└──┴──┴──┴──┴──┴──┴──┘ └──┴──┴──┴──┴──┴──┴──┘ + ↑ 全部作废 紧凑排列,后面全是空闲 +``` + +✅ 分配极快(指针一推就行),**天然无碎片** +❌ **浪费一半内存**,存活对象多时复制成本高 + +--- + +## 总结对比 + +| 算法 | 速度 | 碎片 | 内存代价 | 适用场景 | +|------|------|------|----------|----------| +| 标记-清除 | ⚡ 快 | ❌ 有碎片 | 无额外 | CMS 老年代 | +| 标记-压缩 | 🐢 慢 | ✅ 无碎片 | 无额外 | G1 老年代 | +| 复制算法 | ⚡ 快 | ✅ 无碎片 | 浪费 50% | 年轻代(死亡率高) | + +实际的 GC 实现通常**混合使用**——比如 Java 的分代 GC:年轻代用复制算法(对象死亡率高,复制量小),老年代用标记-压缩(对象存活久,复制成本太高)。 + +## 练习题 + +??? question "题目一:为什么复制算法通常只用于年轻代?" + ??? success "答案" + 因为研究表明绝大多数对象都是"朝生夕灭"的,年轻代中每次 Minor GC 后存活对象通常不到 10%。复制算法只需要复制极少量存活对象,效率很高。而老年代对象存活率高,复制成本太大,所以用标记-压缩。 + +??? question "题目二:标记-清除为什么会导致碎片越来越多?" + ??? success "答案" + 标记-清除只是就地释放死对象的内存,不会移动任何活对象。随着程序不断分配和释放,空洞越来越分散,连续空闲空间越来越小。大对象可能因为找不到连续空间而触发 Full GC 甚至 OOM。 diff --git a/docs/jvm/tri-color-marking.md b/docs/jvm/tri-color-marking.md new file mode 100644 index 0000000..3b6d92b --- /dev/null +++ b/docs/jvm/tri-color-marking.md @@ -0,0 +1,203 @@ +# GC Roots 与三色标记法 + +!!! note "GC Roots 是可达性分析的起点,三色标记法是现代低延迟 GC 的核心标记算法,通过写屏障解决并发时的漏标问题。" + +--- + +## GC Roots + +GC Root 是垃圾回收的**起点**。从这些对象出发,能走到的就是活的,走不到的就是垃圾。 + +### 七种 GC Root + +| 类型 | 说明 | 示例 | +|------|------|------| +| 虚拟机栈中的引用 | 每个线程栈帧中的局部变量 | 方法里的 `User user = new User()` | +| 静态变量 | 类的 static 引用 | `private static AppConfig instance` | +| 常量池引用 | static final 引用的对象 | `private static final String NAME` | +| JNI 引用 | 本地方法中持有的 Java 对象 | `native void method(Buffer buf)` | +| 活跃线程 | 每个正在运行的线程 | `Thread` 对象 | +| 同步锁 | 被 `synchronized` 持有的对象 | `synchronized (sharedLock)` | +| Class 对象 | 已加载类对应的 Class 对象 | `User.class` | + +### 可达性分析图 + +```mermaid +graph TD + R1[虚拟机栈局部变量] --> A[对象 A] + A --> B[对象 B] + B --> C[对象 C] + B --> D[对象 D] + + R2[静态变量] --> E[对象 E] + E --> F[对象 F] + + R3[JNI 引用] --> G[对象 G] + + R4[活跃线程] --> H[对象 H] + R5[同步锁] --> I[对象 I] + R6[Class 对象] --> J[对象 J] + + K[对象 K] -.->|无引用链| R1 + L[对象 L] -.->|仅被 K 引用| K + + style K fill:#ff6b6b,color:#fff + style L fill:#ff6b6b,color:#fff + style R1 fill:#51cf66,color:#fff + style R2 fill:#51cf66,color:#fff + style R3 fill:#51cf66,color:#fff + style R4 fill:#51cf66,color:#fff + style R5 fill:#51cf66,color:#fff + style R6 fill:#51cf66,color:#fff +``` + +K 和 L 没有任何 GC Root 能到达 → 垃圾,可回收。 + +--- + +## 三色标记法 + +垃圾回收中用来标记对象存活状态的算法,是 G1、ZGC、Go 等现代 GC 的核心。 + +### 三种颜色 + +| 颜色 | 含义 | 最终状态 | +|------|------|---------| +| ⚪ 白色 | 还没扫描到的 | 垃圾,可回收 | +| 🔘 灰色 | 扫描过了,但它引用的对象还没扫 | 等待继续扫描 | +| ⚫ 黑色 | 扫描过了,它引用的对象也都扫了 | 存活 | + +### 完整标记过程 + +用一个例子演示: + +``` +对象关系: +GC Roots → A → B → C → G + ↓ ↓ + D (G 仅被 C 引用) +GC Roots → E → F +``` + +**初始状态**:GC Root 直接引用的对象标灰,其余全白。 + +``` +⚫ A ⚪ B ⚪ C ⚪ D ⚫ E ⚪ F ⚪ G +``` + +**扫描 A**:A 有引用 → B,A 变黑,B 变灰。 + +``` +⚫ A 🔘 B ⚪ C ⚪ D ⚫ E ⚪ F ⚪ G +``` + +**扫描 B**:B 有引用 → C、D,B 变黑,C 和 D 变灰。 + +``` +⚫ A ⚫ B 🔘 C 🔘 D ⚫ E ⚪ F ⚪ G +``` + +**扫描 C**:C 有引用 → G,C 变黑,G 变灰。 + +``` +⚫ A ⚫ B ⚫ C 🔘 D ⚫ E ⚪ F 🔘 G +``` + +**扫描 D、F、G**:无更多引用,全部变黑。 + +``` +⚫ A ⚫ B ⚫ C ⚫ D ⚫ E ⚫ F ⚫ G +``` + +所有对象变黑,标记完成,无白色对象可回收。 + +```mermaid +graph LR + subgraph "① 初始状态" + A1[⚫A] --> B1[⚪B] + E1[⚫E] --> F1[⚪F] + end + subgraph "② 扫描 A" + A2[⚫A] --> B2[🔘B] + E2[⚫E] --> F2[⚪F] + end + subgraph "③ 扫描 B" + A3[⚫A] --> B3[⚫B] + B3 --> C3[🔘C] + B3 --> D3[🔘D] + E3[⚫E] --> F3[⚪F] + end +``` + +### 流程总结 + +```mermaid +graph TD + A[GC Root 标灰] --> B[取一个灰色对象] + B --> C[扫描它的引用] + C --> D[灰变黑,引用的白变灰] + D --> E{还有灰色?} + E -->|是| B + E -->|否| F[剩余白色 = 垃圾] +``` + +--- + +## 并发标记的两大问题 + +三色标记通常和业务线程**并发执行**,会产生问题: + +### 问题一:漏标 — 活对象被误判为垃圾 💀 + +**同时**满足两个条件才会漏标: + +1. 存在黑色对象到白色对象的**新引用** +2. 所有灰色对象到该白色对象的引用都被**删除** + +```mermaid +sequenceDiagram + participant GC as GC 线程 + participant App as 业务线程 + + Note over GC: ⚫A 🔘B ⚪C
A→C, B→C + App->>App: A 删除 A→C + App->>App: B 新增 B→C + Note over GC: A 是黑色,不会再被扫描
C 是白色 → 误判为垃圾!💥 +``` + +### 问题二:多标 — 死对象被当成活的(浮动垃圾)😅 + +并发期间某个对象变成垃圾,但标记阶段已经过了它 → 这次回收不了。下次 GC 就能回收,**不致命**,只是多占一轮内存。 + +### 解决漏标 + +| 方案 | 策略 | 使用者 | 优缺点 | +|------|------|--------|--------| +| **增量更新** | 黑色新增引用时,把它变回灰色,重新扫描 | CMS | 缺点:Remark 阶段可能较长 | +| **SATB**(原始快照) | 删除引用时,记录被删除的对象,标记它 | G1 | 缺点:可能多标浮动垃圾,但不会漏标 | +| **染色指针 + 读屏障** | 硬件级别解决 | ZGC | 暂停 <1ms | + +### 对比总结 + +| | 传统标记 | 三色标记 | +|---|---------|---------| +| 状态 | 二元(已访问/未访问) | 三态(白/灰/黑) | +| 并发能力 | 无法区分"还没扫到"和"确认是垃圾" | 精确追踪进度,写屏障维护一致性 | +| 使用场景 | 单线程 GC | 并发 GC(G1、ZGC、Go) | + +--- + +## 练习题 + +??? question "题目一:为什么多标(浮动垃圾)不致命,但漏标会导致程序崩溃?" + ??? success "答案" + 多标只是把本该回收的对象多留了一轮,下次 GC 就能回收,影响只是多占一点内存。漏标则相反——活对象被当成垃圾回收了,程序还在用这个对象但内存已经被释放,导致空指针、数据损坏甚至 JVM 崩溃。 + +??? question "题目二:G1 的 SATB 和 CMS 的增量更新有什么区别?" + ??? success "答案" + 增量更新在"新增引用"时拦截:黑色对象新增了对白色对象的引用,就把黑色变灰重新扫描。SATB 在"删除引用"时拦截:灰色对象删除了对白色对象的引用,就把被删除的白色对象记录下来,在标记结束时强制标灰。G1 选 SATB 是因为删除引用更常见(对象字段赋 null),拦截成本更低。 + +## 相关链接 + +- [深入理解 Java 虚拟机 — 三色标记与写屏障](https://book.douban.com/subject/34907498/) +- [Oracle — ZGC: A Scalable Low-Latency Garbage Collector](https://docs.oracle.com/en/java/javase/21/gctuning/z-garbage-collector.html) diff --git a/mkdocs.yml b/mkdocs.yml index 4b83bc9..e80aa49 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -120,6 +120,13 @@ nav: - HeavyKeeper: algorithm/heavykeeper.md - 二叉树的遍历: algorithm/binary-tree-traversal.md - 队列: algorithm/queue.md + - JVM: + - jvm/index.md + - 垃圾回收基础: jvm/garbage-collection.md + - 内存碎片与整理: jvm/memory-fragmentation.md + - 分代 GC: jvm/generational-gc.md + - GC 回收器对比: jvm/gc-collectors.md + - GC Roots 与三色标记: jvm/tri-color-marking.md - 七牛云: - qiniu-cloud/index.md - 前后端架构: qiniu-cloud/architecture.md