ᕕ( ᐛ )ᕗ Jimyag's Blog

Go slice 底层实现原理全解析

· 2598 words · ~ 13 min read

Go 的 slice 是日常开发中最常用的数据结构之一,但它的很多行为——为什么 append 有时会让两个变量"断开",为什么 copy 需要目标 slice 有足够长度,为什么 nil slice 和空 slice 在某些场合表现不同——如果不了解底层机制,遇到时容易踩坑。

本文从 runtime 源码出发,完整梳理 slice 的数据结构、创建、append、copy、子切片、扩容算法,并给出每个操作的执行流程图和内存布局示意。


数据结构

Go 运行时里 slice 对应 runtime/slice.go 中的 slice 结构体:

1
2
3
4
5
6
// runtime/slice.go
type slice struct {
    array unsafe.Pointer // 指向底层数组的指针
    len   int            // 当前元素个数
    cap   int            // 底层数组容量
}

编译器对外暴露的是 reflect.SliceHeader(与 slice 内存布局完全一致):

1
2
3
4
5
6
// reflect/value.go
type SliceHeader struct {
    Data uintptr
    Len  int
    Cap  int
}

一个 slice 变量在栈上占 24 字节(64 位平台:3 × 8 字节),存的是"描述符"而不是数据本身。

1
2
3
4
5
6
7
8
slice 变量(栈上,24 bytes)         底层数组(堆上)
┌──────────────────────┐            ┌───┬───┬───┬───┬───┬───┐
│ array  ──────────────┼──────────► │ 0 │ 1 │ 2 │ 3 │ 4 │ 5 │
├──────────────────────┤            └───┴───┴───┴───┴───┴───┘
│ len = 3              │              ▲               ▲
├──────────────────────┤            元素 0          超出 len 但
│ cap = 6              │                            在 cap 内
└──────────────────────┘

创建方式

字面量

1
s := []int{1, 2, 3}

编译器在静态区分配初始数组,运行时构造一个 slice 描述符指向它:

1
2
3
s.array → [1, 2, 3](静态/堆上,具体由逃逸分析决定)
s.len   = 3
s.cap   = 3

make

1
s := make([]int, 3, 6)
1
2
3
s.array → [0, 0, 0, _, _, _](堆上,cap=6 个元素的内存)
s.len   = 3
s.cap   = 6

执行路径:

  flowchart TD
    A["make([]int, 3, 6)"] --> B{编译器判断能否\n在栈上分配}
    B -->|小对象且不逃逸| C[栈上分配]
    B -->|大对象或逃逸| D["runtime.makeslice()"]
    D --> E["math.MulUintptr(et.size, cap)\n检查溢出"]
    E --> F["mallocgc(size, et, true)\n分配并清零内存"]
    F --> G[返回 unsafe.Pointer]
    G --> H["构造 slice{array, len, cap}"]
    C --> H

makeslice 的关键源码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// runtime/slice.go
func makeslice(et *_type, len, cap int) unsafe.Pointer {
    mem, overflow := math.MulUintptr(et.size, uintptr(cap))
    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)
}

从数组切片

1
2
arr := [6]int{0, 1, 2, 3, 4, 5}
s := arr[1:4]

编译器直接用 arr[1] 的地址构造 slice 描述符,不分配新内存:

1
2
3
4
5
6
arr:  [0][1][2][3][4][5]
           ▲       ▲
           │       │
s.array ───┘       │
s.len = 3          │
s.cap = 5  ────────┘(从 arr[1] 到 arr[5] 共 5 个位置)

子切片(re-slice)

1
2
s1 := []int{0, 1, 2, 3, 4, 5}  // len=6, cap=6
s2 := s1[2:5]                   // len=3, cap=4

re-slice 不分配新内存,两个描述符指向同一底层数组:

1
2
3
4
5
6
7
8
9
底层数组:  [0][1][2][3][4][5]
            ▲        ▲
s1.array ───┘        │
s1.len = 6           │
s1.cap = 6           │
                     │(s2.array 偏移 2 个元素)
s2.array ────────────┘  即 &底层数组[2]
s2.len = 3
s2.cap = 4  (从 [2] 到 [5] 共 4 个位置)

关键推论:修改 s2[0] 等于修改 s1[2],因为它们指向同一内存地址。

三索引切片(full slice expression)

1
s3 := s1[2:5:5]  // low:high:max,cap = max - low = 3

通过限制 cap,可以防止子切片意外修改父 slice 后面的数据。


append 操作

append 是 Go slice 中最复杂的操作,分两条路径:

  flowchart TD
    A["append(s, elems...)"] --> B{"len + len(elems) <= cap?"}
    B -->|是,容量足够| C["将元素写入底层数组\ns[len:len+n]"]
    C --> D[更新 len,返回新描述符]
    B -->|否,需要扩容| E["growslice()"]
    E --> F[计算新容量 newcap]
    F --> G["mallocgc() 分配新底层数组"]
    G --> H[memmove 拷贝旧数据]
    H --> I[写入新元素]
    I --> J["更新 len, cap, array\n返回新描述符"]

容量足够时 append:

1
2
s := make([]int, 3, 6)  // len=3, cap=6
s = append(s, 10)
1
2
before: array→[0,0,0,_,_,_]  len=3  cap=6
after:  array→[0,0,0,10,_,_] len=4  cap=6(array 不变,共享同一块内存)

容量不足时 append:

1
2
s := []int{1, 2, 3}  // len=3, cap=3
s = append(s, 4)
1
2
3
4
before: s.array → [1, 2, 3]         len=3  cap=3(旧数组)
               growslice 分配新数组
after:  s.array → [1, 2, 3, 4, _, _] len=4  cap=6(新数组)
                   旧数组还在堆上,直到 GC 回收

扩容算法(growslice)

Go 1.18 对扩容算法做了调整,以下是 Go 1.18+ 的逻辑:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// runtime/slice.go(简化)
func growslice(et *_type, old slice, cap int) slice {
    newcap := old.cap
    doublecap := newcap + newcap

    if cap > doublecap {
        newcap = cap
    } else {
        const threshold = 256
        if old.cap < threshold {
            newcap = doublecap  // 小 slice 直接翻倍
        } else {
            for 0 < newcap && newcap < cap {
                // 从翻倍逐渐过渡到 1.25x,公式:每次增长 (newcap + 3*threshold) / 4
                newcap += (newcap + 3*threshold) / 4
            }
        }
    }
    // 内存对齐后实际分配的 cap 可能更大
    ...
}

扩容策略流程图:

  flowchart TD
    A["需要容量 cap,当前 old.cap"] --> B{"cap > 2 × old.cap?"}
    B -->|是| C[newcap = cap]
    B -->|否| D{"old.cap < 256?"}
    D -->|是| E["newcap = 2 × old.cap\n(直接翻倍)"]
    D -->|否| F["循环:newcap += (newcap + 768) / 4\n直到 newcap >= cap\n(从 2x 平滑收敛到 1.25x)"]
    C --> G[内存对齐,按 size class 向上取整]
    E --> G
    F --> G
    G --> H["mallocgc() 分配新数组"]
    H --> I[memmove 拷贝旧数据]

Go 1.18 之前临界值是 1024,之后改为 256,目的是让中等大小的 slice 扩容更平滑。


copy 操作

1
n := copy(dst, src)

copy 底层调用 runtime.memmove,按元素类型的大小做内存拷贝:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// runtime/slice.go
func slicecopy(toPtr unsafe.Pointer, toLen int,
               fromPtr unsafe.Pointer, fromLen int, width uintptr) int {
    if fromLen == 0 || toLen == 0 {
        return 0
    }
    n := fromLen
    if toLen < n {
        n = toLen  // 以较小的 len 为准
    }
    // width == 0 说明是零大小元素,无需拷贝数据
    if width == 0 {
        return n
    }
    memmove(toPtr, fromPtr, uintptr(n)*width)
    return n
}

