因为 slice 仅是 24 字节的头结构体(ptr+len+cap),unsafe.sizeof 只计算其固定开销,不包含底层数组内存;真实占用为 cap × elemsize 经 runtime.roundupsize 对齐后的实际分配量,含内部碎片。
为什么 unsafe.sizeof([]int{}) 总是 24 字节,却不能反映真实内存开销
因为 slice 本身只是个头结构体:struct{array unsafe.pointer; len, cap int},在 64 位系统下固定占 24 字节、按指针对齐(8 字节),unsafe.sizeof 只测这个头,完全不包含底层数组。真正吃内存的是 cap * unsafe.sizeof(t) 对齐后的实际分配量。
常见错误现象:用 unsafe.Sizeof(s) 判断 slice 内存占用,结果发现压测时 heap profile 显示对象大小远超预期——那多出来的全是底层数组。
- 底层数组分配由
runtime.mallocgc控制,必须满足unsafe.Alignof(T)要求(如[]int64要求 8 字节对齐,[][16]byte要求 16 字节对齐) - 分配器会调用
runtime.roundupsize(neededBytes)向上取整到最近的 size class 档位(如 8、16、32、48、64…9216 字节) - 最终 cap =
roundupsize(cap * elemSize) / elemSize,除法截断后可能比“理论 cap”小
make([]T, 0, N) 分配的内存不一定等于 N * unsafe.Sizeof(T)
这是最常被忽略的隐性开销来源。例如 make([]int64, 0, 1025):理论需 1025 × 8 = 8200 字节,但 roundupsize(8200) 在 Go 当前 size class 中返回 8320 或 9216,多出的 120–1016 字节就是内部碎片。
元素类型越“难整除”,浪费越明显:
-
[]byte(elemSize=1):几乎总能精准匹配档位,碎片少 -
[]int(elemSize=8):多数档位是 8 的倍数,表现尚可 -
struct{a int; b [13]byte}(elemSize=24):24 不是常见档位的约数,容易跨档,碎片率飙升
高频创建这类切片(如 HTTP handler 中临时解析 header)会快速抬高 GC 压力和堆内存 RSS。
嵌入结构体时,slice 字段不会额外触发填充,但数组对齐会影响整体布局
很多人担心把 []byte 放进 struct 会导致编译器为它插 padding,其实不会:slice 作为字段,只贡献 24 字节且天然对齐(三个字段都是指针/整数大小),编译器顺序放置,无填充。
真正影响结构体大小的是底层数组的对齐要求——当该 struct 被数组化或作为 map value 存储时,整个 struct 的大小必须满足其最宽字段(含底层数组首地址对齐需求)的对齐约束。
- 若 struct 包含
data []int64,且你后续通过append让它扩容,新分配的底层数组起始地址需 8 字节对齐,但这不改变 struct 本身的 layout - 但如果 struct 是
type Buf struct { hdr [16]byte; data []byte },而data底层数组实际用了 2048 字节,那么Buf实例在 slice 或 array 中排列时,仍只按自身大小(比如 32 字节)对齐,底层数组内存是独立分配的
扩容时的对齐不是为了缓存行,别误判优化方向
看到 cap 从 7 变成 8([]int)就以为“对齐了 64 字节缓存行”,这是典型误解。64 字节只是 roundupsize(56) 碰巧返回的档位,不是目标;Go 分配器不控制物理地址,无法保证缓存行对齐。
真正值得盯住的是访问模式:
- 顺序遍历
s[i]天然友好预取器,起始偏移是否对齐 64 字节影响极小 - 随机跳转(如按 hash 取模访问)才容易引发 cache miss,这和扩容算法无关
- 若真要优化 locality,优先做
make([]T, 0, N)预分配 + 减少中间切片截取(避免底层数组被长期持有)
对齐规则影响的是单次分配的内存效率,不是运行时访问性能——这点容易被 benchmark 带偏,尤其在空闲机器上跑 microbenchmark 时,缓存足够大,差异根本看不出来。











