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 的作用

scanSizeheapSetType 的返回值,用于向 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 调步器报告扫描工作量。

相关推荐
techdashen41 分钟前
Go 中如何处理错误
开发语言·后端·golang
孙克旭_1 小时前
JVM 入门详解:JVM、JRE、JDK 区别,JVM 基础结构梳理
java·开发语言·jvm
随遇而安zx1 小时前
【地基篇】---JVM 内存结构知识点(Java 8 运行时数据区深度解析)
java·开发语言·jvm
GoGeekBaird1 小时前
Agent 时代,你的生产环境,真的敢让它裸奔吗
后端·agent
FfHUCisI1 小时前
Golang 切片扩容策略
开发语言·数据库·golang
IT_陈寒1 小时前
Redis Pipeline用错竟比不用还慢,这个坑我帮你踩过了
前端·人工智能·后端
en.en..1 小时前
Ubuntu嵌入式开发 export环境变量
java·开发语言·数据库
ynchyong1 小时前
JavaScript Promise 实战:.then() 与 .catch() 的区别及回调函数封装指南
开发语言·javascript·ecmascript·promise·then
名字还没想好☜2 小时前
Go 1.21 context.WithoutCancel 实战:父 context 取消了,收尾任务还要继续跑
开发语言·后端·golang·go