执行路径:

  flowchart TD
    A["copy(dst, src)"] --> B["n = min(len(dst), len(src))"]
    B --> C{n == 0?}
    C -->|是| D[返回 0]
    C -->|否| E{elementSize == 0?}
    E -->|是,零大小类型| F[返回 n,无需拷贝]
    E -->|否| G["memmove(dst.array, src.array, n × elementSize)"]
    G --> H[返回 n]

copy 的内存行为示意:

1
2
3
4
5
6
7
8
src: [1][2][3][4][5]   len=5
dst: [0][0][0]         len=3  ← 只有 3 个位置

copy(dst, src) 后:

dst: [1][2][3]         ← 只复制了 3 个元素(dst 的 len 决定上限)
src: [1][2][3][4][5]   ← 不变
返回值 = 3

nil slice 与 empty slice

1
2
3
var s1 []int        // nil slice
s2 := []int{}       // empty slice
s3 := make([]int,0) // empty slice

内存布局对比:

1
2
3
4
5
6
7
8
9
s1 (nil slice):
┌──────────┬─────┬─────┐
│ array=nil│len=0│cap=0│
└──────────┴─────┴─────┘

s2 / s3 (empty slice):
┌──────────────┬─────┬─────┐
│ array=zeroptr│len=0│cap=0│  ← zeroptr 是全局非 nil 地址
└──────────────┴─────┴─────┘

runtime.zerobase 是一个全局的零大小变量,所有空 slice 和空 map 的 array 指针都指向它,避免了 0 字节分配对 GC 造成压力。

关键差异:

操作 nil slice empty slice
len(s) 0 0
cap(s) 0 0
s == nil true false
append(s, 1) 可以 可以
json.Marshal(s) "null" "[]"

json.Marshal 是最常见的陷阱:返回 null 还是 [] 取决于 slice 是否为 nil。


完整操作时序图

  sequenceDiagram
    participant User as 用户代码
    participant Compiler as 编译器
    participant Runtime as 运行时 (runtime)
    participant GC as GC / 内存分配器

    User->>Compiler: make([]int, 3, 6)
    Compiler->>Runtime: makeslice(*_type, 3, 6)
    Runtime->>Runtime: 检查 len/cap 合法性
    Runtime->>GC: mallocgc(6×8, ..., true)
    GC-->>Runtime: unsafe.Pointer(清零内存)
    Runtime-->>User: slice{ptr, 3, 6}

    User->>Compiler: append(s, 10, 20, 21, 22)
    Compiler->>Runtime: 计算 need = 3+4=7 > cap=6
    Runtime->>Runtime: growslice(*_type, old, 7)
    Runtime->>Runtime: newcap = 12(6×2,因 6<256)
    Runtime->>GC: mallocgc(12×8, ...)
    GC-->>Runtime: newptr
    Runtime->>Runtime: memmove(newptr, oldptr, 3×8)
    Runtime-->>User: slice{newptr, 7, 12}

    User->>Compiler: copy(dst, src)
    Compiler->>Runtime: slicecopy(dst.array, dst.len, src.array, src.len, 8)
    Runtime->>Runtime: n = min(len(dst), len(src))
    Runtime->>Runtime: memmove(dst.array, src.array, n×8)
    Runtime-->>User: n

常见陷阱

append 后两个变量"断开"

1
2
3
4
5
6
7
s1 := make([]int, 3, 10)
s2 := s1[:3]       // s2 与 s1 共享底层数组,cap=10
s2 = append(s2, 99)

// s1[3] == 99,因为 cap 够,append 没有触发扩容
// 但 s1.len 还是 3,所以 s1[3] 读不到(需要 s1[:4])
// 当 s2 继续 append 超过 cap=10 后,两者才真正断开

子切片导致大数组无法被 GC 回收

1
2
3
4
5
6
bigData := make([]byte, 1<<20)  // 1 MB
small := bigData[:10]           // len=10,但 cap=1<<20
// bigData 虽然不再使用,但 small 持有同一底层数组,1 MB 无法被 GC 回收

// 修复:用 copy 或 append 显式断开共享
small = append([]byte(nil), bigData[:10]...)

并发写 slice 不安全

多个 goroutine 同时 append 到同一 slice 没有任何内置同步,必须用 sync.Mutex 或 channel 保护。slice 描述符本身的赋值(写 len/cap/array 三个字段)也不是原子操作。

make 的 len 陷阱:append 不从头开始

1
2
3
4
s := make([]int, 5)     // len=5, cap=5,五个元素已经是 0
s = append(s, 1)        // 追加到第 6 个位置,而不是第 1 个

fmt.Println(s)  // [0 0 0 0 0 1],而不是 [1]

如果只想预分配容量,应该用 make([]int, 0, 5)。用 make([]int, 5) 再 append 是最常见的初学者陷阱之一。

函数传参:外部 slice 不会被内部 append 改变

slice 描述符是值传递,函数内对 len/cap/array 的修改不会反射到调用方:

1
2
3
4
5
6
7
func addElem(s []int) {
    s = append(s, 99)   // 只修改了函数栈上的局部描述符
}

s := []int{1, 2, 3}
addElem(s)
fmt.Println(s)  // [1 2 3],99 没有追加进来

如果需要在函数内修改 slice 本身(长度、容量、底层数组),需要传指针或返回新 slice:

1
2
3
func addElem(s *[]int) {
    *s = append(*s, 99)
}

注意:函数内直接修改元素(s[0] = 99)是有效的,因为共享底层数组;改变 len/cap 才需要指针或返回值。

append 两个 slice 时意外覆盖

s1 有剩余容量时,append(s1, s2...) 会直接写入 s1 底层数组,同时影响所有共享该数组的 slice:

1
2
3
4
5
6
base := make([]int, 0, 5)
s1 := append(base, 1, 2, 3)   // len=3, cap=5,共享 base 底层数组
s2 := append(base, 4, 5, 6)   // 也从 base 开始写,覆盖了 s1 的数据!

fmt.Println(s1)  // [4 5 6],不是 [1 2 3]
fmt.Println(s2)  // [4 5 6]

根源是 base 的 cap 足够,两次 append 都在同一块内存上写。修复方式是每次 append 前先断开共享:

1
2
s1 := append(base[:len(base):len(base)], 1, 2, 3)  // 用三索引限制 cap,强制扩容
s2 := append(base[:len(base):len(base)], 4, 5, 6)

range 遍历拿到的是值拷贝

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
type Point struct{ X, Y int }
points := []Point{{1, 2}, {3, 4}}

for _, p := range points {
    p.X = 100   // 修改的是副本,不影响原 slice
}
fmt.Println(points)  // [{1 2} {3 4}]

// 如果需要修改,用下标
for i := range points {
    points[i].X = 100
}

对元素类型较大的 slice,range 还会带来额外的拷贝开销,可以改用下标遍历或存指针的 slice。

删除元素的正确姿势

Go 没有内置的删除方法,常见有两种写法:

删除下标 i 的元素,保持顺序:

1
s = append(s[:i], s[i+1:]...)

删除下标 i 的元素,不保持顺序(性能更好):

1
2
s[i] = s[len(s)-1]
s = s[:len(s)-1]

第一种写法会把 i 之后的元素全部左移,时间复杂度 O(n)。第二种用最后一个元素填坑,O(1)。

对于存指针或含指针字段的元素,删除后还需要清零尾部元素防止内存泄漏:

1
2
3
4
// 保序删除,清零释放引用
copy(s[i:], s[i+1:])
s[len(s)-1] = nil  // 或对应类型的零值
s = s[:len(s)-1]

循环中 goroutine 捕获 slice 元素地址

1
2
3
4
5
6
s := []int{1, 2, 3}
for _, v := range s {
    go func() {
        fmt.Println(v)  // 捕获的是循环变量 v 的地址,最终可能打印 3 3 3
    }()
}

Go 1.22 之前循环变量只有一个,所有 goroutine 共享同一个 v。修复方式:

1
2
3
4
5
6
for _, v := range s {
    v := v  // 重新声明,创建新变量
    go func() {
        fmt.Println(v)
    }()
}

Go 1.22 起每次迭代的循环变量是独立的,此问题不再出现。

对 nil slice 调用 append 是安全的,但赋值 index 不是

1
2
3
var s []int
s = append(s, 1)  // 安全,append 会处理 nil 情况
s[0] = 1          // panic:index out of range,nil slice 的 len=0

nil slice 可以 append,但不能直接通过下标赋值——这两者的行为经常被混淆。

含指针元素的 slice 增加 GC 扫描压力

GC 在标记阶段需要扫描所有可达指针。[]int 不含指针,GC 直接跳过底层数组;[]*MyStruct[]interface{} 含指针,GC 必须逐元素扫描。

1
2
3
4
5
// GC 不扫描元素内部
data := make([]int, 1_000_000)

// GC 必须扫描所有 100 万个指针
ptrs := make([]*MyStruct, 1_000_000)

如果 slice 元素数量巨大且生命周期长(如全局缓存),优先用值类型而不是指针类型,或者考虑 []uint64 + 手动序列化来彻底绕开 GC 扫描。

截断 slice 后尾部指针不会被 GC 回收

1
2
3
4
5
6
7
s := make([]*int, 10)
for i := range s {
    v := i
    s[i] = &v
}

s = s[:5]  // 只截断了 len,底层数组后 5 个指针仍然存在,不会被 GC 回收

正确做法是清零截断部分:

1
2
3
4
5
tail := s[5:]
for i := range tail {
    tail[i] = nil
}
s = s[:5]

Go 1.21 引入了内置 clear,可以一行完成:

1
2
clear(s[5:])   // 将 s[5:] 中所有元素设为零值
s = s[:5]

奇淫技巧

零拷贝 string 与 []byte 互转

标准转换 []byte(str)string(b) 都会分配新内存并拷贝。对性能敏感的路径可以用 unsafe 跳过拷贝:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// Go 1.20+ 推荐写法
import "unsafe"

func stringToBytes(s string) []byte {
    return unsafe.Slice(unsafe.StringData(s), len(s))
}

func bytesToString(b []byte) string {
    return unsafe.String(unsafe.SliceData(b), len(b))
}

使用约束:转换后的 []byte 绝对不能写入,因为 string 底层内存是只读的,写入会触发 panic 或数据竞争。只读操作(序列化、哈希、比较)是安全的。

Go 1.20 之前的写法依赖 reflect.SliceHeader,但该类型在 Go 1.17 起已标记为不推荐,优先用 unsafe.Slice / unsafe.SliceData

原地 filter,零分配

通常 filter 写法会分配新 slice:

1
2
3
4
5
6
7
// 有分配
result := make([]int, 0, len(s))
for _, v := range s {
    if v%2 == 0 {
        result = append(result, v)
    }
}

原地复用底层数组,零分配:

1
2
3
4
5
6
7
n := s[:0]  // len=0,cap 和 array 与 s 相同
for _, v := range s {
    if v%2 == 0 {
        n = append(n, v)
    }
}
s = n

注意:原地 filter 会修改原底层数组,如果外部还有其他 slice 共享它,需要先 copy 一份。

用 append 实现 insert

Go 没有内置 insert,用 append + copy 组合:

