一.go语言中slice底层原理(2026-5-7)

目录

1.slice的底层结构是怎样的?

[2.Slice 是怎么扩容的?(版本差异是考点!)](#2.Slice 是怎么扩容的?(版本差异是考点!))

[2.1 扩容的触发时机](#2.1 扩容的触发时机)

[2.2 Go 1.17 及以前的扩容规则(老规则)](#2.2 Go 1.17 及以前的扩容规则(老规则))

[2.3 Go 1.18 及以后的扩容规则(新规则,更平滑)](#2.3 Go 1.18 及以后的扩容规则(新规则,更平滑))

[2.4 扩容后发生了什么?(关键)](#2.4 扩容后发生了什么?(关键))

3.从⼀个切⽚截取出另⼀个切⽚,修改新切⽚的值会影响原来的切⽚内容吗

[4、Slice 作为函数参数传递,会影响原 Slice 吗?](#4、Slice 作为函数参数传递,会影响原 Slice 吗?)

[4.1 先纠正一个概念:Go 只有"值传递"](#4.1 先纠正一个概念:Go 只有"值传递")

[4.2 修改元素 vs Append,结果截然不同!](#4.2 修改元素 vs Append,结果截然不同!)

[场景 1:函数内修改元素 → 会影响外层](#场景 1:函数内修改元素 → 会影响外层)

[场景 2:函数内 Append 但不接收返回值 → 不会影响外层](#场景 2:函数内 Append 但不接收返回值 → 不会影响外层)

[场景 3:函数内 Append 且传指针 → 会影响外层](#场景 3:函数内 Append 且传指针 → 会影响外层)


1.slice的底层结构是怎样的?

Slice 不是数组,而是一个"结构体",它本质上是对底层数组的"视图"或"窗口"。

slice 实际上是⼀个结构体,包含 三个字段:长度、容量、底层数组。

复制代码
type slice struct {
    array unsafe.Pointer  // 元素指针:窗户指向公寓楼的起始位置
    len   int             // 长度:窗户当前开了多大(你能看到几个元素)
    cap   int             // 容量:窗户最多能开到多大(从起始位置到数组末尾还剩多少)
}
  • array:存的是内存地址,指向底层数组的某个位置。

  • len:你通过 s[i] 能访问到的合法索引范围(0len-1)。

  • cap:从 array 指向的位置开始,到底层数组末尾还有多少空闲座位。

Slice vs 数组

特性 数组 [5]int 切片 []int
长度 固定,是类型的一部分([3]int[4]int 是不同类型) 可变
传递 值传递,拷贝整个数组(笨重) 值传递结构体(轻量,仅拷贝 24 字节,指针+len+cap)
扩容 不能扩容 自动扩容

入门最容易犯的错: 以为 []int[5]int 差不多。记住:数组是实体楼,Slice 是窗户

2.Slice 是怎么扩容的?(版本差异是考点!)

Slice 扩容是 Go 面试的必考题 ,而且一定要区分 Go 1.17 及以前Go 1.18 及以后 的规则,PDF 里也明确分开了。

2.1 扩容的触发时机

当你执行 append(s, x) 时,如果 len == cap(窗户已经开到最大了,没位置了),就必须扩容。

2.2 Go 1.17 及以前的扩容规则(老规则)

  1. 如果期望容量(newCap)大于当前容量的 2 倍:直接按期望容量分配。

  2. 如果当前切片长度小于 1024:容量直接翻倍(×2)。

  3. 如果当前切片长度大于等于 1024:每次只增加 25%(×1.25),直到够装为止。

举例:

  • s := []int{1,2,3}cap=3,append 一个元素 → 长度 3 < 1024,翻倍 → 新容量 6

  • s := make([]int, 1500)cap=1500,append 一个元素 → 长度 ≥ 1024,增加 25% → 新容量 1925(1500×1.25)。

2.3 Go 1.18 及以后的扩容规则(新规则,更平滑)

Go 团队发现老规则在 1024 附近跳跃太大,做了更精细的梯度设计:

  • 当原容量(oldCap)小于 256 时 :新容量 = oldCap × 2(直接翻倍)。

  • 当原容量(oldCap)大于等于 256 时 :新容量 = oldCap + (oldCap + 3×256) / 4

翻译成人话:

  • 小切片(<256):翻倍,因为小数组拷贝成本低,翻倍能减少内存分配次数。

  • 大切片(≥256):渐进式放缓,从接近 2 倍慢慢过渡到接近 1.25 倍,避免大内存的浪费。

举例:

  • oldCap = 100 → 新容量 200

  • oldCap = 256 → 新容量 = 256 + (256 + 768)/4 = 256 + 256 = 512(刚好还是 2 倍)。

  • oldCap = 1000 → 新容量 = 1000 + (1000 + 768)/4 = 1000 + 442 = 1442(约 1.44 倍)。

2.4 扩容后发生了什么?(关键)

扩容不是原地扩建 ,而是另起炉灶

  1. 在内存中申请一块全新的、更大的数组

  2. 把旧数组的数据逐字节拷贝到新数组。

  3. Slice 的 array 指针指向新数组,lencap 更新。

  4. 旧数组会被 GC 回收(如果没有被别的 Slice 引用)。

3.从⼀个切⽚截取出另⼀个切⽚,修改新切⽚的值会影响原来的切⽚内容吗

在截取完之后,如果新切⽚没有触发扩容,则修改切⽚元素会影响原切⽚,如果触发了扩容则不会。

复制代码
package main
import "fmt"

func main() {
       slice := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
       s1 := slice[2:5]
       s2 := s1[2:6:7]
       s2 = append(s2, 100)
       s2 = append(s2, 200)
       s1[2] = 20
       fmt.Println(s1)
       fmt.Println(s2)
       fmt.Println(slice)
}

运行结果:

复制代码
[2 3 20]
[4 5 6 7 100 200]
[0 1 2 3 20 5 6 7 100 9]

s1 从 slice 索引2(闭区间)到索引5(开区间,元素真正取到索引4),长度为3,容量默认到数组结尾,为8。

s2从 s1 的索引2(闭区间)到索引6(开区间,元素真正取到索引5),容量到索引7(开区间,真正到索引6),为5。

接着,向 s2 尾部追加⼀个元素 100:

复制代码
s2 = append(s2, 100)

s2 容量刚好够,直接追加。不过,这会修改原始数组对应位置的元素。这⼀改动,数组和 s1 都可以看得到。

再次向 s2 追加元素200

复制代码
s2 = append(s2, 200)

这时,s2 的容量不够⽤,该扩容了。于是,s2 另起炉灶,将原来的元素复制新的位置,扩⼤⾃⼰的容量。并且为了应对未来可能的 append 带来的再⼀次扩容,s2 会在此次扩容的时候多留⼀些 buffer,将新的容量将扩⼤为原始容量的2倍,也就是10了。

最后,修改 s1 索引为2位置的元素:

复制代码
s1[2] = 20

这次只会影响原始数组相应位置的元素。它影响不到 s2 了,⼈家已经远⾛⾼飞了。

再提⼀点,打印 s1 的时候,只会打印出 s1 长度以内的元素。所以,只会打印出3个元素,虽然它的底层数组不⽌3个元素。

4、Slice 作为函数参数传递,会影响原 Slice 吗?

4.1 先纠正一个概念:Go 只有"值传递"

在 Go 语言中只存在值传递,要么是值的副本,要么是指针的副本。

当你把 Slice 传进函数时,发生的是结构体的值拷贝 ------拷贝了一个包含 array指针lencap 的新结构体。这非常轻量(24 字节),但新旧两个 Slice 的 array 指针指向的是同一个底层数组

4.2 修改元素 vs Append,结果截然不同!

场景 1:函数内修改元素 → 会影响外层
复制代码
func f(s []int) {
    for i := range s {
        s[i] += 1  // 通过指针找到底层数组,直接改原数据
    }
}

func main() {
    s := []int{1, 1, 1}
    f(s)
    fmt.Println(s) // [2 2 2]  ← 变了!
}

原因: 虽然 s 的结构体是副本,但 array 指针指向的是同一片内存,修改元素就是修改底层数组。

场景 2:函数内 Append 但不接收返回值 → 不会影响外层
复制代码
func myAppend(s []int) []int {
    s = append(s, 100)  // 这里 s 是副本,append 可能改变 s 的 array 指针
    return s
}

func main() {
    s := []int{1, 1, 1}
    newS := myAppend(s)
    fmt.Println(s)     // [1 1 1]      ← 没变!
    fmt.Println(newS)  // [1 1 1 100]  ← 新的
}

原因: append 后如果发生扩容,函数内的 s 指向了新数组,但外层的 s 依然指向旧数组。而且函数内的 s 是副本,它的 len/cap 变化不会反馈给外层。

场景 3:函数内 Append 且传指针 → 会影响外层

如果你真的想在函数里"加长"原 Slice,必须传指针:

复制代码
func myAppendPtr(s *[]int) {
    *s = append(*s, 100)  // 通过指针修改外层变量本身
}

func main() {
    s := []int{1, 1, 1}
    myAppendPtr(&s)
    fmt.Println(s)  // [1 1 1 100]  ← 真的变了!
}
相关推荐
审判长烧鸡3 小时前
Go 内存优化骚操作
go·内存优化
~|Bernard|4 小时前
二.go语言中map的底层原理(2026-5-8)
算法·golang·哈希算法
平凡但不平庸的码农5 小时前
Go 错误处理详解
开发语言·后端·golang
web守墓人7 小时前
【go语言】go语言实现go-torch, 完成Lenet-5的搭建,训练,以及pth和onnx模型导出
开发语言·后端·golang
平凡但不平庸的码农7 小时前
Go 语言常用标准库详解
开发语言·后端·golang
平凡但不平庸的码农8 小时前
Go context 包详解
开发语言·后端·golang
焗猪扒饭10 小时前
极简案列入门golang依赖注入工具wire
后端·go
~|Bernard|12 小时前
三,go语言中channel的底层原理
开发语言·后端·golang
且去填词12 小时前
Go并发模式进阶:从Worker Pool到可取消任务调度器
数据库·oracle·golang