golang基础:slice的扩容策略 | 青训营笔记

311 阅读8分钟

这是我参与「第三届青训营 -后端场」笔记创作活动的的第1篇笔记

引子

今天在学Golang中slice时看到资料上写的slice的扩容机制为:

image.png

于是就在自己的电脑上跟着试了一下,发现了一些与资料上不太符合的情况

func main() {
   s := make([]int, 10, 10)   // 容量为10的切片
   fmt.Printf("%d\n", cap(s)) // 10

   s = append(s, 1)           // 向前一个切片添加一个数据,容量不够了,进行扩容
   fmt.Printf("%d\n", cap(s)) // 20(容量为之前容量的两倍)

   s1 := make([]int, 257, 257) // 容量为257的切片
   fmt.Printf("%d\n", cap(s1)) // 257

   s1 = append(s1, 1)          // 向前一个切片添加一个数据,容量不够了,进行扩容
   fmt.Printf("%d\n", cap(s1)) // 608(并非之前容量的两倍)

   s2 := make([]int, 1024, 1024) // 容量为1024的切片
   fmt.Printf("%d\n", cap(s2))   // 1024

   s2 = append(s2, 1)          // 向前一个切片添加一个数据,容量不够了,进行扩容
   fmt.Printf("%d\n", cap(s2)) // 1536(并非之前容量的1.25倍)
}

然后就开始漫长的搜索,发现几乎所有的资料描述的都和上面的策略一样,以1024为界限变为原来的2倍和1.25倍 于是就开始看起了源码

源码

源码位置 在源码的growslice然后中,以下是相关策略的部分代码:

func growslice(et *_type, old slice, cap int) slice {
   
   //省略了一些判断

   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
         }
      }
   }
}

看了上面源码后发现,在某次go的版本更新后,更新了slice的扩容策略,使得slice在扩容时变得更加平滑。在之前的策略中小于1024是容量直接翻倍,大于1024变为之前的1.25倍,在更新之后slice在容量m小于256时翻倍,在m>=256时 m=m+(m+3*256)/4,随着m数值的增大,m增加的倍数从2趋近到1.25,接下来将这个策略带入到测试代码之后验证是否正确。

测试

func main() {
   s := make([]int, 256, 256) // 容量为256的切片
   fmt.Printf("%d\n", cap(s)) // 256

   s = append(s, 1)           // 向前一个切片添加一个数据,容量不够了,进行扩容
   fmt.Printf("%d\n", cap(s)) // 512(容量为之前容量的两倍)

   s1 := make([]int, 257, 257) // 容量为257的切片
   fmt.Printf("%d\n", cap(s1)) //

   s1 = append(s1, 1)          // 向前一个切片添加一个数据,容量不够了,进行扩容
   fmt.Printf("%d\n", cap(s1)) // 608(并非之前容量的两倍,也并非是1.25*m+192)

   s2 := make([]int, 1024, 1024) // 容量为1024的切片
   fmt.Printf("%d\n", cap(s2))   // 1024

   s2 = append(s2, 1)          // 向前一个切片添加一个数据,容量不够了,进行扩容
   fmt.Printf("%d\n", cap(s2)) // 1536(并非之前容量的1.25倍)
}

根据上述代码发现有些时候cap并不是按照这个策略扩充的,这是怎么回事呢,原来在这个策略后,还会对上述策略生成的新容量进行一次操作,相关源码如下:

var lenmem, newlenmem, capmem uintptr
// Specialize for common values of et.size.
// For 1 we don't need any division/multiplication.
// For goarch.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 == 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 {
      // 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)
}

得到新容量后,均需要根据slice的类型size,算出新的容量所需的内存情况capmem,然后再进行capmem向上取整,得到新的所需内存,除上类型size,得到真正的最终容量,作为新的slice的容量,这时所得到的才是新的容量。

源码中相关函数(roundupsize)

roundupsize

func roundupsize(size uintptr) uintptr {
   if size < _MaxSmallSize {
      if size <= smallSizeMax-8 {
         return uintptr(class_to_size[size_to_class8[divRoundUp(size, smallSizeDiv)]])
      } else {
         return uintptr(class_to_size[size_to_class128[divRoundUp(size-smallSizeMax, largeSizeDiv)]])
      }
   }
   if size+_PageSize < size {
      return size
   }
   return alignUp(size, _PageSize)
}
var size_to_class8 = [smallSizeMax/smallSizeDiv + 1]uint8{0, 1, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13, 14, 14, 15, 15, 16, 16, 17, 17, 18, 18, 18, 18, 19, 19, 19, 19, 20, 20, 20, 20, 21, 21, 21, 21, 22, 22, 22, 22, 23, 23, 23, 23, 24, 24, 24, 24, 25, 25, 25, 25, 26, 26, 26, 26, 26, 26, 26, 26, 27, 27, 27, 27, 27, 27, 27, 27, 28, 28, 28, 28, 28, 28, 28, 28, 29, 29, 29, 29, 29, 29, 29, 29, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31}

