Go语言切片原理深入剖析|青训营

56 阅读4分钟

这是青训营笔记的第8篇文章。

本文是上一篇文章Go语言切片原理初探|青训营 - 掘金 (juejin.cn)的续集。

追加

下面介绍的是切片的追加操作. 通过 append 操作,可以在 slice 末尾,额外新增一个元素. 需要注意,这里的末尾指的是针对 slice 的长度 len 而言. 这个过程中倘若发现 slice 的剩余容量已经不足了,则会对 slice 进行扩容. 扩容有关的内容我们放到 2.7 小节再作展开.

 

func Test_slice(t *testing.T){
    s := []int{2,3,4}  
    s = append(s,5)
    // s: [2,3,4,5]
}

 

结合我个人的经验,一些 go 的初学者在对 slice 进行初始化以及赋值操作时,有可能会因为对 slice 中 len 和 cap 概念的混淆,最终出现错误的使用方式,形如下面这个代码示例:

func Test_slice(t *testing.T){
    s := make([]int,5)
    for i := 0; i < 5; i++{
       s = append(s, i)
    }
    // 结果为:
    // s: [0,0,0,0,0,0,1,2,3,4]
}

我们预期的操作时声明出一个长度为 5 的 slice,同时依次向其中填入 0,1,2,3,4 的五个元素,然而按照上述代码执行下来,得到的结果是事与愿违的,其原因在于

  • • 我们通过 make 操作,声明了一个长度和容量均为 5 的切片 s,此时前 5 个元素已经被填充为零值
  • • 接下来执行 append 操作时,只会在长度末尾进行追加. 最终会引发扩容,并最终得到结果为 [0,0,0,0,0,0,1,2,3,4,5]

 

针对于我们意图,下面给出两个正确的使用示例:

示例一:

倘若大家希望使用 append 操作完成 slice 赋值,则应该在初始化 slice 时,给其设置不同的长度 len 和容量 cap 值,cap 和 len 之间的差值就是预留出来用于 append 操作的空间. 具体代码如下:

func Test_slice(t *testing.T){
    s := make([]int,0,5)
    for i := 0; i < 5; i++{
       s = append(s, i)
    }
    // 结果为:
    // s: [0,1,2,3,4]
}

 

示例二:

我们将 slice 的长度和容量都设置为 5,然后通过遍历 slice 的方式进行执行位置元素的赋值(不使用 append 操作):

func Test_slice(t *testing.T){
    s := make([]int,5)
    for i := 0; i < 5; i++{
       s[i] = i
    }
    // 结果为:
    // s: [0,1,2,3,4]
}

 

上面介绍的两种使用方式都是正确且规范的. 这里称之为规范的核心原因在于,我们在创建 slice 时,如果能够预估到其未来所需的容量空间,则应该提前分配好对应容量,避免在运行过程中频繁触发扩容操作,这样会对性能产生不利的影响.

 

切片扩容

下面我们捋一下切片扩容的流程. 当 slice 当前的长度 len 与容量 cap 相等时,下一次 append 操作就会引发一次切片扩容.

    // len:4, cap: 4
    s := []int{2,3,4,5}
    // len:5, cap: 8    
    s = append(s,6)

  切片的扩容流程源码位于 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}
}

 

元素删除

从切片中删除元素的实现思路,本质上和切片内容截取的思路是一致的.

比如,我们期望删除 slice 中的首个元素,在操作上等同于从切片 index = 1 开始向后进行内容截取:

func Test_slice(t *testing.T){
    s := []int{0,1,2,3,4}
    // [1,2,3,4]
    s = s[1:]
}

 

如果我们希望删除 slice 的尾部元素,则操作等价于截取切片内容,并将终点设置在 len(s) - 1 的位置:

func Test_slice(t *testing.T){
    s := []int{0,1,2,3,4}
    // [0,1,2,3]
    s = s[0:len(s)-1]
}

 

如果需要删除 slice 中间的某个元素,操作思路则是采用内容截取加上元素追加的复合操作,可以先截取待删除元素的左侧部分内容,然后在此基础上追加上待删除元素后侧部分的内容:

func Test_slice(t *testing.T){
    s := []int{0,1,2,3,4}
    // 删除 index = 2 的元素
    s = append(s[:2],s[3:]...)
    // s: [0,1,3,4], len: 4, cap: 5
    t.Logf("s: %v, len: %d, cap: %d", s, len(s), cap(s))
}

 

最后,当我们需要删除 slice 中的所有元素时,也可以采用切片内容截取的操作方式:s[:0]. 这样操作后,slice header 中的指针 array 仍指向远处,但是逻辑意义上其长度 len 已经等于 0,而容量 cap 则仍保留为原值.

func Test_slice(t *testing.T){
    s := []int{0,1,2,3,4}
    s = s[:0]
    // s: [], len: 0, cap: 5
    t.Logf("s: %v, len: %d, cap: %d", s, len(s), cap(s))
}

 

切片拷贝

slice 的拷贝可以分为简单拷贝和完整拷贝两种类型.

要实现简单拷贝,我们只需要对切片的字面量进行赋值传递即可,这样相当于创建出了一个新的 slice header 实例,但是其中的指针 array、容量 cap 和长度 len 仍和老的 slice header 实例相同.

操作实例如下,最终输出的结果中,s 和 s1 的地址是一致的.

func Test_slice(t *testing.T) {
    s := []int{01234}
    s1 := s
    t.Logf("address of s: %p, address of s1: %p", s, s1)
}

 

这里再声明一下,切片的截取操作也属于是简单拷贝,以下面操作代码为例,s 和 s1 会使用同一片内存空间,只不过地址起点位置偏移了一个元素的长度. s1 和 s 的地址,刚好相差 8 个 byte.

func Test_slice(t *testing.T) {
    s := []int{01234}
    s1 := s[1:]
    t.Logf("address of s: %p, address of s1: %p", s, s1)
}

 

slice 的完整复制,指的是会创建出一个和 slice 容量大小相等的独立的内存区域,并将原 slice 中的元素一一拷贝到新空间中.

在实现上,slice 的完整复制可以调用系统方法 copy,代码示例如下,通过日志打印的方式可以看到,s 和 s1 的地址是相互独立的:

func Test_slice(t *testing.T) {
    s := []int{01234}
    s1 := make([]intlen(s))
    copy(s1, s)
    t.Logf("s: %v, s1: %v", s, s1)
    t.Logf("address of s: %p, address of s1: %p", s, s1)
}