1
2
3
4
5
6
7
// 在下标 i 处插入 v
func insert(s []int, i int, v int) []int {
    s = append(s, 0)       // 先扩一位(触发扩容或不触发)
    copy(s[i+1:], s[i:])   // i 之后的元素右移
    s[i] = v
    return s
}

原地反转

双指针从两端向中间逼近,每次交换一对元素,直到两指针相遇。

  flowchart TD
    A["reverse(s)"] --> B["i = 0\nj = len(s) - 1"]
    B --> C{i < j ?}
    C -->|否,相遇或交叉| D[完成,返回]
    C -->|是| E["swap s[i] ↔ s[j]"]
    E --> F["i++\nj--"]
    F --> C

[1, 2, 3, 4, 5] 为例,每一轮的内存状态:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
初始:   [1] [2] [3] [4] [5]
         i↑               j↑

第 1 轮: swap(s[0], s[4])
        [5] [2] [3] [4] [1]
             i↑       j↑

第 2 轮: swap(s[1], s[3])
        [5] [4] [3] [2] [1]
                 i↑ j↑      ← i < j 仍成立,但下一轮 i==j,停止

结果:   [5] [4] [3] [2] [1]

偶数长度(如 [1,2,3,4])时,两指针在中间错过(i=2, j=1,i > j)自然终止,不会多做一次交换。

完整实现(泛型,适用任意可比类型):

1
2
3
4
5
func reverse[T any](s []T) {
    for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 {
        s[i], s[j] = s[j], s[i]
    }
}

时间复杂度 O(n/2) = O(n),空间复杂度 O(1),原地操作不分配内存。


用 append 实现双端队列

标准库没有双端队列,用 slice 可以快速实现一个,适合数据量不大、不追求极致性能的场景。

数据结构

1
type Deque[T any] []T

底层就是一个普通 slice,不需要额外字段。

四个操作的实现与内存行为

PushBack(尾插)

1
2
3
func (d *Deque[T]) PushBack(v T) {
    *d = append(*d, v)
}
  flowchart TD
    A["PushBack(v)"] --> B{"len < cap ?"}
    B -->|是| C["array[len] = v\nlen++"]
    B -->|否| D["growslice:分配新数组\nmemmove 拷贝旧数据"]
    D --> E["array[len] = v\nlen++,更新 array/cap"]
    C --> F[返回]
    E --> F
1
2
3
4
before: [A][B][C][_][_]   len=3 cap=5
PushBack(D):
after:  [A][B][C][D][_]   len=4 cap=5(未扩容,O(1) 均摊)

PushFront(头插)

1
2
3
func (d *Deque[T]) PushFront(v T) {
    *d = append([]T{v}, *d...)
}
  flowchart TD
    A["PushFront(v)"] --> B["分配临时 []T{v}\n(长度 1 的新数组)"]
    B --> C["append([]T{v}, *d...)\n必然触发扩容(cap 只有 1)"]
    C --> D["growslice:分配 1+len(d) 大小的新数组"]
    D --> E["array[0] = v"]
    E --> F["memmove 将旧数据复制到 array[1:]"]
    F --> G["更新描述符,len = old_len + 1"]
    G --> H[返回]
1
2
3
4
before: [A][B][C]   len=3
PushFront(Z):
        新分配:  [Z][_][_][_][_][_]  (容量由 growslice 决定)
        复制后:  [Z][A][B][C][_][_]  len=4

PushFront 每次必然分配新数组,时间复杂度 O(n)。频繁头插的场景,应该改用环形缓冲区或链表。

PopBack(尾删)

1
2
3
4
5
6
func (d *Deque[T]) PopBack() T {
    s := *d
    v := s[len(s)-1]
    *d = s[:len(s)-1]
    return v
}
  flowchart TD
    A["PopBack()"] --> B["v = array[len-1]"]
    B --> C["len--(缩短描述符,不释放内存)"]
    C --> D["返回 v"]
1
2
3
before: [A][B][C][D]   len=4 cap=6
PopBack() → 返回 D
after:  [A][B][C][ ]   len=3 cap=6(D 仍在内存中,等待被覆盖或 GC)

