Golang 数组和切片的区别?切片的扩容机制是什么?

一句话总结:
数组是固定长度、值语义的连续内存块切片是动态长度,由“底层数组指针 + len + cap”组成的结构体。切片传参时拷贝的是这个结构体,但底层数组仍然共享。append 在容量足够时直接写,容量不足时触发扩容:计算新容量、内存对齐、分配新数组、复制旧元素。

下面从数组、切片、两者区别,再到切片扩容源码机制,层层递进。


一、数组:固定长度的值类型

数组声明方式:

var a [3]int
b := [3]int{1, 2, 3}
c := [...]int{1, 2, 3} // 编译器推断长度,类型仍是 [3]int

数组的特点:

  1. 长度固定,编译期确定。
  2. 长度是类型的一部分[3]int[4]int 是不同类型。
  3. 值类型,赋值和传参都会拷贝整个数组。
  4. 内存连续,所有元素在内存中挨着存放。
  5. 可比较,如果元素类型可比较,那么数组可以用 ==!= 比较。
  6. 可以作为 map 的 key。

示例:

func modifyArr(a [3]int) {
    a[0] = 100
}

func main() {
    arr := [3]int{1, 2, 3}
    modifyArr(arr)
    fmt.Println(arr) // [1 2 3],函数内修改的是副本
}

如果想在函数内修改原数组,要传数组指针:

func modifyArr(a *[3]int) {
    a[0] = 100
}

数组的长度是类型的一部分:

var a [3]int
var b [4]int

// a = b // 编译错误:cannot use b (variable of type [4]int) as [3]int

数组适合固定长度的场景,比如:

  • 固定大小的矩阵;
  • 哈希 key;
  • 需要值语义拷贝的小集合。

二、切片:动态长度的“数组视图”

切片声明方式:

var s []int              // nil slice
s1 := []int{}            // 空切片,但不是 nil
s2 := make([]int, 0, 10) // len=0, cap=10
s3 := []int{1, 2, 3}     // len=cap=3

切片底层并不是直接存所有元素,而是存一个结构体。运行时大致是这样的:

type slice struct {
    array unsafe.Pointer // 指向底层数组的某个位置
    len   int            // 当前长度
    cap   int            // 当前容量
}

在 64 位机器上,一个切片头大约 24 字节:
8 字节指针 + 8 字节 len + 8 字节 cap

所以切片本身是值类型,但因为它内部有指针,所以表现出“引用语义”。

1. 切片表达式与容量

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

s := arr[1:4]
fmt.Println(len(s), cap(s)) // 3 4

为什么 cap(s) 是 4?

因为 sarr[1] 开始,到底层数组末尾 arr[4],还有 4 个位置:

arr:  [0] [1] [2] [3] [4]
s:        ^----------> cap=4

切片表达式规则:

s[low:high]

新切片的:

  • array 指向 s[low]
  • len = high - low
  • cap = cap(s) - low

如果想限制容量,可以使用三索引切片:

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

s := arr[1:4:4] // len=3, cap=3

cap = max - low = 4 - 1 = 3
这样后续 append 一旦超过 3,就会扩容,不会影响原数组。

2. nil 切片和空切片

var s1 []int       // nil
s2 := []int{}      // 非 nil,但 len=0
s3 := make([]int, 0) // 非 nil,但 len=0

fmt.Println(s1 == nil) // true
fmt.Println(s2 == nil) // false
fmt.Println(s3 == nil) // false

区别:

  • nil 切片:len=0cap=0,可以 append,可以遍历。
  • 空切片:len=0cap=0,但不是 nil
  • JSON 序列化时:nil 切片变成 null,空切片变成 []

3. 切片共享底层数组

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

s2[0] = 100
fmt.Println(s1) // [1 100 3 4 5]

因为 s2s1 共享底层数组。

如果 append 时容量足够,也会修改原数组:

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

s2 = append(s2, 99)
fmt.Println(s1) // [1 2 3 99 5]

如果使用三索引限制容量:

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

s2 = append(s2, 99)
fmt.Println(s1) // [1 2 3 4 5]
fmt.Println(s2) // [2 3 99]

此时 s2 容量不足,触发扩容,不再共享原数组。


