首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >Go 1.26: 内存分配优化分析 将小对象的分配速度提升了 30%

Go 1.26: 内存分配优化分析 将小对象的分配速度提升了 30%

作者头像
萝卜要努力
发布2026-01-07 18:41:25
发布2026-01-07 18:41:25
640
举报
文章被收录于专栏:萝卜要加油萝卜要加油

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 内存分配优化的实现原理,从基准测试数据、汇编代码分析到源码实现机制,带你全面理解这一编译器与运行时协同优化的技术细节。

测试平台

代码语言:javascript
复制

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

小对象分配实测

为了验证这一提升,我写了一个基准测试:

代码语言:javascript
复制

// 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}  
    }  
}

执行测试:

代码语言:javascript
复制

gotip test -bench=. -count=10 > new_results.txt 
go test -bench=. -count=10 > old_results.txt 

测试结果对比:

Pasted image 20260104221437
Pasted image 20260104221437

从数据可以看出:

  • 16 字节对象:从 7.24ns 降至 5.08ns,提升约 30%
  • 32 字节对象:从 9.17ns 降至 6.03ns,提升约 34%
  • 64 字节对象:从 10.08ns 降至 7.66ns,提升约 24%
  • 带指针对象:从 9.77ns 降至 5.77ns,提升高达 41%

这些数据充分证明了 Go 1.26 在小对象内存分配上的巨大进步。 再通过一个更简单的 Demo 分析一下汇编代码的区别:

代码语言:javascript
复制

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}  
}

查看汇编差异:

代码语言:javascript
复制

go build -gcflags="-S" malloc.go 2>&1 |grep CALL
gotip build -gcflags="-S" malloc.go 2>&1 |grep CALL
Pasted image 20260104221711
Pasted image 20260104221711

从汇编结果来看,在 1.24 版本中,调用的是通用的 runtime.newobject(SB);而在 1.26 中,直接调用了特化的 runtime.mallocgcSmallNoScanSC4(SB)。这就是性能差异的根源。

通用分配器的“全能”负担

在 Go 1.25 及之前的版本中,几乎所有的堆分配请求(如 new(T))最终都会汇聚到一个通用的入口函数:runtime.mallocgc

这个函数非常强大,但也因此背负了沉重的包袱。它需要处理:

  • 各种尺寸:从 8 字节到数 MB。
  • 各种类型:包含指针的(Scan)和不包含指针的(Noscan)。
  • 各种状态:GC 标记阶段、辅助 GC、内存清零等。

由于要“全能”,mallocgc 内部充满了复杂的逻辑判断。即使你只是分配一个 16 字节的整数,运行时也必须在执行路径上重新计算它的 Size Class,读取类型的元数据,并进行多次分支跳转。这种运行时的多态决策累积了不可忽视的开销。

在 Go 1.26 中,编译器不再总是调用通用 newobject,而是尽可能直接调用 runtime 里按大小/类别特化的分配函数。

Go 1.26 的黑科技:特化分配 (Specialized Malloc)

Go 1.26 引入的 Size-Specialized Malloc,本质上是对这一结构性矛盾的修正。

既然:

  • Size Class 的划分是静态的
  • 小对象的大小在编译期可知
  • 是否包含指针也是类型系统的一部分 那么就没有理由继续让 runtime 在热路径上重复计算。

于是 Go 1.26 做了一件非常关键的事:

为每一个 Size Class 生成专属的分配模板, 并让编译器在 SSA 阶段直接选中它。

从这一刻开始:

  • Size Class 的选择不再是 runtime 的责任
  • mallocgc 不再是所有分配的必经之路
  • runtime 只负责执行“已经选好的最优方案”

这就是所谓的:

把分配决策从 runtime,“左移”到编译期。

Go 1.26 的内存分配优化本质上是一个编译器与运行时的协同优化

整体架构流程:

代码语言:javascript
复制

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["快速分配 + 内联清零"]

模板系统:AST 级别的“外科手术”

Go 1.26 没有选择使用 text/template 这种基于字符串替换的传统代码生成方式,而是采用了更为硬核的 AST(抽象语法树)操作

核心模板位于 src/runtime/malloc_stubs.go。在这个文件中,定义了像 mallocStub 这样的通用模板函数。它们看起来是普通的 Go 代码,但包含了一些特殊的“占位符”变量,例如 elemsize_sizeclass_noscanint_

代码语言:javascript
复制

// src/runtime/malloc_stubs.go 简化示例
func mallocStub() unsafe.Pointer {
    // 这里的 sizeclass_ 是一个变量,但在生成的代码中会被替换为常量 4
    sizeclass := sizeclass_ 
    // ...
}

这种设计的巧妙之处在于:

  1. 类型安全:模板本身就是合法的 Go 代码,可以通过编译检查,IDE 也能提供补全和跳转。
  2. 精确控制:生成器不是在处理文本,而是在处理语法结构。这意味着它可以精确地识别“将变量 elemsize_ 的所有引用替换为常量 32”,而不用担心字符串匹配的误伤。

代码生成:幕后功臣 mkmalloc

位于 src/runtime/_mkmalloc/ 目录下的 mkmalloc.gomksizeclasses.go 是这一优化的“制造工厂”。它们在构建 Go 工具链时运行,生产出我们最终看到的特化代码。

