go源码-数据结构-切片

81 阅读3分钟

概念

切片是为了动态使用数组而创建的结构。我们知道数组创建之后,大小不可变。而在开发过程中,有时候会根据代码的实际运行结果,动态操作数组,改变数组的大小。直接创建一个足够大的数组会非常浪费内存,而创建内存大小恰好的数组是不现实的。所以引入了切片,切片在动态操作的过程中,如果容量不够会触发扩容

基本结构

切片的结构如下:

编译期间的Slice
type Slice struct {  
    Elem *Type // element type  
}
运行时的Slice
type Slice struct {  
    Data uintptr  
    Len int  
    Cap int  
}

我们将编译期间的Slice看成Slice1,运行时的Slice看成Slice2Slice1的源码位于src\cmd\compile\internal\typesSlice2的源码位于\src\internal\unsafeheader。为什么要把Slice的结构分成编译期间和运行时呢。个人理解(我们知道切片是为了动态的操作数组而引入的,在运行时,必然会改变、创建Slice的内部状态。并且切片的容量大多时候运行时才能确定,所以将切片的完整结构Slice2放在运行时创建。但是在编译期间,对切片的类型进行检查是必须的。所以就有了Slice1Slice1。Slice1用来存储元素类型,在编译期间进行检查,而Slice2存储切片的所有内部实现)。

初始化

不同于数组的两种初始化方式:arr := [2]int{1, 2}arr := [...]int{1, 2}。切片有三种初始化方法:

  1. arr [begin:end] 或者 slice[begin:end] . 在左闭右开区间[begin,end)截取数组或者切片获得新切片
  2. slice := []int{1,2,3} 通过字面量初始化切片
  3. slice := make([]int, 5, 10) 通过make关键字创建切片,5表示当前切片的长度,10表示切片的当前容量

对于三种初始化方法,第一种是最底层的方式。 注意:

  • 注意1:使用方法1初始化切片,新的切片仍然是使用原来的数组,在没有发生扩容之前,改变任意一个都会互相影响。
  • 注意2:使用方法2初始化切片,会被解析成两部分:创建数组、使用方式1操作数组初始化切片
  • 注意3:使用方法3初始化切片,会进行两个判断:切片是否足够小、是否发生逃逸。如果切片很小不发生逃逸,则和注意2类似只是少了字面量的赋值;反之,在堆上初始化切片,调用\go\src\runtime\slice.go:runtime.makeslice代码如下:
func makeslice(et *_type, len, cap int) unsafe.Pointer {  
    mem, overflow := math.MulUintptr(et.Size_, uintptr(cap))  
    // 运行时检测 1 内存是否溢出、2 分配内存是否足够、3 长度是否大于0小于容量
    if overflow || mem > maxAlloc || len < 0 || len > cap {  
        mem, overflow := math.MulUintptr(et.Size_, uintptr(len))  
        if overflow || mem > maxAlloc || len < 0 {  
            panicmakeslicelen()  
        }  
        panicmakeslicecap()  
    }  
    // 分配内存
    return mallocgc(mem, et, true)  
}

追加和扩容

向切片中追加元素,如果长度超过容量则会触发扩容,扩容的代码位于\go\src\runtime\slice.go:growslice,如下:

func growslice(oldPtr unsafe.Pointer, newLen, oldCap, num int, et *_type) slice {
    ...
    newcap := oldCap  
    doublecap := newcap + newcap  
    // 如果新长度大于两倍容量,则最终扩容容量为新长度
    if newLen > doublecap {  
        newcap = newLen  
    } else {  
        // 如果老容量低于256,则双倍扩容
        const threshold = 256  
        if oldCap < threshold {  
            newcap = doublecap  
        } else {  
            // 老容量高于256,则按照如下公式扩容
            for 0 < newcap && newcap < newLen {  
                newcap += (newcap + 3*threshold) / 4  
            }  
            if newcap <= 0 {  
                newcap = newLen  
            }  
        }  
    }
...
}

需要注意的是,触发扩容之后,具体的内存计算还要考虑内存对齐,内存大小向上对齐之后,容量可能会发生细微的增长。

复制切片

切片的复制,不是依次复制,而是调用runtime.memmove直接进行整块复制。

参考文献:《Go语言设计与实现》 @Draven