三、数组 vs 切片 对比

维度数组 [N]T切片 []T
长度固定,编译期确定动态,运行期可变
类型长度是类型一部分长度不是类型一部分
赋值/传参拷贝整个数组拷贝切片头,底层数组共享
可比较可以比较,若元素可比较不可直接比较,只能和 nil
作为 map key可以不可以
扩容不支持append 可触发扩容
底层结构连续元素指针 + len + cap
使用场景固定长度、值语义动态集合、函数参数、缓冲区

一句话:

数组是“一整个盒子”,切片是“盒子的一个窗口 + 长度 + 容量”。


四、切片扩容机制

1. 什么时候扩容?

使用 append 时:

s = append(s, x)

如果追加后长度没有超过容量:

newLen := len(s) + 1
if newLen <= cap(s) {
    // 直接写入底层数组
    // 返回新的 slice header
}

此时不会分配新数组,只是 len 增加。

如果:

newLen > cap(s)

就会触发扩容,进入 runtime.growslice

流程简化如下:

append(s, elems...)
        |
newLen = len(s) + len(elems)
        |
newLen <= cap(s)? --是--> 直接写入,返回 s[:newLen]
        |
        否
        v
growslice:
  1. 计算 newcap
  2. 内存对齐 roundupsize
  3. 分配新数组
  4. 复制旧元素
  5. 返回新 slice

2. 容量计算策略:Go 1.18+

目前主流 Go 版本,扩容策略大致如下:

newcap := oldCap
doublecap := newcap + newcap

if newLen > doublecap {
    newcap = newLen
} else {
    const threshold = 256
    if oldCap < threshold {
        newcap = doublecap
    } else {
        for newcap < newLen {
            newcap += (newcap + 3*threshold) / 4
        }
    }
}

解释:

  1. 如果需要的容量超过旧容量的 2 倍,直接取需要的容量。
    例如旧容量 2,一次追加 100 个元素,需要 102,那么直接按 102 算。

  2. 旧容量小于 256:直接翻倍。
    例如 1 → 2,2 → 4,4 → 8,8 → 16……

  3. 旧容量大于等于 256:按近似 1.25 倍平滑增长。
    公式是:

    newcap += (newcap + 3*256) / 4
    

    它不是严格 1.25 倍,而是为了从小切片的 2 倍增长平滑过渡到大切片的 1.25 倍增长。

  4. 官方不保证扩容策略
    不同 Go 版本、不同元素类型、不同架构,实际容量都可能不同,所以不要依赖扩容倍数。

Go 1.17 及以前策略不同,大致是:

  • 旧容量小于 1024:翻倍;
  • 旧容量大于等于 1024:增长 25%。

3. 内存对齐:实际容量可能比计算值大

计算完 newcap 后,并不是直接分配:

newcap * 元素大小

而是要把字节数向上取整到内存分配器的 size class,这个过程叫 roundupsize

例如:

s := make([]int, 0)
s = append(s, make([]int, 100)...)

理论需要 100 个 int,64 位下 int 是 8 字节:

100 * 8 = 800 字节

内存分配器可能把 800 字节对齐到 896 字节:

896 / 8 = 112

所以最终 cap(s) 可能不是 100,而是 112。

这就是为什么你看到:

理论扩容 1.25 倍,实际 cap 却不是精确的 1.25 倍。

因为还要经过内存对齐。

4. 一个容量增长观察示例

以 64 位、[]int、Go 1.18+ 为例,可能观察到类似序列:

0 -> 1
1 -> 2
2 -> 4
4 -> 8
8 -> 16
16 -> 32
32 -> 64
64 -> 128
128 -> 256
256 -> 512
512 -> 848 左右
848 -> 1280 左右
1280 -> 1792 左右
...

注意:

  • 后面的 848、1280 是理论计算 + 内存对齐的结果;
  • 不同 Go 版本、不同元素类型可能不同;
  • 如果是 []int32[]struct{},最终 cap 也会不同。

示例代码:

s := make([]int, 0)
prev := cap(s)

for i := 0; i < 20; i++ {
    s = append(s, i)
    if cap(s) != prev {
        fmt.Printf("len=%d cap=%d\n", len(s), cap(s))
        prev = cap(s)
    }
}

