Golang - slice 内部实现原理解析
一.Go中的数组和slice的关系
1.数组
在几乎所有的计算机语言中,数组的实现都是一段连续的内存空间,Go语言数组的实现也是如此,但是Go语言中的数组和C语言中数组还是有所不同的
- C语言数组变量是指向数组第一个元素的指针
- Go语言的数组是一个值,一个数组变量就代表整个数组,意味着Go语言的数组在传递的时候,传递是是原数组的拷贝!
这也就意味着数组在传递的时候,对大数组来说,内存代价会非常大,影响性能,传递数组指针可以解决这个问题,但是数组指针也有一个弊端:
- 原数组的指针指向改变了,那函数里面的指针指向也会跟着改变,某些情况下,可能会产生意想不到的bug
slice的出现,便是为了解决这个问题
2.slice
先来看一张图,上图中,ptr就是指向底层数组的指针,len是指slice的长度,cap是指slice的容量
- slice本身并不是动态数组或者数组指针,它的内部实现是通过指针引用底层数组,设置相关的属性,将数据的读写操作限定在指定的区域内
- slice本身是一个只读读写,你修改的是底层数组,而不是slice本身,其工作机制类似于数组指针的一种封装
- slice是对数组中一个连续片段的引用,所以slice是一个引用类型
当然从宏观和使用上来说,你可以将slice当做一个长度可变的数组,类似C++的Vector。
二.slice的初始化方式
方式1:字面量
s = s[2:4]
指针指向s[2],容量是3,长度是2
需要注意的是,尽量不要采用字面量这种方式初始化slice,除非情况特殊,因为一个字面量数组可以初始化很多个slice,修改一个slice,会影响另一个slice的值,因为引用的都是同一个底层数组
比如下图
sliceA和sliceB都是同一个底层数组,并且有重叠的部分!Array[2],30
方式2:make
s := make([]byte, 5)
最为安全的slice初始化方式,推荐使用,除非业务特殊你实在想让你的slice共用同一个底层数组,再补充一个图,来说明slice长度和容量的区别
长度4,代表此时4个元素,容量6,代表总共可以装6个元素,还有两个位置空闲
三.slice的扩容规则
slice可以理解为动态数组,既然是动态数组,那必然需要进行扩容,slice扩容遵循以下规则:
- slice容量小于1024个元素,则扩容后容量直接翻倍,
- slice容量不小于1024个元素,则每次增加原来容量是四分之一
- 如果扩容后,还是比底层数组的容量小,那么slice的指针还是指向原来的底层数组。
- 如果扩容后,超过了底层数组的容量,那么会开辟一块新内存,并将原来的值拷贝过来,这种情况,slice的任何操作都不会影响原底层数组
四. slice的拷贝
1.浅拷贝情况
- 浅拷贝,拷贝的是地址,只是复制指向对象的指针
- slice是引用类型数据,默认引用类型数据,全部都是浅拷贝,slice,Map等
slice2 := slice1
- slice1和slice2指向的都是同一个底层数组,任何一个数组元素被改变,都可能会影响两个slice
- 在slice触发扩容操作前,slice1和slice2指向的都是相同数组,但在触发扩容操作后,二者指向的就不一定是相同的底层数组了,具体可参考上诉slice的扩容规则
2. 深拷贝情况
- 深拷贝,拷贝的是数据本身,会创建一个新对象
copy(slice2, slice1)
- 新对象和原对象不共享内存,在新建对象的内存中开辟一个新的内存地址,新对象的值修改不会影响原对象值,既然内存地址不同,释放内存地址时,可以分别释放
五. slice内存泄露情况
当slice的底层数组很大,但slice所取元素数量很小时,底层数组占据的大部分空间都是被浪费的
- 比如b数组很大,slice a只引用了b很小的一部分,只要slice a还在,b数组就永远不会被回收,就是造成了内存泄露!
var a []int
func test(b []int) {
a = b[:1] // 和b共用一个底层数组
return
}
解决方法:
- 不再引用b数组,将需要的数据复制到一个新的slice中,这样新slice的底层数组,就和b数组无任何关系了
var a []int
func test(b []int) {
a = make([]int, 1)
copy(a, b[:0])
return
}
六. slice 非并发安全
slice不是并发安全的,要并发安全,有两种方法:
- 加锁
- channle
1.加锁
适合于对性能要求不高的场景,毕竟锁的粒度太大,这种方式属于通过共享内存来实现通信
func TestSliceConcurrencySafeByMutex(t *testing.T) {
var lock sync.Mutex //互斥锁
a := make([]int, 0)
var wg sync.WaitGroup
for i := 0; i < 10000; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
lock.Lock()
defer lock.Unlock()
a = append(a, i)
}(i)
}
wg.Wait()
t.Log(len(a))
// equal 10000
}
2.channle
适合于对性能要求大的场景,channle就是专用于goroutine间通信的,这种方式属于通过通信来实现共享内存,而Go的箴言便是:尽量通过通信来实现内存共享,而不是通过共享内存来实现通信,推荐此方法!
func TestSliceConcurrencySafeByChanel(t *testing.T) {
buffer := make(chan int)
a := make([]int, 0)
// 消费者
go func() {
for v := range buffer {
a = append(a, v)
}
}()
// 生产者
var wg sync.WaitGroup
for i := 0; i < 10000; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
buffer <- i
}(i)
}
wg.Wait()
t.Log(len(a))
// equal 10000
}
七. 小结
根据上述内容,可以总结出以下几点:
- 创建slice时应根据实际需要预分配容量,避免追加过程中频繁扩容,有助于性能提升
- slice是非并发安全的,如要实现并发安全,请采用锁或channle
- 大数组作为函数参数时,会复制整个数组,消耗过多内存,建议采用slice或指针
- 如果只用到大的slice或数组的一部分,建议将需要部分复制到新的slice中取,减少内存占用
- 多个slice指向相同的底层数组时,修改其中一个slice,可能会影响其他slice的值
- slice作为参数传递时,比数组更为高效,因为slice本身的结构就比较小!所以你参数传递时,传slice和传slice的引用,其实开销区别不大
- slice在扩容时,可能会发生底层数组的变更和内存拷贝
参考: