“Go 的 new 和 make 到底占多少内存”要看分配器:它把小对象按 68 个固定大小的 class 分配,超过 32 KiB 的对象直接从页堆取页。这篇逐个验证:class 表、取整与浪费、tiny 分配器、malloc header、大对象的页取整,以及不同大小的分配耗时(包括一个我没有查明的非单调现象)。
// 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 边界多占几十字节——可以用上一篇结构体对齐里的方法算好再取舍。
[]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 精确的指针信息)。
[]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 上,值得在自己的机器上量一量。