go slice 扩容实现

简介: go slice 扩容实现

前些天发现了一个巨牛的人工智能学习网站,通俗易懂,风趣幽默,忍不住分享一下给大家。点击跳转到网站

基于 Go 1.19。

go 的切片我们都知道可以自动地进行扩容,具体来说就是在切片的容量容纳不下新的元素的时候,

底层会帮我们为切片的底层数组分配更大的内存空间,然后把旧的切片的底层数组指针指向新的内存中:

目前网上一些关于扩容倍数的文章都是基于相对旧版本的 Go 的,新版本中,现在切片扩容的时候并不是那种准确的小于多少容量的时候就 2 倍扩容,

大于多少容量的时候就 1.25 倍扩容,其实这个数值多少不是非常关键的,我们只需要知道的是:

在容量较小的时候,扩容的因子更大,容量大的时候,扩容的因子相对来说比较小

扩容的示例

我们先通过一个简单的示例来感受一下切片扩容是什么时候发生的:

var slice = []int{1, 2, 3}
fmt.Println(slice, len(slice), cap(slice))
slice = append(slice, 4)
fmt.Println(slice, len(slice), cap(slice))

在这个例子中,slice 切片初始化的时候,长度和容量都是 3(容量不指定的时候默认等于长度)。

因此切片已经容纳不下新的元素了,在我们往 slice 中追加一个新的元素的时候,

我们发现,slice 的长度和容量都变了,

长度增加了 1,而容量变成了原来的 2 倍。

在 1.18 版本以后,旧的切片容量小于 256 的时候,会进行 2 倍扩容。

实际扩容倍数

其实最新的扩容规则在 1.18 版本中就已经发生改变了,具体可以参考一下这个 commit

runtime: make slice growth formula a bit smoother

大概意思是:

在之前的版本中:对于 <1024 个元素,增加 2 倍,对于 >=1024 个元素,则增加 1.25 倍。

而现在,使用更平滑的增长因子公式。 在 256 个元素后开始降低增长因子,但要缓慢。

它还给了个表格,写明了不同容量下的增长因子:

starting cap growth factor
256 2.0
512 1.63
1024 1.44
2048 1.35
4096 1.30

从这个表格中,我们可以看到,新版本的切片库容,并不是在容量小于 1024 的时候严格按照 2 倍扩容,大于 1024 的时候也不是严格地按照 1.25 倍来扩容。

growslice 实现

在 go 中,切片扩容的实现是 growslice 函数,位于 runtime/slice.go 中。

growslice 有如下参数:

  • oldPtr: 旧的切片的底层数组指针。
  • newLen: 新的切片的长度(= oldLen + num)。
  • oldCap: 旧的切片的容量。
  • num: 添加的元素数。
  • et: 切片的元素类型(也即 element type)。

返回一个新的切片,这个返回的切片中,底层数组指针指向新分配的内存空间,长度等于 oldLen + num,容量就是底层数组的大小。

growslice 实现步骤

  1. 一些特殊情况判断:如 et.size == 0,切片元素不需要占用空间的情况下,直接返回。
  2. 根据 newLen 计算新的容量,保证新的底层数组至少可以容纳 newLen 个元素。
  3. 计算所需要分配的新的容量所需的内存大小。
  4. 分配新的切片底层数组所需要的内存。
  5. 将旧切片上的底层数组的数据复制到新的底层数组中。

注意:这个函数只是实现扩容,新增的元素没有在这个函数往切片中追加。

growslice 源码剖析

说明:

  1. 整数有可能会溢出,所以代码里面会判断 newLen < 0
  2. 如果切片的元素是空结构体或者空数组,那么 et.size == 0
  3. 在计算新切片的容量的时候,会根据切片的元素类型大小来做一些优化。
  4. 新切片容量所占用的内存大小为 capmem
  5. 新切片所需要的内存分配完成后,会将旧切片的数据复制到新切片中。
  6. 最后返回指向新的底层数组的切片,其长度为 newLen,容量为 newcap
// growtslice 为切片分配新的存储空间。
func growslice(oldPtr unsafe.Pointer, newLen, oldCap, num int, et *_type) slice {
  // oldLen 为旧的切片底层数组的长度
  oldLen := newLen - num
  // 分配的新的长度不能小于 0(整数溢出的时候会是负数)
  if newLen < 0 {
    panic(errorString("growslice: len out of range"))
  }
  // 如果结构或数组类型不包含大小大于零的字段(或元素),则其大小为零。
  //(空数组、空结构体,type b [0]int、type zero struct{})
  // 两个不同的零大小变量在内存中可能具有相同的地址。
  if et.size == 0 {
    // append 不应创建具有 nil 指针但长度非零的切片。
    // 在这种情况下,我们假设 append 不需要保留 oldPtr。
    return slice{unsafe.Pointer(&zerobase), newLen, newLen}
  }
  // newcap 是新切片底层数组的容量
  newcap := oldCap
  // 两倍容量
  doublecap := newcap + newcap
  if newLen > doublecap {
    // 如果追加元素之后,新的切片长度比旧切片 2 倍容量还大,
    // 则将新的切片的容量设置为跟长度一样
    newcap = newLen
  } else {
    const threshold = 256
    if oldCap < threshold {
      // 旧的切片容量小于 256 的时候,
      // 进行两倍扩容。
      newcap = doublecap
    } else {
      // oldCap >= 256
      // 检查 0<newcap 以检测溢出并防止无限循环。
      for 0 < newcap && newcap < newLen {
        // 从小切片的增长 2 倍过渡到大切片的增长 1.25 倍。
        newcap += (newcap + 3*threshold) / 4
      }
      // 当 newcap 计算溢出时,将 newcap 设置为请求的上限。
      if newcap <= 0 {
        newcap = newLen
      }
    }
  }
  // 计算实际所需要的内存大小
  // 是否溢出
  var overflow bool
  // lenmem 表示旧的切片长度所需要的内存大小
  //(lenmem 就是将旧切片数据复制到新切片的时候指定需要复制的内存大小)
  // newlenmem 表示新的切片长度所需要的内存大小
  // capmem 表示新的切片容量所需要的内存大小
  var lenmem, newlenmem, capmem uintptr
  // 根据 et.size 做一些计算上的优化:
  // 对于 1,我们不需要任何除法/乘法。
  // 对于 goarch.PtrSize,编译器会将除法/乘法优化为移位一个常数。
  // 对于 2 的幂,使用可变移位。
  switch {
  case et.size == 1: // 比如 []byte,所需内存大小 = size
    lenmem = uintptr(oldLen)
    newlenmem = uintptr(newLen)
    capmem = roundupsize(uintptr(newcap))
    overflow = uintptr(newcap) > maxAlloc
    newcap = int(capmem)
  case et.size == goarch.PtrSize: // 比如 []*int,所需内存大小 = size * ptrSize
    lenmem = uintptr(oldLen) * goarch.PtrSize
    newlenmem = uintptr(newLen) * goarch.PtrSize
    capmem = roundupsize(uintptr(newcap) * goarch.PtrSize)
    overflow = uintptr(newcap) > maxAlloc/goarch.PtrSize
    newcap = int(capmem / goarch.PtrSize)
  case isPowerOfTwo(et.size): // 比如 []int64,所需内存大小 = size << shift,也就是 size * 2^shift(2^shift 是 et.size)
    var shift uintptr
    if goarch.PtrSize == 8 {
      // Mask shift for better code generation.
      shift = uintptr(sys.TrailingZeros64(uint64(et.size))) & 63
    } else {
      shift = uintptr(sys.TrailingZeros32(uint32(et.size))) & 31
    }
    lenmem = uintptr(oldLen) << shift
    newlenmem = uintptr(newLen) << shift
    capmem = roundupsize(uintptr(newcap) << shift)
    overflow = uintptr(newcap) > (maxAlloc >> shift)
    newcap = int(capmem >> shift)
    capmem = uintptr(newcap) << shift
  default: // 没得优化,直接使用乘法了
    lenmem = uintptr(oldLen) * et.size
    newlenmem = uintptr(newLen) * et.size
    capmem, overflow = math.MulUintptr(et.size, uintptr(newcap))
    capmem = roundupsize(capmem)
    newcap = int(capmem / et.size)
    capmem = uintptr(newcap) * et.size
  }
  // 检查是否溢出,以及是否超过最大可分配内存
  if overflow || capmem > maxAlloc {
    panic(errorString("growslice: len out of range"))
  }
  // 分配实际所需要的内存
  var p unsafe.Pointer
  if et.ptrdata == 0 { // 不包含指针
    // 分配 capmem 大小的内存,不清零
    p = mallocgc(capmem, nil, false)
    // 这里只清空从 add(p, newlenmem) 开始大小为 capmem-newlenmem 的内存,
    // 也就是前面的 newlenmem 长度不清空。
    // 因为最后的 capmem-newlenmem 这块内存,实际上是额外分配的容量。
    // 前面的那部分会被旧切片的数据以及新追加的数据覆盖。
    memclrNoHeapPointers(add(p, newlenmem), capmem-newlenmem)
  } else {
    // 分配 capmem 大小的内存,需要进行清零
    p = mallocgc(capmem, et, true)
    if lenmem > 0 && writeBarrier.enabled {
      // Only shade the pointers in oldPtr since we know the destination slice p
      // only contains nil pointers because it has been cleared during alloc.
      bulkBarrierPreWriteSrcOnly(uintptr(p), uintptr(oldPtr), lenmem-et.size+et.ptrdata)
    }
  }
  // 旧切片数据复制到新切片中,复制的内容大小为 lenmem
  //(从 oldPtr 复制到 p)
  memmove(p, oldPtr, lenmem)
  return slice{p, newLen, newcap}
}

