这是我参与「第五届青训营 」伴学笔记创作活动的第 5 天
对于程序员来讲,在编程时常常需要使用一个可存储连续数据,且可以使用下标进行索引查询的对象。在多数编程语言中,这种数据结构被实现为数组。
Golang 对这种数据结构做了进一步的功能划分,分为数组(array)和切片(slice)。
日常使用中,前者更推荐在需要定义存储连续型对象时使用,而后者常常使用其作为某个可变数组的指针。鉴于 slice 的灵活性更强,所以日常使用中, slice 的使用范围更广。本文就从源码角度分析 slice 的实现原理,便于读者更好掌握 slice 的使用。
本文分析的源码是 Go 1.19 版本。
结构定义
slice 结构体定义在 runtime 包下。slice 是基于 array 实现的,底层仍是一个数组,可以理解为对底层数组的抽象。
type slice struct {
array unsafe.Pointer
len int
cap int
}
slice:总共占用 24 个字节
array:指向底层数组的指针,占用 8 个字节len:切片长度,占用 8 个字节cap:切片容量,其值总是不小于len,占用 8 个字节
初始化
slice 初始化的方式主要有 4 种
// 使用 var 直接声明
var s1 []int
// 使用字面量推导
s2 := []int{1, 2, 3}
// 使用 make 创建
s3 := make([]int, 3, 5)
// 从切片或数组中截取
s4 := arr[1:3]
进一步通过分析汇编代码,可以发现底层是通过调用 runtime.makeslice 函数来实现 slice 的初始化。下面是源码中 makeslice 函数的主要实现部分。
func makeslice(et *_type, len, cap int) unsafe.Pointer {
// 正常情况下,cap > len, 以 cap 计算需要的内存大小
mem, overflow := math.MulUintptr(et.size, uintptr(cap))
// len > cap,以 len 计算需要的内存大小;非法情况 panic
if overflow || mem > maxAlloc || len < 0 || len > cap {
mem, overflow := math.MulUintptr(et.size, uintptr(len))
if overflow || mem > maxAlloc || len < 0 {
panicmakeslicelen()
}
panicmakeslicecap()
}
// 分配内存
return mallocgc(mem, et, true)
}
makeslice 函数首先计算 slice 需要的内存大小,然后调用 mallocgc 函数进行内存的分配。内存大小为元素个数乘元素类型大小。
扩容
切片由于是动态长度的,所以当使用 append 函数增加元素但当前 cap 不足时,会发生 slice 的扩容。
Go 1.18 版本之前采用的扩容规则为
- 如果新申请的容量大于 2 倍的原
cap,则扩容后的newcap为申请容量 - 如果原
slice的len小于 1024,那么newcap = 2 * cap - 如果原
slice的len大于等于 1024,那么newcap = 1.25 * cap
Go 1.18 版本之后对扩容原则进行了修改,使扩容后的容量更加平滑。
- 如果新申请的容量大于 2 倍的原
cap,则扩容后的newcap为申请容量 - 如果原
slice的cap小于 256,那么newcap = 2 * cap - 如果原
slice的cap大于等于 256,那么newcap = (cap + 3*256) / 4
func growslice(et *_type, old slice, cap int) slice {
// ...
newcap := old.cap
doublecap := newcap + newcap // 变为 2 倍
if cap > doublecap {
newcap = cap // 超过 2 倍,变为新的 cap
} else {
const threshold = 256
if old.cap < threshold {
newcap = doublecap
} else {
// Check 0 < newcap to detect overflow
// and prevent an infinite loop.
for 0 < newcap && newcap < cap {
// 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) / 4 // 采用新的扩容规则,更加平滑
}
// Set newcap to the requested cap when
// the newcap calculation overflowed.
if newcap <= 0 {
newcap = cap
}
}
}
var overflow bool
var lenmem, newlenmem, capmem uintptr
// 根据元素类型计算需要的 capmem
switch {
case et.size == 1:
lenmem = uintptr(old.len)
newlenmem = uintptr(cap)
capmem = roundupsize(uintptr(newcap))
overflow = uintptr(newcap) > maxAlloc
newcap = int(capmem)
case et.size == goarch.PtrSize:
lenmem = uintptr(old.len) * goarch.PtrSize
newlenmem = uintptr(cap) * goarch.PtrSize
capmem = roundupsize(uintptr(newcap) * goarch.PtrSize)
overflow = uintptr(newcap) > maxAlloc/goarch.PtrSize
newcap = int(capmem / goarch.PtrSize)
case isPowerOfTwo(et.size):
var shift uintptr
if goarch.PtrSize == 8 {
shift = uintptr(sys.Ctz64(uint64(et.size))) & 63
} else {
shift = uintptr(sys.Ctz32(uint32(et.size))) & 31
}
lenmem = uintptr(old.len) << shift
newlenmem = uintptr(cap) << shift
capmem = roundupsize(uintptr(newcap) << shift)
overflow = uintptr(newcap) > (maxAlloc >> shift)
newcap = int(capmem >> shift)
default:
lenmem = uintptr(old.len) * et.size
newlenmem = uintptr(cap) * et.size
capmem, overflow = math.MulUintptr(et.size, uintptr(newcap))
capmem = roundupsize(capmem)
newcap = int(capmem / et.size)
}
if overflow || capmem > maxAlloc {
panic(errorString("growslice: cap out of range"))
}
// 根据 capmem 分配内存
var p unsafe.Pointer
if et.ptrdata == 0 {
p = mallocgc(capmem, nil, false)
} else {
p = mallocgc(capmem, et, true)
if lenmem > 0 && writeBarrier.enabled {
bulkBarrierPreWriteSrcOnly(uintptr(p), uintptr(old.array), lenmem-et.size+et.ptrdata)
}
}
// 清除原切片数组内存
memmove(p, old.array, lenmem)
return slice{p, old.len, newcap}
}
值得一提的是,其中在根据元素类型计算内存大小时使用的 roundupsize 函数就是应用了 Go 中的内存对齐思想进行了向上取整,具体的分析可以参考这篇 文章。
总结
本文从源码角度分析了 Golang 中 slice 的用法,希望读者可以对切片实现原理有更清晰的认识。