可能输出:

len=1 cap=1
len=2 cap=2
len=3 cap=4
len=5 cap=8
len=9 cap=16
len=17 cap=32
...

5. 扩容时做了什么?

growslice 核心步骤:

  1. 计算 newcap
  2. 根据元素大小计算字节数;
  3. 调用 roundupsize 做内存对齐;
  4. 分配新底层数组;
  5. 把旧数组的 oldLen 个元素复制到新数组;
  6. 返回新的 slice header。

注意:

  • 只复制旧 len 个元素,不是复制旧 cap 个;
  • 是浅拷贝,元素值复制;
  • 如果元素包含指针,指针值被复制,指向的对象仍相同;
  • 旧数组如果没有其他引用,会被 GC 回收;
  • append 返回的是新切片,必须接收返回值:
s = append(s, x) // 正确
append(s, x)     // 错误,返回值被丢弃

五、常见坑与面试题

1. 切片作为函数参数,append 后外层看不到

func f(s []int) {
    s = append(s, 1)
}

func main() {
    s := []int{}
    f(s)
    fmt.Println(len(s)) // 0
}

原因:函数内拷贝的是切片头,append 返回的新切片头只赋给了函数内局部变量。

正确写法:

func f(s []int) []int {
    return append(s, 1)
}

2. 容量足够时,函数内修改元素会影响外层

func f(s []int) {
    s[0] = 100
}

func main() {
    s := []int{1, 2, 3}
    f(s)
    fmt.Println(s) // [100 2 3]
}

因为底层数组共享。

3. 多个切片共享底层数组

s1 := []int{1, 2, 3, 4, 5}
s2 := s1[1:3]

s2[0] = 100
fmt.Println(s1) // [1 100 3 4 5]

4. 大切片内存泄漏

func lastN(s []int, n int) []int {
    return s[len(s)-n:]
}

返回的切片仍然引用原底层数组,如果原数组很大,会导致大数组无法回收。

优化:

func lastN(s []int, n int) []int {
    res := make([]int, n)
    copy(res, s[len(s)-n:])
    return res
}

5. 切片不可比较

s1 := []int{1, 2, 3}
s2 := []int{1, 2, 3}

// fmt.Println(s1 == s2) // 编译错误

只能和 nil 比较:

fmt.Println(s1 == nil)

Go 1.21 可以使用 slices.Equal

import "slices"

fmt.Println(slices.Equal(s1, s2)) // true

6. 并发 append 不安全

切片本身不是并发安全的。多个 goroutine 同时 append 会导致数据竞争,必须加锁:

var mu sync.Mutex
var s []int

mu.Lock()
s = append(s, 1)
mu.Unlock()

或者使用 channel。

7. 预分配容量提升性能

如果知道大概要放多少元素:

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

如果知道确切长度:

s := make([]int, 1000)
for i := range s {
    s[i] = i
}

Go 1.21+ 还可以用:

import "slices"

s = slices.Grow(s, 1000)

六、总结

  1. 数组

    • 固定长度;
    • 长度是类型一部分;
    • 值类型,赋值和传参拷贝整个数组;
    • 可比较,可作为 map key;
    • 适合固定大小场景。
  2. 切片

    • 动态长度;
    • 底层是 array + len + cap
    • 传参拷贝切片头,但底层数组共享;
    • 不可直接比较;
    • 适合动态集合、函数参数、缓冲区。
  3. 切片扩容

    • appendnewLen <= cap,直接写底层数组;
    • 否则进入 growslice
    • Go 1.18+:小于 256 翻倍,大于等于 256 按约 1.25 倍平滑增长;
    • 计算完还要经过 roundupsize 内存对齐,所以实际 cap 可能更大;
    • 扩容会分配新数组、复制旧元素;
    • 官方不保证扩容策略,不要依赖具体倍数。
  4. 最佳实践

    • 能预估容量就预分配:make([]T, 0, n)
    • 注意切片共享底层数组带来的副作用;
    • 大切片截取小切片时,必要时用 copy 避免内存泄漏;
    • 并发写切片必须加锁。
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Aerkui

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值