bigywater.tech
Go · 53

内存分配器:mcache、mcentral、mheap 与 size class

“Go 的 new 和 make 到底占多少内存”要看分配器:它把小对象按 68 个固定大小的 class 分配,超过 32 KiB 的对象直接从页堆取页。这篇逐个验证:class 表、取整与浪费、tiny 分配器、malloc header、大对象的页取整,以及不同大小的分配耗时(包括一个我没有查明的非单调现象)。

68 个
size class 的数量,8 字节到 32768 字节
640 vs 576
576 字节的数组:含指针时占 640 字节(多 8 字节 header 后落入下一个 class),不含指针 576 字节
1 字节
tiny 分配器下 new(byte) 实际占的堆:16 个共用一个 16 字节块

size class 表

实测 · 本机执行输出
  // class  bytes/obj  bytes/span  objects  tail waste  max waste  min align
  //     1          8        8192     1024           0     87.50%          8
  //     2         16        8192      512           0     43.75%         16
  //     3         24        8192      341           8     29.24%          8
  //     4         32        8192      256           0     21.88%         32
  //     5         48        8192      170          32     31.52%         16
  //     6         64        8192      128           0     23.44%         64
  //     7         80        8192      102          32     19.07%         16
  //     8         96        8192       85          32     15.95%         32
  //     9        112        8192       73          16     13.56%         16
  //    10        128        8192       64           0     11.72%        128
  ……(共 68 个 class,最后几行)
  //    64      24576       24576        1           0     11.45%       8192
  //    65      27264       81920        3         128     10.00%        128
  //    66      28672       57344        2           0      4.91%       4096
  //    67      32768       32768        1           0     12.50%       8192
  
  // alignment  bits  min obj size
  //         8     3             8
  86:	MaxSmallSize       = 32768
  90:	NumSizeClasses     = 68
  91:	PageShift          = 13
  94:	TinySize           = 16
  internal/runtime/gc/malloc.go:47:	MinSizeForMallocHeader = goarch.PtrSize * goarch.PtrBits

运行时把 8 字节到 32 KiB 之间的对象分成 68 个固定大小的 class;每个 class 的内存由若干个 8 KiB 页组成的 span 提供,span 里按对象大小切开。class 的步长在小处是 8 或 16 字节,在大处是 1 KiB 以上,表里的“max waste”列给出每个 class 的最大浪费比例(小 class 可高达 87.5%,大多数 class 在 5% 到 30% 之间)。

请求大小向上取整

实测 · 本机执行输出
  请求     1 字节 → 落在    16 字节的 class(该 class 在这段时间多出 1250 次分配);浪费 15 字节(93.8%)
  请求     8 字节 → 落在    16 字节的 class(该 class 在这段时间多出 10000 次分配);浪费 8 字节(50.0%)
  请求     9 字节 → 落在    16 字节的 class(该 class 在这段时间多出 20000 次分配);浪费 7 字节(43.8%)
  请求    16 字节 → 落在    16 字节的 class(该 class 在这段时间多出 20000 次分配);浪费 0 字节(0.0%)
  请求    17 字节 → 落在    24 字节的 class(该 class 在这段时间多出 20000 次分配);浪费 7 字节(29.2%)
  请求    25 字节 → 落在    32 字节的 class(该 class 在这段时间多出 20000 次分配);浪费 7 字节(21.9%)
  请求    33 字节 → 落在    48 字节的 class(该 class 在这段时间多出 20000 次分配);浪费 15 字节(31.2%)
  请求    49 字节 → 落在    64 字节的 class(该 class 在这段时间多出 20000 次分配);浪费 15 字节(23.4%)
  请求    65 字节 → 落在    80 字节的 class(该 class 在这段时间多出 20000 次分配);浪费 15 字节(18.8%)
  请求   100 字节 → 落在   112 字节的 class(该 class 在这段时间多出 20000 次分配);浪费 12 字节(10.7%)
  请求   200 字节 → 落在   208 字节的 class(该 class 在这段时间多出 20000 次分配);浪费 8 字节(3.8%)
  请求   600 字节 → 落在   640 字节的 class(该 class 在这段时间多出 20000 次分配);浪费 40 字节(6.2%)
  请求  1030 字节 → 落在  1152 字节的 class(该 class 在这段时间多出 20000 次分配);浪费 122 字节(10.6%)
  请求  5000 字节 → 落在  5376 字节的 class(该 class 在这段时间多出 20000 次分配);浪费 376 字节(7.0%)
  请求 16000 字节 → 落在 16384 字节的 class(该 class 在这段时间多出 20000 次分配);浪费 384 字节(2.3%)