总结

go 的切片在容量较小的情况下,确实会进行 2 倍扩容,但是随着容量的增长,扩容的增长因子会逐渐降低。

新版本的 growslice 实现中,只有容量小于 256 的时候才会进行 2 倍扩容,

然后随着容量的增长,扩容的因子会逐渐降低(但并不是直接降到 1.25,而是一个相对缓慢的下降)。


目录
相关文章
|
6月前
|
Go 索引
Go 语言中同一 slice 上的切片其底层数组是否是同一个
Go 语言中同一 slice 上的切片其底层数组是否是同一个
53 0
|
3月前
|
人工智能 编译器 Go
go slice 基本用法
go slice 基本用法
51 1
|
3月前
|
存储 算法 Go
|
3月前
|
监控 Serverless Go
Golang 开发函数计算问题之Go 语言中切片扩容时需要拷贝原数组中的数据如何解决
Golang 开发函数计算问题之Go 语言中切片扩容时需要拷贝原数组中的数据如何解决
|
5月前
|
Java Go
Go 中 slice 的内存管理机制
Go 中 slice 的内存管理机制
|
5月前
|
Go
Go 语言是如何实现切片扩容
Go 语言是如何实现切片扩容
|
6月前
|
Go 索引
Go 语言切片(Slice)
Go 语言切片(Slice)
36 1
|
6月前
|
存储 Java Go
Go 语言切片如何扩容?(全面解析原理和过程)
Go 语言切片如何扩容?(全面解析原理和过程)
118 2
|
6月前
|
Go 数据处理
Go杂记1-切片Slice作为函数参数那点事儿
Go杂记1-切片Slice作为函数参数那点事儿
42 0
|
6月前
|
存储 安全 Java
Go Slice的底层实现原理深度解析
在Go语言的世界里,切片(Slice)是一种极其重要的数据结构,它以其灵活性和高效性在众多编程场景中扮演着核心角色。本文将深入探讨Go切片的底层实现原理,通过实例和源码分析,带你领略Go语言设计之美。