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生成不经过人脑思考的。

相关推荐
用户580559502104 小时前
channel原理解析(流程图+源码解读)
go
程序员爱钓鱼11 小时前
Go语言实战案例-Redis连接与字符串操作
后端·google·go
岁忧1 天前
(nice!!!)(LeetCode 每日一题) 1277. 统计全为 1 的正方形子矩阵 (动态规划)
java·c++·算法·leetcode·矩阵·go·动态规划
HyggeBest1 天前
Golang 并发原语 Sync Cond
后端·架构·go
mao毛1 天前
Go 1.25 重磅发布:性能飞跃、工具升级与新一代 GC 来袭
后端·go
郭京京1 天前
mongodb基础
mongodb·go
程序员爱钓鱼1 天前
Go语言实战案例-使用SQLite实现本地存储
后端·google·go
江湖十年1 天前
Go 1.25 终于迎来了容器感知 GOMAXPROCS
后端·面试·go
岁忧2 天前
(nice!!!)(LeetCode 每日一题) 679. 24 点游戏 (深度优先搜索)
java·c++·leetcode·游戏·go·深度优先