深入理解 Go slice

前言

slice是go中很基础的一个数据结构,本文将从源码和汇编的角度,分析slice的参数传递,append,扩容,截取,拷贝,遍历。这些操作的实现原理

基于源码go1.24.1

数据结构,初始化

slice底层结构为:

go 复制代码
// src/reflect/value.go
type SliceHeader struct {
    Data uintptr
    Len  int
    Cap  int
}
  • Data:指向底层数组的起始地址

  • Len:当前 slice 的长度

    • 代表当前slice已经装了多少个元素 ,可以访问[0:len)范围内的元素。不能访问index>=len范围内的元素,否则会panic
    • 也代表下次append时,往哪个index追加
  • Cap:当前 slice 的容量。当len等于cap时,再append就需要扩容了

初始化

slice有3种初始化方法:

指定len和cap初始化:其len是0,cap是8

适用场景:先分配一块空闲,后续用append添加元素

go 复制代码
func main() {
    s := make([]int, 0, 8)
    fmt.Println(len(s))  // 0
    fmt.Println(cap(s))  // 8
}

指定cap初始化:其len和cap都是8

使用场景:先分配一块空闲,后续根据索引修改元素,而非用append

go 复制代码
func main() {
    s := make([]int, 8)
    fmt.Println(len(s))  // 8
    fmt.Println(cap(s))  // 8
}

初始化时同时赋值,其len和cap都是3

适用场景:已知初始元素的值,希望直接初始化

go 复制代码
func main() {
    s := []int{1, 2, 3}
    fmt.Println(len(s))  // 3
    fmt.Println(cap(s))  // 3
}

作为参数传递

Go 中通过参数传递 slice,核心机制是:将slice header拷贝了一份传到被调用方法中

下面将调用者称为caller,被调用者称为callee

核心机制为:

  • callee修改底层数组,会反映到caller :例如callee内通过 s[i] = x 修改元素,是通过 data 指针访问底层数组
  • 不管callee怎么操作,caller 的 len 和 cap 不变 :因为callee对 slencap 的修改(如 s = s[:2])只影响拷贝的 header,不会影响原 slice
    • 除非callee最后把s返回给caller

下面看slice header是怎么被传递的

go 复制代码
func main() {
    s := []int{1, 2, 3}
    changeSlice(s)
}

func changeSlice(s []int) {
    s[0] = 1
    s[1] = 1
    s[2] = 1
}

main方法的汇编代码:

assembly 复制代码
LEAQ    main..autotmp_2+24(SP), AX
MOVQ    $1, (AX)
MOVQ    $2, 8(AX)
MOVQ    $3, 16(AX)
MOVQ    AX, main.s+48(SP)
MOVL    $3, BX
MOVQ    BX, CX
CALL    main.changeSlice(SB)
  1. 底层数组在栈上分配内存,取SP+24的地址作为slice底层数组的首地址
  2. 将slice的数据1,2,3放到栈上对应位置
  3. 将3赋值给BX和CX
  4. 将AX,BX,CX作为参数,调changeSlice方法
    1. AX:存储slice底层数组的地址
    2. BX:slice的len
    3. CX:slice的cap

因此在方法间传递切片时,会对 sliceheader实例本身进行一次值拷贝,然后将 sliceheader的副本传到局部方法中

append追加

go 复制代码
func changeSlice(s []int) {
    s = append(s, 4)
}

流程:

  • 如果slice后面还有空间,在就末尾追加一个元素
  • 否则扩容,再在后面追加一个元素

我们看看changeSlice的汇编代码:

首先,s底层数组地址,len,cap,被caller放在寄存器AX,BX,CX中

assembly 复制代码
00029 INCQ    BX
00032 CMPQ    CX, BX
00035 JCC     39
00037 JMP     41
00039 JMP     60
  • INCQ BX:尝试将 len 加 1(因为要 append 一个元素)。
  • CMPQ CX, BX:比较 new_len(BX)和 cap(CX)
  • JCC 39:如果CX >= BX,也就是cap>=new_len,表示不用扩容,就跳转到39,进而跳转到60,执行追加操作
  • JMP 41:否则跳转到41,执行扩容操作

下面是扩容操作:

assembly 复制代码
00041 MOVL    $1, DI
00046 LEAQ    type:int(SB), SI
00053 CALL    runtime.growslice(SB)
00058 JMP     60
  • MOVL $1, DI:表示要追加 1 个元素。
  • LEAQ type:int(SB), SI:将 int 类型信息传给 SI 寄存器(用于 growslice 知道元素大小和类型)
  • CALL runtime.growslice(SB):调用运行时函数进行切片扩容。

