Golang-Slice 内部实现原理解析

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在扩容时,可能会发生底层数组的变更和内存拷贝

参考:

posted @ 2022-01-14 17:31  西*风  阅读(338)  评论(0编辑  收藏  举报