1. 精密计算规格表 (mksizeclasses.go)

内存分配的第一步是确定“多大算多大”。mksizeclasses.go 负责静态计算最优的内存规格表(Size Classes)。

  • 碎片控制:它通过模拟计算,寻找每个规格类对应的最佳页数(Page Count),目标是将尾部浪费(Tail Waste)严格控制在 12.5% 以内。
  • 消除除法指令:它预计算了乘法魔数(Magic Numbers)。在运行时,计算“对象是 Span 中的第几个”通常需要除法(offset / size),这是一条昂贵的 CPU 指令。通过预计算,这被优化为 (offset * Magic) >> Shift,将延迟降低到纳秒级。
2. 静态特化与强制内联 (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,构建 mallocScanTablemallocNoScanTable 两个函数指针数组。

代码语言:javascript
复制

var mallocNoScanTable = [513]func(...) unsafe.Pointer{
    // ...
    mallocgcSmallNoScanSC4, // 索引 4 直接指向特化函数
    // ...
}

当编译器在 SSA 阶段决定使用特化分配时,它只需要根据对象大小计算出索引(编译期已知),就能通过一条指令直接跳转到最优的汇编实现。

在构建过程中,Go 工具链会基于模板生成 malloc_generated.go,包含 67 个 Size Class 的特化函数:

代码语言:javascript
复制

// 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 优化

在编译阶段,SSA(Static Single Assignment)中间表示会进行以下优化:

代码语言:javascript
复制

// 编译器内部逻辑(伪代码)
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, ...)
    }
}

Size Class 系统详解

Go 的内存分配器继承自 TCMalloc 的设计思想,使用 Size Class 系统来对抗内存碎片。

67 种大小级别

Go 1.26 定义了 67 种 Size Class(0-66),每个 Class 对应一个固定的对象大小:

代码语言:javascript
复制

// 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:

  • 17-24 字节的对象 → Size Class 3 (24 字节)
  • 25-32 字节的对象 → Size Class 4 (32 字节)

对象大小

分配策略

函数选择

0-32KB

Size Class 分配

特化函数 (如果 < 512B) 或 mallocgc

32KB-512KB

单独 Span 分配

mallocgc (small path)

>512KB

直接从 Heap 分配

mallocgc (large path)

Go1. 26 为什么快?

总结一下,有几个原因

减少间接调用
  • 旧版本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 优化,将其直接编译为一组高效的赋值指令,避免了函数调用开销。

代码语言:javascript
复制

// 源码层面 (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
更好的 CPU 缓存利用

直接访问 mcache.alloc[4] 使得内存访问模式更加可预测,CPU 可以更好地进行数据预取,减少 Cache Miss。

适用场景与限制

适用条件

特化分配需要满足以下条件:

  1. 大小固定:编译期能确定对象大小。
  2. 小对象:大小 < 512 字节。
  3. 实验开启GOEXPERIMENT=sizespecializedmalloc(1.26 默认开启)。
  4. 环境限制:未启用 Sanitizer(race/asan/msan/valgrind)且非 Plan9 系统。

总结

Go 1.26 的内存分配优化是一次教科书式的编译器与运行时协同优化

核心技术点回顾

  1. 模板生成系统:利用 AST 操作生成代码,比字符串模板更安全、精确。
  2. 编译器 SSA 优化:在中间代码阶段识别固定大小分配,直接派发给特化函数。
  3. AST 级强制内联:消除函数调用,极致压榨 CPU 性能。
  4. 常量折叠带来的清零优化:利用 memclrNoHeapPointers 对常量大小的 Intrinsic 优化,避免通用函数调用。

给开发者的建议

对于 Go 开发者而言,这是一个零成本的性能红利。你只需要:

  1. 升级到 Go 1.26
  2. 无需修改代码,自动享受提升。
  3. 对于追求极致性能的热点路径,尽量使用固定大小的小对象(< 512B),以充分利用这一优化。

Go 语言在保持“简单”的同时,正通过越来越先进的编译器技术,不断突破性能的极限。

参考资料:

  • Go 1.26 Release Notes[1]
  • Go Source: runtime/malloc.go[2]
  • Change Log: Size-specialized malloc[3]

引用链接

[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

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2026-01-07,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 萝卜要加油 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 小对象分配实测
  • 通用分配器的“全能”负担
  • Go 1.26 的黑科技:特化分配 (Specialized Malloc)
  • 模板系统:AST 级别的“外科手术”
  • 代码生成:幕后功臣 mkmalloc
    • 1. 精密计算规格表 (mksizeclasses.go)
    • 2. 静态特化与强制内联 (mkmalloc.go)
  • 编译器 SSA 优化
  • Size Class 系统详解
    • 67 种大小级别
    • 大小映射与分配策略
  • Go1. 26 为什么快?
    • 减少间接调用
    • 消除类型检查开销
    • 内联清零优化
    • 更好的 CPU 缓存利用
  • 适用场景与限制
    • 适用条件
  • 总结
  • 核心技术点回顾
  • 给开发者的建议
  • 参考资料:
  • 引用链接
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档