Golang基础数据结构—切片

177 阅读10分钟

0. 数组

在介绍go的切片之前,我们需要先了解一下go的数组。

数组是一个由固定长度特定类型元素组成的序列,数组长度是其类型的组成部分,如下:

a := [...]int{1, 2, 3} // 新建一个长度为3,类型为int的数组
a = [4]int{1, 2, 3, 4} // 编译器报错,长度为3的数组不能和长度为3的数组互相赋值

Go语言中数组是值语义。一个数组变量就是整个数组,并不像C语言一样会有一个隐式的指向第一个元素的指针,而是一个完整的值。这也就是说,数组本身的赋值和函数传参都是以整体复制的方式处理的。

a := [3]int{1, 2, 3}
t.Log(a) // [1 2 3]

changeArray := func(a [3]int) {
   a[0]++
   a[1]++
   a[2]++
   t.Log(a) // [2 3 4]
}

changeArray(a)
t.Log(a) // [1 2 3]

长度为 0 的数组在内存中并不占用空间。

a := [0]int{}
t.Logf("%p, %d, %d", &a, len(a), cap(a))

b := struct{}{}
t.Logf("%p", &b)

如上,发现ab的地址打印是相同的,这是因为在go中,所有不占空间的量的地址都是zerobase,实际并不会为ab分配空间。

// base address for all 0-byte allocations
var zerobase uintptr

1. 切片

切片就是一种简化版的动态数组,切片的底层是基于数组的。因为动态数组的长度是不固定,切片的长度自然也就不能是类型的组成部分了。因为切片的灵活性,在go中的应用比数组要频繁的多。首先我们看一下切片的定义方式:

var (
    a []int               // nil 切片, 和 nil 相等, 一般用来表示一个不存在的切片
    b = []int{}           // 空切片, 和 nil 不相等, 一般用来表示一个空的集合
    c = []int{1, 2, 3}    // 有 3 个元素的切片, len 和 cap 都为 3
    d = c[:2]             // 有 2 个元素的切片, len 为 2, cap 为 3
    e = c[0:2:cap(c)]     // 有 2 个元素的切片, len 为 2, cap 为 3
    f = c[:0]             // 有 0 个元素的切片, len 为 0, cap 为 3
    g = make([]int, 3)    // 有 3 个元素的切片, len 和 cap 都为 3
    h = make([]int, 2, 3) // 有 2 个元素的切片, len 为 2, cap 为 3
    i = make([]int, 0, 3) // 有 0 个元素的切片, len 为 0, cap 为 3
)

之后看一下go中的切片在运行时的底层结构,定义如下:

// SliceHeader is the runtime representation of a slice.
// It cannot be used safely or portably and its representation may
// change in a later release.
// Moreover, the Data field is not sufficient to guarantee the data
// it references will not be garbage collected, so programs must keep
// a separate, correctly typed pointer to the underlying data.
type SliceHeader struct {
   Data uintptr
   Len  int
   Cap  int
}

可以看到,切片结构由三部分信息组成:指向底层数据的指针Data、切片长度Len和指向切片底层数组内存空间的最大容量Cap,也就是说,对于切片数据的赋值操作其实是SliceHeader结构体的复制,不会涉及到底层数据的复制。

下面,我们将通过一些例子看一下切片一些操作的底层内存操作。

1.1 切片的底层存储

首先,我们给出一个打印切片各个元素、地址、长度和容量的函数:

func printSlice(s []int) {
   fmt.Printf("%+v, addr: %p, len: %d, cap: %d\n", s, s, len(s), cap(s))
}

然后我们初始化一个新的切片sliceA,并通过sliceA新建一个sliceB,以下所有的实验都是以此两个切片为基础:

sliceA := make([]int, 0, 8)
sliceA = append(sliceA, []int{1, 2, 3, 4, 5}...)
printSlice(sliceA) // [1 2 3 4 5], addr: 0x140000282c0, len: 5, cap: 8

sliceB := sliceA[2:4]
printSlice(sliceB) // [3 4], addr: 0x140000282d0, len: 2, cap: 6

其在内存中如下图所示,通过make函数func make([]T, len, cap) []T生成的sliceA,其底层数组的长度是cap值,也就是8。

sliceB通过通过切片操作sliceA[2:4]生成,其和sliceA指向的底层数组是同一个,但是指针指向的位置向后偏移两个int占据的字节长度(16个字节)。其cap为指针到底层数组最后位置的容量。

因为sliceAsliceB使用的底层数组一致,所以对于两个切片都涉及的元素的修改,都会影响另一个,譬如:

sliceB[0] = 100
printSlice(sliceA) // [1 2 100 4 5], addr: 0x1400010c200, len: 5, cap: 8
printSlice(sliceB) // [100 4], addr: 0x1400010c210, len: 2, cap: 6

可以看到,对于sliceB[0]的修改,也会影响到sliceA,切片的底层是数组,通过切片操作得来的新切片和旧切片共用底层数组,对值的修改可能会同时改变二者,使用中需要慎重。

1.2 添加切片元素

1.2.1 在尾部追加(append)

append操作时有两种场景:

当append之后的长度小于等于容量

当append之后的长度小于等于cap,将会直接利用底层数组剩余的空间,如下,可以看出,sliceB并没有发生地址变化,其和sliceA还是共用一个底层数组(地址相差0x10);

sliceC := []int{11, 12}
sliceB = append(sliceB, sliceC...)
printSlice(sliceA) // [1 2 3 4 11], addr: 0x140000282c0, len: 5, cap: 8
printSlice(sliceB) // [3 4 11 12], addr: 0x140000282d0, len: 4, cap: 6
printSlice(sliceC) // [11 12], addr: 0x14000024300, len: 2, cap: 2

当append之后的长度大于容量

当append之后的长度大于cap时,将会分片一块更大的区域来容纳新的底层数组,也就是会发生切片扩容,所以在预先知道切片容量的情况下,最好显示地申请切片容量;如下,当sliceBsliceC的长度和超过sliceB现有的cap时,就会发生扩容,扩容后的sliceB发生了变化,反而是发生了扩容切换地址后不会影响sliceA的元素。

sliceA := make([]int, 0, 8)
sliceA = append(sliceA, []int{1, 2, 3, 4, 5}...)
printSlice(sliceA) // [1 2 3 4 5], addr: 0x1400010a200, len: 5, cap: 8

sliceB := sliceA[2:4]
printSlice(sliceB) // [3 4], addr: 0x1400010a210, len: 2, cap: 6

sliceC := []int{11, 12, 13, 14, 15}
sliceB = append(sliceB, sliceC...)
printSlice(sliceA) // [1 2 3 4 5], addr: 0x1400010a200, len: 5, cap: 8
printSlice(sliceB) // [3 4 11 12 13 14 15], addr: 0x140001002a0, len: 7, cap: 12
printSlice(sliceC) // [11 12 13 14 15], addr: 0x14000140030, len: 5, cap: 5

image.png

其实从SliceHeader的结构可以看出,Len字段可以让切片很快索引到最后一位元素,append操作的复杂度是O(1)。至于Go切片扩容的机制,可以参考Go 1.18 全新的切片扩容机制

1.2.2 插入数据

除了在尾部追加数据,在切片头部或者中间插入元素,一般都会导致内存的重新分配,且会导致已有的元素发生复制,所以性能比尾部追加数据要差很多。因此,切片并不适合大量随机插入的场景。以下是在位置i处插入x

a = append(a, 0)     // 切片扩展 1 个空间
copy(a[i+1:], a[i:]) // a[i:] 向后移动 1 个位置
a[i] = x             // 设置新添加的元素

1.3 删除切片元素

1.3.1 删除开头元素

以下方式可以以极高的效率删除开头元素,复杂度是O(1)。但是需要注意的是,底层数组没有发生改变,第0个位置的内存仍旧没有释放。如果有大量这样的操作,头部的内存会一直被占用。

a = a[1:] // 删除开头一个元素,移动指针,复杂度O(1)
a = a[N:] // 删除开头N个元素,移动指针,复杂度O(1)

1.3.2 删除尾部元素

a = a[:len(a)-1] // 删除尾部 1 个元素,复杂度O(1)
a = a[:len(a)-N] // 删除尾部 N 个元素,复杂度O(1)

1.3.3 删除中间元素

a = a[:i+copy(a[i:], a[i+1:])] // 删除中间 1 个元素 
a = a[:i+copy(a[i:], a[i+N:])] // 删除中间 N 个元素

1.4 零值切片和空切片

当一个新Slice初始化的时候,它的底层其实会产生一个隐藏的 Array,这个 Slice 与 Array 内存共享。Slice除了拥有属性 length 以外,还有capacity(即底层 Array 的 length)。

var a []int            // 零值切片
b := []int{}           // 空切片
c := make([]int, 0, 0) // 空切片

printSlice(a) // [], addr: 0x0, len: 0, cap: 0
printSlice(b) // [], addr: 0x1007f9840, len: 0, cap: 0
printSlice(c) // [], addr: 0x1007f9840, len: 0, cap: 0

t.Log(a == nil) // true
t.Log(b == nil) // false
t.Log(c == nil) // false

t.Log(reflect.DeepEqual(a, b)) // false
t.Log(reflect.DeepEqual(a, c)) // false
t.Log(reflect.DeepEqual(b, c)) // true

如上,零值切片和空切片在输出结果,lencap取值上都是相同的,但是:

  • 零值切片的值是nil,但是空切片不是;
  • 零值切片没有地址,空切片可以取址,地址就是上面所述的zerobase,所以切片bc是完全相同的。

2. 切片的内存技巧

2.1 利用切片操作共用底层数组的特性

前面说过,切片的底层是数组,对于切片而言,len0但是cap不为0的切片大有用处,如下可以根据过滤条件原地删除切片元素的算法:

func filter(s []int, f func(int) bool) []int {
   r := s[:0]
   for _, v := range s {
      if !f(v) {
         r = append(r, v)
      }
   }
   return r
}

切片高效操作的要点就是降低内存分配的次数,尽量保证append操作不会引起切片扩容。

也正因为切片操作会共用底层数组的特点,我们在使用中也要注意避免内存泄漏,特别是当只需要大切片的一小部分数据时,最好采用copy将数据拷贝,避免一直占用底层数组,使其得不到释放。

2.2 高效复制切片

C不同,在Go中,一般而言,由make调用分配的切片元素会在调用中被置零,从Go工具链的v1.15开始,在下面的代码中,y[:m]不会被置零(mcopy返回的结果),因为它们将在后面的copy操作中被覆盖。

y := make([]int, len(s))
copy(y, s)

但是,此优化的不足是它需要一些条件才能生效:

  • 被克隆的切片必须呈现出纯标识符(包括限定标识符);
  • make调用只能接受两个参数;
  • copy调用不得在另一语句中呈现为表达式。

如下是一些benchmark测试的结果,可以看出,满足以上条件的make+copy效率确实高些:

func BenchmarkMakeAndCopy(b *testing.B) {
   for i := 0; i < b.N; i++ {
      y := make([]int, len(s))
      copy(y, s)
   }
}

func BenchmarkMakeAndCopy1(b *testing.B) {
   for i := 0; i < b.N; i++ {
      y := make([]int, len(s))
      _ = copy(y, s)
   }
}

func BenchmarkMakeAndCopy2(b *testing.B) {
   for i := 0; i < b.N; i++ {
      y := make([]int, len(s), len(s))
      copy(y, s)
   }
}

func BenchmarkAppend(b *testing.B) {
   var y []int
   for i := 0; i < b.N; i++ {
      y = append([]int(nil), s...)
      _ = y
   }
}

结果是:

BenchmarkMakeAndCopy
BenchmarkMakeAndCopy-8    	  340911	      2992 ns/op
BenchmarkMakeAndCopy1
BenchmarkMakeAndCopy1-8   	  328609	      3598 ns/op
BenchmarkMakeAndCopy2
BenchmarkMakeAndCopy2-8   	  319249	      3609 ns/op
BenchmarkAppend
BenchmarkAppend-8         	  392926	      3456 ns/op

值得注意的是,如果我们将BenchmarkAppend中的_ = y语句放在循环后面,其性能和BenchmarkMakeAndCopy相差无几。

