Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第4章 append()

本章深入剖析 Go 中 append 内置函数的语义、Runtime 扩容实现 growslice 的算法、Slice 的所有权与共享坑,以及 GC 对旧底层数组的处理,帮助你在工程中写出高性能且不踩坑的 Slice 代码。

4.1 append 到底做了什么

是什么

append 是 Go 的内置函数,用于向 Slice 追加元素,其函数签名如下:

// The append built-in function appends elements to the end of a slice.
// If it has sufficient capacity, the destination is resliced to accommodate the
// new elements. If it does not, a new underlying array will be allocated.
func append(slice []Type, elems ...Type) []Type

它接受一个 Slice(底层数组首元素指针、长度 len 与容量 cap)和若干待追加元素,返回一个新的 Slice。其语义可以用下面这段简化伪代码描述:

func append(slice []Type, elems ...Type) []Type {
    newLen := len(slice) + len(elems)
    if newLen <= cap(slice) {
        // 容量够:原地写
        copy(slice[len(slice):cap(slice)], elems)
        return slice[:newLen]
    }
    // 容量不够:分配新数组 + 拷贝旧元素 + 写入新元素
    newSlice := growslice(...)
    copy(newSlice[len(slice):], elems)
    return newSlice
}

为什么这样设计 / 底层实现要点

要理解 append 的行为,必须回到 Slice 的运行时表示。Go Runtime 中 Slice 的真实结构定义在 runtime/slice.go

type slice struct {
    array unsafe.Pointer // 指向底层数组首元素
    len   int            // 当前长度
    cap   int            // 当前容量
}

逐字段解释:

  • array:是一个 unsafe.Pointer,指向底层数组第一个元素的地址。Slice 的所有读写操作最终都通过它定位内存。
  • len:表示当前 Slice “可见“的元素个数,len() 内置函数直接读这个字段。
  • cap:表示底层数组从 array 开始能容纳的元素总数。cap() 内置函数直接读这个字段。

Slice 头本身是一个 值类型(结构体),赋值和函数传参都会复制这三字段;但它内部的 array 指针让多个 Slice 可以共享同一个底层数组。这种“值类型头 + 指针共享底层数组“的设计,是 append 一切坑的根源。

编译器对 append 有特殊处理:它不是普通的函数调用,而是会被编译成内联的若干条机器指令 + 必要时调用 runtime.growslice。当 len + n <= cap 时,根本不会进入 growslice,直接在原数组上写入并返回一个新的 Slice 头(array 指针相同、len 增大、cap 不变)。

工程实践与常见坑

最经典的坑:底层数组共享

package main

import "fmt"

func main() {
    a := make([]int, 1, 2) // len=1, cap=2
    b := append(a, 10)     // 容量够,b.array == a.array
    b[0] = 99              // a[0] 也变成 99!
    fmt.Println(a[0], b[0])  // 99 99
    fmt.Println(len(a), len(b)) // 1 2
}

因为 append 在容量足够时不会重新分配底层数组,ab 共享同一块内存,b[0] = 99 同时修改了 a[0]。这就是为什么在 第3章 Slice 中强调:任何时候通过 appendreslice 产生的新 Slice 都可能与原 Slice 共享底层数组,除非发生了扩容

另一个常见坑:忘记接收返回值

package main

import "fmt"

func main() {
    s := make([]int, 0, 1)
    s = append(s, 1) // 正确:必须接收返回值
    // 下面这行代码虽然能编译通过,但 `go vet` 会报警告:
    // "result of append is not used"
    // 因为 append 返回的新 Slice 头被丢弃,s 没有变化
    // append(s, 2)
    fmt.Println(s)
}

要点:把 Slice 想象成“数组的一段视图“。视图之间可以重叠,写操作会互相可见。append 返回的 Slice 头可能指向新数组,所以必须接收。

4.2 growslice()

是什么

runtime.growslice 是 Runtime 中真正负责 Slice 扩容的函数。当 append 发现容量不足时,会调用它。Go 1.21+ 中的函数签名如下:

// runtime/slice.go
func growslice(oldPtr unsafe.Pointer, newLen, oldCap, num int, et *_type) slice

参数含义:

  • oldPtr:旧底层数组首元素指针,用于拷贝旧元素。
  • newLen:扩容后新 Slice 的长度(oldLen + num)。
  • oldCap:扩容前的容量。
  • num:本次要追加的元素个数。
  • et:元素类型信息(runtime._type),含大小、对齐等。

返回值是一个新的 slice 结构体(即上一节的三字段结构),其 array 指向新分配的内存。

为什么这样设计 / 底层实现要点

growslice 的核心流程(简化伪代码):

func growslice(oldPtr unsafe.Pointer, newLen, oldCap, num int, et *_type) slice {
    oldLen := newLen - num

    // 1. 元素大小为 0 的特殊处理
    if et.size == 0 {
        return slice{unsafe.Pointer(&zerobase), newLen, max(newLen, oldCap)}
    }

    // 2. 计算新容量 newcap
    newcap := oldCap
    doublecap := newcap + newcap
    if newLen > doublecap {
        newcap = newLen
    } else {
        const threshold = 256
        if oldCap < threshold {
            newcap = doublecap
        } else {
            for 0 < newcap && newcap < newLen {
                newcap += (newcap + 3*threshold) / 4
            }
            if newcap <= 0 {
                newcap = newLen
            }
        }
    }

    // 3. 根据元素大小对齐到内存分配器的尺寸类(size class)
    var capmem uintptr
    switch {
    case et.size == 1:
        capmem = roundupsize(uintptr(newcap))
        newcap = int(capmem)
    case et.size == 2:
        capmem = roundupsize(uintptr(newcap) * 2)
        newcap = int(capmem / 2)
    case et.size == 4:
        capmem = roundupsize(uintptr(newcap) * 4)
        newcap = int(capmem / 4)
    // ... 其他 size 分支
    }

    // 4. 分配新内存(true 表示清零)
    p := mallocgc(capmem, et, true)

    // 5. 把旧元素拷贝到新内存
    memmove(p, oldPtr, uintptr(oldLen)*et.size)

    return slice{p, newLen, newcap}
}

几个关键设计点:

  1. et.size == 0 的特判:空结构体 struct{} Slice 不占用实际数据内存,所有元素“指向“同一个全局变量 runtime.zerobase。扩容只调整 len/cap,零分配。但 Slice 头本身仍存在。

  2. 三段式容量计算newLen > 2*oldCap 时直接采用 newLenoldCap < 256 时翻倍;超过 256 后走平滑过渡。这部分在 4.3 节展开。

  3. roundupsize 对齐:Go 的内存分配器(runtime.mallocgc)按 size class 分配(参考 runtime/sizeclasses.go),如果直接按 newcap * et.size 申请会浪费内存或返回过大的块。roundupsize 把请求字节数向上取整到最近的 size class,从而得到实际分配的字节数,反推回 newcap。这就是为什么 cap 经常不等于你预期的 2*oldCap

  4. memmove 拷贝:用 memmove 而非 for 循环逐元素拷贝,因为 memmove 是经过高度优化的 SIMD 实现,对小块也能批量搬运。注意 memmove 允许源和目标重叠,但这里源和目标是两块独立内存,所以不会有重叠问题。

  5. 零值初始化mallocgc(..., true) 的第三个参数表示返回的内存需要清零。这样追加位置之后的元素都是零值,不会泄露之前被释放对象的内容(安全考虑)。

工程实践与常见坑

  • 不要假设 cap 一定翻倍:很多人记得“Go Slice 扩容是 2 倍“,但实际上由于 roundupsize 对齐,cap 的实际值经常不是精确的 2*oldCap。例如 make([]int, 0, 1) 后 append 一个 int,得到的 cap 是 2;但 make([]int, 0, 1000) append 后的值可能是 1280 或其它。详见 4.6 节。
  • 大 Slice 扩容代价高memmove 是 O(n) 操作。如果 Slice 已经有几百万个元素,每次扩容都会触发一次百万级的拷贝。生产环境请务必预分配容量。
  • 零大小元素也有开销[]struct{} 虽然不分配数据内存,但 Slice 头本身仍要分配,且 Runtime 仍要维护 len/cap

4.3 扩容算法

是什么

Go Slice 的扩容算法决定 append 时新 cap 的取值。算法在 Go 1.18 做过一次重要调整,从“硬阈值 1024“改为“基于 256 的平滑过渡“,Go 1.21 沿用新算法。

为什么这样设计 / 底层实现要点

旧算法(Go 1.17 及之前):

if newLen > doublecap {
    newcap = newLen
} else {
    if oldCap < 1024 {
        newcap = doublecap
    } else {
        newcap = oldCap + oldCap/4  // 1.25 倍
    }
}

新算法(Go 1.18+)核心逻辑:

newcap := oldCap
doublecap := newcap + newcap
if newLen > doublecap {
    newcap = newLen
} else {
    const threshold = 256
    if oldCap < threshold {
        newcap = doublecap            // 小 Slice 翻倍
    } else {
        for 0 < newcap && newcap < newLen {
            newcap += (newcap + 3*threshold) / 4  // 平滑过渡
        }
        if newcap <= 0 {
            newcap = newLen
        }
    }
}

为什么把阈值从 1024 改为 256?为什么用循环?

  1. 内存利用率:旧算法在 oldCap >= 1024 之后一刀切为 1.25 倍,导致在 512~2048 区间内扩容行为不够平滑——小 Slice 浪费内存(翻倍后用不到),中 Slice 又扩得太少。新算法通过 newcap += (newcap + 3*threshold)/4 = newcap*1.25 + 192 的循环,让增长系数从 2.0 平滑过渡到 1.25。

  2. 避免一次性跨度过大:当 newLen 远大于 oldCap 时(例如一次性 append(s, bigSlice...)),算法可能需要多次循环才能到达。循环内每次加上 (newcap + 3*threshold)/4,相当于在保留平滑增长的同时让算法在 O(log(newLen-oldCap)) 步内收敛。

  3. threshold = 256 的选择:与 Go 内存分配器的 size class 边界对齐较好,256 字节正好是某些分配路径的一个分界点,便于缓存与对齐。

新算法等价于:当 oldCap < 256newcap = max(newLen, oldCap*2);之后每次按 newcap = newcap*1.25 + 192 增长直到不小于 newLen

完成 newcap 计算后,还要经过 roundupsize 把总字节数对齐到 size class。比如 et.size == 8newcap == 30 时,总字节数 240 对齐到 256(sizeclass 表中 256 是一个 class),实际 newcap 就是 32。

下表对比新旧算法在几个典型 oldCap 下的表现(newLen = oldCap + 1):

oldCap旧算法 newcap(理论)新算法 newcap(理论)说明
64128(2x)128(2x)一致
256512(2x)512(边界仍 2x)一致
5121024(2x)832(1.625x)新算法更省
10241280(1.25x)1472(1.4375x)新算法略多
40965120(1.25x)5312(1.296x)接近

注意:上表是 roundupsize 之前的“理论值“,实际 cap 还会被 size class 对齐再放大一些。

工程实践与常见坑

  • 不要依赖精确的 cap:算法会随 Go 版本变化,代码里写死 cap 判断是反模式。
  • 大 Slice 的扩容倍数更接近 1.25:对几百万级别的 Slice,每次扩容只多 25% 左右,意味着频繁扩容。务必预分配。
  • 批量 append 比 append 多次更快s = append(s, arr...) 只触发一次扩容判断,而循环 for _, x := range arr { s = append(s, x) } 可能触发多次。但现代编译器会做 append 链优化,差距不如想象中大;可读性更重要。

