235 lines
7.2 KiB
Markdown
235 lines
7.2 KiB
Markdown
|
|
---
|
|||
|
|
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泄漏排查]]
|