本文起于一次验证slice扩容机制的测试,搜索引擎和AI给我们关于slice扩容机制的解释大多是"低于1024翻倍,超过则乘1.25倍"(以下用"1024机制"代指),但是在我基于go1.24.1的结果却完全不是这么回事,难道是营销号太多误导我们了吗?以下将结合源码学习,实践是检验真理的唯一标准。
一. 验证我们随手搜到的"1024机制"
go
a := make([]int, 0, 512)
for i := 0; i < 513; i++ {
a = append(a, i)
}
fmt.Println(cap(a))
// 848
结果并不是预想中的1024,而是848
二. 扩容的过程是怎么样的?
1. 扩容的函数签名
go
func growslice(oldPtr unsafe.Pointer, newLen, oldCap, num int, et *_type) slice
参数: oldPtr:原切片的数据指针(指向底层数组的起始地址)。 newLen:扩容后的新长度(原长度 + 新增元素数量num)。 oldCap:原切片的容量。 num:本次需要新增的元素数量(newLen = 原长度 + num)。 et:切片元素的类型信息(包含大小、是否含指针等元数据)。 返回值:扩容后的新切片(slice结构体,包含新数据指针、新长度、新容量)。
2. 核心逻辑:
go
oldLen := newLen - num // 计算原切片的长度(新长度 = 原长度 + 新增元素数)
if raceenabled {
callerpc := sys.GetCallerPC()
racereadrangepc(oldPtr, uintptr(oldLen*int(et.Size_)), callerpc, abi.FuncPCABIInternal(growslice))
}
if msanenabled {
msanread(oldPtr, uintptr(oldLen*int(et.Size_)))
}
if asanenabled {
asanread(oldPtr, uintptr(oldLen*int(et.Size_)))
}
if newLen < 0 {
panic(errorString("growslice: len out of range"))
}
if et.Size_ == 0 {
// append should not create a slice with nil pointer but non-zero len.
// We assume that append doesn't need to preserve oldPtr in this case.
return slice{unsafe.Pointer(&zerobase), newLen, newLen}
}
扩容前先进行了一波校验和处理,主要是获取旧切片容量和特殊处理。
go
newcap := nextslicecap(newLen, oldCap)
然后看看关键的nextslicecap函数内部:
go
func nextslicecap(newLen, oldCap int) int {
newcap := oldCap
doublecap := newcap + newcap
if newLen > doublecap {
return newLen
}
const threshold = 256
if oldCap < threshold {
return doublecap
}
for {
// Transition from growing 2x for small slices
// to growing 1.25x for large slices. This formula
// gives a smooth-ish transition between the two.
newcap += (newcap + 3*threshold) >> 2
// We need to check `newcap >= newLen` and whether `newcap` overflowed.
// newLen is guaranteed to be larger than zero, hence
// when newcap overflows then `uint(newcap) > uint(newLen)`.
// This allows to check for both with the same comparison.
if uint(newcap) >= uint(newLen) {
break
}
}
// Set newcap to the requested cap when
// the newcap calculation overflowed.
if newcap <= 0 {
return newLen
}
return newcap
}
很明显并不是"1024机制"
这里分三种情况:
首先计算 "2 倍旧容量"(doublecap)。 a. 如果需要的新长度(newLen)比 2 倍旧容量还大,说明 2 倍扩容都不够,直接返回newLen(必须至少容纳这么多元素)。
b. 定义阈值threshold=256,**对于容量小于 256 的 "小切片",直接返回 2 倍旧容量,**简单高效。
c. 对于容量 ≥256 的切片进入循环计算新容量: (newcap + 3 * threshold) >> 2 等价于 (newcap + 768) / 4 简单点就是:newcap=(newcap * 1.25 + 192),不够用就循环计算扩容 作用是让切片扩容倍率从2无限趋近于1.25,避免大切片一下扩容太多浪费内存。
3. 看到这里是不是以为结束了?并不是!
按上边的逻辑,这段代码打印的结果应该是(512 * 1.25 + 192) = 832,但为什么是848呢?
go
a := make([]int, 0, 512)
for i := 0; i < 513; i++ {
a = append(a, i)
}
fmt.Println(cap(a))
// 848
顺着nextslicecap方法返回到growslice方法内,发现还有后续:
go
var overflow bool
var lenmem, newlenmem, capmem uintptr
noscan := !et.Pointers() // 元素是否不包含指针(影响GC处理方式)
switch {
case et.Size_ == 1: // 元素类型大小为1字节(如[]byte)
lenmem = uintptr(oldLen) // 原数据内存大小 = 原长度 * 1
newlenmem = uintptr(newLen) // 新长度内存大小 = 新长度 * 1
capmem = roundupsize(uintptr(newcap), noscan) // 新容量内存大小(向上对齐到内存页)
overflow = uintptr(newcap) > maxAlloc // 检查是否超过最大可分配内存
newcap = int(capmem) // 对齐后更新新容量
case et.Size_ == goarch.PtrSize: // 元素类型大小等于指针大小(如[]*int)
lenmem = uintptr(oldLen) * goarch.PtrSize
newlenmem = uintptr(newLen) * goarch.PtrSize
capmem = roundupsize(uintptr(newcap)*goarch.PtrSize, noscan)
overflow = uintptr(newcap) > maxAlloc/goarch.PtrSize // 防止溢出
newcap = int(capmem / goarch.PtrSize) // 对齐后更新新容量
case isPowerOfTwo(et.Size_): // 元素类型大小是2的幂(如2、4、8字节)
var shift uintptr
// 判断32位和64位架构
if goarch.PtrSize == 8 {
// Mask shift for better code generation.
shift = uintptr(sys.TrailingZeros64(uint64(et.Size_))) & 63
} else {
shift = uintptr(sys.TrailingZeros32(uint32(et.Size_))) & 31
}
// 用移位代替乘法(如8字节元素,8 = 2 ^ 3 x<<3)
shift := uintptr(sys.TrailingZeros64(uint64(et.Size_))) // 计算2的幂
// 计算原切片数据占用的内存大小:oldLen * et.Size_
lenmem = uintptr(oldLen) << shift // oldLen=5,元素大小8字节(shift=3),lenmen = 5*8 = 5<<3 = 40
// 计算新长度(newLen)对应的内存大小:newLen * et.Size_
newlenmem = uintptr(newLen) << shift
// 计算新容量(newcap)对应的内存大小,并向上对齐到内存页大小
// 先通过左移计算newcap*et.Size_,再调用roundupsize完成对齐
capmem = roundupsize(uintptr(newcap)<<shift, noscan)
// 检查是否溢出:新容量对应的内存是否超过最大可分配内存maxAlloc
overflow = uintptr(newcap) > (maxAlloc >> shift)
// 根据对齐后的内存大小,反推新的容量(对齐后可能比原newcap大)
// capmem是对齐后的总内存,除以元素大小(右移shift位)得到新容量
newcap = int(capmem >> shift)
// 重新计算对齐后新容量对应的准确内存大小(确保无偏差)
capmem = uintptr(newcap) << shift
default: // 其他元素类型大小(非2的幂)
lenmem = uintptr(oldLen) * et.Size_
newlenmem = uintptr(newLen) * et.Size_
capmem, overflow = math.MulUintptr(et.Size_, uintptr(newcap)) // 安全乘法(检测溢出)
capmem = roundupsize(capmem, noscan) // 内存对齐
newcap = int(capmem / et.Size_) // 对齐后更新新容量
capmem = uintptr(newcap) * et.Size_
}
按照原本的扩容newcap = 832,因为元素类型是int,在64位机器上占8字节,这里case到了isPowerOfTwo(et.Size_),经过一顿操作,来到了"848非832"的"罪魁祸首":roundupsize内存对齐,
4. 大火收汁:内存对齐
roundupsize函数签名
go
func roundupsize(size uintptr, noscan bool) (reqSize uintptr)
参数: size:用户实际需要的内存大小(例如,切片扩容时 "元素数量 × 元素大小" 的理论值)。 noscan:布尔值,标记对象是否不包含指针(true 表示无需 GC 扫描,如 int 切片;false 表示包含指针,如 string 切片,因为 string 内部有指向字节数组的指针)。 返回值:reqSize,即对齐后的内存块大小(分配器实际会分配的内存大小)。
roundupsize 函数是 Go 内存分配器中实现 "内存块大小对齐" 的核心逻辑,它决定了最终分配的内存块具体多大。其核心目标是:将用户请求的内存大小(如切片扩容时计算的理论内存)转换为内存分配器预定义的 "标准块大小",以优化内存分配效率、减少碎片。
go
func roundupsize(size uintptr, noscan bool) (reqSize uintptr) {
reqSize = size
// maxSmallSize=32768 mallocHeaderSize=8
if reqSize <= maxSmallSize-mallocHeaderSize { // 小对象,32760KB
// Small object.
if !noscan && reqSize > minSizeForMallocHeader { // !noscan && !heapBitsInSpan(reqSize)
reqSize += mallocHeaderSize // // 为含指针的对象增加元数据头
}
// (reqSize - size) is either mallocHeaderSize or 0. We need to subtract mallocHeaderSize
// from the result if we have one, since mallocgc will add it back in.
// smallSizeMax = 1024
if reqSize <= smallSizeMax-8 { // 较小的小对象,1016KB
// smallSizeDiv = 8
return uintptr(class_to_size[size_to_class8[divRoundUp(reqSize, smallSizeDiv)]]) - (reqSize - size)
}
return uintptr(class_to_size[size_to_class128[divRoundUp(reqSize-smallSizeMax, largeSizeDiv)]]) - (reqSize - size)
}
// Large object. Align reqSize up to the next page. Check for overflow.
reqSize += pageSize - 1
if reqSize < size {
return size
}
return reqSize &^ (pageSize - 1)
}
函数逻辑可分为 "小对象处理" 和 "大对象处理" 两部分,核心差异在于对齐策略(小对象用预定义规格,大对象按页对齐)。
a. 小对象处理(容量≤32760KB,最常见,切片扩容多属于此类)
若对象 包含指针(!noscan,如 string 切片),且大小超过 minSizeForMallocHeader(通常为 16 字节),则需要额外添加 mallocHeaderSize(8 字节)的元数据头,用于 GC 跟踪指针。 若对象 不包含指针(noscan,如 int 切片),则无需添加,节省内存。
看到这里,第一反应就是string的切片在内存对齐后跟int不一定一样,没错确实不一样,具体可以自行测试。
Go 内存分配器预定义了一系列 "高效内存块规格"(size classes),小对象必须对齐到这些规格。根据大小不同,分为两类映射:
较小的小对象(矮个子里的侏儒)(容量 ≤ 1016 字节):
go
return uintptr(class_to_size[size_to_class8[divRoundUp(reqSize, smallSizeDiv)]]) - (reqSize - size)
divRoundUp(a, b):a 按 b 向上取整,例如,divRoundUp(9, 8) = (9 + 8 - 1) / 8 = 2 size_to_class8:预定义数组,将 "8 字节步长取整后的索引" 映射到对应的 "内存块类别"(每个类别对应一个标准大小)。 class_to_size:预定义数组,根据 "内存块类别" 返回该类别的实际大小(标准块大小)。
较大的小对象(矮个子里挑高个)( 1016字节 < 容量 ≤ 32760字节):
go
return uintptr(class_to_size[size_to_class128[divRoundUp(reqSize-smallSizeMax, largeSizeDiv)]]) - (reqSize - size)
逻辑类似侏儒对象,但按 128 字节步长(largeSizeDiv=128)计算,使用 size_to_class128 映射表。
b. 大对象处理(超过小对象阈值)
直接按操作系统页大小对齐
go
// 向上对齐到下一个页边界
reqSize += pageSize - 1 // 例如,pageSize=4096,1000 → 1000+4095=5095
if reqSize < size { // 检查溢出(防止数值超过 uintptr 最大值)
return size
}
return reqSize &^ (pageSize - 1) // 按页对齐(5095 &^ 4095 = 4096)
大对象通常直接从操作系统申请内存,按页对齐可最大化内存访问效率(与 CPU 缓存、内存页管理匹配)。
三. 回到开头
**还记得文章开头的测试吗?我们测试的是一个int元素的切片,内存对齐前计算得到newcap=832,内存对齐的时候计算832 * 8 = 6656字节,属于"较大的小对象",所以最终计算得到内存对齐结果为6784,最终容量为6784/8=848 **
四. 为什么很多文章说的是"1024机制"呢?
经过简单的查询得知,在go1.18版本之前都是符合"1024机制",1.18后就更新成现在这样了,所以如果在go1.18版本发布后,也就是2022年3月15日之后,再不基于版本直接锁死"1024机制"的文章,可以放心避雷了,因为大概率是不经过求证而复制粘贴或者直接AI生成不经过人脑思考的。