go slice

152 阅读9分钟

以下内容皆来自 公众号 小徐的编程世界,B站视频链接

切片扩容机制

切片的扩容流程源码位于 runtime/slice.go 文件的 growslice 方法当中,其中核心步骤如下:

  • 倘若扩容后预期的新容量小于原切片的容量,则 panic
  • 倘若切片元素大小为 0(元素类型为 struct{}),则直接复用一个全局的 zerobase 实例,直接返回
  • 倘若预期的新容量超过老容量的两倍,则直接采用预期的新容量
  • 倘若老容量小于 256,则直接采用老容量的2倍作为新容量
  • 倘若老容量已经大于等于 256,则在老容量的基础上扩容 1/4 的比例并且累加上 192 的数值,持续这样处理,直到得到的新容量已经大于等于预期的新容量为止
  • 结合 mallocgc 流程中,对内存分配单元 mspan 的等级制度,推算得到实际需要申请的内存空间大小
  • 调用 mallocgc,对新切片进行内存初始化
  • 调用 memmove 方法,将老切片中的内容拷贝到新切片中
  • 返回扩容后的新切片

func growslice(et *_type, old slice, cap int) slice {
    //... 
    if cap < old.cap {
        panic(errorString("growslice: cap out of range"))
    }


    if et.size == 0 {
        // 倘若元素大小为 0,则无需分配空间直接返回
        return slice{unsafe.Pointer(&zerobase), old.lencap}
    }


    // 计算扩容后数组的容量
    newcap := old.cap
    // 取原容量两倍的容量数值
    doublecap := newcap + newcap
    // 倘若新的容量大于原容量的两倍,直接取新容量作为数组扩容后的容量
    if cap > doublecap {
        newcap = cap
    } else {
        const threshold = 256
        // 倘若原容量小于 256,则扩容后新容量为原容量的两倍
        if old.cap < threshold {
            newcap = doublecap
        } else {
            // 在原容量的基础上,对原容量 * 5/4 并且加上 192
            // 循环执行上述操作,直到扩容后的容量已经大于等于预期的新容量为止
            for 0 < newcap && newcap < cap {             
                newcap += (newcap + 3*threshold) / 4
            }
            // 倘若数值越界了,则取预期的新容量 cap 封顶
            if newcap <= 0 {
                newcap = cap
            }
        }
    }


    var overflow bool
    var lenmem, newlenmem, capmem uintptr
    // 基于容量,确定新数组容器所需要的内存空间大小 capmem
    switch {
    // 倘若数组元素的大小为 1,则新容量大小为 1 * newcap.
    // 同时会针对 span class 进行取整
    case et.size == 1:
        lenmem = uintptr(old.len)
        newlenmem = uintptr(cap)
        capmem = roundupsize(uintptr(newcap))
        overflow = uintptr(newcap) > maxAlloc
        newcap = int(capmem)
    // 倘若数组元素为指针类型,则根据指针占用空间结合元素个数计算空间大小
    // 并会针对 span class 进行取整
    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)
    // 倘若元素大小为 2 的指数,则直接通过位运算进行空间大小的计算   
    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)
    // 兜底分支:根据元素大小乘以元素个数
    // 再针对 span class 进行取整     
    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)
    }




    // 进行实际的切片初始化操作
    var p unsafe.Pointer
    // 非指针类型
    if et.ptrdata == 0 {
        p = mallocgc(capmem, nilfalse)
        // ...
    } else {
        // 指针类型
        p = mallocgc(capmem, et, true)
        // ...
    }
    // 将切片的内容拷贝到扩容后的位置 p 
    memmove(p, old.array, lenmem)
    return slice{p, old.len, newcap}
}

3.1 问题1

func Test_slice(t *testing.T){
    s := make([]int,10)  
    s = append(s,10)
    t.Logf("s: %v, len of s: %d, cap of s: %d",s,len(s),cap(s))
}

答案为:

s: [0 0 0 0 0 0 0 0 0 0 10], len of s: 11cap of s: 20

原因在于:

