

Go 1.26 RC1 已经发布,这意味着它很快将会正式与大家见面。在 Go 1.26 的发布草案中,有一行小字特别引人注目:
reducing the cost of some small (<512 byte) memory allocations by up to 30% < 512 字节的小对象内存分配速度提高了约 30%。
这意味着我们不需要修改任何代码,就能在某些场景下获得显著的性能提升。本文将深入剖析 Go 1.26 内存分配优化的实现原理,从基准测试数据、汇编代码分析到源码实现机制,带你全面理解这一编译器与运行时协同优化的技术细节。
测试平台
goos: darwin
goarch: arm64
pkg: blog-example/go1.26/malloc
cpu: Apple M4
✗ go version
go version go1.24.9 darwin/arm64
✗ gotip version
go version go1.26-devel_f8ee0f84 Fri Jan 2 19:26:36 2026 -0800 darwin/arm64
为了验证这一提升,我写了一个基准测试:
// https://gist.github.com/hxzhouh/f662b3b149e10106f6a30a5355883919
package main
import (
"testing"
)
// 定义不同大小的结构体
type Small16 struct {
A int64
B int64
}
type Small32 struct {
A, B, C, D int64
}
type Small64 struct {
Data [8]int64
}
// 全局变量,防止编译器完全优化掉分配
var (
sink16 *Small16
sink32 *Small32
sink64 *Small64
)
// --- 16 字节分配测试 ---
func BenchmarkAlloc16(b *testing.B) {
for i := 0; i < b.N; i++ {
// Go 1.26 会将此调用替换为特化的 mallocgcSmallNoscan16
sink16 = &Small16{A: int64(i), B: int64(i)}
}
}
// --- 32 字节分配测试 ---
func BenchmarkAlloc32(b *testing.B) {
for i := 0; i < b.N; i++ {
sink32 = &Small32{A: 1, B: 2, C: 3, D: 4}
}
}
// --- 64 字节分配测试 ---
func BenchmarkAlloc64(b *testing.B) {
for i := 0; i < b.N; i++ {
sink64 = &Small64{}
}
}
// --- 包含指针的分配 (Scan 路径) ---
type WithPtr struct {
A *int
B int
}
var sinkPtr *WithPtr
func BenchmarkAllocWithPtr(b *testing.B) {
val := 42
for i := 0; i < b.N; i++ {
sinkPtr = &WithPtr{A: &val, B: i}
}
}
执行测试:
gotip test -bench=. -count=10 > new_results.txt
go test -bench=. -count=10 > old_results.txt
测试结果对比:

从数据可以看出:
这些数据充分证明了 Go 1.26 在小对象内存分配上的巨大进步。 再通过一个更简单的 Demo 分析一下汇编代码的区别:
type Data32 struct {
a, b, c, d int64
}
//go:noinline
func createData() *Data32 {
// 在 Go 1.26 中,这里会被优化为调用特化函数
return &Data32{a: 1, b: 2, c: 3, d: 4}
}
查看汇编差异:
go build -gcflags="-S" malloc.go 2>&1 |grep CALL
gotip build -gcflags="-S" malloc.go 2>&1 |grep CALL