2.3 如果确定append需要重新分配内存,那么裁剪第一个实参会节省内存

如下,不过如果append操作不会触发内存分配的话,那么不要进行裁剪,结果会适得其反。

x := make([]byte, 100, 500)
y := make([]byte, 500)
z1 := append(x, y...)
z2 := append(x[:len(x):len(x)], y...)
t.Log(cap(z1)) // 896
t.Log(cap(z2)) // 640

2.4 切片类型转换

Go语言是无法直接进行类型转换的,有时候我们可以通过unsafe包实现不同类型之间的转换,从而实现零拷贝。比如,当需要对一个[]float64类型的切片进行高速排序时,我们可以将其强制转换为[]int类型(因为 float64 遵循 IEEE754 浮点数标准特性,当浮点数有序时对应的整数也必然是有序的),这可以提升代码的性能。

const size = 10000000

var (
   slice  []int     = make([]int, size)
   sliceF []float64 = make([]float64, size)
)

func init() {
   rand.Seed(2)
   for i := 0; i < size; i++ {
      slice[i] = rand.Int()
      sliceF[i] = rand.NormFloat64()
   }
}

func TestSortFloats(t *testing.T) {
   sort.Float64s(sliceF)
}

func TestSortFloatsByInts(t *testing.T) {
   c := float64sToInts(sliceF)
   sort.Ints(c)
}

func float64sToInts(s []float64) []int {
   var c []int
   aHdr := (*reflect.SliceHeader)(unsafe.Pointer(&s))
   cHdr := (*reflect.SliceHeader)(unsafe.Pointer(&c))
   *cHdr = *aHdr
   
   return c
}

然后分别执行两个test方法,如下:

=== RUN   TestSortFloats
--- PASS: TestSortFloats (1.65s)
=== RUN   TestSortFloatsByInts
--- PASS: TestSortFloatsByInts (1.39s)

可以发现,将[]float64转换为[]int类型后的排序速度,有了明显的提升。这个方法可行的前提是要保证[]float64中没有NaNInf等非规范的浮点数(因为浮点数中NaN不可排序,正0和负0相等,但是整数中没有这类情形)。

切片类型转换的注意事项

如上,我们通过unsafe包实现类型转换,可以实现零拷贝,在一些情况下可以提升代码的性能,但是,我们是需要关注一些注意事项的。例如还是由[]float64类型转换为[]int类型,我们写出如下的方法,看似是没有什么问题的,测试起来好像也没有问题。

func float64sToInts2(s []float64) []int {
   aHdr := (*reflect.SliceHeader)(unsafe.Pointer(&s))

   ch := &reflect.SliceHeader{
      Data: aHdr.Data,
      Len:  aHdr.Len,
      Cap:  aHdr.Cap,
   }

   return *(*[]int)(unsafe.Pointer(ch))
}

但这其实是错误的,在reflect.SliceHeader的定义中明确指出:

Moreover, the Data field is not sufficient to guarantee the data it references will not be garbage collected, so programs must keep a separate, correctly typed pointer to the underlying data.

SliceHeaderData字段是一个uintptr类型。由于 Go 语言只有值传递。因此,在上述代码中,出现将Data字段作为值拷贝,无法保证其所引用的数据不会被GC

func float64sToInts2(s []float64) []int {
   aHdr := (*reflect.SliceHeader)(unsafe.Pointer(&s))

   ch := &reflect.SliceHeader{
      Data: aHdr.Data,
      Len:  aHdr.Len,
      Cap:  aHdr.Cap,
   }
   
   // 在此处,不再有对s的引用,如果发生了GC,那么s会被回收,其对应的底层数组也会被回收

   return *(*[]int)(unsafe.Pointer(ch))
}

3. 小结

本文总结了Go中切片的一些属性,特别是其内存特点值得关注,最后针对于类型转换可能引起GC的问题,值得深思。

4. 参考文献

1.3 数组、字符串和切片

切片(slice)性能及陷阱

Go编程优化101

Go SliceHeader 和 StringHeader,你知道吗?

SliceHeader Literals in Go create a GC Race and Flawed Escape-Analysis. Exploitation with unsafe.Pointer on Real-World Code

Go 1.18 全新的切片扩容机制