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)
}
}
}
由此看来,切片是按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)
}
传入我们截取切片的两种函数进行测试,结果如下
由此可知,截取切片的变量一直引用原切片的底层数组,导致原切片1M大小的底层数组内存无法释放,空间开销巨大。而copy则新建空间,使原切片内存被回收,空间开销小
推荐阅读