请求的字节数向上取整到最近的 class,例如 17 → 24、33 → 48、1030 → 1152。上面实测的落点与表一致。这意味着“结构体多一个字段”有时一个字节都不多占,有时跨过 class 边界多占几十字节——可以用上一篇结构体对齐里的方法算好再取舍。

tiny 分配器

实测 · 本机执行输出
  []any 自己每个槽占 16.0 字节(下面的数字已经扣掉)
  new(byte)(1 字节,不含指针)               每个对象占堆约 1.0 字节
  new(int32)(4 字节,不含指针)              每个对象占堆约 4.0 字节
  new(int64)(8 字节,不含指针)              每个对象占堆约 8.0 字节
  new([9]byte)(9 字节,不含指针)            每个对象占堆约 16.0 字节
  new([12]byte)(12 字节,不含指针)          每个对象占堆约 16.0 字节
  new(P8)(8 字节,含指针)                  每个对象占堆约 8.0 字节
  new([16]byte)(16 字节,不含指针)          每个对象占堆约 16.0 字节
  new([17]byte)(17 字节,不含指针)          每个对象占堆约 24.0 字节
  new(T24)(24 字节,不含指针)               每个对象占堆约 24.0 字节

不含指针、小于 16 字节的对象不独占一个 class 槽,而是顺序放进同一个 16 字节块。实验里 new(byte) 每个只占 1 字节(2 万次分配只多了 1250 个 16 字节块的分配),new(int64) 占 8 字节。放不下第二个的 [9]byte、[12]byte 各占一个 16 字节块。含指针的对象不走 tiny(要给 GC 精确的指针信息)。

malloc header

实测 · 本机执行输出
  []any 自己每个槽占 16.0 字节(下面的数字已经扣掉)
  [64]*int(512 字节,含指针)           每个对象占堆约 512.1 字节
  [65]*int(520 字节,含指针)           每个对象占堆约 576.0 字节
  [72]*int(576 字节,含指针)           每个对象占堆约 640.0 字节
  [72]int(576 字节,不含指针)           每个对象占堆约 576.0 字节
  [128]*int(1024 字节,含指针)         每个对象占堆约 1152.0 字节
  [128]int(1024 字节,不含指针)         每个对象占堆约 1024.0 字节

常量 MinSizeForMallocHeader 是 512(指针大小 × 指针位数)。含指针且大于 512 字节的对象,实测要多占 8 字节(源码里把它叫 malloc header,内容是什么我没有核对);不含指针的对象没有这个开销。所以 576 字节的含指针数组(加 header 后 584)落入 640 字节的 class,不含指针的只占 576。含指针的大数组/结构体比想象的更占内存。

大对象

实测 · 本机执行输出
  make([]byte,  32768):每个占堆约 32771 字节(= 4.00 个 8 KiB 页)
  make([]byte,  32769):每个占堆约 40962 字节(= 5.00 个 8 KiB 页)
  make([]byte,  33000):每个占堆约 40962 字节(= 5.00 个 8 KiB 页)
  make([]byte,  40960):每个占堆约 40960 字节(= 5.00 个 8 KiB 页)
  make([]byte,  40961):每个占堆约 49152 字节(= 6.00 个 8 KiB 页)
  make([]byte,  65536):每个占堆约 65536 字节(= 8.00 个 8 KiB 页)
  make([]byte, 100000):每个占堆约 106496 字节(= 13.00 个 8 KiB 页)

大于 32 KiB 的对象不走 size class,直接从页堆取整数个 8 KiB 页:33000 字节占 5 页(40960),恰好 40960 也是 5 页,40961 就是 6 页。大对象分配成本高(65536 字节约 3.4 µs),而且零值初始化要清整块内存。

分配成本

