《Go总结》- Slice

268 阅读4分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第1天,点击查看活动详情

之前基本没有写过文章,所以试着把用到的东西或者看到过的面试题的理解作为笔记的形式记录下来,希望以后用到的时候可以快速的构建知识体系。开始阶段先总结一下Go中的数据类型(Slice,Map,Chan,Struct等)。本篇主要总结Slice切片的一些个人理解。当前使用的Go版本为1.17.8

主要内容

  • 数组和切片的区别
  • 切片数据结构介绍
  • 切片扩容机制
  • 总结

数组和切片的区别

Golang中数组是确定大小的,声明的时候需要指定大小,切片可以理解为对数组的封装,是可以动态扩容的。

切片数据结构介绍

源码位置 : src/runtime/slice.go

 type slice struct {
     array unsafe.Pointer
     len   int
     cap   int
 }
  1. array是一个底层数组,可以被多个切片引用
  2. len是当前切片所拥有的元素个数
  3. cap是当前切片可用的底层数组的大小,也就是当前切片的容量(看图)

看这样一个程序来理解len和cap的区别

 s1 := []int{0, 0, 0, 0, 0}
 s2 := s1[2:4]
 fmt.Printf("s1 : len : %d, cap : %d\n ", len(s1), cap(s1))
 fmt.Printf("s2 : len : %d, cap : %d\n ", len(s2), cap(s2))

输出:

 s1 : len : 5, cap : 5
 s2 : len : 2, cap : 3

画图看一下

那么这两个切片底层数组是同一个么,程序来验证下

 s1 := []int{0, 0, 0, 0, 0}
 s2 := s1[2:4]
 s1[2] = 1
 fmt.Println(s1)
 fmt.Println(s2)
 
 //输出
 [0 0 1 0 0]
 [1 0]

可以看到修改了底层数组的第三个位置的元素值,两个切片的相应值也发生了变化。

切片扩容规则

直接看源码 --Go1.17.8

在Go中,int类型字节数取决于你的环境,比如我的环境是64位机,那么int和int64一样都是占8个字节。可以使用unsafe.Sizeof测试一下,后续需要用到计算。

 var a int
 var b int64
 var c byte
 fmt.Println("bytes : ", unsafe.Sizeof(a))
 fmt.Println("bytes : ", unsafe.Sizeof(b))
 fmt.Println("bytes : ", unsafe.Sizeof(c))
 ​
 //输出
 bytes :  8
 bytes :  8
 bytes :  1
Go的切片扩容总的来说有两部分

(1)第一部分:根据旧切片容量和期望容量算出一个新容量

(2)第二部分:根据数据类型对第一步的容量进行一个RoundUp修正操作

第一部分----根据旧切片容量和期望容量计算出一个新容量,以如下场景进行推理计算

 func main(){
     s := make([]int, 3)
     fmt.Println("扩容前容量:", cap(s))
     s = append(s, 1, 2, 3, 4)
     fmt.Println("扩容后容量:", cap(s))
 }
 ​
 //输出
 扩容前容量: 3
 扩容后容量: 8
 ​
 func growslice(et *_type, old slice, cap int) slice {
     //et指的是元素的类型,old是原切片,cap是期望容量,需要的容量,也就是旧切片的容量 + 新加入的元素的个数(比如现在切片容量为3,向切片append 4个元素,那么期望容量就是7)
     newcap := old.cap   //newcap = 3 
     doublecap := newcap + newcap  //doublecap = 6
     if cap > doublecap {  //根据当前场景执行这个if分支,所以最后的newcap容量就是7
         newcap = cap
     } else {
         if old.cap < 1024 {
             newcap = doublecap
         } else {
             // Check 0 < newcap to detect overflow
             // and prevent an infinite loop.
             for 0 < newcap && newcap < cap {
                 newcap += newcap / 4
             }
             // Set newcap to the requested cap when
             // the newcap calculation overflowed.
             if newcap <= 0 {
                 newcap = cap
             }
         }
     }
 }

通过上述计算发现newcap并不是输出的8,因为还要进行一步修正操作。除此之外根据源码,对第一步计算可以做一下如下的总结

1.如果需要的容量大于当前容量的两倍,那么新的容量就是需要的容量

2.如果需要的容量小于当前容量的两倍

    2.1. 如果旧容量小于1024,那么扩容变成原来的22.2. 如果旧容量大于1024,那么扩容变成原来的1.25

PS: Go1.18版本这个1024阈值改成了256,后面的1/4的增长也做了改变

第二部分----根据数据类型进行一个RoundUp修正操作

 //根据et元素的类型会进行一个分支选择,根据上面测试,int类型在64位机为8字节. 看一下sys,PtrSize介绍在64位上也是8,所以会进入第二分支, 根据上面第一步计算newcap = 7  
 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)  //我们只看容量,根据计算推理为rounduosize(7*8),下面跳转
    overflow = uintptr(newcap) > maxAlloc/sys.PtrSize
    newcap = int(capmem / sys.PtrSize)
 case isPowerOfTwo(et.size):
    var shift uintptr
    if sys.PtrSize == 8 {
       // Mask shift for better code generation.
       shift = uintptr(sys.Ctz64(uint64(et.size))) & 63
    } else {
       shift = uintptr(sys.Ctz32(uint32(et.size))) & 31
    }
    lenmem = uintptr(old.len) << shift
    newlenmem = uintptr(cap) << shift
    capmem = roundupsize(uintptr(newcap) << shift)
    overflow = uintptr(newcap) > (maxAlloc >> shift)
    newcap = int(capmem >> shift)
 default:
    lenmem = uintptr(old.len) * et.size
    newlenmem = uintptr(cap) * et.size
    capmem, overflow = math.MulUintptr(et.size, uintptr(newcap))
    capmem = roundupsize(capmem)
    newcap = int(capmem / et.size)
 }
 ​
 ​
 //然看一下rounduosize函数,此时size为56 
 func roundupsize(size uintptr) uintptr {
     if size < _MaxSmallSize {  // _MaxSmallSize为常量 32768
         if size <= smallSizeMax-8 {  //smallSizeMax为常量1024那么此时会进入这个分支
             //可以看到进行了三次套娃操作。。smallSizeDiv为常量 8,继续跳转
             return uintptr(class_to_size[size_to_class8[divRoundUp(size, smallSizeDiv)]])
         } else {
             return uintptr(class_to_size[size_to_class128[divRoundUp(size-smallSizeMax, largeSizeDiv)]])
         }
     }
     if size+_PageSize < size {
         return size
     }
     return alignUp(size, _PageSize)
 }
 ​
 ​
 //看一下divRoundUp(56,8) 
 func divRoundUp(n, a uintptr) uintptr {
     // a is generally a power of two. This will get inlined and
     // the compiler will optimize the division.
     return (n + a - 1) / a
 }
 ​
 //输入两个值后输出为7 
 //然后此时看一下size_to_class8[7],可以看到第7个元素为6 
 var size_to_class8 = [smallSizeMax/smallSizeDiv + 1]uint8{0, 1, 2, 3, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13,} //未展示全部 
 //然后我们看一下class_to_size[6],可以看到第6个元素为64
 var class_to_size = [_NumSizeClasses]uint16{0, 8, 16, 24, 32, 48, 64, 80, 96, 112, 128, 144, 160, 176, 192, 208, 224, 240} //未展示全部 
 ​
 //回到最开始代码的分支
 case et.size == sys.PtrSize:  //当前用例进入这个分支
 lenmem = uintptr(old.len) * sys.PtrSize
 newlenmem = uintptr(cap) * sys.PtrSize
 capmem = roundupsize(uintptr(newcap) * sys.PtrSize) //此时capmem计算为64
 overflow = uintptr(newcap) > maxAlloc/sys.PtrSize
 newcap = int(capmem / sys.PtrSize) //计算newcap = int(64 / 8) = 8 
 //所以最后cap的值为8
      

总结


根据上面的推理,来对扩容机制做一下总结

1.根据现有的容量和需要的容量计算出一个新容量值

    1.1.如果需要的容量大于当前容量的两倍,那么新的容量就是需要的容量

    1.2。如果需要的容量小于当前容量的两倍

        1.2.1. 如果当前容量小于阈值,那么扩容为原来的2倍(Go1.181.17的阈值不一样)

        1.2.2. 如果当前容量大于阈值,那么扩容为原来的1.25倍(Go1.18也做了规则修改)

2.根据元素的数据类型,对计算出的新的容量进行修正

这里主要对扩容机制做了介绍,我感觉Slice中扩容是会被经常问到的知识点,扩容的情形也有很多的情况分支,我没有一一去实验测试,如果发现上面推理有错误的话请及时指出。由于我也是在学习过程中,文章只是用来进行总结,所以里面可能难免会有一些错误,如果发现请及时联系我,我会及时修正笔记🌹。