Go工程师体系课 019

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

(0)
Walker的头像Walker
上一篇 12小时前
下一篇 15小时前

相关推荐

  • Go工程师体系课 005

    微服务开发 创建一个微服务项目,所有的项目微服务都在这个项目中进行,创建joyshop_srv,我们无创建用户登录注册服务,所以我们在项目目录下再创建一个目录user_srv 及user_srv/global(全局的对象新建和初始化)user_srv/handler(业务逻辑代码)user_srv/model(用户相关的 model)user_srv/pro…

  • Go工程师体系课 011

    查询的倒排索引 1. 什么是倒排索引? 倒排索引(Inverted Index)是一种数据结构,用于快速查找包含特定词汇的文档。它是搜索引擎的核心技术之一。 1.1 基本概念 正排索引:文档 ID → 文档内容(词列表) 倒排索引:词 → 包含该词的文档 ID 列表 1.2 为什么叫"倒排"? 倒排索引将传统的"文档包含哪些词"的关系倒转为"词出现在哪些文档…

  • 编程基础 0009_testing详解

    Go testing 详解 目录 testing 包基础 表格驱动测试 子测试 t.Run 基准测试 Benchmark 测试覆盖率 TestMain httptest 包 Mock 和接口测试技巧 模糊测试 Fuzz 1. testing 包基础 1.1 测试文件和函数命名规则 Go 测试遵循严格的命名约定: 测试文件以 _test.go 结尾(如 use…

    后端开发 18小时前
    100
  • Go工程师体系课 008

    订单及购物车 先从库存服务中将 srv 的服务代码框架复制过来,查找替换对应的名称(order_srv) 加密技术基础 对称加密(Symmetric Encryption) 原理: 使用同一个密钥进行加密和解密 就像一把钥匙,既能锁门也能开门 加密速度快,适合大量数据传输 使用场景: 本地文件加密 数据库内容加密 大量数据传输时的内容加密 内部系统间的快速通…

  • Go工程师体系课 009

    其它一些功能 个人中心 收藏 管理收货地址(增删改查) 留言 拷贝inventory_srv--> userop_srv 查询替换所有的inventory Elasticsearch 深度解析文档 1. 什么是Elasticsearch Elasticsearch是一个基于Apache Lucene构建的分布式、RESTful搜索和分析引擎,能够快速地…

简体中文 繁体中文 English