Go 內存模型與 GC
1. 內存分配基礎
1.1 棧(Stack)與堆(Heap)
┌─────────────────────────────┐
│ 堆 (Heap) │ ← 動態分配,GC 管理
│ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │ obj │ │ obj │ │ obj │ │
│ └─────┘ └─────┘ └─────┘ │
├─────────────────────────────┤
│ goroutine 棧 1 │ ← 2KB 起始,自動增長
│ ┌─────────────────────┐ │
│ │ 局部變量、函數參數 │ │
│ └─────────────────────┘ │
├─────────────────────────────┤
│ goroutine 棧 2 │
│ ┌─────────────────────┐ │
│ │ 局部變量、函數參數 │ │
│ └─────────────────────┘ │
└─────────────────────────────┘
- 棧分配:速度極快(只需移動棧指針),函數返回自動回收
- 堆分配:需要 GC 回收,有額外開銷
- Go 編譯器通過逃逸分析決定變量分配在棧還是堆
1.2 逃逸分析(Escape Analysis)
# 查看逃逸分析結果
go build -gcflags="-m" main.go
go build -gcflags="-m -m" main.go # 更詳細
// 不逃逸:局部變量,分配在棧上
func noEscape() int {
x := 42
return x // 值拷貝,x 不逃逸
}
// 逃逸:返回指針,分配在堆上
func escape() *int {
x := 42
return &x // x 逃逸到堆,因為函數返回後還要用
}
// go build -gcflags="-m" 輸出: moved to heap: x
// 逃逸:接口類型參數
func printVal(v interface{}) {
fmt.Println(v) // v 逃逸(interface{} 導致)
}
// 逃逸:閉包引用
func closure() func() int {
x := 0
return func() int {
x++ // x 被閉包捕獲,逃逸到堆
return x
}
}
// 逃逸:發送到 channel
func chanEscape(ch chan *int) {
x := 42
ch <- &x // x 逃逸
}
// 不逃逸:已知大小的切片
func noEscapeSlice() {
s := make([]int, 10) // 不逃逸(編譯期已知大小)
_ = s
}
// 逃逸:運行時大小的切片
func escapeSlice(n int) {
s := make([]int, n) // 逃逸(編譯期不知道大小)
_ = s
}
1.3 變量何時分配到堆上
| 場景 | 是否逃逸 |
|---|---|
| 返回局部變量的指針 | 逃逸 |
| 賦值給 interface{} | 逃逸 |
| 閉包捕獲局部變量 | 逃逸 |
| 發送指針到 channel | 逃逸 |
| slice/map 運行時大小 | 可能逃逸 |
| 超過一定大小的對象 | 逃逸 |
| 純值類型局部變量 | 不逃逸 |
| 編譯期已知大小的 slice | 不逃逸 |
2. Go 內存分配器
Go 的內存分配器基於 tcmalloc(Thread-Caching Malloc)思想。
2.1 三級結構
goroutine ──► mcache (每個P一個,無鎖)
│
▼
mcentral (全局,有鎖,按 size class)
│
▼
mheap (全局,向 OS 申請)
│
▼
操作系統 (mmap/sbrk)
- mcache:每個 P 擁有一個,分配小對象時無需加鎖
- mcentral:當 mcache 用完時,從 mcentral 獲取,需要加鎖
- mheap:當 mcentral 也不夠時,從 mheap 申請,mheap 向 OS 要內存
2.2 mspan 與 size class
Go 將對象按大小分為約 70 個 size class(8B, 16B, 32B, 48B, ...32KB)。
- 微小對象(<16B):使用 tiny allocator,多個小對象可共享一個內存塊
- 小對象(16B-32KB):按 size class 從 mcache 分配
- 大對象(>32KB):直接從 mheap 分配
// 觀察分配行為
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("堆分配次數: %d\n", m.Mallocs)
fmt.Printf("堆釋放次數: %d\n", m.Frees)
fmt.Printf("堆使用字節: %d\n", m.HeapAlloc)
fmt.Printf("系統內存: %d\n", m.Sys)
3. 垃圾回收(GC)
3.1 三色標記法
Go 使用併發三色標記-清除算法。
白色(White): 未被標記,GC 結束後回收
灰色(Gray): 已標記但子對象未掃描
黑色(Black): 已標記且子對象已掃描,保留
標記過程:
1. 所有對象初始為白色
2. 從根對象(棧、全局變量)出發,標記為灰色
3. 取出一個灰色對象,掃描它引用的所有對象:
- 白色 → 標記為灰色
- 已經是灰色或黑色 → 跳過
- 當前對象標記為黑色
4. 重復步驟3,直到沒有灰色對象
5. 剩餘的白色對象就是垃圾,回收
初始: [白A] → [白B] → [白C]
↗
[白D] → [白E]
Step1: [灰A] → [白B] → [白C] (A 是根對象)
↗
[灰D] → [白E] (D 是根對象)
Step2: [黑A] → [灰B] → [白C] (掃描A,B變灰)
↗
[黑D] → [灰E] (掃描D,E變灰)
Step3: [黑A] → [黑B] → [灰C] (掃描B,C變灰)
↗
[黑D] → [黑E] (掃描E,C已灰)
Step4: [黑A] → [黑B] → [黑C] (掃描C,完成)
[黑D] → [黑E]
→ 沒有白色對象,全部保留
3.2 寫屏障(Write Barrier)
GC 與用戶代碼併發執行時,用戶可能修改引用關係。寫屏障確保不會遺漏存活對象。
Go 1.8+ 使用混合寫屏障(Hybrid Write Barrier):
- 標記開始時,棧上的對象全部標為黑色
- 堆上新創建的對象標為黑色
- 被覆蓋的指針指向的對象標為灰色
3.3 GC 觸發條件
// 1. 堆內存增長到上次 GC 後的 GOGC% 時觸發(默認 100%,即翻倍)
// 環境變量控制
GOGC=100 // 默認,堆增長 100% 時觸發
GOGC=200 // 堆增長 200% 時觸發(GC 頻率降低,內存佔用增加)
GOGC=50 // 堆增長 50% 時觸發(GC 更頻繁,內存佔用降低)
GOGC=off // 關閉 GC
// 2. 定時觸發:距離上次 GC 超過 2 分鐘
// 3. 手動觸發
runtime.GC()
3.4 GOMEMLIMIT(Go 1.19+)
軟內存上限,當接近上限時 GC 會更積極地回收。
// 環境變量
GOMEMLIMIT=1GiB // 軟限制 1GB
GOMEMLIMIT=512MiB
// 代碼中設置
import "runtime/debug"
debug.SetMemoryLimit(1 << 30) // 1GB
// 典型用法:容器環境
// 容器限制 2GB,設置 GOMEMLIMIT=1.5GiB
// 留 0.5GB 給非堆內存(棧、goroutine等)
3.5 GC 階段
用戶代碼運行 │ STW │ 併發標記 │ STW │ 併發清除 │ 用戶代碼運行
│Mark │ │Mark │ │
│Setup│ │Term │ │
│ │ │ │ │
<1ms <1ms
- Mark Setup (STW):開啓寫屏障,通常 <1ms
- 併發標記:與用戶代碼並行運行
- Mark Termination (STW):關閉寫屏障,通常 <1ms
- 併發清除:回收白色對象
4. 內存洩漏常見場景
4.1 goroutine 洩漏
// 洩漏:goroutine 永遠阻塞在 channel
func leak() {
ch := make(chan int)
go func() {
val := <-ch // 永遠收不到數據,goroutine 洩漏
fmt.Println(val)
}()
// 函數返回,ch 沒人發數據,goroutine 永遠不會結束
}
// 修復:使用 context 取消
func noLeak(ctx context.Context) {
ch := make(chan int)
go func() {
select {
case val := <-ch:
fmt.Println(val)
case <-ctx.Done():
return // 可以退出
}
}()
}
4.2 time.After 在循環中使用
// 洩漏:每次循環創建一個 Timer,直到觸發才會被 GC
func leak() {
ch := make(chan int)
for {
select {
case v := <-ch:
fmt.Println(v)
case <-time.After(5 * time.Second): // 每次循環都新建 Timer
fmt.Println("timeout")
}
}
}
// 修復:使用 time.NewTimer 並手動 Reset
func noLeak() {
ch := make(chan int)
timer := time.NewTimer(5 * time.Second)
defer timer.Stop()
for {
timer.Reset(5 * time.Second)
select {
case v := <-ch:
fmt.Println(v)
case <-timer.C:
fmt.Println("timeout")
}
}
}
4.3 slice 底層數組未釋放
// 洩漏:切片引用大數組的一小部分
func leak() []byte {
data := make([]byte, 1<<20) // 1MB
// ... 填充數據
return data[:10] // 返回 10 字節,但底層 1MB 數組不會被回收
}
// 修復:拷貝需要的數據
func noLeak() []byte {
data := make([]byte, 1<<20)
result := make([]byte, 10)
copy(result, data[:10])
return result
}
4.4 全局變量持有引用
// 洩漏:全局 map 不斷增長
var cache = make(map[string][]byte)
func processRequest(key string, data []byte) {
cache[key] = data // 只增不刪,內存不斷增長
}
// 修復:設置過期策略或大小限制
// 使用 LRU cache 或定期清理
5. runtime 包常用函數
package main
import (
"fmt"
"runtime"
"runtime/debug"
)
func main() {
// CPU 和 Goroutine 信息
fmt.Println("CPU 核數:", runtime.NumCPU())
fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))
fmt.Println("goroutine 數:", runtime.NumGoroutine())
fmt.Println("Go 版本:", runtime.Version())
// 內存統計
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("堆分配: %d MB\n", m.HeapAlloc/1024/1024)
fmt.Printf("系統內存: %d MB\n", m.Sys/1024/1024)
fmt.Printf("GC 次數: %d\n", m.NumGC)
fmt.Printf("上次 GC 暫停: %d μs\n", m.PauseNs[(m.NumGC+255)%256]/1000)
// 手動觸發 GC
runtime.GC()
// 設置 GC 百分比(等價於 GOGC 環境變量)
debug.SetGCPercent(200) // 堆增長 200% 時觸發 GC
// 設置內存限制(Go 1.19+)
debug.SetMemoryLimit(512 << 20) // 512MB
// 釋放未使用的內存給 OS
debug.FreeOSMemory()
// SetFinalizer: 對象被 GC 回收前執行清理
// 注意:不保證一定執行,不要用於關鍵清理
type Resource struct{ Name string }
r := &Resource{"test"}
runtime.SetFinalizer(r, func(r *Resource) {
fmt.Println("清理資源:", r.Name)
})
}
主題測試文章,只做測試使用。發佈者:Walker,轉轉請注明出處:https://walker-learn.xyz/archives/6785