目录

Go 源码深度解析之切片 Slice(数据结构/扩容机制)

一、简介

写在前面:先写个结论,让大家对该结构的源码有个大概了解,之后再一步一步解析源码:

  • slice是引用类型,底层是一个指向指针的数组,支持动态扩容

  • 底层数据结构(三个字段)

    • array:指向所引用的数组指针(unsafe.Pointer 可以表示任何可寻址的值的指针)

    • len:长度,当前引用切片的元素个数

    • cap:容量,当前引用切片的容量(底层数组的元素总数)

  • 底层扩容机制:当前容量小于1024(256),则 2 倍扩容,反之则为 1.25 倍(2~1.25之间平滑过渡)

  • 扩容创建新的切片,将旧切片数据迁移到新切片,清除旧切片

  • 数组与切片的区别:切片是引用类型,数组是值类型,切片可以动态扩容,底层也是一个数组

二、源码

$GOROOT$\src\runtime\slice.go

(一)数据结构

type slice struct {
    array unsafe.Pointer                                 // 指向所引用的数组指针(`unsafe.Pointer` 可以表示任何可寻址的值的指针)
    len   int                                                     // 长度,当前引用切片的元素个数
    cap   int                                                      // 容量,cap 一定是大于或等于 len 的。否则会导致 panic
}

(二)创建Slice

makeslice64函数只是做了些校验,其内部也是调用makeslice,

makeslice函数主要是通过len、cap计算slice所需内存大小,然后调用mallocgc进行内存的分配。

// 该函数传入需要初始化的切片的类型,长度以及容量,返回的指针会通过调用方组建成一个完成的slice结构体
func makeslice(et *_type, len, cap int) unsafe.Pointer {
   // 调用MulUintptr函数:获取创建该切片需要的内存,以及是否溢出
    mem, overflow := math.MulUintptr(et.size, uintptr(cap))
  //  如果溢出 | 超过能够分配的最大内存(2^32 - 1) | 非法输入, 报错并返回
    if overflow || mem > maxAlloc || len < 0 || len > cap {
    // 下面的注释:
   // 在创建一个长度非常大的切片时,如果超出了底层数组的容量,通常会发生“cap out of range”错误。
    // 但是go会优先返回 len 溢出的错误

        mem, overflow := math.MulUintptr(et.size, uintptr(len))
        if overflow || mem > maxAlloc || len < 0 {
      // panic 错误:len溢出
            panicmakeslicelen()
        }
     // panic 错误:容量溢出
        panicmakeslicecap()
    }
 // 调用mallocgc函数分配一块连续内存并返回该内存的首地址
    return mallocgc(mem, et, true)
}
//MulUintptr返回a*b以及乘法是否溢出。
//在受支持的平台上,这是编译器降低的内在值。
func MulUintptr(a, b uintptr) (uintptr, bool) {
    if a|b < 1<<(4*goarch.PtrSize) || a == 0 {
        return a * b, false
    }
    overflow := b > MaxUintptr/a
    return a * b, overflow
}

(三)append-扩容-growslice

append操作其实是调用了runtime/slice.go中的growslice函数

  • 重新申请内存,之后将数据赋值过来
  • 当原切片cap<1024 时,<新cap> = 2 * <老cap>
  • 当原切片cap>1024 时,<新cap> = 1.25*<老cap>
  • 之后进行内存对齐,内存对齐相关可参考【Golang】详解内存对齐

1.18 最新版本的扩容机制:

  • 小于 256 则 newcap=2 x oldcap
  • 大于 256,则扩容机制 在 2 ~ 1.25 倍 之间,newcap += (newcap + 3*threshold) / 4

(四)切片深拷贝

当我们使用copy时,底层调用的源码函数为makeslicecopy;

这个函数最终就是调用 runtime.memmove 来实现 slice 的 copy 的

