引言
Go1.18之前切片的扩容是以容量1024为临界点,当旧容量 < 1024个元素,扩容变成2倍;当旧容量 > 1024个元素,那么会进入一个循环,每次增加25%直到大于期望容量。
然而这个扩容机制已经被Go 1.18弃用了,官方说新的扩容机制能更平滑地过渡。
测试代码
go
复制代码
//go 1.18
func TestSliceGrowing(t *testing.T) {
s := []int{}
for i := 0; i < 4098; i++ {
s = append(s, i)
t.Log(len(s), cap(s))
}
}
运行结果:
yaml
复制代码
=== RUN TestSliceGrowing
slice_test.go:32: 1 1
slice_test.go:32: 2 2
slice_test.go:32: 3 4
slice_test.go:32: 4 4
slice_test.go:32: 5 8
slice_test.go:32: 6 8
slice_test.go:32: 7 8
slice_test.go:32: 8 8
slice_test.go:32: 9 16
......
slice_test.go:32: 128 128
slice_test.go:32: 129 256
......
slice_test.go:32: 256 256
slice_test.go:32: 257 512
......
slice_test.go:32: 512 512
slice_test.go:32: 513 848
......
slice_test.go:32: 848 848
slice_test.go:32: 849 1280
......
slice_test.go:32: 1280 1280
slice_test.go:32: 1281 1792
......
slice_test.go:32: 1792 1792
slice_test.go:32: 1793 2560
......
slice_test.go:32: 2560 2560
slice_test.go:32: 2561 3408
......
slice_test.go:32: 3408 3408
slice_test.go:32: 3409 5120
Go 1.17版本切片扩容
Go 1.17切片扩容时会进行内存对齐,这个和内存分配策略相关。进行内存对齐之后,新 slice 的容量是要 大于等于老 slice 容量的 2倍或者1.25倍。
- 当新切片需要的容量cap大于两倍扩容的容量,则直接按照新切片需要的容量扩容;
- 当原 slice 容量 < 1024 的时候,新 slice 容量变成原来的 2 倍;
- 当原 slice 容量 > 1024,进入一个循环,每次容量变成原来的1.25倍,直到大于期望容量。
公式如下:
go
复制代码
// runtime/slice.go
// et:表示slice的一个元素;old:表示旧的slice; cap:表示新切片需要的容量;
func growslice(et *_type, old slice, cap int) slice {
if cap < old.cap {
panic(errorString("growslice: cap 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 old.array in this case.
return slice{unsafe.Pointer(&zerobase), old.len, cap}
}
newcap := old.cap
// 两倍扩容
doublecap := newcap + newcap
// 新切片需要的容量大于两倍扩容的容量,则直接按照新切片需要的容量扩容
if cap > doublecap {
newcap = cap
} else {
// 原 slice 容量小于 1024 的时候,新 slice 容量按2倍扩容
if old.cap < 1024 {
newcap = doublecap
} else { // 原 slice 容量超过 1024,新 slice 容量变成原来的1.25倍。
// Check 0 < newcap to detect overflow
// and prevent an infinite loop.
for 0 < newcap && newcap < cap {
newcap += newcap / 4
}
// Set newcap to the requested cap when
// the newcap calculation overflowed.
if newcap <= 0 {
newcap = cap
}
}
}
// 后半部分还对 newcap 作了一个内存对齐,这个和内存分配策略相关。进行内存对齐之后,新 slice 的容量是要 大于等于 老 slice 容量的 2倍或者1.25倍。
var overflow bool
var lenmem, newlenmem, capmem uintptr
// Specialize for common values of et.size.
// For 1 we don't need any division/multiplication.
// For sys.PtrSize, compiler will optimize division/multiplication into a shift by a constant.
// For powers of 2, use a variable shift.
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 == sys.PtrSize:
lenmem = uintptr(old.len) * sys.PtrSize
newlenmem = uintptr(cap) * sys.PtrSize
capmem = roundupsize(uintptr(newcap) * sys.PtrSize)
overflow = uintptr(newcap) > maxAlloc/sys.PtrSize
newcap = int(capmem / sys.PtrSize)
case isPowerOfTwo(et.size):
var shift uintptr
if sys.PtrSize == 8 {
// Mask shift for better code generation.
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)
}
}
Go 1.18版本切片扩容
Go1.18不再以1024为临界点,而是设定了一个值为256的threshold
,以256为临界点;超过256,不再是每次扩容1/4,而是每次增加(旧容量+3*256)/4;
- 当新切片需要的容量cap大于两倍扩容的容量,则直接按照新切片需要的容量扩容;
- 当原 slice 容量 < threshold 的时候,新 slice 容量变成原来的 2 倍;
- 当原 slice 容量 > threshold,进入一个循环,每次容量增加(旧容量+3*threshold)/4。
公式如下:
如下代码所示:
go
复制代码
//1.18
newcap := old.cap
doublecap := newcap + newcap
if cap > doublecap {
newcap = 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
}
}
}
在1.18中,优化了切片扩容的策略,让底层数组大小的增长更加平滑: 通过减小阈值并固定增加一个常数,使得优化后的扩容的系数在阈值前后不再会出现从2到1.25的突变,该commit作者给出了几种原始容量下对应的“扩容系数”:
oldcap | 扩容系数 |
---|---|
256 | 2.0 |
512 | 1.63 |
1024 | 1.44 |
2048 | 1.35 |
4096 | 1.30 |
可以看到,Go1.18的扩容策略中,随着容量的增大,其扩容系数是越来越小的,可以更好地节省内存。
我们可以试着求一个极限,当oldcap远大于256的时候,扩容系数将会变成1.25。
总结
总的来说,Go的设计者不断优化切片扩容的机制,其目的只有一个:就是控制让小的切片容量增长速度快一点,减少内存分配次数,而让大切片容量增长率小一点,更好地节省内存。
- 如果只选择翻倍的扩容策略,那么对于较大的切片来说,现有的方法可以更好的节省内存。
- 如果只选择每次系数为1.25的扩容策略,那么对于较小的切片来说扩容会很低效。
- 之所以选择一个小于2的系数,在扩容时被释放的内存块会在下一次扩容时更容易被重新利用