Go内存模型与GC机制:高性能编程的核心

1 内存模型

1.1 操作系统内存模型

在探讨Golang的存储模型之前,我们可以首先回顾一下操作系统中的多次存储模型设计。可以参看我的这篇文章的第二章节:原子操作CAS与锁实现-CSDN博客。有提到高于存储的体系结构

我们可以看出,从上至下依次是CPU -> 寄存器 -> 缓存 -> 内存 -> 磁盘;从上至下的访问速度越来越慢;我们可以从中获取的关键信息:

  • 多级模型
  • 动态切换

1.2 虚拟内存与物理内存

虚拟内存是操作系统提供的一种抽象,它让每个进程都认为自己独享整个内存空间。实际物理内存由操作系统统一管理,通过页表建立映射关系。

复制代码
┌─────────────────┐     ┌─────────────────┐
│   进程1虚拟内存  │     │   进程2虚拟内存  │
│ 0x0000 ~ 0xFFFF │     │ 0x0000 ~ 0xFFFF │
└────────┬────────┘     └────────┬────────┘
         │                        │
         ▼                        ▼
┌─────────────────────────────────────────┐
│              页表映射                    │
├─────────────────────────────────────────┤
│        物理内存 (实际硬件)                │
└─────────────────────────────────────────┘

操作系统的内存管理分配有很多内容,这里我们不展开叙述。后续如果有时间作者会另写一篇关于操作系统内存管理的学习博客。读者也可以参看其他的博客。

虚拟内存关键特性:

  • 按需分页:仅在实际访问时才分配物理页
  • 页面置换:当物理内存不足时,将不常用的页换出到磁盘
  • 写时复制:多个进程共享同一物理页,直到有进程尝试写入

总之其主要作用就是让用户与底层硬件中添加一个代理层,优化用户体验。

1.3 分页管理

分页是虚拟内存管理的核心技术,将虚拟内存和物理内存划分为固定大小的页(通常4KB)。

什么是请求分页管理方式:

总而言之:请求分页管理是一种虚拟内存技术。程序被划分为固定大小的"页",只有当前运行需要的页才会被调入物理内存。当程序访问不在内存中的页时,会触发"缺页中断",操作系统负责将所需页从磁盘调入,并可能根据算法(如LRU)将内存中不常用的页换出。这种方式实现了内存的高效利用,允许运行比物理内存更大的程序。

1.4 Golang内存模型

Go的内存模型建立在操作系统虚拟内存之上,但有自己的管理策略。Go运行时从操作系统申请大块内存,然后自己进行精细管理。

复制代码
// Go内存布局
┌─────────────────────────────────────────┐
│  栈区 (Stack)                           │ ← 每个goroutine有独立的栈
│  Goroutine 1 Stack (2KB~1GB)           │
│  Goroutine 2 Stack                      │
│  ...                                    │
├─────────────────────────────────────────┤
│  堆区 (Heap)                            │ ← GC管理,所有goroutine共享
│  ┌─────────────────┐                    │
│  │  tiny对象 (<16B)│                    │
│  │  small对象(16B~32KB)│                │
│  │  large对象(>32KB)│                   │
│  └─────────────────┘                    │
├─────────────────────────────────────────┤
│  全局数据区 (Data/BSS)                  │ ← 程序启动时分配
│  - 全局变量                            │
│  - 常量                                │
│  - 代码                                │
└─────────────────────────────────────────┘
  • 以空间换时间,一次缓存,多次复用

由于每次向操作系统申请内存的操作很重,那么不妨一次多申请一些,以备后用.

Golang 中的堆 mheap 正是基于该思想,产生的数据结构. 我们可以从两个视角来解决 Golang 运行时的堆:

I 对操作系统而言,这是用户进程中缓存的内存

II 对于 Go 进程内部,堆是所有对象的内存起源

  • 多级缓存,实现无/细锁化

堆是 Go 运行时中最大的临界共享资源,这意味着每次存取都要加锁,在性能层面是一件很可怕的事情.

在解决这个问题,Golang 在堆 mheap 之上,依次细化粒度,建立了 mcentral、mcache 的模型,下面对三者作个梳理:

  • mheap:全局的内存起源,访问要加全局锁
  • mcentral:每种对象大小规格(全局共划分为 68 种)对应的缓存,锁的粒度也仅限于同一种规格以内
  • mcache:每个 P(正是 GMP 中的 P)持有一份的内存缓存,访问时无锁

全局总览:

上图是 Thread-Caching Malloc 的整体架构图,Golang 正是借鉴了该内存模型. 我们先看眼架构,有个整体概念,后续小节中,我们会不断对细节进行补充

2 Go内存模型详解

2.1 堆内存管理机制

Go的堆内存管理采用了三级缓存架构 ,从TCMalloc中汲取了设计灵感,旨在实现高并发、低延迟的内存分配。

2.1.1 内存单元mspan

mspan是内存管理的基本单元,代表一段连续的内存页。

Go 复制代码
// runtime/mspan.go
type mspan struct {
    next        *mspan     // 链表中的下一个span
    prev        *mspan     // 链表中的前一个span
    startAddr   uintptr    // 起始地址
    npages      uintptr    // 包含的页数(每页8KB)
    spanclass   spanClass  // 大小等级
    nelems      uintptr    // 对象总数
    allocCount  uintptr    // 已分配对象数
    freeindex   uintptr    // 下一个空闲对象索引
    allocBits   *gcBits    // 分配位图
    gcmarkBits  *gcBits    // GC标记位图
    
    // 状态
    state       mSpanState // mSpanDead, mSpanInUse等
    sweepgen    uint32     // 清扫代
}

mspan的特质:

  • mspan是golang内存管理的最小单元
  • mspan大小是page的整数倍(Go中的page大小为8KB),且内部的页是连续的(至少在虚拟内存的视角中是这样的)
  • 每个span根据空间大小以及面向分配的对象大小会被划分为不同的等级
  • 同等级的mspan会从属于一个mcentral,最终会被组织成为链表,因此带有前后指针(prev、next)
  • 由于同等级的mspan内聚于同一个mcentral,所以会基于同一把互斥锁管理
  • mspan会基于bitMap辅助快速找到空闲内存块(块大小为对应等级下的object大小),此时需要使用到Ctz64算法

2.1.2 内存单元等级spanClass

Go将对象按大小分为68个固定级别 ,实现零碎片分配

Go 复制代码
// 大小分级规则
tiny对象: 0~16字节(特殊处理,合并分配)
small对象: 16字节~32KB(67个固定级别)
large对象: >32KB(特殊处理)

// spanClass编码(8位)
// 高7位:大小等级(0-66表示small,67表示large)
// 最低位:是否包含指针(0=不包含,1=包含)

// 计算对象所属的spanClass
func sizeclass(size uintptr) spanClass {
    if size <= smallSizeMax {  // 32KB
        // 使用预计算的尺寸表
        if size <= 1024-8 {
            return spanClass(size_to_class8[divRoundUp(size, smallSizeDiv)])
        }
        return spanClass(size_to_class128[divRoundUp(size-smallSizeMax/2, largeSizeDiv)])
    }
    // 大对象
    return 0
}
class bytes/obj bytes/span objects tail waste max waste
1 8 8192 1024 0 87.50%
2 16 8192 512 0 43.75%
3 24 8192 341 8 29.24%
4 32 8192 256 0 21.88%
...
66 28672 57344 2 0 4.91%
67 32768 32768 1 0 12.50%

设计优势:

  1. 零碎片:每个span只分配固定大小的对象
  2. 快速分配:通过freeindex直接找到空闲槽位
  3. GC优化:指针对象和非指针对象分开,减少扫描开销

2.1.3 线程缓存mcache

每个P(处理器)都有独立的本地缓存,实现无锁分配

Go 复制代码
// runtime/mcache.go
type mcache struct {
    // 每个spanClass对应一个mspan
    alloc [numSpanClasses]*mspan
    
    // tiny分配器(<16B的特殊优化)
    tiny       uintptr    // 当前tiny块起始地址
    tinyoffset uintptr    // 下一个空闲偏移
    tinyAllocs uintptr    // tiny分配次数统计
    
    // 栈缓存
    stackcache [_NumStackOrders]stackfreelist
    
    // 本地alloc统计
    local_scan    uintptr
    local_tinyallocs uintptr
}
  1. mcache是每个P独有的缓存,因此交互无锁
  2. mcache将每种spanClass等级的mspan各缓存了一个,总数为2(nocan维度)*68(大小维度) = 136
  3. mcache中还有一个为对象分配器tiny allocator,用于处理小于16B对象内存分配

2.1.4 中心缓存mcentral

当mcache的mspan用尽时,从mcentral获取,需要加锁

Go 复制代码
// runtime/mcentral.go
type mcentral struct {
    spanclass spanClass
    partial  [2]spanSet  // 包含空闲对象的span列表
    full     [2]spanSet  // 无空闲对象的span列表
    // partial[0]: 清扫过的span
    // partial[1]: 未清扫的span
}
  1. mcache申请span -> 从partial0获取
  2. 如果partial0为空 -> 从partial1获取
  3. 如果partial1为空 -> 从mheap申请新的额span
  4. span用尽 -> 移动到full列表
  5. span完全空闲,归还给mheap

