Go源码学习(基于1.24.1)-slice扩容机制-实践才是真理

本文起于一次验证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生成不经过人脑思考的。

相关推荐
梁梁梁梁较瘦1 天前
边界检查消除(BCE,Bound Check Elimination)
go
梁梁梁梁较瘦1 天前
指针
go
梁梁梁梁较瘦1 天前
内存申请
go
半枫荷1 天前
七、Go语法基础(数组和切片)
go
梁梁梁梁较瘦2 天前
Go工具链
go
半枫荷2 天前
六、Go语法基础(条件控制和循环控制)
go
半枫荷3 天前
五、Go语法基础(输入和输出)
go
小王在努力看博客3 天前
CMS配合闲时同步队列,这……
go
Anthony_49264 天前
逻辑清晰地梳理Golang Context
后端·go
Dobby_055 天前
【Go】C++ 转 Go 第(二)天:变量、常量、函数与init函数
vscode·golang·go