切片

切片是一个动态数组的抽象,以指针引用底层数组,限定读写区域。

// runtime/slice.go
type slice struct {
	array unsafe.Pointer
	len int
	cap int
}
  • 引用类型,未初始化为 nil
  • 基于数组,初始化值或make函数创建
  • len 元素数量, cap 返回容量
  • 不支持 ==, != 操作

切片截取

截取时常见创建 slice 的方法,可以从数组或者 slice 中直接截取,需要制定起止索引位置。
值得注意的时,截取的新老 slice 相互影响的前提时共用底层数组,如果因为 append 操作使得 新 slice 底层数组扩容列,移动到新的位置,那么二者就不会相互影响了。
截取操作:data[low,high,max], max>=high>=low
例如:

old := []int{0,1,2,3,4,5,6,7,8,9}
new1 := old[2:5] // new1 从索引2闭区间到索引5开区间即真正索引4位置,长度为3,容量到数组末尾即8
// new1 == [2,3,4,x,x,x,x,x] // len=3 cap=8 x:表示空
new2 := new1[2:6:7] // 从new1中2-6截取,容量到索引7,即cap=7-2=5
// new2 == [4,5,6,7,x] 因为容量还是小于old 所以还是在底层数据操作
new2 = append(new2, 100) // new2 的 len == cap == 5 长度达到容量了
new2 = append(new2, 200) // new2 需要扩容了
new1[2] = 20 // 只会更改new1 和 old slice,new2 已经不与他们共用底层数组了
fmt.Println(old) // [0,1,2,3,20,5,6,7,100,9]
fmt.Println(new1) // [2,3,20]
fmt.Println(new2) // [4,5,6,7,100,200]

Go 语⾔是如何实现切⽚扩容的?

  • Go1.18之前切片的扩容是以容量1024为临界点
  • 当旧容量 < 1024个元素,扩容变成2倍;
  • 当旧容量 > 1024个元素,那么会进入一个循环,每次增加25%直到大于期望容量。
  • 然而这个扩容机制已经被Go 1.18弃用了,官方说新的扩容机制能更平滑地过渡。就是控制让小的切片容量增长速度快一点,减少内存分配次数,而让大切片容量增长率小一点,更好地节省内存。
  • Go 1.17切片扩容时会进行内存对齐,这个和内存分配策略相关。进行内存对齐之后,新 slice 的容量是要 大于等于老 slice 容量的 2倍或者1.25倍。
    • 当新切片需要的容量cap大于两倍扩容的容量,则直接按照新切片需要的容量扩容;
    • 当原 slice 容量 < 1024 的时候,新 slice 容量变成原来的2倍;
    • 当原 slice 容量 > 1024,进入一个循环,每次容量变成原来的1.25倍,直到大于期望容量。
  • Go1.18不再以1024为临界点,而是设定了一个值为256threshold,以256为临界点;超过256,不再是每次扩容1/4,而是每次增加(旧容量+3*256)/4
    • 当新切片需要的容量cap大于两倍扩容的容量,则直接按照新切片需要的容量扩容;
    • 当原 slice 容量 < threshold 的时候,新 slice 容量变成原来的 2 倍;
    • 当原 slice 容量 > threshold,进入一个循环,每次容量增加(旧容量+3*threshold)/4。
  • 切片的扩容都是调用 growSlice 方法;
    func growslice(et *_type, old slice, cap int) slice {
        // 新容量默认为原有容量
        newcap := old.cap
        doublecap := newcap + newcap
        // 如果新容量大于旧容量的两倍,则直接按照新容量大小申请
        if cap > doublecap {
            newcap = cap
        } else {
            // 如果原有长度小于1024,则新容量是旧容量的2倍
            if old.len < 1024 {
                newcap = doublecap
            } else {
                // 按照原有容量的 1/4 增加,直到满足新容量的需要
                for 0 < newcap && newcap < cap {
                    newcap += newcap / 4
                }
                // 检查容量是否溢出。
                if newcap <= 0 {
                    newcap = cap
                }
            }
        }
    }
    
    Go1.18 版本
    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
            }
          }
      }
      ...
    }
    
  • 切片在扩容时会进行内存对齐,这个和内存分配策略有关,进行内存对齐后切片扩容的容量要大于等于旧的切片容量的2倍或者1.25倍,当原切片容量小于1024的时候,新切片容量按照原来的2倍进行扩容,原slice容量超过1024的时候,新slice容量变成原来的1.25倍。

总结:尽量对切片设置初始容量值,以避免 append 调用 growslice,因为新的切片容量比旧的大,会开辟新的地址,拷贝数据,降低性能。