Go 运行时内存分配与 GC 位图深度剖析

前言

本文深入探讨 Go 运行时内存分配的核心机制,特别是堆位图(Heap Bits) 的存储与写入、有头/无头对象 的分配策略,以及 GC 程序(GC Program) 的展开过程。

注意:本文基于 Go 1.23.0 源码进行分析。


一、内存分配的核心概念

1.1 对象头(Header) vs 无头(NoHeader)

Go 运行时根据对象大小和类型,采用两种不同的内存分配策略:

特性 无头(NoHeader) 有头(Header)
指针信息存储 存储在 span 的堆位图中 存储在对象头部 (*_type 指针)
对象结构 [对象数据] [头部元数据][对象数据]
内存开销 零额外开销 每个对象额外 8 字节
适用场景 小对象(<= 32KB),数量多 大对象 / 需要快速类型判断

1.2 堆位图(Heap Bits)的作用

堆位图是 GC 扫描堆内存时,用于快速判断每个指针大小的单元是否是指针的核心数据结构。其核心规则是:

  • 1 bit = 1 个指针大小的单元(64 位系统上为 8 字节)

  • bit = 1:该单元是指针,GC 需要追踪

  • bit = 0:该单元是标量,GC 无需处理


二、无头(NoHeader)分配:堆位图的写入

2.1 核心函数:span.writeHeapBitsSmall

无头路径的堆位图写入由 span.writeHeapBitsSmall 函数完成,其流程如下:

go

复制代码
func (span *mspan) writeHeapBitsSmall(x, dataSize uintptr, typ *_type) uintptr {
    // 1. 从类型描述符获取 GC 掩码
    src0 := readUintptr(getGCMask(typ))
    src := src0
    
    // 2. 扩展掩码到整个 dataSize
    if typ.Size_ == goarch.PtrSize {
        src = (1 << (dataSize / goarch.PtrSize)) - 1
    } else {
        for i := typ.Size_; i < dataSize; i += typ.Size_ {
            src |= src0 << (i / goarch.PtrSize)
        }
    }
    
    // 3. 计算堆位图目标地址
    dstBase := span.heapBitsBase()
    o := (x - span.base()) / goarch.PtrSize
    i := o / 64      // 第几个 uintptr
    j := o % 64      // 起始位偏移
    bits := dataSize / goarch.PtrSize
    
    // 4. 位运算写入
    dst := (*uintptr)(add(dstBase, i*8))
    *dst = (*dst)&^(((1<<bits)-1)<<j) | (src << j)
    
    return typ.PtrBytes
}

2.2 核心位运算解析

go

复制代码
*dst = (*dst)&^(((1<<bits)-1)<<j) | (src << j)

这行代码分为两步:

操作 作用
(*dst) &^ mask 将目标位的 bits 位清零,保留其他位
` (src << j)`

为什么需要左移? 因为 src 的 bit 0 对应对象内部的第一个指针,但在全局堆位图中,该对象可能不是从 bit 0 开始的。左移就是将位图从"对象内偏移"映射到"全局堆位图偏移"。


三、有头(Header)分配:对象头部与类型信息

3.1 内存布局

有头分配的内存块布局如下:

text

复制代码
内存块起始地址
│
├─ [0 : mallocHeaderSize)     ← 头部区域(存 `*_type` 指针)
│   (header = (**_type)(x))
│
└─ [mallocHeaderSize : elemsize) ← 对象数据区域
    (x = add(x, mallocHeaderSize) 返回给调用者)

3.2 头部写入

go

复制代码
// 从 span 获取空闲内存
v := nextFreeFast(span)
x = unsafe.Pointer(v)

// 有头路径:设置头部
if hasHeader {
    header = (**_type)(x)        // 将起始地址解释为 `**_type`
    x = add(x, mallocHeaderSize) // 指针后移,指向数据区域
    size -= mallocHeaderSize
}

// 后续将类型指针写入头部
*header = gctyp

为什么 (**_type)(x) 是安全的? 因为调用者已经确保这块内存的前 mallocHeaderSize 字节预留给头部,且大小足够存储一个 *_type 指针。


四、GC 程序(GC Program):压缩的指针位图

4.1 什么是 GC 程序?

当类型的指针布局非常复杂(如嵌套数组、结构体链)时,类型元数据中存储的不是一个直接的位图,而是一个压缩的 GC 程序。这是一种字节码指令集,用于在运行时生成完整的指针位图。

为什么需要 GC 程序?

  • 节省空间:直接存储位图可能占用大量内存

  • 按需展开:只有分配对象时才展开

4.2 三条核心指令

指令字节 名称 作用
00000000 stop 停止执行
0nnnnnnn emit n bits 直接输出 n 个比特
1nnnnnnn c / 10000000 n c repeat 将之前输出的位重复 c 次

4.3 展开流程

go

复制代码
// 1. 计算所需空间
spaceNeeded := alignUp(unsafe.Sizeof(_type{}), goarch.PtrSize)
heapBitsOff := spaceNeeded
spaceNeeded += alignUp(typ.PtrBytes/goarch.PtrSize/8, goarch.PtrSize)
npages := alignUp(spaceNeeded, pageSize) / pageSize

// 2. 分配手动管理的内存
systemstack(func() {
    progSpan = mheap_.allocManual(npages, spanAllocPtrScalarBits)
    memclrNoHeapPointers(unsafe.Pointer(progSpan.base()), progSpan.npages*pageSize)
})

// 3. 构造 dummy _type
gctyp = (*_type)(unsafe.Pointer(progSpan.base()))
gctyp.GCData = (*byte)(add(unsafe.Pointer(progSpan.base()), heapBitsOff))
gctyp.TFlag = abi.TFlagUnrolledBitmap

// 4. 运行 GC 程序,展开位图
runGCProg(addb(typ.GCData, 4), gctyp.GCData)

4.4 runGCProg 执行流程

runGCProg 是一个字节码解释器,其核心逻辑如下:

go

复制代码
func runGCProg(prog, dst *byte) uintptr {
    var bits uintptr   // 位缓冲区
    var nbits uintptr  // 缓冲区有效位数
    
    for {
        // 1. 刷新完整字节
        for ; nbits >= 8; nbits -= 8 {
            *dst = uint8(bits)
            dst = add1(dst)
            bits >>= 8
        }
        
        // 2. 读取指令
        inst := uintptr(*prog)
        prog = add1(prog)
        n := inst & 0x7F
        
        if inst&0x80 == 0 {
            // Literal: 直接输出 n 个比特
            if n == 0 { break }  // stop
            // 读取 (n+7)/8 个字节,写入位缓冲区
            // ...
        } else {
            // Repeat: 重复输出
            // 读取变长整数 c,将之前的 n 个比特重复 c 次
            // ...
        }
    }
    
    // 3. 最终刷新
    // 将 bits 中剩余的位写入 dst
    return totalBits
}

五、scanSize 的作用

scanSize 是 heapSetType 的返回值,用于向 GC 调步器(Pacer)报告本次分配需要扫描的堆内存大小。

路径 scanSize 值 含义
无头路径 typ.PtrBytes 只需扫描对象中包含指针的部分
有头路径 span.elemsize 需要扫描整个对象(头部 + 数据)

最终累加到 c.scanAlloc,用于 GC 辅助标记(Mark Assist)的信用结算。


六、关键源码文件索引

功能 文件路径
堆位图写入 src/runtime/mbitmap.go
内存分配 src/runtime/malloc.go
GC 程序展开 src/runtime/mbitmap.go (runGCProg)
GC 控制器 src/runtime/mgcpacer.go
类型元数据 src/runtime/type.go

七、总结

核心知识点回顾

  1. 无头分配:小对象使用堆位图存储指针信息,无额外内存开销。

  2. 有头分配 :大对象在头部存储 *_type 指针,指向类型描述符。

  3. 堆位图写入:通过位运算将类型掩码写入 span 的堆位图区域。

  4. GC 程序:压缩的指针位图编码,运行时展开以节省内存。

  5. scanSize:向 GC 调步器报告扫描工作量。

相关推荐
1024奇点8 小时前
【仓颉语言入门 · 第二篇 第 22 课】
开发语言·ide·开源
实名上网宋凯宣8 小时前
Django
后端·python·django
Warren2Lynch8 小时前
C4 模型详解:利用 Visual Paradigm 可视化软件架构的综合指南
开发语言·uml
Knight_AL9 小时前
Java 文件下载时文件名中的 `#` 被转换成 `%23`?一次 URL 编码与解码问题排查
java·开发语言
利威尔xu9 小时前
Go IM 源码实战(14):本地缓存快 3000 倍,失效却难 10 倍
后端·缓存·微服务·面试·golang
FRing0310 小时前
vue2干货总结2(v-bind、v-for、v-model、指令修饰符、v-bind操作class和style)
开发语言·前端·vue.js·vue2
逃逸线LOF10 小时前
Ribbon负载均衡原理(Irule组件、源码分析)
后端·spring cloud·ribbon
by2099910 小时前
C++ 智能指针的所有权与使用
开发语言·c++·经验分享
砚底藏山河10 小时前
python量化自定义周期 K 线合成
java·开发语言·数据库·python·金融
慢云智慧空间10 小时前
从设备智能到空间理解,空间智能终端解决方案商如何重新定义行业标准
后端