Golang 中的 slice | 青训营笔记

84 阅读2分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第 5 天

对于程序员来讲,在编程时常常需要使用一个可存储连续数据,且可以使用下标进行索引查询的对象。在多数编程语言中,这种数据结构被实现为数组。

Golang 对这种数据结构做了进一步的功能划分,分为数组(array)和切片(slice)。

日常使用中,前者更推荐在需要定义存储连续型对象时使用,而后者常常使用其作为某个可变数组的指针。鉴于 slice 的灵活性更强,所以日常使用中, slice 的使用范围更广。本文就从源码角度分析 slice 的实现原理,便于读者更好掌握 slice 的使用。

本文分析的源码是 Go 1.19 版本。

结构定义

slice 结构体定义在 runtime 包下。slice 是基于 array 实现的,底层仍是一个数组,可以理解为对底层数组的抽象。

type slice struct {
    array unsafe.Pointer
    len   int
    cap   int
}

slice:总共占用 24 个字节

  • array:指向底层数组的指针,占用 8 个字节
  • len:切片长度,占用 8 个字节
  • cap:切片容量,其值总是不小于 len,占用 8 个字节

初始化

slice 初始化的方式主要有 4 种

// 使用 var 直接声明
var s1 []int

// 使用字面量推导
s2 := []int{1, 2, 3}

// 使用 make 创建
s3 := make([]int, 3, 5)

// 从切片或数组中截取
s4 := arr[1:3]

进一步通过分析汇编代码,可以发现底层是通过调用 runtime.makeslice 函数来实现 slice 的初始化。下面是源码中 makeslice 函数的主要实现部分。

func makeslice(et *_type, len, cap int) unsafe.Pointer {
    // 正常情况下,cap > len, 以 cap 计算需要的内存大小
    mem, overflow := math.MulUintptr(et.size, uintptr(cap))

    // len > cap,以 len 计算需要的内存大小;非法情况 panic
    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)
}

makeslice 函数首先计算 slice 需要的内存大小,然后调用 mallocgc 函数进行内存的分配。内存大小为元素个数乘元素类型大小。

扩容

切片由于是动态长度的,所以当使用 append 函数增加元素但当前 cap 不足时,会发生 slice 的扩容。

Go 1.18 版本之前采用的扩容规则为

  • 如果新申请的容量大于 2 倍的原 cap,则扩容后的 newcap 为申请容量
  • 如果原 slicelen 小于 1024,那么 newcap = 2 * cap
  • 如果原 slicelen 大于等于 1024,那么 newcap = 1.25 * cap

Go 1.18 版本之后对扩容原则进行了修改,使扩容后的容量更加平滑。

  • 如果新申请的容量大于 2 倍的原 cap,则扩容后的 newcap 为申请容量
  • 如果原 slicecap 小于 256,那么 newcap = 2 * cap
  • 如果原 slicecap 大于等于 256,那么 newcap = (cap + 3*256) / 4
func growslice(et *_type, old slice, cap int) slice {
    // ...

    newcap := old.cap
    doublecap := newcap + newcap  // 变为 2 倍
    if cap > doublecap {  
        newcap = cap  // 超过 2 倍,变为新的 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  
            }  
        }  
    }  

    var overflow bool  
    var lenmem, newlenmem, capmem uintptr  

    // 根据元素类型计算需要的 capmem
    switch {  
    case et.size == 1:  
        lenmem = uintptr(old.len)  
        newlenmem = uintptr(cap)  
        capmem = roundupsize(uintptr(newcap))  
        overflow = uintptr(newcap) > maxAlloc  
        newcap = int(capmem)  
    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)  
    case isPowerOfTwo(et.size):  
        var shift uintptr  
        if goarch.PtrSize == 8 {  
            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)  
    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)  
    }  


    if overflow || capmem > maxAlloc {  
        panic(errorString("growslice: cap out of range"))  
    }  

    // 根据 capmem 分配内存
    var p unsafe.Pointer  
    if et.ptrdata == 0 {  
        p = mallocgc(capmem, nil, false)
    } else {  
        p = mallocgc(capmem, et, true)  
        if lenmem > 0 && writeBarrier.enabled {    
            bulkBarrierPreWriteSrcOnly(uintptr(p), uintptr(old.array), lenmem-et.size+et.ptrdata)  
        }  
    }  
    
    // 清除原切片数组内存
    memmove(p, old.array, lenmem)  

    return slice{p, old.len, newcap}  
}

值得一提的是,其中在根据元素类型计算内存大小时使用的 roundupsize 函数就是应用了 Go 中的内存对齐思想进行了向上取整,具体的分析可以参考这篇 文章

总结

本文从源码角度分析了 Golang 中 slice 的用法,希望读者可以对切片实现原理有更清晰的认识。