前言
本文深入探讨 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 |
七、总结
核心知识点回顾
-
无头分配:小对象使用堆位图存储指针信息,无额外内存开销。
-
有头分配 :大对象在头部存储
*_type指针,指向类型描述符。 -
堆位图写入:通过位运算将类型掩码写入 span 的堆位图区域。
-
GC 程序:压缩的指针位图编码,运行时展开以节省内存。
-
scanSize:向 GC 调步器报告扫描工作量。