在 Go 语言中,数组(array)和切片(slice)是两种不同的数据结构,它们在内存分配机制上存在着显著差异。深入理解这些差异及原理并恰当使用,能够帮助我们提高代码的执行效率。
在使用上,由于语法糖的存在,很多初学者对于二者并不敏感。数组的写法是 [n]int,切片则是 []int,区别仅在于是否在 [] 中体现其长度。
从实现上讲,slice 是 array 的一种封装再实现,将长度不可变的数组,封装进一个结构体,达到长度可变(扩容)的效果。slice 结构体如下:
// go1.12.6
type slice struct {
array unsafe.Pointer // 指向数组
len int // 数组长度,即当前已利用的长度
cap int // 数组容量,即内存的预分配
}1. 二者在内存方面差异
通过对二者内存的分配、管理和传参的理解,在开发中,根据实际情况做出更优的选择,使得程序高效、安全运行。
1.1 核心定义与类型
- 数组:固定长度的同类型元素序列,长度是类型的组成部分(如
[3]int和[4]int是不同类型)。 - 切片:动态长度的、指向底层数组的指针(引用类型),由 (
ptr,len,cap) 三元组组成(ptr 指向底层数组,len 是当前长度,cap 是底层数组容量)。
1.2 内存分配的核心差异
(1) 分配时机与大小
数组:
内存大小在编译期确定,由类型和长度直接决定(总大小 = 元素大小 × 长度)。例如[3]int占24字节(假设int为8字节)。
分配发生在声明时:- 若为字面量初始化(如
a := [3]int{1,2,3}),编译器可能将其分配在栈上(小数组)或堆上(大数组,由逃逸分析决定)。 - 若通过
new([n]T)创建,必然在堆上分配(返回指针)。
- 若为字面量初始化(如
切片:
切片本身的结构(ptr,len,cap)是固定大小的(64位系统下占24字节),但它的底层数组需要动态分配内存。
底层数组的分配时机由切片的创建方式决定:- 字面量初始化(如
s := []int{1,2,3}):直接分配底层数组(长度等于容量),切片头指向该数组。 - 使用
make([]T, len, cap):显式指定长度和容量,分配底层数组(容量 ≥ 长度)。 - 空切片(如
var s []int或s := make([]int, 0)):初始时底层数组为 nil,无实际内存分配,直到首次赋值或append时触发分配。
- 字面量初始化(如
(2) 内存可变性
- 数组:
内存大小不可变,声明后长度和容量(长度即容量)固定,无法扩容或缩容。若需调整长度,必须创建新数组并复制数据。 - 切片:
底层数组可动态扩容。当通过append添加元素超过当前容量时,Go 会自动分配一个更大的新底层数组(通常按原容量的2倍扩容,容量较大时可能按1.25倍),将旧数据复制到新数组,并更新切片的ptr和cap。旧底层数组若未被其他切片引用,会被垃圾回收。
(3) 内存共享与引用
- 数组:
数组是值类型,赋值或传参时会复制整个数组(内存拷贝)。因此,两个不同的数组变量即使元素相同,内存地址也不同。 - 切片:
切片是引用类型,赋值或传参时仅复制切片头(ptr,len,cap),底层数组被多个切片共享。修改任一切片的元素(如s[i] = x)会影响所有共享该底层数组的切片。
1.3 传参机制的差异
在 Go 语言中,所有参数传递都是值传递,即便是参数为指针,也依然伴随着 copy,只不过给指针创建副本的代价是固定的(8 字节),不会因为数据的大小而产生变化。
因此,也不存在引用传递这个概念,从其他语言(C++)转型过来的朋友,尤其要注意。
- 数组传参:(复制整个数组)
数组是值类型,当作为参数传递给函数时,Go 会完整复制整个数组的内存(即创建一个新的同类型数组,并将原数组的所有元素逐一拷贝到新数组中)。
例如,传递一个长度为N的[N]int数组时,复制的字节数为N * sizeof(int)(假设int占8字节,则复制8N字节)。 - 切片传参:(复制切片头)
切片是引用类型,其本质是一个包含ptr(底层数组指针)、len(长度)、cap(容量)的结构体(64位系统下占24字节)。当切片作为参数传递时,Go仅复制这个结构体的副本(即复制24字节),不会复制底层数组。
切片的传参开销是固定的(24 字节),与底层数组的长度无关;而数组的传参开销随长度线性增长(8N 字节)。当数组很长时,切片传参的效率远高于数组(因复制开销仅 24 字节 vs 数组的 8*N 字节)。
因此,在需要处理大数据或动态调整长度的场景下,应优先使用切片;在需要严格值语义且数组较小时,则考虑使用数组。
另外,Go 编译器会对小数组(通常长度较小,如 [3]int)进行优化:若数组在函数调用中未被修改,可能直接通过指针访问原数组(避免复制)。但对于大数组,这种优化无法生效,必须完整复制。
2. Slice的长度与容量
Slice 的核心特性由两个关键属性定义:长度(Length,len)和容量(Capacity,cap)。它们共同决定了 Slice 的行为(如能否追加元素、是否共享底层数组等)
- 长度(
len)len是Slice中当前有效元素的数量(即“已使用的元素个数”)。它是Slice的“可见范围”,访问超出len的索引会触发panic(越界错误)。 - 容量(cap)
cap是Slice底层数组从Slice起始位置到数组末尾的元素总数(即“底层数组可用的最大元素个数”)。它决定了Slice能容纳的最大长度(无需扩容)。
3. Slice 的截取与扩容
通过对 slice 截取与扩容的深入理解,帮助我们在开发中把控数据截取修改的安全性,以及降低数据扩容的频次,提高执行效率。
3.1 截取(Slicing)
Slice 的截取(Slicing)是其核心特性之一,允许从一个已有的 Slice 或数组中创建一个新的 Slice,新 Slice 与原数据共享底层数组的部分元素。理解截取机制有助于高效处理数组子集、避免数据复制,并正确管理内存共享。
截取的语法与基本概念
Slice 的截取通过 [low:high] 语法实现,其中:
low:起始索引(包含),表示新Slice从原数据的第low个元素开始。high:结束索引(不包含),表示新Slice到原数据的第high个元素结束(不包含该元素)。
新 Slice 的长度(Len)为 high - low,容量(Cap)为原 Slice 的容量(Cap)减去 low(即底层数组从 low 到末尾的可用空间)。
截取的底层逻辑
Slice 的本质是 (ptr, len, cap) 三元组,截取操作的本质是创建一个新的 Slice 头,其 ptr 指向原底层数组的 low 位置,len 为 high - low,cap 为原 cap - low。
关键特点:
- 截取不会复制底层数组的元素,仅复制
Slice头(24字节),因此时间复杂度为O(1),效率极高。 - 新
Slice与原数据共享底层数组,修改其中一个的元素会直接影响另一个(除非底层数组被重新分配,扩容)。
3.2 扩容(Growth)
Slice 的扩容(Growth)是其动态调整容量的核心机制,用于解决因元素追加(append)导致容量不足的问题。扩容的本质是分配新的底层数组,并将旧数据复制到新数组中,从而扩展 Slice 的容量。理解扩容逻辑有助于:
- 预判内存分配行为,优化性能(如预分配容量)。
- 避免因共享底层数组导致的意外修改。
- 合理使用 append,减少不必要的内存复制。
扩容触发条件
Slice 的扩容仅在通过 append 追加元素时触发,当当前容量(cap)不足以容纳新元素的总数量(即 len(s) + n,n 为追加的元素数量)时,必须触发扩容。
- 扩容策略:小数组 vs 大数组
Go 的扩容策略根据原容量的大小分为两种模式,目的是平衡内存利用率和复制效率: - 小数组(原容量较小):2 倍扩容
若原容量(oldCap)较小(通常指oldCap ≤ 512),扩容后的新容量(newCap)为原容量的2倍。
因为,小数组扩容频率高,2倍扩容可减少未来可能的多次扩容操作,降低整体复制成本。
注:在go1.17的版本中,阈值是1024,而非512。 - 大数组(原容量较大):
1.65~1.25倍扩容
若原容量(oldCap)较大(通常指oldCap > 1024),扩容后的新容量为原容量的1.65 ~ 1.25倍,随着容量变大,这个系数逐渐减小。
因为,大数组扩容频率低,1.N倍扩容可避免内存过度浪费(2倍可能导致大量内存闲置)。
注:在go1.17的版本中,是持续1.25倍扩容,后面的版本优化为从1.65递减。 - 极端情况:直接满足需求容量
若通过 append 显式指定了足够大的容量(如append(s, make([]T, 1000)...)),或扩容后的 newCap 超过上述策略的计算值,则直接使用所需的最小容量(newLen)。
扩容的底层实现(growslice 函数)
Go 的扩容逻辑由运行时(runtime 包)的 growslice 函数实现,核心步骤如下:
- 计算新容量
根据原容量(oldCap)和需要的最小容量(newLen),确定新容量(newCap): - 分配新底层数组
调用mallocgc函数为新容量(newCap)分配内存,内存大小为newCap* 元素大小(et.size)。 - 复制旧数据到新数组
使用 memmove 函数将旧底层数组的元素(old.Data到old.Data+old.Len* 元素大小)复制到新数组的起始位置。 - 更新 Slice 头
返回一个新的sliceHeader,其ptr指向新数组,len为newLen(len(s) + n),cap为newCap。
注意事项
扩容后原
Slice不受影响
扩容会生成一个新的底层数组,原Slice仍指向旧数组(除非原Slice被重新赋值)。例如:s1 := []int{1, 2, 3} s2 := s1 // s2 与 s1 共享底层数组 s1 = append(s1, 4) // s1 扩容,指向新数组 fmt.Println(s2) // 输出 [1 2 3](s2 仍指向旧数组)- 扩容的内存分配位置
新底层数组始终分配在堆上(即使原Slice是栈上的),因为Slice可能被外部引用(如返回给其他函数),生命周期需延长至堆级别。运行时的逃逸分析会确保扩容后的数组逃逸到堆。另外,容量过大的Slice也必然会被分配到堆上,以防止栈溢出,目前栈的内存一般为2MB。 - 复制成本与性能优化
小数组的2倍扩容虽然会增加单次复制成本,但减少了未来扩容次数(如从3→6→12,仅需两次扩容即可支持12个元素)。
大数组的1.65倍扩容平衡了内存利用率和复制成本(如从512→848,仅增加336个元素空间)。 避免频繁扩容
在已知需要大量元素时,可通过 make 预分配足够的容量,减少扩容次数:// 预分配容量为 1000 的 Slice(避免多次扩容) s := make([]int, 0, 1000) for i := 0; i < 1000; i++ { s = append(s, i) // 不会触发扩容,直接填充预分配的空间 }
关于扩容系数的调整,详细信息可以查看这个 commit。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用。你还可以使用@来通知其他用户。