var size_to_class128 = [(_MaxSmallSize-smallSizeMax)/largeSizeDiv + 1]uint8{31, 32, 33, 34, 35, 36, 36, 37, 37, 38, 38, 39, 39, 39, 40, 40, 40, 41, 42, 42, 43, 43, 43, 43, 43, 44, 44, 44, 44, 44, 44, 45, 45, 45, 45, 46, 46, 46, 46, 46, 46, 47, 47, 47, 48, 48, 49, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 52, 52, 53, 53, 53, 53, 54, 54, 54, 54, 54, 55, 55, 55, 55, 55, 55, 55, 55, 55, 55, 55, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 57, 57, 57, 57, 57, 57, 58, 58, 58, 58, 58, 58, 58, 58, 58, 58, 58, 58, 58, 58, 58, 58, 59, 59, 59, 59, 59, 59, 59, 59, 59, 59, 59, 59, 59, 59, 59, 59, 60, 60, 60, 60, 60, 61, 61, 61, 61, 61, 61, 61, 61, 61, 61, 61, 62, 62, 62, 62, 62, 62, 62, 62, 62, 62, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 65, 65, 65, 65, 65, 65, 65, 65, 65, 65, 65, 66, 66, 66, 66, 66, 66, 66, 66, 66, 66, 66, 66, 66, 66, 66, 66, 66, 66, 66, 66, 66, 66, 66, 66, 66, 66, 66, 66, 66, 66, 66, 66}

var class_to_size = [_NumSizeClasses]uint16{0, 8, 16, 32, 48, 64, 80, 96, 112, 128, 144, 160, 176, 192, 208, 224, 240, 256, 288, 320, 352, 384, 416, 448, 480, 512, 576, 640, 704, 768, 896, 1024, 1152, 1280, 1408, 1536, 1792, 2048, 2304, 2688, 3072, 3200, 3456, 4096, 4864, 5376, 6144, 6528, 6784, 6912, 8192, 9472, 9728, 10240, 10880, 12288, 13568, 14336, 16384, 18432, 19072, 20480, 21760, 24576, 27264, 28672, 32768}

举个例子

func main() {
   s := make([]int, 2, 2)
   fmt.Println(cap(s)) //2
   s = append(s, 1, 2, 3)
   fmt.Println(cap(s)) //6
}

在这里进行扩容大小的计算,传入参数为bit。如果申请空间为5字节40bit,则入参size uintptr值为40; 接着调用了 roundupsize 函数,et.size=8,传入 40,所以divRoundUp返回为= 5;获取 size_to_class8 数组中索引为 5 的元素为 5;获取 class_to_size 中索引为 5 的元素为 48,roundupsize 返回48,表示比特大小。 最终,新的 slice 的容量为 6

总结

由上面例子可以得知在1.18版本之后,slice在进行扩容时不再是和1024进行比较,而是和256进行比较,更新策略变为:

一、appendslice中比较参数

append 发生扩容时,原有切片长度简述为len1,容量cap;添加切片长度len2;则新申请容量是n = len1 + len2; 当新申请容量n大于原有切片容量cap时,调用growslice函数;

二、调用growslice函数

(一)、计算最终申请容量(newcap)

1.如果当前所需容量(cap)大于原先的容量的两倍(doublecap),则最终申请容量(newcap)为当前所需容量(cap)

2.如果条件一不满足,则进行如下判断:

3.如果原切片长度(old.cap)小于256,则最终申请容量(newcap)等于原容量两倍(doublecap);

4.否则,最终申请容量(newcap,初始值为old.cap),执行(newcap += (newcap + 3*threshold) / 4)操作直到大于所需申请容量(cap),如果newcap溢出,最终申请容量(newcap)等于所需容量(cap)

(二)、对最终申请容量进行内存分配

根据slice的类型size,算出最终申请容量(newcap)所需的内存情况capmem,然后再进行capmem向上取整,得到新的所需内存,除上类型size,得到真正的最终容量,作为新的slice的容量