Go切片理解与性能分析

712 阅读5分钟

1 切片本质

因为操作灵活,可动态分配空间,使得我们在Go中经常使用切片。那么切片是怎么实现的呢?

1.1 数组

切片的实现是由数组抽象出来的。而数组具有以下性质

  • 定义时已经固定了长度大小
  • 内存中为一段连续的存储空间
  • 每个元素类型相同

不同长度的数组无法进行赋值
 a := [3]int{1, 3, 2}
 b := [4]int{1, 2, 3, 4}
 a = b // cannot use b (variable of type [4]int) as type [3]int in assignment

自动推导数组长度
a := [...]int{1, 3, 2}
fmt.Println(len(a)) // 3

go中函数传参是按值传递的,函数栈会拷贝一份参数的值,之后随着函数栈回收,拷贝参数的内存也被回收。 由于是拷贝,因此在函数中改变传入数组的值并不会改变源数组的值,除非使用数组指针当作参数传递。
func updateArray(arr [3]int) (b [3]int) {
	arr[0] = 0
	b = arr
	return
}

func updateSourceArray(arr *[3]int) (b [3]int) {
	arr[0] = -1
	b = *arr
	return
}

func TestArray(t *testing.T) {
	var a = [3]int{1, 2, 3}
	b := updateArray(a)
	fmt.Printf("a: %v, b: %v\n", a, b) // a: [1 2 3], b: [0 2 3] a未改变

	b = updateSourceArray(&a)
	fmt.Printf("a: %v, b: %v\n", a, b) // a: [-1 2 3], b: [-1 2 3] a改变 
}

1.2 切片

数据结构

type slice struct {
	array unsafe.Pointer
	len   int
	cap   int
}

len表示切片的长度,即储存了真实数据的数量。cap表示切片的容量,即切片最多能接纳多少数量的数据。 array是指向任意类型的unsafe.Pointer指针

type Pointer *ArbitraryType
//ArbitraryType仅用于文档目的,实际上不是
//unsafe包的一部分。它表示任意Go表达式的类型。
type ArbitraryType int

那么切片如何通过一个unsafe.Pointer指针来构建一个数组的呢?

执行时先是通过makeslice函数计算切片要占用的内存空间以及申请一片连续的内存。 计算方式为

切片内存 = 切片单个元素大小 * 申请容量

若发现有切片内存超过最大内存,长度小于0,长度大于容量的情况,就会报错,停止申请内存空间,否则使用mallocgc函数去申请内存。

func makeslice(et *_type, len, cap int) unsafe.Pointer {
   mem, overflow := math.MulUintptr(et.size, uintptr(cap))
   if overflow || mem > maxAlloc || len < 0 || len > cap {
      // NOTE: Produce a 'len out of range' error instead of a
      // 'cap out of range' error when someone does make([]T, bignumber).
      // 'cap out of range' is true too, but since the cap is only being
      // supplied implicitly, saying len is clearer.
      // See golang.org/issue/4085.
      mem, overflow := math.MulUintptr(et.size, uintptr(len))
      if overflow || mem > maxAlloc || len < 0 {
         panicmakeslicelen()
      }
      panicmakeslicecap()
   }

   return mallocgc(mem, et, true)
}

mallocgc函数内容过多,在此不深入分析。只需知道,若申请空间较小时会由per-P缓存的空闲列表分配,若大于32kB则直接在堆中分配内存。

// Allocate an object of size bytes.
// Small objects are allocated from the per-P cache's free lists.
// Large objects (> 32 kB) are allocated straight from the heap.
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
    ...
}

2 切片的空间变化

append(slice []Type, elems ...Type) 传入指定切片以及要添加的元素,然后返回新的切片。在此测试下切片随着元素增加,容量改变的情况。

func TestSlice(t *testing.T) {
   const num = 4000 // 要插入4000个元素
   var a []int
   b := 0

   for i := 0; i < num; i++ {
      a = append(a, i)
      
      // 容量变化时打印
      if cap(a) != b {
         fmt.Printf("len: %d, cap: %d\n", len(a), cap(a))
         b = cap(a)
      }
   }
}

image.png

由此看来,切片是按0,1,2,4,8翻倍来扩容的,直到512才以一定容量扩增。小容量时翻倍扩增是为了减少频繁的内存分配而导致的性能下降,因为这次切片扩容了,说明该切片很快下次还会扩容。而大容量时不翻倍扩增容量是为了减少内存的消耗,512的容量一般足够用了,再翻倍的话剩余空间会浪费。

注意,若slice的容量足够,则返回切片仍和slice共用同一底层数组,所以一般仍以slice接收返回切片。slice容量不够,才会通过make, copy来创建新的底层数组。

3 切片的操作

切片的内置函数仅为四个,分别是 len()cap() 查看切片的len,cap属性。 copy() 复制切片, append() 添加元素到指定切片,然后返回新的切片。 而我们仅通过append函数便可以实现切片的复制、插入、删除操作。

copy

b := make([]T, len(a))
copy(b, a)
b := append([]T(nil), a...)

Insert

a = append(a[:i], append([]T{x}, a[i:]...)...)

delete

a = append(a[:i], a[i+1:]...)

delete(GC)

将最后的空间置空,方便回收

if i < len(a) - 1 {
    copy(a[i:], a[i+1:]) 
}
a[len(a)-1] = nil
a = a[:len(a)-1]

4 切片的性能分析

在原切片基础上进行切片截取操作,由于没有创建新的底层数组,因此使用的是原切片的底层数组。若截取切片的变量一直没有取消引用,则底层数组的空间也会一直没有释放,会带来空间浪费,对此建议使用copy来替代截取操作。以下做出测试。

func lastNumsByCopy(arr []int) []int {
   dest := make([]int, 2)
   copy(dest, arr[len(arr)-2:])
   return dest
}

func lastNumsBySlice(arr []int) []int {
   return arr[len(arr)-2:]
}

分别使用copy和截取两种方式获取切片的最后两个元素

  • 第一种是copy切片返回一个新的切片
  • 第二种是截取切片,返回引用的切片
func insertWithCap(cap int) []int {
   rand.Seed(time.Now().UnixNano())
   result := make([]int, cap)
   for i := 0; i < cap; i++ {
      result[i] = rand.Int()
   }
   return result
}

随机生成cap大小的切片。

在64位操作系统中,1个int刚好是 8 Byte,128个int即 8 * 128 = 1024 = 1K内存, 而1K * 1024 = 1M内存,由此知 128 * 1024 个int刚好为1M内存。

func printMen(t *testing.T) {
   t.Helper()
   var m runtime.MemStats
   runtime.ReadMemStats(&m)
   t.Logf("%.2f M", float64(m.Alloc/1024.0/1024.0))
}

func useLastElement(t *testing.T, f func([]int) []int) {
   t.Helper()
   ans := make([][]int, 0)
   for i := 0; i < 100; i++ {
      origin := insertWithCap(128 * 1024) // 1M大小
      ans = append(ans, f(origin))
   }

   printMen(t)
   _ = ans // 引用ans
}

useLastElement中,我们遍历了100次,每次都生成1M大小的切片, 然后我们使用f函数得到截取的切片,之后储存并使用。

printMen是打印函数栈分配的内存情况。

func TestWayOfSlice(t *testing.T) {
   useLastElement(t, lastNumsBySlice)
}

func TestWayOfCopy(t *testing.T) {
   useLastElement(t, lastNumsByCopy)
}

传入我们截取切片的两种函数进行测试,结果如下

image.png

由此可知,截取切片的变量一直引用原切片的底层数组,导致原切片1M大小的底层数组内存无法释放,空间开销巨大。而copy则新建空间,使原切片内存被回收,空间开销小

推荐阅读

切片(slice)性能及陷阱