3

在 Go 语言中,数组(array)和切片(slice)是两种不同的数据结构,它们在内存分配机制上存在着显著差异。深入理解这些差异及原理并恰当使用,能够帮助我们提高代码的执行效率。

在使用上,由于语法糖的存在,很多初学者对于二者并不敏感。数组的写法是 [n]int,切片则是 []int,区别仅在于是否在 [] 中体现其长度。

从实现上讲,slicearray 的一种封装再实现,将长度不可变的数组,封装进一个结构体,达到长度可变(扩容)的效果。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]int24 字节(假设 int8 字节)。
    分配发生在声明时:

    • 若为字面量初始化(如 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 []ints := make([]int, 0)):初始时底层数组为 nil,无实际内存分配,直到首次赋值或 append 时触发分配。

(2) 内存可变性

  • 数组:
    内存大小不可变,声明后长度和容量(长度即容量)固定,无法扩容或缩容。若需调整长度,必须创建新数组并复制数据。
  • 切片:
    底层数组可动态扩容。当通过 append 添加元素超过当前容量时,Go 会自动分配一个更大的新底层数组(通常按原容量的 2 倍扩容,容量较大时可能按 1.25 倍),将旧数据复制到新数组,并更新切片的 ptrcap。旧底层数组若未被其他切片引用,会被垃圾回收。

(3) 内存共享与引用

  • 数组:
    数组是值类型,赋值或传参时会复制整个数组(内存拷贝)。因此,两个不同的数组变量即使元素相同,内存地址也不同。
  • 切片:
    切片是引用类型,赋值或传参时仅复制切片头(ptr, len, cap),底层数组被多个切片共享。修改任一切片的元素(如 s[i] = x)会影响所有共享该底层数组的切片。

1.3 传参机制的差异

在 Go 语言中,所有参数传递都是值传递,即便是参数为指针,也依然伴随着 copy,只不过给指针创建副本的代价是固定的(8 字节),不会因为数据的大小而产生变化。

因此,也不存在引用传递这个概念,从其他语言(C++)转型过来的朋友,尤其要注意。

  • 数组传参:(复制整个数组)
    数组是值类型,当作为参数传递给函数时,Go 会完整复制整个数组的内存(即创建一个新的同类型数组,并将原数组的所有元素逐一拷贝到新数组中)。
    例如,传递一个长度为 N[N]int 数组时,复制的字节数为 N * sizeof(int)(假设 int8 字节,则复制 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
    lenSlice 中当前有效元素的数量(即“已使用的元素个数”)。它是 Slice 的“可见范围”,访问超出 len 的索引会触发 panic(越界错误)。
  • 容量(cap)
    capSlice 底层数组从 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 位置,lenhigh - lowcap 为原 cap - low

关键特点:

  • 截取不会复制底层数组的元素,仅复制 Slice 头(24 字节),因此时间复杂度为 O(1),效率极高。
  • Slice 与原数据共享底层数组,修改其中一个的元素会直接影响另一个(除非底层数组被重新分配,扩容)。

3.2 扩容(Growth

Slice 的扩容(Growth)是其动态调整容量的核心机制,用于解决因元素追加(append)导致容量不足的问题。扩容的本质是分配新的底层数组,并将旧数据复制到新数组中,从而扩展 Slice 的容量。理解扩容逻辑有助于:

  • 预判内存分配行为,优化性能(如预分配容量)。
  • 避免因共享底层数组导致的意外修改。
  • 合理使用 append,减少不必要的内存复制。

扩容触发条件

Slice 的扩容仅在通过 append 追加元素时触发,当当前容量(cap)不足以容纳新元素的总数量(即 len(s) + nn 为追加的元素数量)时,必须触发扩容。

  • 扩容策略:小数组 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 函数实现,核心步骤如下:

  1. 计算新容量
    根据原容量(oldCap)和需要的最小容量(newLen),确定新容量(newCap):
  2. 分配新底层数组
    调用 mallocgc 函数为新容量(newCap)分配内存,内存大小为 newCap * 元素大小(et.size)。
  3. 复制旧数据到新数组
    使用 memmove 函数将旧底层数组的元素(old.Dataold.Data + old.Len * 元素大小)复制到新数组的起始位置。
  4. 更新 Slice 头
    返回一个新的 sliceHeader,其 ptr 指向新数组,lennewLen(len(s) + n)capnewCap

注意事项

  1. 扩容后原 Slice 不受影响
    扩容会生成一个新的底层数组,原 Slice 仍指向旧数组(除非原 Slice 被重新赋值)。例如:

    s1 := []int{1, 2, 3}
    s2 := s1          // s2 与 s1 共享底层数组
    s1 = append(s1, 4) // s1 扩容,指向新数组
    fmt.Println(s2)    // 输出 [1 2 3](s2 仍指向旧数组)
  2. 扩容的内存分配位置
    新底层数组始终分配在堆上(即使原 Slice 是栈上的),因为 Slice 可能被外部引用(如返回给其他函数),生命周期需延长至堆级别。运行时的逃逸分析会确保扩容后的数组逃逸到堆。另外,容量过大的 Slice 也必然会被分配到堆上,以防止栈溢出,目前栈的内存一般为 2MB
  3. 复制成本与性能优化
    小数组的 2 倍扩容虽然会增加单次复制成本,但减少了未来扩容次数(如从 3→6→12,仅需两次扩容即可支持 12 个元素)。
    大数组的 1.65 倍扩容平衡了内存利用率和复制成本(如从 512→848,仅增加 336 个元素空间)。
  4. 避免频繁扩容
    在已知需要大量元素时,可通过 make 预分配足够的容量,减少扩容次数:

    // 预分配容量为 1000 的 Slice(避免多次扩容)
    s := make([]int, 0, 1000) 
    for i := 0; i < 1000; i++ {
     s = append(s, i) // 不会触发扩容,直接填充预分配的空间
    }

关于扩容系数的调整,详细信息可以查看这个 commit


soroqer
71 声望9 粉丝