若元素是指针类型,尾部的指针不会被 GC 回收,需要手动清零:

1
2
3
4
5
6
7
8
func (d *Deque[T]) PopBack() T {
    s := *d
    v := s[len(s)-1]
    var zero T
    s[len(s)-1] = zero  // 清零,释放 GC 引用
    *d = s[:len(s)-1]
    return v
}

PopFront(头删)

1
2
3
4
5
6
func (d *Deque[T]) PopFront() T {
    s := *d
    v := s[0]
    *d = s[1:]
    return v
}
  flowchart TD
    A["PopFront()"] --> B["v = array[0]"]
    B --> C["array 指针前移一个元素\nlen--,cap--"]
    C --> D["返回 v"]
1
2
3
4
5
6
before: [A][B][C][D]   array→0x10, len=4, cap=6
PopFront() → 返回 A
after:  [_][B][C][D]   array→0x18, len=3, cap=5
             (A 所在的内存不再可达,但整个底层数组仍被引用)

PopFront 的隐患:每次头删只是把 array 指针向后移,底层数组头部的内存永远不会被 GC 回收,持续 PopFront 会造成内存"单向泄漏"。对于只做尾插头删(FIFO 队列)场景,泄漏会随操作次数线性增长。

解决办法一:PopFront 后定期把剩余数据 copy 到新 slice:

1
2
3
4
5
func (d *Deque[T]) Compact() {
    fresh := make([]T, len(*d))
    copy(fresh, *d)
    *d = fresh
}

解决办法二:改用环形缓冲区实现,头尾都是 O(1) 且无泄漏,参考 golang.org/x/exp/slices 或自行实现。

各操作复杂度对比

操作 时间复杂度 是否分配内存
PushBack O(1) 均摊 仅扩容时
PushFront O(n) 每次
PopBack O(1)
PopFront O(1) 否(但有内存泄漏风险)

slice 实现的双端队列适合"大量尾插、少量头删"或"临时用用"的场景。若头尾操作频率相近,推荐换用基于环形缓冲区的实现。

三索引切片做防御性传参

向函数传 slice 时,如果不希望被调用方通过 append 意外写入父 slice 的后续空间:

1
2
3
4
5
// 不安全:callee 可以 append 进 buf 的剩余空间
process(buf[:n])

// 安全:限制 cap,append 会强制扩容而不影响 buf
process(buf[:n:n])

这在实现类似 io.Reader 的缓冲分发逻辑时非常有用。

利用 copy 返回值快速求重叠长度

1
2
// min(len(a), len(b)) 的语义化写法
n := copy(a, b)  // 返回实际复制的元素数,即 min(len(a), len(b))

有时比显式 if 判断更简洁,且语义清晰。

预分配是最有效的性能手段

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 不知道最终大小,一次次扩容
var s []int
for i := 0; i < 10000; i++ {
    s = append(s, i)  // 触发约 14 次 growslice + memmove
}

// 已知大小,一次分配
s := make([]int, 0, 10000)
for i := 0; i < 10000; i++ {
    s = append(s, i)  // 零次扩容
}

不知道精确大小时,给一个合理的上界估计也比完全不预分配好。append 的均摊复杂度虽然是 O(1),但每次扩容都有 memmove 开销,数据量大时差距明显。


小结

操作 分配新内存 共享底层数组 时间复杂度
字面量创建 O(n)
make O(n)(清零)
re-slice a[i:j] O(1)
append(不扩容) O(k)
append(扩容) O(n)
copy 否(目标已有) O(min(n,m))

理解 slice 的核心是区分"描述符"和"底层数组":描述符是值拷贝,底层数组是引用语义。append 触发扩容时会切断共享,不触发时不会。这条规律能解释绝大多数 slice 相关的困惑行为。


参考:

#Go #Slice #底层原理 #内存管理