学完 2.3 小节的内容,我们了解到,基于 make([]int, 10) 的方式初始化切片的话其长度 len 和容量 cap 均为 10,且前10个元素是已经切实被分配过的(虽然会被填充为零值). 此时进行 append 操作,会在末尾进行元素追加,由于切片的长度和容量是相等的,因此已经没有剩余可用的空间了,于是会进一步引发切片的扩容操作.

基于 2.7 小节,我们了解到在切片原容量小于 256 的情况下,扩容时会采用原容量的2倍作为新的容量,于是在新切片中,长度增加为 11,而容量则翻倍变成 20.

 

3.2 问题2

func Test_slice(t *testing.T){
    s := make([]int,0,10)  
    s = append(s,10)
    t.Logf("s: %v, len of s: %d, cap of s: %d",s,len(s),cap(s))
}

答案为:

s: [10], len of s: 1cap of s: 10

原因在于:

make([]int, 0, 10) 的方式使得切片长度为 0,容量为 10,实际上还有长度为 10 的缓存空间. 于是这一次 append 操作,会直接使用已有的空间,不会引发扩容. 结果中,切片长度从 0 增加为 1,容量则维持为 10 不变.

 

3.3 问题3

func Test_slice(t *testing.T){
    s := make([]int,10,11)  
    s = append(s,10)
    t.Logf("s: %v, len of s: %d, cap of s: %d",s,len(s),cap(s))
}

答案为:

s: [0 0 0 0 0 0 0 0 0 0 10], len of s: 11cap of s: 11

问题3和问题2类似,由于容量大于长度,因此仍有足够的空间,这次 append 操作不会引发扩容.

 

3.4 问题4

func Test_slice(t *testing.T){
    s := make([]int,10,12)  
    s1 := s[8:]
    t.Logf("s1: %v, len of s1: %d, cap of s1: %d",s1,len(s1),cap(s1))
}

 

答案为:

s1: [0 0], len of s1: 2cap of s1: 4

截取操作会以 s[8] 作为内存空间的起点,截取所得新切片 s1 的长度和容量强依赖于原切片 s 的长度和容量,并在此基础上减去头部 8 个未使用到的单位.

 

3.5 问题5

func Test_slice(t *testing.T){
    s := make([]int,10,12)  
    s1 := s[8:9]
    t.Logf("s1: %v, len of s1: %d, cap of s1: %d",s1,len(s1),cap(s1))
}

答案为:

s1: [0], len of s1: 1cap of s1: 4

问题5和问题4类似,我们需要注意虽然 s[8:9] 的截取操作限定了 s1 的右边界,但这只是长度意义上的,对于容量,s1 仍然和 s 保持强关联性.

 

3.6 问题6

func Test_slice(t *testing.T){
    s := make([]int,10,12)  
    s1 := s[8:]
    s1[0] = -1
    t.Logf("s: %v",s)
}

答案为:

s: [0 0 0 0 0 0 0 0 -1 0]

s1 是在 s 基础上截取得到的,属于一次引用传递,底层共用同一片内存空间,其中 s[x] 等价于 s1[x+8]. 因此修改了 s1[0] 会直接影响到 s[8] .

 

3.7 问题7

func Test_slice(t *testing.T){
    s := make([]int,10,12)  
    v := s[10]
    // 求问,此时数组访问是否会越界
}

答案:会发生 panic.

初始化时设定了切片长度为10,容量为 12. 容量是物理意义上的,但长度是逻辑意义上的,判断是否越界以逻辑意义为准,因此 index = 10 已经越界.

 

3.8 问题8

func Test_slice(t *testing.T){
    s := make([]int,10,12)  
    s1 := s[8:]
    s1 = append(s1,[]int{10,11,12}...)
    v := s[10]
    // ...
    // 求问,此时数组访问是否会越界
}