growslice 的返回值:

  • AX: 新的数据指针(可能已迁移)
  • BX: 新的长度(原 len + 1
  • CX: 新的容量

最后是往尾部append一个元素:

assembly 复制代码
00060 LEAQ    (AX)(BX*8), DX
00064 LEAQ    -8(DX), DX
00068 MOVQ    $4, (DX)
  • LEAQ (AX)(BX*8), DX:计算 &data[len] 的地址,并放到DX
    • AX 是底层数组指针(data
    • BX 是新的 len(注意:此时 BXlen+1,但我们要写的是 len 位置)
    • BX*8:因为 int 在 64 位系统上是 8 字节
    • (AX)(BX*8) 表示 data + len*8,即 &data[len]
  • LEAQ -8(DX), DX:减去 8 字节,得到 &data[len-1],也就是最后一个有效位置(即刚追加的位置
    • 这是因为 BXlen+1,所以 data + (len+1)*8 - 8 = data + len*8,即 &data[len] 正确位置
  • MOVQ $4, (DX):将值 4 写入该地址

扩容

slice可以自动进行扩容,当调用append方法时,如果老的len == cap,即容纳不下新的元素 的时,编译器会调runtime.growslice方法,帮我们为切片的底层数组分配更大的内存空间

方法签名为:

go 复制代码
func growslice(oldPtr unsafe.Pointer, newLen, oldCap, num int, et *_type) slice 
参数 说明
oldPtr 原切片底层数组的指针
newLen 扩容后的新长度(即 oldLen + num)
oldCap 原切片的容量
num 要追加的元素个数(这里是 1)
et 元素类型信息(包含大小、是否含指针等)

具体流程如下:

拿到原始长度:

go 复制代码
oldLen := newLen - num

计算新的容量

go 复制代码
newcap := nextslicecap(newLen, oldCap)

nextslicecap

其中计算新容量nextslicecap方法如下:

  • newLen:扩容后的目标长度
  • oldCap:当前切片的容量
  • 返回值:新容量
go 复制代码
func nextslicecap(newLen, oldCap int) int {
    newcap := oldCap
    doublecap := newcap + newcap
    // 如果目标长度 newLen 已经大于当前容量的两倍,那就直接返回 newLen
    if newLen > doublecap {
       return newLen
    }

    const threshold = 256
    // 如果当前容量小于 256,则直接返回 oldCap * 2
    if oldCap < threshold {
       return doublecap
    }
    // 
    for {
        // 等价于:newcap += newcap/4 + 192
        newwcap += (newcap + 3*threshold) >> 2
        if uint(newcap) >= uint(newLen) {
          break
       }
    }

    return newcap
}

整体流程为:

  1. 如果目标长度 newLen 已经大于当前容量的两倍,那就直接返回 newLen

例如:

go 复制代码
s := make([]int, 1, 10)
s = append(s, make([]int, 1000)...)

此时 newLen = 1001doublecap = 20,显然 1001 > 20,所以直接返回 1001

  1. 当前容量小于 256,则直接返回 oldCap * 2
    1. 小切片频繁 append 时,翻倍增长可以显著减少内存分配次数
  2. 当前容量大于256,逐步从翻倍增长过渡到1.25倍增长
    1. 新容量公式为:newcap += newcap/4 + 192
    2. newcap 很小时(接近 256),192 占比大 → 增长更快(接近 2x)
    3. newcap 很大时,192 可忽略 → 增长趋近于 1.25x
oldCap newcap 增长量 增长率
256 256/4 + 192 = 256 2
1000 1000/4 + 192 ≈ 442 1.44
10000 10000/4 + 192 ≈ 2692 1.27
100000 100000/4 + 192 ≈ 25192 1.25

以上是go1.18之后的扩容策略。1.18之前策略是:

  • 如果老容量小于1024,按照2倍扩容

  • 如果老容量大于等于1024,按照1.25倍扩容

1.18之前的扩容策略有什么问题?

  • 不够平滑:容量小于1024时2倍扩容,大于1024突然降到1.25倍扩容
  • 容量增长不单调 :正常应该是较大的初始容量扩容后有较大的最终容量

例如以下代码:

go 复制代码
x1 := make([]int, 897)
x2 := make([]int, 1024)
y := make([]int, 100)
println(cap(append(x1, y...))) // 2048
println(cap(append(x2, y...))) // 1280

x1的容量比x2小,都增加100个元素后x1的容量反而比x2大,容量增长变得不单调了 于是,go官方在这次commit github.com/golang/go/c...后 ,将扩容策略改成现在这样

分配内存

计算完新的容量后,下一步是为新的底层数组分配内存

根据新的容量,计算要分配多少内存:

go 复制代码
switch {
case et.Size_ == 1:
   // ...
case et.Size_ == goarch.PtrSize:
   // ...
case isPowerOfTwo(et.Size_):
   // ...
default:
   lenmem = uintptr(oldLen) * et.Size_
   newlenmem = uintptr(newLen) * et.Size_
   capmem, overflow = math.MulUintptr(et.Size_, uintptr(newcap))
   capmem = roundupsize(capmem, noscan)
   newcap = int(capmem / et.Size_)
   capmem = uintptr(newcap) * et.Size_
}
  • lenmem:旧数据占用的字节数(oldLen * elemSize
  • newlenmem:新长度所需字节数
  • capmem:新容量所需字节数(向上对齐)
  • newcap:最终确定的新容量(可能因对齐微调)

roundupsize:按照内存分配块的大小向上修正 内存分配各个size如下:{0, 8, 16, 24, 32, 48, 64, 80, 96, 112...}

例如,如果slice为int类型,有5个元素,应该占40个字节。但由于分配到size=48的内存块,会向上修正到48字节,也就是cap变为6

调mallocgc分配内存

go 复制代码
p = mallocgc(capmem, nil, false)

将原数组 oldPtrlenmem 字节复制到新内存 p

go 复制代码
memmove(p, oldPtr, lenmem)

返回扩容后slice

go 复制代码
return slice{p, newLen, newcap}

截取

slice支持以下截取操作:

go 复制代码
func main() {
    s := []int{1, 2, 3, 4, 5, 6}
    fmt.Println(&s[0])   // 0xc00000e300
    fmt.Println(len(s))  // 6
    fmt.Println(cap(s))  // 6

    s1 := s[1:3]
    fmt.Println(s1)       // [2 3] 
    fmt.Println(&s1[0])   // 0xc00000e308
    fmt.Println(len(s1))  // 2
    fmt.Println(cap(s1))  // 5
}

s1从s中,根据开头和结尾进行截取出[1,3)范围的数据

截取操作的一般化描述为:newSlice := original[low:high]

s1的

  • newLen=2,因为截取出2个元素
    • 计算公式:newLen = high - low
    • 如果截取操作不指定结束位置,例如s[1:],表示从原始slice第二个位置截取到最后,新slice的len就是原始slice的len-1
  • newCap=5,为此次截取的开头算到原slice的cap结尾
    • 计算公式为:newCap = originalCap(原slice的cap) - low
  • 底层数组开头指向的地址,为原slice底层数组开头指向的地址+8

我们看看底层汇编

初始化slice s:

scss 复制代码
00041 LEAQ    main..autotmp_3+24(SP), DX
00046 MOVQ    DX, main..autotmp_2+120(SP) // 保存数组地址到120(SP)
00053 MOVQ    $1, (DX)
00062 MOVQ    $2, 8(DX)
00072 MOVQ    $3, 16(DX)
00082 MOVQ    $4, 24(DX)
00097 MOVQ    $5, 32(DX)
00112 MOVQ    $6, 40(DX)        

初始化slice s1:

  1. 将s的地址+8,作为s1的底层数组的地址
  2. 将2最为s1的len
  3. 将3作为s2的cap
assembly 复制代码
00120 MOVQ    main..autotmp_2+120(SP), AX // 将数组地址赋值给AX
00157 ADDQ    $8, AX
00184 MOVL    $2, BX
00189 MOVL    $5, CX

可以看出:截取操作代价很低,就是new一个slice结构,底层数组复用原来的

此时如果对往s1后面append,因为len还没超过cap,就会在data指向的数组后面追加,且该数组和s为同一个,就会修改原数组下标为3位置的值:

go 复制代码
func main() {
    s := []int{1, 2, 3, 4, 5, 6}
    s1 := s[1:3]
    s1 = append(s1, 8)

    fmt.Println(s)  // [1 2 3 8 5 6] 
    fmt.Println(s1)  // [2 3 8] 
}

拷贝

slice的拷贝分为浅拷贝和深拷贝

浅拷贝

通过对slice变量赋值,可以创建出了一个新的slice header实例,但是其中的指针array、容量cap和长度len 仍和老的slice header实例相同.

go 复制代码
func main() {
    s := []int{0, 1, 2, 3, 4}
    s1 := s

    fmt.Println(&s[0])   // 0xc00000e300
    fmt.Println(&s1[0])  // 0xc00000e300

    fmt.Println(len(s), cap(s))  // 5 5
    fmt.Println(len(s1), cap(s1))  // 5 5
}

深拷贝

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

什么情况需要深拷贝?

  1. 需要独立修改,不希望影响原始数据
  2. 为了并发安全,多goroutine操作时,避免竞态条件

go内建函数copy能完成切片的深拷贝,将原切片source的内容拷贝的目标切片target

go 复制代码
func main() {
    source := []int{1, 2, 3, 4, 5, 6}
    fmt.Println(source, len(source), cap(source))  // [1 2 3 4 5 6] 6 6

    target := make([]int, 6, 6)
    copy(target, source)
    fmt.Println(target, len(target), cap(target))  // [1 2 3 4 5 6] 6 6

    fmt.Println(&source[0])  // 0xc00000e300
    fmt.Println(&target[0])  // 0xc00000e330
}

需要注意:要拷贝多少个元素,是根据min(len(source),len(target))决定的。因此需要将目标切片的len设置为和原切片一样,才能保证将原切片原封不动地拷贝到目标切片

举个例子,假设target的len=3,那就只会拷贝source中前3个元素到target

go 复制代码
func main() {
    source := []int{1, 2, 3, 4, 5, 6}
    fmt.Println(source, len(source), cap(source))  // [1 2 3 4 5 6] 6 6

    target := make([]int, 3, 6)
    copy(target, source)
    fmt.Println(target, len(target), cap(target))  // [1 2 3] 3 6
}

遍历

最后介绍slice的for range遍历操作:

go 复制代码
func main() {
    source := []int{1, 2, 3, 4, 5, 6}

    for i, v := range source {
       handle(i, v)
    }
}

for range循环,对应的汇编代码为:

初始source:

asm 复制代码
// 数组分配在栈上SP+48的位置,将数组首地址放到CX
00044 LEAQ    main..autotmp_5+48(SP), CX
00049 MOVQ    CX, main..autotmp_4+120(SP)

00056 MOVQ    $1, (CX)
00065 MOVQ    $2, 8(CX)
00075 MOVQ    $3, 16(CX)
00085 MOVQ    $4, 24(CX)
00100 MOVQ    $5, 32(CX)
00115 MOVQ    $6, 40(CX)
00123 MOVQ    main..autotmp_4+120(SP), CX
// 将数组首地址放到SP+128
00155 MOVQ    CX, main..autotmp_3+128(SP)

初始化循环遍历:

assembly 复制代码
// 循环变量i
00187 MOVQ    $0, main..autotmp_6+40(SP)
// 循环长度len
00196 MOVQ    $6, main..autotmp_7+32(SP)
00205 JMP     207
00207 MOVQ    main..autotmp_6+40(SP), CX
// 比较len和i
00212 CMPQ    main..autotmp_7+32(SP), CX
// 如果len > i,跳转到221
00217 JGT     221
// 否则跳转到270,结束循环
00219 JMP     270

循环体:

asm 复制代码
// 将i赋值给局部变量
00226 MOVQ    AX, main.i+24(SP)
00231 MOVQ    main..autotmp_6+40(SP), CX
00236 SHLQ    $3, CX
00240 ADDQ    main..autotmp_3+128(SP), CX
// 读取下标i的值s[i]
00248 MOVQ    (CX), BX
// 将s[i]的值赋值给局部变量v
00251 MOVQ    BX, main.v+16(SP)
// 调handle方法,参数在SP+16,SP+24的位置
00256 CALL    main.handle(SB)
00261 JMP     263
// i自增
00263 INCQ    main..autotmp_6+40(SP)
// 跳到207继续循环
00268 JMP     207

整体上就是常规的for循环。需要注意一个核心机制,for循环体中:

  • range 会对每个元素进行值拷贝 ,然后赋值给 v
  • 每次循环,v都会被重新赋值为s[i]的副本

对应到本文中的例子,for range中的每个v都是拷贝出来的单独变量,在SP+16中。而source底层数组在SP+48

这也能很好解释为啥下单将每个元素 * 2不生效:因为将SP+16位置的值X2,并不会影响原始数组里的值

go 复制代码
func main() {
    s := []int{1, 2, 3, 4, 5}

    for _, v := range s {
       v *= 2
    }

    fmt.Println(s)  // [1 2 3 4 5] 
}
相关推荐
hankeyyh7 小时前
golang 易错点-slice copy
后端·go
郭京京1 天前
go语言sync.Map和atomic包
go
懒得更新1 天前
Go语言微服务架构实战:从零构建云原生电商系统
后端·go
程序员爱钓鱼1 天前
Go语言实战案例:执行基本的增删改查
后端·google·go
程序员爱钓鱼1 天前
Go语言实战案例:连接MySQL数据库
后端·google·go
岁忧2 天前
(LeetCode 每日一题) 1780. 判断一个数字是否可以表示成三的幂的和 (数学、三进制数)
java·c++·算法·leetcode·职场和发展·go
太凉2 天前
Go语言设计模式之函数选项模式
go
程序员爱钓鱼2 天前
Go语言实战案例:静态资源服务(CSS、JS、图片)
后端·google·go
程序员爱钓鱼2 天前
Go语言实战案例:接入支付宝/微信模拟支付回调接口
后端·google·go