GO 运行时内部原理
Go slice 内部原理
slice 不是数组,是一个只有 24 字节的小结构体:指针 + 长度 + 容量。append 有时候是"原地写、大家共享同一块内存",有时候是"另起炉灶、彻底分家"——区别只在于容量还够不够。这篇文章配了一个和真实 go run 输出逐项比对过的内存模拟场景,演示一次真实存在的"隐形覆盖"事故是怎么发生的。
01 · slice 到底是什么
三个字段,仅此而已
Go 里的 []T 在运行时就是这样一个结构体:
type slice struct {
array unsafe.Pointer
len int
cap int
}
把一个 slice 赋值给另一个变量、当参数传给函数,复制的都只是这 24 个字节(64 位系统上,一个指针 + 两个 int),底层数组不会被复制。这意味着两个 slice 变量完全可能指向同一块内存——通过下标改其中一个,另一个也会跟着变。而 append 会不会打破这种共享,完全取决于容量还够不够:够,就在原地写;不够,就分配一块新内存、拷贝过去、旧的不再共享。这个"够不够"的边界,正是本文要讲清楚的地方。
02 · 三个核心概念
切片头 / 底层数组 / growslice
指针 + len + cap 三个字段。len 是"当前能看到多少个元素",cap 是"不重新分配内存的前提下,最多能长到多少"。两个 slice 的头可以指向同一块底层数组的不同(或重叠的)区间。
真正连续存放元素的内存。用 make、字面量或者一次扩容创建,创建之后大小固定——它自己不知道有几个 slice 头正指着它。
只有 len+num > cap 时才会被调用:申请一块新的、通常更大的内存,把旧数据搬过去,再把新元素写进去。搬完之后,这个 slice 的头指向新数组,和原来共享旧数组的其它 slice 彻底没关系了。
func makeslice(et *_type, len, cap int) unsafe.Pointer {
mem, overflow := math.MulUintptr(et.Size_, uintptr(cap))
if overflow || mem > maxAlloc || len < 0 || len > cap {
// NOTE: Produce a 'len out of range' error instead of a
// 'cap out of range' error when someone does make([]T, bignumber).
// 'cap out of range' is true too, but since the cap is only being
// supplied implicitly, saying len is clearer.
// See golang.org/issue/4085.
mem, overflow := math.MulUintptr(et.Size_, uintptr(len))
if overflow || mem > maxAlloc || len < 0 {
panicmakeslicelen()
}
panicmakeslicecap()
}
return mallocgc(mem, et, true)
}
03 · 现场直播
一次真实存在的"隐形覆盖"事故
下面这段场景先用 go run 跑出真实结果,再逐项比对生成的轨迹:两个 slice 共享同一个底层数组,其中一个的 append 会在你完全没有碰它的情况下,悄悄改变另一个的内容。
04 · 扩容公式
2 倍,还是 1.25 倍
要不要扩容、扩多大,由 nextslicecap 决定——这也是整篇文章里最常被问到的一段源码:
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
}
规则很直白:原容量小于 256 时直接翻倍;超过 256 之后,翻倍的代价开始变得明显,改成每次增长约 1.25 倍,用一个连续的公式在两种增长率之间做平滑过渡,而不是在 256 这个点上有个突兀的转折。用一个 []byte 实测(go run,Go 1.26)增长轨迹长这样:
len= 1 cap= 32 (grew from 0)
len= 33 cap= 64 (grew from 32)
len= 65 cap=128 (grew from 64)
len=129 cap=256 (grew from 128)
len=257 cap=512 (grew from 256)
len=513 cap=896 (grew from 512)
32→64→128→256→512 都还是干净的翻倍,直到 512→896——涨幅只有 1.75 倍而不是精确的 2 倍或 1.25 倍。这就引出下一节的内容:实际拿到的 cap,还要再被内存分配器修正一轮。
05 · 为什么 cap 有时候"不是整数倍"
size class 取整
nextslicecap 算出来的只是一个"期望容量"。真正申请内存时,growslice 还会调用 roundupsize,把字节数向上取整到内存分配器预先划分好的一个"规格"(size class)——这是 Go 内存分配器为了减少碎片、加速分配统一做的事,不只是切片在用。
default:
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_
}
所以上一节 512→896 这一跳,其实是 nextslicecap 先算出约 832,再被 roundupsize 向上修正到分配器里最接近的规格 896。也正因为这样,永远不要假设 append 之后的 cap 一定等于你手算出来的那个数——它可能因为取整而更大。
06 · growslice 做了什么
分配、搬迁、脱钩
// growslice allocates new backing store for a slice.
//
// arguments:
//
// oldPtr = pointer to the slice's backing array
// newLen = new length (= oldLen + num)
// oldCap = original slice's capacity.
// num = number of elements being added
// et = element type
//
// return values:
//
// newPtr = pointer to the new backing store
// newLen = same value as the argument
// newCap = capacity of the new backing store
//
// Requires that uint(newLen) > uint(oldCap).
// Assumes the original slice length is newLen - num
//
// A new backing store is allocated with space for at least newLen elements.
// Existing entries [0, oldLen) are copied over to the new backing store.
// Added entries [oldLen, newLen) are not initialized by growslice
// (although for pointer-containing element types, they are zeroed). They
// must be initialized by the caller.
// Trailing entries [newLen, newCap) are zeroed.
//
// growslice's odd calling convention makes the generated code that calls
// this function simpler. In particular, it accepts and returns the
// new length so that the old length is not live (does not need to be
// spilled/restored) and the new length is returned (also does not need
// to be spilled/restored).
//
对应模拟器里的那次扩容:s2 的 len 已经追上 cap(4),再 append 一个元素就必须先申请新数组,把 s2 当前能看到的那几个元素原样搬过去,新元素写在后面,剩下的容量留空(置零)。搬完之后 s2 的切片头整体换成指向新数组——它和 s1 之间那种"改一个影响另一个"的关系,到这一步彻底结束。
07 · copy 呢
内置函数 copy 只是老实地搬数据
和 append 不一样,copy(dst, src) 从不扩容、也从不分配内存——它只是把 min(len(dst), len(src)) 个元素从 src 搬到 dst 已有的空间里,搬不下的部分直接被忽略:
// slicecopy is used to copy from a string or slice of pointerless elements into a slice.
func slicecopy(toPtr unsafe.Pointer, toLen int, fromPtr unsafe.Pointer, fromLen int, width uintptr) int {
if fromLen == 0 || toLen == 0 {
return 0
}
n := fromLen
if toLen < n {
n = toLen
}
if width == 0 {
return n
}
size := uintptr(n) * width
...
if size == 1 { // common case worth about 2x to do here
// TODO: is this still worth it with new memmove impl?
*(*byte)(toPtr) = *(*byte)(fromPtr) // known to be a byte pointer
} else {
memmove(toPtr, fromPtr, size)
}
return n
}
这也是为什么"深拷贝一个 slice、和原来彻底脱离关系"的标准写法是 dst := make([]T, len(src)); copy(dst, src)——先用 make 要一块全新的内存,再用 copy 把值搬过去,两步都不涉及共享。
08 · 两个经典的坑
顺手提两个常见错误
- 往函数里传 slice,函数内部的 append 到底会不会影响调用者? 答案是"看运气"——如果函数内 append 没有触发扩容(cap 还够),写的是共享的底层数组,调用者能看到;一旦触发了扩容,函数内部的 slice 头指向了新数组,调用者手里的旧头完全不知情,函数返回后什么都不会变。这正是模拟器场景里
s1/s2那种"一会儿共享一会儿分家"的行为,只是换成了函数调用的形式——不确定性是内建的,不是写法问题。 - 三索引切片表达式
s[low:high:max]可以把 cap 精确限制成max-low。 想切一段出去给别人用、又不希望对方 append 的时候不小心污染你自己后面的数据,就把max设成high:这样对方的 slice 一旦 append 就会立刻触发扩容(cap 已经等于 len,没有多余空间),从源头上杜绝共享。
full := make([]int, 10)
danger := full[2:5] // cap = 8,别人 append 时可能悄悄写到 full[5] 往后
safe := full[2:5:5] // cap = 3(= 5-2),len 已经等于 cap
// 别人一 append 就必然扩容,不会碰到 full 的其它部分
09 · 参考与说明
引用与这个模拟器做了哪些简化
- 本文所有源码引用均来自 golang/go,commit
72aa6db7943024b48c4d41c1fbc32b57b9fa036e,文件src/runtime/slice.go,遵循 Go 项目的 BSD-3-Clause 协议(© The Go Authors)。每段引用都标了具体文件、行号,并附了指向该 commit 的永久链接。 - 模拟器场景和第 04 节的字节切片增长轨迹都是真实跑过的(
go run,Go 1.26),不是手算的猜测值——尤其是 cap 的具体数字,手算nextslicecap拿到的和实际测得的经常对不上,差的那部分就是roundupsize的取整。 - 模拟器省略了
race/msan/asan检测这些和内存安全工具相关的分支,以及 GC 写屏障相关的处理——它们不影响"共享 vs 分家"这条主线逻辑。 roundupsize具体怎么划分 size class,属于 Go 内存分配器(mallocgc)自己的话题,这篇没有展开——用一句话概括就是"分配器预先准备了一批固定大小的内存块规格,申请多少字节就找刚好够用的最小规格"。