从汇编结果来看,在 1.24 版本中,调用的是通用的 runtime.newobject(SB);而在 1.26 中,直接调用了特化的 runtime.mallocgcSmallNoScanSC4(SB)。这就是性能差异的根源。
在 Go 1.25 及之前的版本中,几乎所有的堆分配请求(如 new(T))最终都会汇聚到一个通用的入口函数:runtime.mallocgc。
这个函数非常强大,但也因此背负了沉重的包袱。它需要处理:
由于要“全能”,mallocgc 内部充满了复杂的逻辑判断。即使你只是分配一个 16 字节的整数,运行时也必须在执行路径上重新计算它的 Size Class,读取类型的元数据,并进行多次分支跳转。这种运行时的多态决策累积了不可忽视的开销。
在 Go 1.26 中,编译器不再总是调用通用 newobject,而是尽可能直接调用 runtime 里按大小/类别特化的分配函数。
Go 1.26 引入的 Size-Specialized Malloc,本质上是对这一结构性矛盾的修正。
既然:
于是 Go 1.26 做了一件非常关键的事:
为每一个 Size Class 生成专属的分配模板, 并让编译器在 SSA 阶段直接选中它。
从这一刻开始:
mallocgc 不再是所有分配的必经之路这就是所谓的:
把分配决策从 runtime,“左移”到编译期。
Go 1.26 的内存分配优化本质上是一个编译器与运行时的协同优化。
整体架构流程:
graph TD
A["Go 源码: &Data32{...}"] --> B["编译器 SSA 阶段"]
B --> C{"大小已知且固定?"}
C -- "YES" --> D{"< 512B?"}
C -- "NO" --> E["runtime.newobject"]
D -- "YES" --> F["特化函数调用: mallocgcSmallNoScanSC4"]
D -- "NO" --> E
F --> G["直接访问 mcache.alloc 4"]
G --> H["快速分配 + 内联清零"]
Go 1.26 没有选择使用 text/template 这种基于字符串替换的传统代码生成方式,而是采用了更为硬核的 AST(抽象语法树)操作。
核心模板位于 src/runtime/malloc_stubs.go。在这个文件中,定义了像 mallocStub 这样的通用模板函数。它们看起来是普通的 Go 代码,但包含了一些特殊的“占位符”变量,例如 elemsize_、sizeclass_ 和 noscanint_。
// src/runtime/malloc_stubs.go 简化示例
func mallocStub() unsafe.Pointer {
// 这里的 sizeclass_ 是一个变量,但在生成的代码中会被替换为常量 4
sizeclass := sizeclass_
// ...
}
这种设计的巧妙之处在于:
elemsize_ 的所有引用替换为常量 32”,而不用担心字符串匹配的误伤。mkmalloc位于 src/runtime/_mkmalloc/ 目录下的 mkmalloc.go 和 mksizeclasses.go 是这一优化的“制造工厂”。它们在构建 Go 工具链时运行,生产出我们最终看到的特化代码。
mksizeclasses.go)内存分配的第一步是确定“多大算多大”。mksizeclasses.go 负责静态计算最优的内存规格表(Size Classes)。
offset / size),这是一条昂贵的 CPU 指令。通过预计算,这被优化为 (offset * Magic) >> Shift,将延迟降低到纳秒级。mkmalloc.go)这是性能提升的核心源泉。mkmalloc.go 遍历所有 67 个规格类,为每一个规格生成专属的分配函数。
常量折叠 (Constant Folding):
它将模板中的 elemsize_ 替换为具体的字面量(如 32)。这意味着生成的代码中,原本的逻辑判断如 if size < 16 会变成 if 32 < 16。Go 编译器(gc)的死代码消除(Dead Code Elimination)通常会直接删除这部分代码,生成极致精简的指令流。
AST 级强制内联 (Manual AST Inlining):
为了突破编译器内联策略的限制(Go 编译器的内联预算是有限的),mkmalloc 采取了激进手段:它手动将 nextFreeFast(快速分配路径)和 writeHeapBitsSmall(堆位图写入)等辅助函数的 AST 节点直接注入 到生成的 mallocgc 函数体中。
这相当于在源代码层面把函数“展开”了。结果是,mallocgcSmallNoScanSC4 内部没有任何函数调用开销,所有逻辑都是线性的,对 CPU 分支预测器极度友好。
生成极速分发表:
最后,它生成 malloc_tables_generated.go,构建 mallocScanTable 和 mallocNoScanTable 两个函数指针数组。
var mallocNoScanTable = [513]func(...) unsafe.Pointer{
// ...
mallocgcSmallNoScanSC4, // 索引 4 直接指向特化函数
// ...
}
当编译器在 SSA 阶段决定使用特化分配时,它只需要根据对象大小计算出索引(编译期已知),就能通过一条指令直接跳转到最优的汇编实现。
在构建过程中,Go 工具链会基于模板生成 malloc_generated.go,包含 67 个 Size Class 的特化函数:
// malloc_generated.go (自动生成)
// Size Class 4: 32 bytes
func mallocgcSmallNoScanSC4(c *mcache, span *mspan, spc spanClass, size uintptr) unsafe.Pointer {
// ... 为 32 字节对象优化的代码 ...
// 编译期常量 elemsize = 32
// 编译器会将 memclrNoHeapPointers 优化为 4 条 MOV 指令
if span.needzero != 0 {
memclrNoHeapPointers(x, elemsize)
}
return v
}
// ... 共 67 个特化函数
在编译阶段,SSA(Static Single Assignment)中间表示会进行以下优化:
// 编译器内部逻辑(伪代码)
func (s *state) expr(n *ir.Node) *ssa.Value {
switch n.Op() {
case ir.ONEW:
typ := n.Type().Elem()
if typ.Size() <= 512 && typ.Size() > 0 {
// 大小已知且 <= 512 字节
sizeClass := sizeToClass(typ.Size())
if !typ.HasPointers() {
// 调用特化函数: OpMallocSmallNoScanSC
return s.newValue1(ssa.OpMallocSmallNoScanSC, sizeClass, ...)
}
// 还有针对含指针对象的 OpMallocSmallScanSC ...
}
// 回退到通用分配
return s.newValue0(ssa.OpNewObject, ...)
}
}
Go 的内存分配器继承自 TCMalloc 的设计思想,使用 Size Class 系统来对抗内存碎片。
Go 1.26 定义了 67 种 Size Class(0-66),每个 Class 对应一个固定的对象大小:
// runtime/sizeclasses.go
const _NumSizeClasses = 67
var class_to_size = [_NumSizeClasses]uint16{
0, // class 0: 特殊处理
8, // class 1
16, // class 2
24, // class 3
32, // class 4
48, // class 5
// ... 最大到 32768 (32KB)
}
对象大小会向上取整到最近的 Size Class:
对象大小 | 分配策略 | 函数选择 |
|---|---|---|
0-32KB | Size Class 分配 | 特化函数 (如果 < 512B) 或 mallocgc |
32KB-512KB | 单独 Span 分配 | mallocgc (small path) |
>512KB | 直接从 Heap 分配 | mallocgc (large path) |
总结一下,有几个原因
newobject -> mallocgc -> 类型检查 -> 计算 Size Class -> 查找 Span -> 分配。mallocgcSmallNoScanSC4 -> 直接访问 mcache.alloc[4] -> 分配。通用 mallocgc 需要在运行时检查 GC 状态、对象大小、是否包含指针等。而特化函数将这些检查硬编码了:mallocgcSmallNoScanSC4 明确知道自己分配的是 32 字节、无指针的对象。
通用路径需要调用 memclrNoHeapPointers(x, size),由于 size 是变量,编译器必须生成函数调用。
而在特化函数中,对象大小 elemsize 是编译期常量(例如 32)。Go 编译器(cmd/compile)在处理 memclrNoHeapPointers(x, 32) 时,会触发 Intrinsic 优化,将其直接编译为一组高效的赋值指令,避免了函数调用开销。
// 源码层面 (malloc_generated.go)
const elemsize = 32
if span.needzero != 0 {
memclrNoHeapPointers(x, elemsize)
}
// 汇编层面 (最终执行的代码)
// 逻辑等价于:
// *(*uint64)(v) = 0
// *(*uint64)(v + 8) = 0
// *(*uint64)(v + 16) = 0
// *(*uint64)(v + 24) = 0
直接访问 mcache.alloc[4] 使得内存访问模式更加可预测,CPU 可以更好地进行数据预取,减少 Cache Miss。
特化分配需要满足以下条件:
GOEXPERIMENT=sizespecializedmalloc(1.26 默认开启)。Go 1.26 的内存分配优化是一次教科书式的编译器与运行时协同优化。
memclrNoHeapPointers 对常量大小的 Intrinsic 优化,避免通用函数调用。对于 Go 开发者而言,这是一个零成本的性能红利。你只需要:
Go 语言在保持“简单”的同时,正通过越来越先进的编译器技术,不断突破性能的极限。
[1]Go 1.26 Release Notes: https://go.dev/doc/go1.26
[2]Go Source: runtime/malloc.go: https://github.com/golang/go/blob/master/src/runtime/malloc.go
[3]Change Log: Size-specialized malloc: https://go-review.googlesource.com/c/go/+/665835