答案:会发生 panic.

  • • 在 s 的基础上截取产生了 s1,此时 s1 和 s 会拥有两个独立的 slice header.
  • • 接下来执行 append 操作时,由于 s 预留的空间不足,s1 会发生扩容
  • • s1 扩容后,会被迁移到新的空间地址,此时 s1 已经和 s 做到真正意义上的完全独立,意味着修改 s1 不再会影响到 s
  • • s 继续维持原本的长度值 10 和容量值 12,因此访问 s[10] 会panic

 

3.9 问题9

func Test_slice(t *testing.T){
    s := make([]int,10,12)  
    s1 := s[8:]
    changeSlice(s1)
    t.Logf("s: %v",s)
}


func changeSlice(s1 []int){
  s1[0] = -1
}

答案:

s: [0 0 0 0 0 0 0 0 -1 0]

切片在传递时属于引用传递,且 s1[0] 和 s[8] 指向同一个元素. 因此在局部方法中,修改了 s1[0] 会直接影响到 s[8] 的内容.

 

3.10 问题10

func Test_slice(t *testing.T){
    s := make([]int,10,12)  
    s1 := s[8:]
    changeSlice(s1)
    t.Logf("s: %v, len of s: %d, cap of s: %d",s, len(s), cap(s))
    t.Logf("s1: %v, len of s1: %d, cap of s1: %d",s1, len(s1), cap(s1))
}


func changeSlice(s1 []int){
  s1 = append(s1, 10)
}

答案:

s: [0 0 0 0 0 0 0 0 0 0], len of s: 10cap of s: 12
s1: [0 0], len of s1: 2cap of s1: 4

虽然切片是引用传递,但是在方法调用时,传递的会是一个新的 slice header.

因此在局部方法 changeSlice 中,虽然对 s1 进行了 append 操作,但这这会在局部方法中这个独立的 slice header 中生效,不会影响到原方法 Test_slice 当中的 s 和 s1 的长度和容量.

 

3.11 问题11

func Test_slice(t *testing.T){
    s := []int{0,1,2,3,4}
    s = append(s[:2],s[3:]...)
    t.Logf("s: %v, len: %d, cap: %d", s, len(s), cap(s))
    v := s[4] 
    // 是否会数组访问越界
}

答案:

输出内容为:

s: [0 1 3 4], len4cap5

会发生 panic

执行完上述 append 操作之后,s 的实际长度为 4,容量维持不变为 5. 此时访问 s[4]会发生数组越界的错误.

 

3.12 问题12

func Test_slice(t *testing.T){
    s := make([]int,512)  
    s = append(s,1)
    t.Logf("len of s: %d, cap of s: %d",len(s),cap(s))
}

答案:

len513cap848

问题11的内容看起来平平无奇,为什么我会选择将其作为压轴呢?原因在于其中暗藏了两个细节,使得这个问题远没有其表面上看上去的那么简单.

首先,如 2.7 小节中谈到的,由于切片 s 原有容量为 512,已经超过了阈值 256,因此对其进行扩容操作会采用的计算共识为 512 * (512 + 3*256)/4 = 832

其次,在真正申请内存空间时,我们会根据切片元素大小乘以容量计算出所需的总空间大小,得出所需的空间为 8byte * 832 = 6656 byte

再进一步,结合分配内存的 mallocgc 流程,为了更好地进行内存空间对其,golang 允许产生一些有限的内部碎片,对拟申请空间的 object 进行大小补齐,最终 6656 byte 会被补齐到 6784 byte 的这一档次. (内存分配时,对象分档以及与 mspan 映射细节可以参考 golang 标准库 runtime/sizeclasses.go 文件,也可以阅读我的文章了解更多细节——golang 内存模型与分配机制)

// class  bytes/obj  bytes/span  objects  tail waste  max waste  min align
//     1          8        8192     1024           0     87.50%          8
//     2         16        8192      512           0     43.75%         16
//     3         24        8192      341           8     29.24%          
// ...
//    48       6528       32768        5         128      6.23%        128
//    49       6784       40960        6         256      4.36%        128 

再终,在 mallocgc 流程中,我们为扩容后的新切片分配到了 6784 byte 的空间,于是扩容后实际的新容量为 cap = 6784/8 = 848.