实测 · 本机执行输出
  make([]byte,      8):中位      7.1 ns/次(最小 7.1 / 最大 7.3);分配 8 字节/次
  make([]byte,     16):中位      7.7 ns/次(最小 7.7 / 最大 7.8);分配 16 字节/次
  make([]byte,     32):中位      8.8 ns/次(最小 8.7 / 最大 9.0);分配 32 字节/次
  make([]byte,     64):中位     11.3 ns/次(最小 11.1 / 最大 11.6);分配 64 字节/次
  make([]byte,    128):中位     70.5 ns/次(最小 62.8 / 最大 71.2);分配 128 字节/次
  make([]byte,    512):中位    101.9 ns/次(最小 99.5 / 最大 104.0);分配 512 字节/次
  make([]byte,   1024):中位    146.3 ns/次(最小 141.9 / 最大 147.4);分配 1024 字节/次
  make([]byte,   4096):中位    408.1 ns/次(最小 403.4 / 最大 427.0);分配 4096 字节/次
  make([]byte,  16384):中位   1255.7 ns/次(最小 1196.8 / 最大 1273.0);分配 16384 字节/次
  make([]byte,  32768):中位   2026.5 ns/次(最小 2002.3 / 最大 2078.7);分配 32768 字节/次
  make([]byte,  65536):中位   3417.0 ns/次(最小 3192.8 / 最大 3479.5);分配 65536 字节/次
  make([]byte, 262144):中位   8436.5 ns/次(最小 8325.5 / 最大 8471.9);分配 262144 字节/次
  ToolTags 里与分配器有关的:goexperiment.sizespecializedmalloc
  同一份代码,GOEXPERIMENT=nosizespecializedmalloc 构建(只列 8 到 1024 字节):
  make([]byte,      8):中位      8.2 ns/次(最小 8.2 / 最大 8.2);分配 8 字节/次
  make([]byte,     16):中位     10.7 ns/次(最小 10.7 / 最大 10.8);分配 16 字节/次
  make([]byte,     32):中位     12.8 ns/次(最小 12.8 / 最大 13.0);分配 32 字节/次
  make([]byte,     64):中位     14.6 ns/次(最小 14.3 / 最大 15.0);分配 64 字节/次
  make([]byte,    128):中位     70.1 ns/次(最小 65.9 / 最大 72.9);分配 128 字节/次
  make([]byte,    512):中位    100.5 ns/次(最小 100.1 / 最大 102.6);分配 512 字节/次
  make([]byte,   1024):中位    145.4 ns/次(最小 142.0 / 最大 150.0);分配 1024 字节/次

小对象(≤ 64 字节)分配只要 7 到 11 ns;1 KiB 约 146 ns;16 KiB 约 1.26 µs;成本大致随字节数增长(包含清零与摊销的 GC 成本)。默认启用的 goexperiment.sizespecializedmalloc 让 ≤ 64 字节的分配快 1 到 4 ns(用 nosizespecializedmalloc 重新构建对比得到)。

一个没有解释的现象

实测 · 本机执行输出
  make([]byte,  64):中位 10.8 ns/次(最小 10.7 / 最大 11.6)
  make([]byte,  80):中位 12.6 ns/次(最小 12.4 / 最大 12.9)
  make([]byte,  96):中位 17.3 ns/次(最小 17.2 / 最大 17.6)
  make([]byte, 112):中位 18.2 ns/次(最小 17.8 / 最大 18.6)
  make([]byte, 120):中位 70.7 ns/次(最小 69.9 / 最大 77.6)
  make([]byte, 128):中位 69.6 ns/次(最小 67.9 / 最大 78.1)
  make([]byte, 144):中位 23.1 ns/次(最小 22.4 / 最大 23.8)
  make([]byte, 192):中位 74.9 ns/次(最小 74.0 / 最大 76.0)
  make([]byte, 256):中位 78.1 ns/次(最小 77.5 / 最大 79.9)

相邻大小的分配耗时并不单调:112 字节 18 ns、128 字节 70 ns、144 字节 23 ns、192 字节 75 ns。可能与不同 class 在 span 中的布局或清零方式有关,但我没有查源码或做对照实验,这里只记录现象。如果你的热路径分配大小恰好落在慢的 class 上,值得在自己的机器上量一量。

分步演示

堆内存分配器
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew);用 go run / go build / go test 直接编译运行,需要第三方库时经代理从 proxy.golang.org 下载;计时用 time.Now / testing.B,数字每次运行会有小幅抖动。class 表是 internal/runtime/gc/sizeclasses.go 的原样摘录;落点用 MemStats.BySize 在分配前后的差统计(BySize 只覆盖 16 KiB 以下的 class);每个对象占的堆 = 100 万(或 20 万)个对象保留后 runtime.GC() 之后的 HeapAlloc 增量减去 []any(16 字节/槽)或切片头(24 字节)的开销;基准测试 5 轮取中位数
    • 没有验证:mcache、mcentral、mheap 之间 span 流转的具体过程(只在表里看到了 span 大小,没有观察)、128/192/256 字节处耗时跳变的原因、amd64 与 Linux 上的 class 与耗时、THP 与堆增长对分配的影响、GOGC 对大对象的清零成本、sizespecializedmalloc 的实现
    京ICP备2026067502号-1京公网安备11010502063487号