This repository has been archived on 2026-05-19. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
obsidian/DEV/GO/GC垃圾回收.md
T

235 lines
7.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [go, golang, runtime, GC, garbage-collection, 三色标记]
create time: 2026-04-24 10:00
---
# GC 垃圾回收
## 概述
Go 的垃圾回收(GC)是**自动、并发、不可移动**的标记-清除算法。它的核心目标是:**在保证正确性的前提下,尽可能减少 STW(Stop-The-World)时间**。
> **思考**:为什么 Go 选择"不可移动"的 GC 策略?对象不可移动对 GC 的实现和性能分别有什么影响?
Go GC 的演进:
| Go 版本 | 特性 |
|---------|------|
| 1.1~1.3 | 半并发 GC(并发标记,STW 终结) |
| 1.5+ | 全并发 GC(并发标记 + 并发清理,STW 仅用于启动和切换) |
| 1.8+ | 混合写屏障(Hybrid Write Barrier),消除扫描阶段的 STW |
| 1.9+ | 更精细的并发控制,STW 时间显著缩短 |
## 三色标记法
三色标记是 Go GC 的核心算法。所有对象被标记为三种颜色之一:
| 颜色 | 含义 |
|------|------|
| **白色** | 未被标记(可能可达,也可能不可达) |
| **灰色** | 已被标记,但其引用的对象尚未扫描 |
| **黑色** | 已被标记,且其引用的对象已全部扫描完毕 |
### 标记规则
```
初始:所有对象 = 白色
根扫描开始:
- 根对象(全局变量、栈上的引用)标记为灰色
- 放入灰色队列
标记循环(并发执行):
1. 从灰色队列取出一个灰色对象 → 标记为黑色
2. 扫描黑色对象引用的所有对象:
- 如果引用对象是白色 → 标记为灰色,加入灰色队列
- 如果引用对象是灰色或黑色 → 不做操作
终止条件:灰色队列为空 → 标记阶段结束
```
### 三色不变式
Go GC 依赖两个不变式来保证正确性:
```
强不变式(Strong Invariant):
黑色对象不能直接引用白色对象
弱不变式(Weak Invariant):
如果黑色对象引用了白色对象,
那么那个白色对象一定是通过其他灰色对象可达的
```
> **核心原理**:如果一个白色对象被黑色对象引用,但它没有通过灰色对象可达 → 说明这个白色对象**真正不可达**,可以安全回收。
```mermaid
flowchart LR
subgraph Roots["根对象 (灰色)"]
R1[灰色对象 R1]
R2[灰色对象 R2]
end
subgraph Objects["所有对象"]
B1[黑色 B1]
B2[黑色 B2]
W1[白色 W1]
W2[白色 W2]
end
R1 -->|"引用"| B1
R1 -->|"引用"| W1
R2 -->|"引用"| B2
B1 -->|"引用"| W2
B2 -->|"引用"| W2
classDef gray fill:#ffa726,color:#fff
classDef black fill:#616161,color:#fff
classDef white fill:#eeeeee,stroke:#333
class R1,R2 gray
class B1,B2 black
class W1,W2 white
```
> **思考**:上图中 B2 直接引用了白色 W2,这违反了"黑色对象不能直接引用白色对象"的强不变式。如果此时 GC 终止,W2 就会被误回收——这就是为什么需要写屏障。
## 写屏障(Write Barrier)
写屏障是并发 GC 的核心机制。在并发标记期间,如果用户代码修改了指针,GC 可能来不及追踪这些变化。写屏障确保:
> **任何指向白色对象的指针写入,都会被记录为指向灰色对象**
### 插入写屏障(Go 1.5+)
在指针赋值**之前**检查:
```go
// 伪代码:插入写屏障逻辑
func writePointer(field, newVal) {
if newVal 是白色 {
newVal 重新标记为灰色 // 确保白色对象不被误回收
}
}
// 实际场景
*p = newObj // 写屏障在这里介入
```
**插入写屏障的局限**:无法处理删除引用(将指针设为 nil 或指向其他对象)的场景。
### 删除写屏障(Go 1.8+)
```go
// 伪代码:删除写屏障逻辑
func writePointer(field, newVal) {
oldVal = *field // 旧值
*field = newVal // 新值
// 如果旧值是黑色,且它引用的白色对象可能只通过它可达
if oldVal 是黑色 and 旧引用对象是白色 {
旧引用对象重新标记为灰色 // 确保白色对象不被漏掉
}
}
```
### 混合写屏障(Go 1.8+,Go 默认使用)
**插入写屏障 + 删除写屏障**的组合:
```
赋值前检查:新值如果是白色 → 标灰(插入屏障)
赋值后检查:旧值如果是黑色 → 旧引用的白色对象标灰(删除屏障)
效果:无论指针如何变化,白色对象都不会被漏标
```
```mermaid
flowchart TD
A["🏁 GC 启动 (STW 极短)"] --> B["📝 并发标记阶段"]
B -->|"标记循环\n(灰色队列→黑色)"| C{"灰色队列\n是否为空?"}
C -->|"否"| B
C -->|"是"| D["🛡️ 写屏障介入\n混合写屏障: 插入+删除"]
D --> E["🏁 标记结束 (STW 极短)"]
E --> F["🗑️ 并发清理阶段"]
F --> G["🏁 GC 完成"]
classDef start fill:#e53935,color:#fff
classDef process fill:#1e88e5,color:#fff
classDef barrier fill:#43a047,color:#fff
classDef done fill:#8e24aa,color:#fff
class A,E start
class B,F process
class D barrier
class G done
```
## 标记-清除 vs 标记-复制
Go 采用**标记-清除(Mark-Sweep)**而非标记-复制的原因:
| 维度 | 标记-清除 | 标记-复制 |
|------|-----------|-----------|
| 内存碎片 | 会产生碎片 | 无碎片 |
| 内存利用率 | 50%(需要空半区) | 100%(碎片由整理消除) |
| GC 速度 | 与存活对象数相关 | 与总对象数相关 |
| 对象移动 | 不移动(简化指针) | 需要移动和更新指针 |
| Go 的选择 | ✅ | ❌ |
> **思考**:既然标记-清除会产生内存碎片,Go 是如何处理的?这与 [内存分配与逃逸分析](./内存分配与逃逸分析.md) 有什么关系?
## Go 的 GC 触发方式
Go 使用**比例触发(Ratelimiting)**而非固定阈值:
```
触发条件:距上次 GC 结束后,分配的数据量达到上次 GC 时存活数据量的 P%
P = GCPercent(可通过 GCPercent() 查询)
Go 1.8+ 目标:单次 GC 的 STW 时间 < 100μs
```
```go
import (
"runtime"
"runtime/debug"
)
func main() {
// 查询当前 GC 比例
p := runtime.GCPercent()
fmt.Println("GCPercent:", p)
// 调整 GC 比例(值越小,GC 越频繁)
debug.SetGCPercent(20) // 默认 100
// 强制触发 GC(仅用于测试/调试)
runtime.GC()
// 查看 GC 统计
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("GC 次数: %d\n", m.NumGC)
fmt.Printf("分配总量: %d bytes\n", m.Alloc)
fmt.Printf("系统分配: %d bytes\n", m.Sys)
}
```
> **关键理解**:`GCPercent` 不是"存活 X% 时触发 GC",而是增量比例——每次 GC 后,系统会根据当前存活量计算触发阈值。
## 总结
Go GC 的设计哲学:
- **并发优先**:标记和清理几乎全部并发执行,STW 时间极短
- **写屏障保障正确性**:混合写屏障确保并发标记的准确性
- **不可移动**:简化运行时,避免指针更新开销
- **增量回收**:GC 开销分摊到每次对象分配上
> **思考**:为什么 Go 不采用分代 GC(Generational GC)?在什么场景下分代 GC 会有更好的表现?
## 关联笔记
- [[DEV/GO/GMP调度模型]]
- [[DEV/GO/内存分配与逃逸分析]]
- [[DEV/GO/Goroutine泄漏排查]]