4.4 为什么 append 返回新的 Slice

是什么

append 的签名要求调用者接收返回值:s = append(s, x)。如果你只是 append(s, x) 而不接收,go vet 会警告 result of append is not used。这是因为 append 不会修改原 Slice 头变量,而是返回一个新的 Slice 头。

为什么这样设计 / 底层实现要点

根本原因:Slice 头是值类型。Slice 在 Go 中没有引用语义,参数传递、变量赋值都是复制三字段结构体。append 接收的 slice 参数是一个副本,对副本的 len/cap/array 修改不会反映到调用方的变量上。

考虑两种情形:

  1. 容量足够append 在原底层数组上写入新元素,返回的新 Slice 头的 array 与原 Slice 相同,但 len 增加了。如果你不接收返回值,原 Slice 变量的 len 不变,新写的数据对你“不可见“——但实际它已经写到了底层数组里,可能导致后续诡异 bug。

  2. 容量不足append 分配了新底层数组,返回的新 Slice 头的 array 是新地址。如果你不接收返回值,原 Slice 变量仍然指向旧底层数组,追加的数据完全丢失。

为什么不把 Slice 设计成引用类型(像 C++ 的 std::vector&)?这是 Go 的核心设计哲学:显式优于隐式。Go 选择让所有东西默认是值语义,引用通过指针显式表达。这样:

  • 函数签名 func f(s []int) 一眼看出“我可能修改底层数组,但不会修改你的 Slice 头“。
  • 调用者写 s = append(s, x) 一眼看出“我的 Slice 头会变“。

工程实践与常见坑

  • 永远写 s = append(s, x):哪怕是单行也必须赋值回去。
package main

import "fmt"

func push(s []int, x int) []int {
    return append(s, x) // 必须返回
}

func main() {
    s := []int{1, 2, 3}
    s = push(s, 4)
    fmt.Println(s) // [1 2 3 4]
}
  • append 后的别名问题a := s; s = append(s, x) 后,如果发生了扩容,a 仍然指向旧底层数组,as 不再共享。但如果没扩容,它们仍共享。这种“有时共享有时不共享“是最容易出 bug 的地方,解决方案见 4.9 节。
package main

import "fmt"

func main() {
    s := make([]int, 3, 5)
    a := s                    // a 与 s 共享底层数组
    s = append(s, 1)          // 容量够,不扩容,a 和 s 仍共享
    a[0] = 99                 // s[0] 也变成 99
    fmt.Println(s[0], a[0])   // 99 99

    for i := 0; i < 10; i++ {
        s = append(s, i)      // 触发扩容,s 指向新数组
    }
    a[1] = 88                 // 只影响 a,不影响 s
    fmt.Println(s[1], a[1])   // 原 s[1] 值 88
}

4.5 copy()

是什么

copy 是另一个 Slice 相关的内置函数:

// The copy built-in function copies elements from a source slice into a
// destination slice and returns the number of elements copied.
func copy(dst, src []Type) int

它把 src 的元素复制到 dst,复制数量是 min(len(dst), len(src)),并返回复制了多少个元素。copy 处理 dstsrc 重叠的情况(底层用 memmove)。

为什么这样设计 / 底层实现要点

copy 的 Runtime 实现是 runtime.typedslicecopy(带类型信息)或 runtime.slicecopy(小型化版本)。简化伪代码:

func slicecopy(to, from unsafe.Pointer, n uintptr, wid uintptr) int {
    if n == 0 || wid == 0 {
        return int(n)
    }
    // memmove 内部会判断方向,正确处理源/目标重叠
    memmove(to, from, n*wid)
    return int(n)
}

参数含义:

  • to/from:目标/源底层数组首元素地址。
  • n:实际要拷贝的元素个数(调用前已求 min(len(dst), len(src)))。
  • wid:每个元素的字节大小。

几个设计要点:

  1. min(len(dst), len(src)) 自动截断:你不需要手动计算长度,copy 不会越界。如果 dstsrc 短,只复制 dst 能装下的部分;反之亦然。

  2. memmove 处理重叠:当你 copy(s[1:], s[:len(s)-1]) 这种“Slice 内部搬运“时,源和目标指向同一块内存。memmove 内部会判断方向,从后向前或从前向后拷贝,保证结果正确。

  3. copy 不是 clonecopy 不会自动分配目标 Slice。常见错误:

package main

import "fmt"

func main() {
    var dst []int
    src := []int{1, 2, 3}
    n := copy(dst, src)        // 啥也没拷贝!dst 的 len 还是 0
    fmt.Println(n, dst)        // 0 []

    dst = make([]int, len(src))
    copy(dst, src)             // 正确
    fmt.Println(dst)           // [1 2 3]
}
  1. 支持 []bytestring 互转copy([]byte, string)copy([]byte, string) 是编译器特例,因为 string 内部是只读字节序列,需要专门处理。

工程实践与常见坑

  • 复制 Slice 必须先 make 目标
dst := make([]int, len(src))
copy(dst, src)
// 或用 append 的语法糖(推荐用 Go 1.21+ 的 slices.Clone):
// dst := append([]int(nil), src...)
  • 删除中间元素:利用 copy 把后面的元素前移。
package main

import "fmt"

func removeAt(s []int, i int) []int {
    copy(s[i:], s[i+1:])
    return s[:len(s)-1]
}

func main() {
    s := []int{1, 2, 3, 4, 5}
    s = removeAt(s, 2)
    fmt.Println(s) // [1 2 4 5]
}
  • 批量插入
package main

import "fmt"

func insertAt(s []int, i int, xs ...int) []int {
    if cap(s) >= len(s)+len(xs) {
        s = s[:len(s)+len(xs)]
    } else {
        news := make([]int, len(s)+len(xs))
        copy(news, s)
        s = news
    }
    // 把 i 之后的内容后移 len(xs) 位
    copy(s[i+len(xs):], s[i:])
    // 把新元素填入 i 处
    copy(s[i:], xs)
    return s
}

func main() {
    s := []int{1, 2, 5}
    s = insertAt(s, 2, 3, 4)
    fmt.Println(s) // [1 2 3 4 5]
}
  • copyappend 的取舍copy 比循环 append 快,因为它直接调 memmove。当知道目标容量时优先 copy

4.6 cap 的变化规律

是什么

本节通过实验数据揭示 cap 在不同初始容量、不同元素大小下的实际变化规律,让你直观感受 roundupsize 的影响。

为什么这样设计 / 底层实现要点

回顾 growslice:先算 newcap(基于翻倍/平滑过渡),再通过 roundupsize 对齐到 size class。runtime/sizeclasses.go 定义了 Go 内存分配器的尺寸类(部分):

sizeclass字节数
18
216
324
432
548
664
780
30256
36512

roundupsize(n) 会把 n 字节向上取整到最近的 size class 字节数。这就是为什么 cap 的实际值经常比预期“大一点“。

下面是 []int64et.size=8)在不同初始 capappend 一次后实际测得cap(Go 1.21,AMD64):

初始 cap期望 (翻倍)实测 cap说明
12216 字节正好是 sizeclass 2
24432 字节正好是 sizeclass 4
48864 字节正好是 sizeclass 6
81616128 字节正好匹配
163232256 字节正好是 sizeclass 30
326464512 字节正好是 sizeclass 36
641281281024 字节匹配
1282562562048 字节匹配
256512512阈值边界,理论翻倍后对齐仍为 512
5121024896进入平滑过渡,理论 newcap=832,对齐到 896
102420481488理论 newcap=1472,对齐后约 1488

上表数据会随 Go 版本与平台变化,请以你本机实测为准。可以用下面的程序验证。

实测程序

package main

import "fmt"

func capAfterAppend(n int) int {
    s := make([]int64, 0, n)
    s = append(s, 1)
    return cap(s)
}

func main() {
    for _, n := range []int{1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096} {
        fmt.Printf("init cap=%-6d -> after append cap=%d\n", n, capAfterAppend(n))
    }
}

运行后你会发现:

  • 小 Slice(oldCap < 256)基本是精确翻倍。
  • 大 Slice 进入平滑过渡,扩容倍数接近 1.25,但因 size class 对齐实际值会略有放大。
  • 偶尔因为 size class 对齐,实际 cap 比“理论 newcap“还要大。

工程实践与常见坑

  • 不要硬编码 cap:基于 cap 的精确值写逻辑会让代码与 Go 版本耦合。
  • 预分配避免依赖扩容make([]T, 0, expected) 一次到位,不触发任何扩容。
  • 观察 GC 压力:如果 cap 增长不符合预期,可能是 size class 对齐导致内存放大。可以用 runtime.ReadMemStats 监控。

4.7 扩容性能分析

是什么

本节从均摊复杂度、缓存友好性、内存分配开销三个角度分析 append 的性能特征,并给出 Benchmark 实测数据。

为什么这样设计 / 底层实现要点

均摊 O(1) 分析

假设初始 cap = 1,每次扩容翻倍,追加 n 个元素的总拷贝次数为:

1 + 2 + 4 + 8 + ... + n/2 + n ≈ 2n - 1

n 次 append 总拷贝 O(n),均摊每次 O(1)。即使大 Slice 用 1.25 倍扩容,均摊仍是 O(1)(因为几何级数收敛)。这是动态数组能作为通用数据结构的核心保证。

缓存友好性

Slice 的底层数组是连续内存,对 CPU L1/L2 缓存非常友好。顺序 append、顺序遍历是 Go 中最高效的内存访问模式之一。相比之下,链表(如 container/list)的节点分散在堆上,缓存命中率差。

内存分配开销

mallocgc 调用涉及 mcache/mcentral/mheap 三级分配(参见内存分配章节)。小 Slice(<= 32KB)通常从 P 的 mcache 拿,无锁;大 Slice 走 mcentral 加锁;超大 Slice(> 32KB)直接 mmap。每次扩容都是一次分配 + 一次 memmove + 一次旧内存释放(GC 回收)。

Benchmark 对比

package main

import "testing"

func BenchmarkAppendDynamic(b *testing.B) {
    for i := 0; i < b.N; i++ {
        s := make([]int, 0)
        for j := 0; j < 1000; j++ {
            s = append(s, j)
        }
    }
}

func BenchmarkAppendPrealloc(b *testing.B) {
    for i := 0; i < b.N; i++ {
        s := make([]int, 0, 1000)
        for j := 0; j < 1000; j++ {
            s = append(s, j)
        }
    }
}

典型结果(Go 1.21,AMD64):

Benchmark时间/操作内存/操作分配次数
AppendDynamic~5 µs~12 KB~10
AppendPrealloc~1 µs~8 KB1

预分配版本快约 5 倍,内存省 30%,分配次数从 10 次降到 1 次。

工程实践与常见坑

  • 能预分配就预分配:哪怕只能估个大概,也比完全不预估强。make([]T, 0, hint) 几乎没有副作用。
  • 批量 appends = append(s, bigSlice...) 一次扩容到位,比循环 append 触发的扩容次数少。
  • 复用 Slice:用 s = s[:0] 重置长度,保留底层数组,避免重复分配。但要注意 GC 不会回收底层数组里被“逻辑删除“的对象引用(详见 4.8 节)。
  • 避免在热路径上扩容:性能敏感的 RPC 序列化、网络包处理等场景,务必在初始化时分配好缓冲区。

4.8 GC 如何处理旧数组

是什么

当 Slice 扩容后,旧底层数组可能成为垃圾。本节解释 Go GC 如何回收旧数组,以及一种常见的“隐性内存泄漏“模式。

为什么这样设计 / 底层实现要点

Go 使用并发三色标记清除 GC(细节参见 GC 章节)。对 Slice 而言:

  1. 扩容时growslice 调用 mallocgc 分配新数组,旧数组仍由原 Slice 头(如果还存在)或共享它的其他 Slice 持有。
  2. GC 标记阶段:GC 从根集合出发,扫描所有可达的 Slice 头,通过 array 指针找到并标记底层数组。
  3. 清除阶段:未被标记的旧数组所在的 mspan 会被回收。

关键点:只要还有一个 Slice 头指向旧数组,旧数组就不会被回收。这就引出了经典的“大数组小引用“内存泄漏:

package main

import "fmt"

func main() {
    big := make([]byte, 1<<20) // 1 MB
    small := big[:10]          // small.array 仍指向 big 的底层数组
    big = nil                  // 期望释放 1 MB
    // 实际上:1 MB 仍然被 small 持有,不会回收!
    fmt.Println(len(small))
}

big = nil 只是把 big 这个 Slice 头的 array 置零,但 smallarray 仍指向那 1MB 内存。GC 通过 small 标记了整个 1MB 数组。

更隐蔽的版本:append 后的别名

package main

import "fmt"

type Foo struct{ X int }

func main() {
    s := make([]*Foo, 1, 1024) // cap=1024,底层数组 8KB
    s[0] = &Foo{}
    big := s                   // big 与 s 共享底层数组
    // 假设后续 s 触发扩容(这里只是示意,实际 cap=1024 足够)
    // s = append(s, more...)
    // big 仍持有旧底层数组,里面的 &Foo{} 不会被 GC
    fmt.Println(big)
}

正确做法:拷贝并切断引用

package main

import "fmt"

type Foo struct{ X int }

func main() {
    big := make([]*Foo, 1<<10)
    big[0] = &Foo{X: 1}

    // 只需要前 10 个,但不想持有整个 1<<10 数组
    small := make([]*Foo, 10)
    copy(small, big[:10])
    big = nil // 现在 1<<10 数组可被 GC 回收

    fmt.Println(small[0])
}

copy + nil 切断是 Go 中显式释放大 Slice 内存的标准模式。

工程实践与常见坑

  • s[:0] 复用要小心对象引用:如果 Slice 里存的是指针,s[:0] 后底层数组里仍持有旧对象,阻止它们被 GC。处理方法是显式置零:
for i := range s {
    s[i] = nil // 或 Foo{}
}
s = s[:0]
  • 解码大 Slice 后只取小段:典型如 json.Unmarshal 把整个 JSON 读到 []byte,然后解析出一个小结构体。如果你保留了对那个 []byte 的引用(哪怕只是切片),整个 JSON 缓冲区都不会被回收。解决:解析后立即 data = nil,或用流式 json.Decoder
  • bytes.Buffer.Reset() 同理Reset 只是把长度置零,底层数组保留。如果 buffer 曾经很大,内存不会自动释放;需要 buffer = bytes.Buffer{} 重新分配一个空 buffer。

4.9 append 的最佳实践

是什么

本节汇总 append 与 Slice 扩容相关的工程实践要点,作为日常编码的速查表。

为什么这样设计 / 底层实现要点

实践要点全部源自前面的分析:

  • Slice 头是值类型 → append 必须赋值回。
  • 扩容会拷贝 → 预分配避免拷贝。
  • 共享底层数组 → 用 copy 切断。
  • GC 看引用 → 显式置零释放内存。

工程实践与常见坑

1. 永远接收 append 返回值

s = append(s, x)        // 正确
append(s, x)            // 错误:vet 会报警告

2. 知道大小时预分配

// 不好
var s []int
for i := 0; i < n; i++ {
    s = append(s, i)
}

// 好
s := make([]int, 0, n)
for i := 0; i < n; i++ {
    s = append(s, i)
}

// 也可以直接 make 长度 + 索引赋值(最快)
s := make([]int, n)
for i := range s {
    s[i] = i
}

3. 不知道大小时给出“合理上限“

// 完全不预估
s := []int{}

// 预估上限(即便过估也比不预估好)
s := make([]int, 0, 128)

4. 用 copy 而非循环赋值

// 慢
for i, v := range src {
    dst[i] = v
}

// 快
copy(dst, src)

5. 过滤元素的惯用法

// 不分配新底层数组(但保留原数组容量)
result := src[:0]
for _, v := range src {
    if keep(v) {
        result = append(result, v)
    }
}

// 干净切断(如果 src 很大且 result 很小)
result := make([]T, 0, len(src))
for _, v := range src {
    if keep(v) {
        result = append(result, v)
    }
}

6. 避免跨 goroutine 共享 Slice 头

Slice 头不是并发安全的。多 goroutine 读写同一个 Slice 必须加锁,或者用 channel 传递所有权。

7. append 链的陷阱

s := []int{1, 2, 3}
t := append(s, 4) // t 与 s 可能共享底层数组
u := append(s, 5) // u 也与 s 共享底层数组!u[3] 可能覆盖 t[3]
// 此时 t[3] 可能是 5 而非 4

规则:一旦对同一 Slice 多次 append 并保留多个结果,必须警惕共享。如果需要独立副本,用 copyappend([]T(nil), s...)

8. 使用 slices 标准库(Go 1.21+)

Go 1.21 引入了 slices 包,提供 InsertDeleteCloneConcat 等函数,封装了底层 copy/append 细节:

package main

import (
    "fmt"
    "slices"
)

func main() {
    s := []int{1, 2, 5}
    s = slices.Insert(s, 2, 3, 4) // [1 2 3 4 5]
    fmt.Println(s)
    s = slices.Delete(s, 1, 3)    // [1 4 5]
    fmt.Println(s)
    c := slices.Clone(s)          // 独立副本
    fmt.Println(c)
}

slices.Cloneappend([]T(nil), s...) 的语法糖,用于安全切断共享。

9. 删除元素后切断引用(指针元素)

package main

import "fmt"

func deleteAtIndex(s []*int, i int) []*int {
    // 先置 nil 让 GC 回收被删除对象
    s[i] = nil
    copy(s[i:], s[i+1:])
    s[len(s)-1] = nil // 收尾置 nil
    return s[:len(s)-1]
}

func main() {
    a, b, c := 1, 2, 3
    s := []*int{&a, &b, &c}
    s = deleteAtIndex(s, 1)
    fmt.Println(*s[0], *s[1]) // 1 3
}

本章小结

本章围绕 append 展开,核心要点:

  1. append 在容量足够时原地写、容量不足时调 growslice 分配新数组并 memmove 拷贝。
  2. Go 1.18+ 的扩容算法用 256 阈值 + 平滑过渡替代了旧的 1024 阈值,roundupsize 进一步对齐到 size class,使 cap 实际值常与理论值有出入。
  3. Slice 头是值类型,append 必须接收返回值,否则丢失扩容结果。
  4. copymemmove 实现,是安全、高效的 Slice 复制手段。
  5. GC 通过 Slice 头的 array 指针追踪底层数组,“大数组小引用“是常见的隐性内存泄漏。
  6. 工程实践:预分配、copy 切断共享、slices 标准库优先。

理解 append 等于理解 Slice 的动态行为,下一章我们将进入 Go Map 的内部世界,看 Go 如何用 bucket + overflow 实现一个高性能的 HashMap。