少数派报告----Edward's Webblog

Some raw thought.

9 October 2026

了解Go 1.27 小对象内存分配的优化

by Edward Yuan

最近 Go 官方发布了一篇关于 Go 1.27 内存分配优化的技术文章,介绍了一项叫作 Size-Specialized Allocation 的改进。根据官方测试,80 字节及以内的小对象分配速度最高提升了 20%–30%,对于内存分配密集型程序,整体性能最高可以提升约 1%。

单看这个数字似乎不算特别明显,但有意思的是,这次优化既没有改变 Go 的内存分配模型,也没有修改 GC 的工作机制,而是从一个很简单的问题入手: 既然编译器已经知道要分配的对象有多大,为什么还需要在运行时重复处理这些信息?

我们先来回顾一下原有的内存分配过程:

当编译器通过逃逸分析,确定某个对象需要分配到堆上时,通常会生成对 runtime.newobject 的调用,随后由 mallocgc 完成实际分配。

Go的内存分配器并不会为每一种大小的对象单独维护内存池,而是通过 Size Class 将对象划分到不同的尺寸等级。

比如:17–24字节的对象统一使用24字节的分配槽位,25–32字节的对象则使用32字节的槽位。

对于常规小对象,分配器可以直接从当前 P 关联的 mcache 中获取内存,避免频繁访问全局堆管理结构。

此外,Go 还需要区分对象是否包含指针,因为这直接关系到 GC 是否需要扫描对象内部的内存。因此,Runtime 使用 Span Class 将 Size Class 和指针属性组合起来:

spanClass = sizeClass<<1 | noPointers

这套机制已经相当高效,但通用的 mallocgc 仍然需要处理各种尺寸、指针属性和特殊情况。即使某个对象的大小在编译期就已经确定,通用分配路径仍然需要保留处理其他情况的逻辑。

Go 1.27的优化思路就是把其中一部分工作提前。

比如下面这个结构体:

type Message struct {
    ID    uint64
    Type  uint32
    Flags uint32
}

func NewMessage() *Message {
    return &Message{ID: 100}
}

在 64 位系统中,Message 正好占用 16 字节,而且不包含指针。

如果这次对象创建确实需要堆分配,那么编译器已经知道它属于 Size Class 2,并且不需要 GC 扫描内部指针。

既然这些条件都是确定的,就可以直接调用对应的专用分配函数,而不必每次进入通用路径。

Go 1.27 为不同的 Span Class 生成了专门的 mallocgc 函数。例如:

mallocgcSmallNoScanSC3

这个函数专门处理 Size Class 3,也就是17–24字节、且不包含指针的对象。

当编译器能够确定 Span Class 时,可以直接生成对专用函数的调用,跳过 newobject 和通用 mallocgc。对于动态长度切片等无法在编译期确定分配大小的情况,仍然通过通用 mallocgc 判断并调用适当的专用函数。

不过,省掉尺寸分类并不是这次优化最大的收益,真正值得关注的是 内存清零。

Go语言需要保证新分配对象满足零值语义。当分配器需要清零一段内存时,可以调用 memclrNoHeapPointers。这个函数本身已经使用汇编进行了优化,但对于只有十几字节的对象,函数调用及内部判断所产生的成本仍然不可忽略。

而专用分配函数的一个特点,就是清零长度可以成为编译期常量。例如要清空16字节的内存,理论上可以直接生成类似这样的操作:

*(*uint64)(ptr) = 0
*(*uint64)(unsafe.Add(ptr, 8)) = 0

实际生成的机器指令取决于 CPU 架构及编译器实现,但原理相同:既然已经确定需要清零多少字节,就不必再通过通用函数动态处理。

这样不仅减少了函数调用和分支,编译器还可以进一步优化分配过程中的部分元数据操作。

Go团队还使用AST工具生成专用函数代码,将部分辅助函数直接展开,并把不常执行的调试检查等逻辑移到慢路径中,从而缩短正常分配的执行过程。

看到这里可能会有一个问题: 既然专用函数更快,为什么不为所有 Size Class 都生成一个?

这里涉及 CPU 的指令缓存(I-cache)。原有的通用 mallocgc 被频繁调用,其主要执行代码通常具有较好的指令缓存局部性。如果为大量不同尺寸分别生成专用函数,虽然每个函数的执行路径变短了,但整体代码体积也会增长。不同专用函数之间开始竞争有限的指令缓存空间,甚至可能挤占业务代码。

另一方面,随着分配对象增大,内存清零本身的写入成本逐渐占据主要部分,此时再减少几个判断或函数调用,收益也越来越有限。

所以专用分配并不是覆盖的尺寸越多越好。

Go 团队经过多轮基准测试,最终选择将优化范围限制在 80 字节及以内。其中,对于不包含指针的极小对象,仍然需要考虑 Tiny Allocator 的特殊处理,并不是所有小对象都走相同的分配路径。

这项优化原计划在 Go 1.26 发布,但由于代码体积和指令缓存方面的权衡,最终推迟到了 Go 1.27。

那么实际使用中有什么区别?

对于普通开发者来说,几乎没有任何区别。只需要使用 Go 1.27 重新编译,符合条件的堆分配就可以自动使用新的分配路径。

如果想验证实际效果,可以编写一个简单的 Benchmark:

package alloc

import "testing"

type Object struct {
    A, B, C uint64
}

var sink *Object

func BenchmarkAlloc24(b *testing.B) {
    for i := 0; i < b.N; i++ {
        sink = new(Object)
    }
}

这里使用一个 24 字节的结构体,通过全局变量保留指针,避免分配被编译器直接消除。

正常运行:

go test -run '^$' -bench BenchmarkAlloc24 -benchmem

然后禁用尺寸专用分配:

GOEXPERIMENT=nosizespecializedmalloc \
go test -run '^$' -bench BenchmarkAlloc24 -benchmem

对比两组结果中的 ns/op,就可以观察单次分配耗时的变化。实际测试最好多次运行,并使用 benchstat 比较,避免 CPU 负载和测试波动造成干扰。

需要注意的是,这项优化并没有减少堆分配次数,也没有直接减少 GC 需要处理的对象数量。它降低的是部分分配操作本身的 CPU 成本。因此,20%–30% 的分配速度提升并不意味着整个程序也能得到相同比例的性能改善。

从Go 1.27的这次改动可以看到,Runtime 的性能优化不一定需要重新设计内存管理器。有时候,将已经确定的信息从运行时提前到编译期,再利用这些信息生成更具体的执行路径,就可以减少大量重复操作。

对于单次分配来说,节省的可能只是几个分支和一次函数调用。但在每秒数百万次的小对象分配中,这些看起来微不足道的开销,累积起来就有了实际意义。

而这次优化另一个值得注意的地方,是 Go 团队没有单纯追求分配函数本身的速度,而是同时考虑了代码体积和 CPU 指令缓存的影响。毕竟, 减少一段代码的执行时间,不代表增加更多这样的代码就能让整个程序更快。

这也是底层性能优化中经常遇到的问题:局部最优与整体最优,并不总是一回事。


原文参考:Size-Specialized Memory Allocation — The Go Blog

tags: