切片扩容是非常影响性能的操作,当初始的cap设置的比较小,而实际的元素非常多的时候,性能损耗就更突出了。

很多时候,我们对这个功能点不够重视,经常也会写一些没有声明 cap 容量的变量声明,然后直接调用 append,逻辑上是没有问题的,但是当 append 的数量足够多的时候,性能问题就突出来了。

live := make([]BigObject, 0)		// 缺少声明cap
live = append(live, BigObject{})

在初始化切片时设置了cap为2,但实际上元素有600个,这时切片容量的增长就称为了一个性能的损耗点。这种情况,底层会按照2倍的扩容方式,2、4、8、16…,总共会扩容9次,每次扩容过程,旧数据内存都会拷贝到新数据的空间。

这种2倍的增长模式,也不是固定的,具体的增长算法,依赖我们预期(这里我叫 eCap,expect capacity)的值,当达到阈值1024,会采用两种不同的增长策略。

引申一个想法

如果初始化切片设置的cap比较小,实际要存储的元素个数也比较小,我们如何评估影响呢?这让我联想到了时间复杂度,时间复杂度评估的就是耗时和数据量N的关系,关注的是一种变化,而非某个具体的N是多少。

所以,创建切片的时候,合理地预估容量非常重要。slice底层的切片扩容机制这里简单说明一下:

扩容的参考依据有两个,当前切片的容量,这里称为 oCap(old capacity)。满足当前切片需求的最小容量,这里称为 eCap。当 oCap 小于 1024时,按照 2 倍 oCap进行扩容,当容量大于 1024时,按照 1/4 oCap的容量增长,这里的 1/4 是具有叠加效应的。我们来实现一下伪代码,假设新的切片容量为 nCap:

if oCap < 1024 {nCap = 2 * oCap
} else {nCap = oCapfor oCap < eCap {nCap = 1/4 * nCap + nCap}
}

对比的看一下 go1.18 下的源代码:

对比一下上面我自己想的伪代码,有一些考虑不完善的地方。

  1. 缺少了 eCap 和2倍 oCap 容量的比较逻辑
  2. oCap值可能是负值,这回导致伪代码的 for 循环变成死循环
newcap := old.cap
doublecap := newcap + newcap
if cap > doublecap {newcap = cap
} else {const threshold = 256if 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}}
}

切片的扩容逻辑比较简单,也没有什么高深的地方。留一个思考题,指定 slice 中的一个下标,将下标前的部分和下标后的部分颠倒?