slice

一、初始化

声明但不初始化

1var sli []int

这个只是声明了 slice 的类型,并没有执行初始化操作,sli 是一个空指针 nil,没有进行内存分配

使用make进行初始化

make 初始化分为两种形式

第一种:

1sli := make([]int,10)

这个会将切片的长度和容量同时设置为10;容量很好理解,即这个切片可以容纳多少元素,而长度为该切片实际有多少元素,这里make会为我们设置10个int类型下的零值(即为0)

第二种:

1sli :=  make([]int,8,10)

这个会讲切片的容量设置为10,长度设置为8,8个元素均为int类型的零值,这里需要注意长度和容量的关系需满足 len < cap,访问元素的时候也只能访问已有的元素,比如sli[7],但是你如果访问sli[8]和sli[9],就会发生越界,导致panic

初始化并赋值

slice 可以直接完成初始化和赋值操作

1sli := []int{1,2,3,4}

这里会将切片的长度和容量都设置为4,且每个元素的值都已经设置好

二、对切片的一些操作

2.1 append

append可以对切片进行追加操作,可以追加元素或切片

假设我们声明了一个int类型的切片arr,追加元素和切片的操作如下所示:

1arr := make([]int,0,3)
2
3arr = append(arr,1)
4
5arr = append(arr,[]int{2,3,4}...)

append操作很灵活,可以有很多操作,而且append和slice的扩容机制有关

2.2 copy

copy是Go语言的内置函数,用于将一个切片(源切片 src)的元素按值复制到另一个切片(目标切片 dst

copy 操作总是涉及值复制。操作完成后,dstsrc 不会共享任何底层数组。它们是完全独立的数据副本。

1s1 := []int{1, 2, 3, 4}
2s2 := make([]int, 2) // s2: [0, 0]
3
4// 复制 s1 到 s2
5n := copy(s2, s1) 
6// 复制的长度是 min(len(s2)=2, len(s1)=4) = 2
7// s2 现在是 [1, 2]。 n=2

2.3 子切片操作

子切片操作是指从一个现有切片或数组中创建一个新的切片。这是切片设计中最容易引起混淆但又最强大的特性。

  • 语法: newSlice := oldSlice[low:high]newSlice := oldSlice[low:high:max]

  • 底层关系: 子切片和原始切片共享同一个底层数组。 它们只是引用了底层数组的不同部分

元素 描述
low 新切片的首个元素的索引(基于原切片/数组),newSlice[0] 对应 oldSlice[low]
high 新切片的长度界限(不包含 high 索引的元素)。新切片的长度high - low
max (容量上限) 可选的第三个索引,用于设定新切片的容量界限。新切片的容量max - low

2.4 获取长度和容量

  • len(s) : 获取切片的长度

  • cap(s) : 获取切片的容量

三、原理解析

3.1 切片头部

切片不是数据容器本身,而是对底层数组的引用和描述。每个切片在内存中都由一个包含三个字段的结构体表示,通常称为“切片头部”(Slice Header)

字段 含义 作用
array 底层数组指针 指向切片所引用数据的起始内存地址
len 切片的长度 切片当前包含的元素数量,即 len(s)
cap 切片的容量 从切片起始位置到其底层数组末尾的元素数量,即 cap(s)

核心关系: 多个切片可以共享同一个 Array 指针,但它们可以有不同的 LenCap,这就是子切片操作的原理

3.2 引用类型

切片在 Go 语言中是引用类型的一种体现(尽管切片头部是按值传递的结构体,但其内部的指针使其行为类似于引用)

  • 传递机制: 当您将一个切片作为函数参数传递时,传递的是切片头部(包含指针、长度、容量)的副本
  • 引用特性: 虽然是副本,但因为副本中的 Array 指针指向了同一个底层数组。因此,在函数内部通过这个切片副本修改元素时(例如 s[i] = value),实际上修改的是底层共享的数据,会影响到原始切片

例外: 如果在函数内部对切片执行 append 且触发了扩容,那么函数内部的切片将指向一个新的底层数组,与外部的原始切片解除关联,后续修改将不再影响外部切片

3.3 切片的扩容机制

当使用 append 函数向切片追加元素,而切片的当前容量(Cap)不足时,就会触发扩容。扩容机制旨在平衡内存效率和性能,其步骤如下:

  1. 判断扩容len(s) + 待追加元素数量 > cap(s) 时,需要扩容
  2. 计算新容量:Go 语言会根据所需的最小容量(即 len(s) + 待追加数量),计算出一个更理想的新容量
    • 当所需容量小于 1024 时:新容量通常是原容量的 2 倍(即 newCap = oldCap * 2
    • 当所需容量大于等于 1024 时:新容量通常是原容量的 1.25 倍(即 newCap = oldCap * 1.25),只到容量满足要求
  3. 分配新数组:在堆上分配一个全新的、更大的底层数组,大小为新计算出的容量
  4. 数据复制:将原底层数组中的所有元素按值复制到新分配的数组中
  5. 更新切片头部append 返回一个新的切片头部,其 Array 指针指向新数组,Len 更新为追加后的长度,Cap 更新为新容量

正是因为这个数据复制过程,频繁的扩容操作会导致性能下降。因此,推荐使用 make([]T, 0, initialCap) 来预分配切片容量

四、性能优化

当通过子切片操作(Reslice) (s[low:high]) 从一个大容量的切片中截取出一小段数据时,新生成的子切片虽然长度很小,但它与原切片共享同一个底层数组

  • 内存驻留: 只要这个小切片(即便是只包含少数元素的变量)仍然存活并被引用,那么它所引用的整个巨大的底层数组就无法被 Go 的垃圾回收器(GC)回收。

  • 后果: 这会导致内存中驻留了大量不再需要的旧数据,造成内存效率低下,在长时间运行的服务器或需要处理大文件的场景中,可能引发内存泄漏或不必要的内存压力。

推荐做法:使用 copy 彻底断开底层关联

为了解决这个潜在的内存驻留问题,我们推荐使用 copy 函数来创建数据的完全独立副本

  1. 分配新数组: 使用 make 函数为新切片分配一个大小刚好够用的新底层数组。
  2. 值复制: 使用 copy 函数将原切片中需要保留的数据按值复制到这个新切片中。
1// 原始切片,底层数组很大
2bigSlice := make([]byte, 0, 1024*1024) // 容量 1MB
3// ... 填充数据
4
5// 1. 【有内存泄漏风险】使用子切片 (Reslice)
6// subSlice 只使用前 100 个元素,但底层数组的 1MB 仍无法释放
7subSlice := bigSlice[:100] 
8
9// 2. 【推荐优化做法】使用 copy
10// 2.1 确定需要的新长度
11newLen := 100 
12// 2.2 分配一个恰好大小的新切片
13optimizedSlice := make([]byte, newLen) 
14// 2.3 复制数据,断开与 bigSlice 底层数组的关联
15copy(optimizedSlice, bigSlice[:newLen]) 
16// 此时,如果 bigSlice 不再被引用,它所占用的 1MB 内存即可被 GC 回收