func slicecopy(to, fm slice, width uintptr) int {
      // 如果源切片或者目标切片有一个长度为0,那么就不需要拷贝,直接 return 
      if fm.len == 0 || to.len == 0 {
        return 0
      }
      // n 记录下源切片或者目标切片较短的那一个的长度
      n := fm.len
      if to.len < n {
        n = to.len
      }
      // 如果入参 width = 0,也不需要拷贝了,返回较短的切片的长度
      if width == 0 {
        return n
      }
      //如果开启竞争检测
      if raceenabled {
        callerpc := getcallerpc()
        pc := funcPC(slicecopy)
        racewriterangepc(to.array, uintptr(n*int(width)), callerpc, pc)
        racereadrangepc(fm.array, uintptr(n*int(width)), callerpc, pc)
      }
      if msanenabled {
        msanwrite(to.array, uintptr(n*int(width)))
        msanread(fm.array, uintptr(n*int(width)))
      }
      size := uintptr(n) * width
      if size == 1 { // common case worth about 2x to do here
        // TODO: is this still worth it with new memmove impl?
        //如果只有一个元素,那么直接进行地址转换
        *(*byte)(to.array) = *(*byte)(fm.array) // known to be a byte pointer
      } else {
        //如果不止一个元素,那么就从 fm.array 地址开始,拷贝到 to.array 地址之后,拷贝个数为size
        memmove(to.array, fm.array, size)
      }
      return n
    }

常见问题

Q1. 为什么要初始化切片的容量?

在已知容量的情况,使用 make([]T, 0 , cap) 初始化切片的容量,可以减少切片的扩容次数,因为每次扩容都需要重新初始化一个新的切片,重新分配内存,并且需要将旧切片的所有元素迁移到新切片,整个过程有一定的消耗。

Q2. nil 切片和空切片有什么区别?

nil 切片的 array 指针为 nil,len 和 cap 为 0;空切片的 array 指针指向一个空数组地址,len 和 cap 也为 0。

两者都可直接 append,但 nil 切片更节省内存(未分配数组):

var nilSlice []int  // nil 切片:array=nil, len=0, cap=0
emptySlice := make([]int, 0)  // 空切片:array=指向空数组, len=0, cap=0

fmt.Println(nilSlice == nil)  // true
fmt.Println(emptySlice == nil)  // false
// 两者都可 append
nilSlice = append(nilSlice, 1)  // 变为 len=1, cap=1

Q3. 切片作为函数参数是值传递还是引用传递?

值传递!但传递的是切片结构体(array 指针、len、cap)的拷贝,由于 array 指针指向同一底层数组,所以修改切片元素会影响原切片;但修改 len 或 cap(如 append 扩容)不会影响原切片(拷贝的指针指向新数组)。

Q4. 如何快速清空切片?有几种方式?

有 3 种方式,根据场景选择:

slice := []int{1,2,3,4}
// 方式 1:重置 len=0(推荐,复用底层数组,高效)
slice = slice[:0]
// 方式 2:赋值为空切片(会创建新空切片,原数组可能被回收)
slice = []int{}
// 方式 3:nil 赋值(原数组可能被回收,后续需重新 append)
slice = nil

Q5. 切片的 cap 为什么不能小于 len?

因为 cap 是底层数组的容量,len 是当前使用的元素个数,使用的元素个数不可能超过数组的容量。

源码中 makeslice 函数会检查 len > cap,若成立则 panic,从底层杜绝这种情况。

总结

切片的核心是“结构体封装 + 底层数组共享”,所有特性都源于这一设计:数据结构的 3 个字段(array、len、cap)决定了切片的行为;扩容机制分“小切片翻倍、大切片增 25%”的两阶段规则,配合内存对齐优化性能;实战中的坑大多源于对“底层数组共享”和“值传递”的理解不到位。

最后在开发上应该注意:

① 初始化切片时,能确定容量就用 make 指定 cap,减少扩容损耗;

② 派生切片后若要修改,先用 append 生成独立切片,避免数据污染;

③ 遍历修改值类型切片时,用索引操作而非 for range 副本。

切片是 Go 中最常用的数据结构,吃透底层原理不仅能避坑,还能写出更高效的代码。

如果在实际开发中遇到切片的特殊场景,欢迎在评论区交流~

版权声明

未经授权,禁止转载本文章。
如需转载请保留原文链接并注明出处。即视为默认获得授权。
未保留原文链接未注明出处或删除链接将视为侵权,必追究法律责任!

本文原文链接: https://fiveyoboy.com/articles/go-source-code-slice/

备用原文链接: https://blog.fiveyoboy.com/articles/go-source-code-slice/