2.1.5 全局堆缓存mheap

管理整个堆内存,从操作系统申请大块内存

Go 复制代码
// runtime/mheap.go
type mheap struct {
    // 页分配器
    pages pageAlloc
    
    // 所有mcentral
    central [numSpanClasses]struct {
        mcentral mcentral
        pad      [cpu.CacheLinePadSize]byte
    }
    
    // 大对象分配
    largealloc  uint64
    nlargealloc uint64
    
    // arena元数据
    arenas [1 << arenaL1Bits]*[1 << arenaL2Bits]*heapArena
    
    // 清扫状态
    sweepgen   uint32
    sweepDrained uint32
}

arena布局:

  • 每个arena: 64MB
  • 每个heapArena: 管理一个arena的元数据
  • arenaL1Bits = 6, arenaL2Bits = 20 (Linux)
  • 支持最大内存: 2^(6+20+20) = 2^46 = 64TB

3 内存逃逸

go语言编译器会自动决定把一个变量放在栈还是放在堆,编译器会做逃逸分析(escape analysis)当发现变量的作用域没有跑出函数范围,就可以在栈上,反之则必须分配在堆

go语言声称这样可以释放程序员关于内存的使用限制,更多的让程序员关注于程序功能逻辑本身。

3.1 什么是逃逸分析

逃逸分析是Go编译器在编译阶段执行的静态分析,用于确定变量的生命周期和分配位置。

逃逸分析的目标

  1. 确定变量是否超出函数作用域
  2. 决定在栈还是堆上分配
  3. 优化内存分配,减少GC压力

3.2 什么时候会内存逃逸

场景1:返回局部变量指针

Go 复制代码
func escapeToHeap() *int {
    x := 42      // 逃逸到堆
    return &x    // 返回指针,生命周期超出函数
}

func noEscape() int {
    x := 42      // 栈上分配
    return x     // 返回值,不逃逸
}

场景2:闭包引用外部变量

Go 复制代码
func closureEscape() func() int {
    y := 100          // 逃逸到堆
    return func() int {
        return y      // 闭包引用外部变量
    }
}

场景3:

Go 复制代码
func sendPointer() {
    ch := make(chan *int, 1)
    data := 42        // 逃逸到堆
    ch <- &data       // 指针发送到channel
}

场景4:在slice中存储指针

Go 复制代码
func slicePointer() []*int {
    arr := make([]*int, 0, 10)
    for i := 0; i < 10; i++ {
        v := i        // 每次循环都逃逸!
        arr = append(arr, &v)
    }
    return arr
}

场景5:接口类型赋值

Go 复制代码
func interfaceEscape() {
    var w io.Writer
    buf := bytes.NewBuffer(make([]byte, 1024))  // 可能逃逸
    w = buf       // 接口赋值可能导致逃逸
    w.Write([]byte("hello"))
}

场景6:变量过大

Go 复制代码
func largeAllocation() {
    // 大对象可能直接在堆上分配
    var large [1024 * 1024]byte  // 1MB数组
    _ = large
}

场景7:调用未知函数

Go 复制代码
func unknownCall() {
    data := make([]byte, 1024)
    // 如果someFunction的实现不可见(在另一个包)
    // 编译器保守假设data可能逃逸
    someFunction(&data)
}

4 Golang的垃圾回收机制

4.1 Go GC的演进历程

GoV1.3版本标记删除:

GoV1.5版本三色标记法:

Golang为什么不选择如java类似的分代垃圾回收机制?

分代垃圾回收机制是现代编程语言(如Java)中一种高效的垃圾回收策略。其核心思想是:根据对象存活时间的不同,将内存划分为几代,并针对不同代采用不同的回收算法和频率,从而提升垃圾回收的整体效率。

然而Golang中存在内存逃逸机制,会在编译过程中将生命周期更长的对象转移到堆中,将生命周期短的对象分配在栈上,并以栈为单位对这部分对象进行回收.

综上,内存逃逸机制减弱了分代算法对Golang GC所带来的优势,考虑分代算法需要产生额外的成本(如不同年代的规则映射、状态管理以及额外的写屏障),Golang 选择不采用分代GC算法.

4.2 屏障机制

上面简单的提了一些关于golang语言GC的发展史,在Golang1.8版本之后,GC策略基本稳定,就是并发三色标记法+混合写屏障的机制

Golang GC 中用到的三色标记法属于标记清扫-算法下的一种实现,由荷兰的计算机科学家 Dijkstra 提出,下面阐述要点:

  • 对象分为三种颜色标记:黑、灰、白
  • 黑对象代表,对象自身存活,且其指向对象都已标记完成
  • 灰对象代表,对象自身存活,但其指向对象还未标记完成
  • 白对象代表,对象尙未被标记到,可能是垃圾对象
  • 标记开始前,将根对象(全局对象、栈上局部变量等)置黑,将其所指向的对象置灰
  • 标记规则是,从灰对象出发,将其所指向的对象都置灰. 所有指向对象都置灰后,当前灰对象置黑
  • 标记结束后,白色对象就是不可达的垃圾对象,需要进行清扫.

从golang的GC的发展史看,主要解决的就是逐渐解决STW对GC程序性能的影响,那么如何在并发GC的同时还能够不让GC清理错误呢?如:

  1. Golang 并发垃圾回收可能存在漏标问题
  2. Golang 并发垃圾回收可能存在多标问题

这就是下面要探讨的问题

4.2.1 强弱三色不变式

漏标问题的本质就是,一个已经扫描完成的黑对象指向了一个被灰\白对象删除引用的白色对象. 构成这一场景的要素拆分如下:

  1. 黑色对象指向了白色对象
  2. 灰、白对象删除了白色对象
  3. 1、2步中涉及的白色对象是同一个对象
  4. 1发生在2之前
  • 强三色不变式:白色对象不能被黑色对象直接引用(直接破坏(1))
  • 弱三色不变式:白色对象可以被黑色对象引用,但要从某个灰对象出发仍然可达该白对象(间接破坏了(1)、(2)的联动)

4.2.2 插入写屏障

插入写屏障(Dijkstra)的目标是实现强三色不变式,保证当一个黑色对象指向一个白色对象前,会先触发屏障将白色对象置为灰色,再建立引用.

特点

  • 保证强三色不变式
  • 不需要删除屏障
  • 但需要重新扫描栈(栈上不启用屏障)

4.2.3 删除写屏障

删除写屏障(Yuasa barrier)的目标是实现弱三色不变式,保证当一个白色对象即将被上游删除引用前,会触发屏障将其置灰,之后再删除上游指向其的引用.

特点

  • 保证弱三色不变式
  • 允许黑色对象指向白色对象
  • 不需要重新扫描栈
  • 但会产生浮动垃圾

4.2.4 混合写屏障

结合上面插入写屏障和删除写屏障机制,二者选择其一即可解决并发GC漏标的问题,至于错标问题,则采用容忍态度,放到GC的下一轮中延后处理即可

然而真实场景中,需要补充一个新的设定------屏障机制无法作用于栈对象

这是因为栈对象可能涉及频繁的轻量操作,倘若这些高频操作都需要一一触发屏障机制,那么所带来的成本将是无法接受的

在这一背景下,单独看插入写屏障或者删除写屏障,都无法真正的解决漏标问题,除非我们引入额外的STW阶段对栈对象的处理进行兜底

为了消除这个额外的STW成本,golang1.8引入了混合写屏障机制,可以视为糅合插入写屏障+删除写屏障的加强版本:

  • GC 开始前,以栈为单位分批扫描,将栈中所有对象置黑
  • GC 期间,栈上新创建对象直接置黑
  • 堆对象正常启用插入写屏障
  • 堆对象正常启用删除写屏障

混合写屏障优势:

  1. 栈上不需要写屏障:提高性能
  2. 不需要重新扫描栈:减少STW时间
  3. 精度更高:减少浮动垃圾
  4. 实现简单:结合两种屏障的优点

5 参考文章

Golang 内存模型与分配机制

Golang 垃圾回收原理分析

5、Golang三色标记混合写屏障GC模式全分析

3、Golang中逃逸现象, 变量"何时栈?何时堆?"

相关推荐
AI人工智能+电脑小能手1 小时前
【大白话说Java面试题 第86题】【Mysql篇】第16题:MySQL 中锁的种类与行锁实现原理?
java·开发语言·数据库·mysql·面试
右耳朵猫AI1 小时前
PHP技术周刊 2026年第20周
开发语言·php
日月云棠1 小时前
12 Enum —— 枚举类型的底层实现
java·后端
工位植物人1 小时前
深入理解Java中的类、抽象类、接口与枚举类
后端
用户2181697049301 小时前
Gin (二) 参数 路由分组
后端
用户925807911481 小时前
nacos服务注册源码浅析
后端
方也_arkling1 小时前
【Java-Day12】接口
java·开发语言
SimonKing1 小时前
Java程序员接入AI的另一种姿势:LangChain4j
java·后端·程序员
小小de风呀1 小时前
de风——【从零开始学 C++】(十)vector的模拟实现
开发语言·c++