Golang | 深入理解数据结构之Slice

前言

日常开发中最常用的数据结构应该就是切片Slice和Map了,本文围绕着Slice的数据结构、扩容方法以及使用过程中需要注意的地方做了深入的介绍

介绍Slice

Slice切片表示一个具有相同数据类型元素的的序列(当然数据类型也可以是interface{}),切片的长度可变,底层使用数组实现。通常写成[]T,其中元素的类型都是T

数据结构

切片在编译期是 cmd/compile/internal/types/type.go 包下的Slice类型,而它的运行时的数据结构位于 reflect.SliceHeader

type SliceHeader struct {
	Data uintptr
	Len  int
	Cap  int
}
复制代码

看一下内存占用,SliceHeader结构占用24个字节

log.Printf("reflect.SliceHeader结构占用的内存大小为:[%d]",unsafe.Sizeof(reflect.SliceHeader{}))
复制代码

image.png

编译期间使用cmd/compile/internal/types/type.go包下的NewSlice方法创建切片

func NewSlice(elem *Type) *Type {
   if t := elem.Cache.slice; t != nil {
      if t.Elem() != elem {
         Fatalf("elem mismatch")
      }
      return t
   }

   t := New(TSLICE)
   t.Extra = Slice{Elem: elem}
   elem.Cache.slice = t
   return t
}
复制代码

获得切片地址和底层数组的指针地址

日常开发不会经常使用到,但可以做了解
getSlicePtr() 函数用于打印切片地址和切片底层数组地址,下文会经常用到

const(
    basePrintInfo = "%s切片的指针地址:[%v],切片数组地址:[%v]"
)

func getSlicePtr(name string, s1 *[]int) { // 需要传slice的指针
	s2 := (*reflect.SliceHeader)(unsafe.Pointer(s1))
	log.Printf(basePrintInfo, name, unsafe.Pointer(s1), unsafe.Pointer(s2.Data))
}
复制代码

image.png

如何定义切片

使用语法糖

s1 := []int{}
复制代码

使用make函数

第一个参数[]int表示切片的类型是int,第二三个参数分别表示len和cap ,cap 大于等于 len

s2 := make([]int , 0 ,0) 
复制代码

var定义切片

使用var声名的切片其实是一个nil切片,它与nil比较返回true
而使用语法糖或者make声名的切片是一个空切片,他们与ni比较返回false

var s3 []int 
复制代码

nil切片指向的数组地址为nil,但一个nil切片依然占24字节大小
image.png

image.png 至今不太理解nil切片的意义,欢迎讨论。

从现有切片复制

s1 := []int{} // 先定义一个现有切片
s1 = append(s1 , 1)
s4 := s1[:1] // 复制现有的切片
复制代码

此处需要注意,s1和s4的地址不同,但他们底层使用相同的数组 ,分别打印他们的地址

image.png

再往s1中添加几条数据,看看结果

s1 := []int{}
s1 = append(s1, 1)
s4 := s1[:1]
log.Println("append之前")
getSlicePtr("s1 ", &s1)
getSlicePtr("s4 ", &s4)
s1 = append(s1, 2, 3, 4, 5, 6, 7, 8)
log.Println("append之后")
getSlicePtr("s1 ", &s1)
getSlicePtr("s4 ", &s4)
复制代码

image.png

  • 可以看出来,随着s1切片大小的扩容,s1切片的地址未发生变化,可是它的底层数组已经指向了另一个数组s4切片的地址和底层数组地址都没有发生变化
  • 由于数组大小是不可变的,随着切片的扩容,切片底层数组的指向会发生变化
  • 同时可以看到,s1和s4切片的地址之间相差24(控制台打印的是16进制,转换成10进制为24),正好是一个SliceHeader结构体的大小

使用反射生成切片

返回reflect.Value类型,需要后续处理,比较麻烦,不建议使用

myType := reflect.TypeOf(i)
slice := reflect.MakeSlice(reflect.SliceOf(myType), 10, 10).Interface()
p := slice.([]int)
p[0]= 1
复制代码

重要方法

切片的扩容

切片使用runtime/slice.go 包下的growslice()方法进行扩容

如果当前切片的容量小于 1024,那么新切片的容量翻倍;
如果当前切片的容量大于 1024,那么新切片的容量每次增加25%

newcap := old.cap
doublecap := newcap + newcap
if cap > doublecap {
   newcap = cap
} else {
   if old.cap < 1024 {
      newcap = doublecap // * 2 
   } else {

      for 0 < newcap && newcap < cap { // 25%
         newcap += newcap / 4 
      }
      if newcap <= 0 {
         newcap = cap
      }
   }
}
复制代码

可是切片扩容的时候真的是那么死板的 翻倍,扩容25%么?当然不是。
切片作为Golang中最基本和最常用的数据结构之一,自然是少不了内存优化的.
原来 newcap只是一个我们的预期容量,实际的容量需要根据切片中的元素大小对齐内存

var overflow bool
var lenmem, newlenmem, capmem uintptr
switch {
case et.size == 1:
   lenmem = uintptr(old.len)
   newlenmem = uintptr(cap)
   capmem = roundupsize(uintptr(newcap))
   overflow = uintptr(newcap) > maxAlloc
   newcap = int(capmem)
case et.size == sys.PtrSize:
   lenmem = uintptr(old.len) * sys.PtrSize
   newlenmem = uintptr(cap) * sys.PtrSize
   capmem = roundupsize(uintptr(newcap) * sys.PtrSize)
   overflow = uintptr(newcap) > maxAlloc/sys.PtrSize
   newcap = int(capmem / sys.PtrSize)
case isPowerOfTwo(et.size):
  ***
default:
  ***
}
复制代码

有关内存对齐的知识可以看这里

最后,会根据切片元素的大小和新容量计算内存,将超出切片长度的内存清空,并拷贝旧切片的内存数据到新申请的内存中,最后返回

var p unsafe.Pointer
if et.ptrdata == 0 {
   p = mallocgc(capmem, nil, false)
   memclrNoHeapPointers(add(p, newlenmem), capmem-newlenmem)
} else {
   p = mallocgc(capmem, et, true)
   if lenmem > 0 && writeBarrier.enabled {
      // 将超出切片长度的内存清空
      bulkBarrierPreWriteSrcOnly(uintptr(p), uintptr(old.array), lenmem-et.size+et.ptrdata)
   }
}
// 将原数组内存中的内容拷贝到新申请的内存中
memmove(p, old.array, lenmem)

return slice{p, old.len, newcap}
复制代码

使用时需要注意

切片是非线程安全的

并发场景使用切片时需要加锁

type SafeSlice struct {
   s    []int
   lock *sync.RWMutex
}

var (
   nums  = 1000 // 协程数
   times = 1000 //循环次数
)

func Benchmark_AppendWithLock(b *testing.B) {
   wg := &sync.WaitGroup{}
   safeSlice := &SafeSlice{
      s:    []int{},
      lock: new(sync.RWMutex),
   }
   wg.Add(nums)

   testFunc := func(wg *sync.WaitGroup, safeSlice *SafeSlice, time int) {
      defer wg.Done()
      for i := 0; i < time; i++ {
         safeSlice.lock.Lock()
         safeSlice.s = append(safeSlice.s, i)
         safeSlice.lock.Unlock()
      }
   }
   b.ResetTimer()
   for i := 0; i < nums; i++ {
      go testFunc(wg, safeSlice, times)
   }
   wg.Wait()
   
   //log.Println("length: ",len(safeSlice.s), " cap: " ,cap(safeSlice.s))
   return
}
复制代码

切片作为参数传递

当切片作为参数传递时,其实只是传了一个实参的拷贝,实参和形参的地址不同,但切片的底层数组会指向同一个数组

func enterTransfer() {
   s := []int{1, 2, 3}
   getSlicePtr("before.s ", &s)
   log.Println("before.s.Data: ",s)

   transferTest1(s)

   getSlicePtr("after.s ",&s)
   log.Println("after.s.Data: ",s)

}

func transferTest(s []int) {
   log.Println("into-transferTest1")
   getSlicePtr("before.s ", &s)
   log.Println("before.s.Data: ", s)
   s[0] = 999
   for i := 0; i < 100; i++ {
      s = append(s, i)
   }
   getSlicePtr("after.s ",&s)
   log.Println("out-transferTest1")
}
复制代码

image.png

  • 我们看到,进入transferTest方法后,切片地址其实已经发生了变化,但是他们底层引用的同一个数组。
  • 当使用 s[0]=999修改了底层数组的数据时,也影响了enterTransfer方法中的切片数据
  • transferTest发生扩容后,切片地址没有变化,可是底层数组的指向已经发生了改变,并且这个改变并没有影响到enterTransfer方法中的切片

那么,我们有没有办法使得transferTest在扩容时后,同时影响enterTransfer切片呢? 当然有的,我们只需要将切片地址作为参数传递就好了

func enterTransfer() {
   s := []int{1, 2, 3}
   getSlicePtr("before.s ", &s)
   log.Println("before.s.Data: ",s)

   transferTest1V2(&s)

   getSlicePtr("after.s ",&s)
   log.Println("after.s.Data: ",s)

}

func transferTest(s []int) {
   log.Println("into-transferTest1")
   getSlicePtr("before.s ", &s)
   log.Println("before.s.Data: ", s)
   s[0] = 999
   for i := 0; i < 100; i++ {
      s = append(s, i)
   }
   getSlicePtr("after.s ",&s)
   log.Println("out-transferTest1")
}
复制代码

image.png

  • 我们可以看到,调用transferTest后,enterTransfer中,切片数组的地址已经被transferTest影响了

内存逃逸

有关内存逃逸的简单介绍可以戳这里
内存逃逸的本质是内存被分配到了堆上 ,对于堆内存的分配不如栈内存快,且需要gc进行内存回收
由于Slice底层还是一个指针,当指针类型作为返回时,会发生内存逃逸,我们来验证一下.

image.png

image.png 可见,切片作为函数返回值时,会发生内存逃逸,因此我们不要通过函数传递过大的切片,会导致内存分配到堆上,增加gc压力

参考资料

go语言设计与实现

猜你喜欢

转载自juejin.im/post/7095097471608553503