深入理解 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] 
}
相关推荐
lekami_兰13 小时前
MySQL 长事务:藏在业务里的性能 “隐形杀手”
数据库·mysql·go·长事务
却尘17 小时前
一篇小白也能看懂的 Go 字符串拼接 & Builder & cap 全家桶
后端·go
ん贤17 小时前
一次批量删除引发的死锁,最终我选择不加锁
数据库·安全·go·死锁
mtngt111 天前
AI DDD重构实践
go
Grassto3 天前
12 go.sum 是如何保证依赖安全的?校验机制源码解析
安全·golang·go·哈希算法·go module
Grassto4 天前
11 Go Module 缓存机制详解
开发语言·缓存·golang·go·go module
程序设计实验室5 天前
2025年的最后一天,分享我使用go语言开发的电子书转换工具网站
go
我的golang之路果然有问题5 天前
使用 Hugo + GitHub Pages + PaperMod 主题 + Obsidian 搭建开发博客
golang·go·github·博客·个人开发·个人博客·hugo
啊汉7 天前
古文观芷App搜索方案深度解析:打造极致性能的古文搜索引擎
go·软件随想
asaotomo8 天前
一款 AI 驱动的新一代安全运维代理 —— DeepSentry(深哨)
运维·人工智能·安全·ai·go