掘金 后端 ( ) • 2024-04-16 09:28

写在文章开头

从一个Java的开发角度来看,切片我们可以理解为Java中的ArrayList即一种动态数组的实现,本文会从源码的角度对切片进行深入剖析,希望对你有帮助。

Hi,我是 sharkChili ,是个不断在硬核技术上作死的 java coder ,是 CSDN的博客专家 ,也是开源项目 Java Guide 的维护者之一,熟悉 Java 也会一点 Go ,偶尔也会在 C源码 边缘徘徊。写过很多有意思的技术博客,也还在研究并输出技术的路上,希望我的文章对你有帮助,非常欢迎你关注我的公众号: 写代码的SharkChili

因为近期收到很多读者的私信,所以也专门创建了一个交流群,感兴趣的读者可以通过上方的公众号获取笔者的联系方式完成好友添加,点击备注 “加群” 即可和笔者和笔者的朋友们进行深入交流。

详解go语言切片

切片的底层结构

我们从切片的实现类slice.go中可以看到其结构体,其本质是通过一个指针array 指向数组,然后用len记录当前使用的长度以及数组的对应容量:

type slice struct {
 array unsafe.Pointer
 len   int
 cap   int
}

就像下面这张图一样,这就是一个len为3,而cap即容量为5的切片的样子。

字面量创建切片

创建切片的方式分别又字面量和常量两种方式创建,字面量创建切片:

 s := []int{1, 2, 3, 4, 5}

然后运行下面这段命令查看生成的汇编码:

go build -gcflags -S main.go

这样一段简单的代码,执行了3个步骤:

  1. 创建长度为5的数组的指针。
  2. 通过newobject构建切片对象。
  3. 分配lencap两个变量的地址空间,这两个变量分别记录当前切片已使用长度和总容量。
  # 创建一个数组指针
   0x0018 00024 (F:\github\test\main.go:7) LEAQ    type:[5]int(SB), AX
        0x001f 00031 (F:\github\test\main.go:7) PCDATA  $1, $0
        0x001f 00031 (F:\github\test\main.go:7) NOP
  # 构建切片对象
        0x0020 00032 (F:\github\test\main.go:7) CALL    runtime.newobject(SB)
        0x0025 00037 (F:\github\test\main.go:7) MOVQ    $1, (AX)
        # 分配len和cap的空间地址
        0x002c 00044 (F:\github\test\main.go:7) MOVQ    $2, 8(AX)
        0x0034 00052 (F:\github\test\main.go:7) MOVQ    $3, 16(AX)

make语法创建切片

我们也可以用make创建一个切片,如下所示,这就是创建一个长度为1且容量为1的切片:

 // 创建一个长度为5,容量为5的整数切片
 slice := make([]int, 1, 1)
 slice = append(slice, 1)
 fmt.Println(slice)

通过指令查看查看汇编码,可以看到使用make创建切片本质是调用了makeslice方法:

 0x0018 00024 (F:\github\test\main.go:7) LEAQ    type:int(SB), AX
0x001f 00031 (F:\github\test\main.go:7) MOVL    $10, BX
0x0024 00036 (F:\github\test\main.go:7) MOVQ    BX, CX
0x0027 00039 (F:\github\test\main.go:7) PCDATA  $1, $0
0x0027 00039 (F:\github\test\main.go:7) CALL    runtime.makeslice(SB)

这里我们也给出slice.gomakeslice方法的基本框架:

func makeslice(et *_type, len, cap int) unsafe.Pointer {
 mem, overflow := math.MulUintptr(et.size, uintptr(cap))
 //.......

 return mallocgc(mem, et, true)
}

切片的扩容机制

都说切片会动态扩容,这里我们创建一个容量为10的切片,在容量以内添加元素,其容量和size都没有变化,一旦追加元素就会触发扩容,可以看到此时已用size为11,容量扩容为原来的1倍,最终容量变为20:

func main() {
 //len和cap都为10
 slice := make([]int, 10)
 fmt.Println(len(slice))
 fmt.Println(cap(slice))
 //len和cap都为10
 slice[0] = 1
 fmt.Println(len(slice))
 fmt.Println(cap(slice))
 //追加一个 len为11 cap为20(两倍扩容)
 s1 := append(slice, 11)
 fmt.Println(len(s1))
 fmt.Println(cap(s1))

}

我们通过slice.go定位到了扩容的方法growslice即看到扩容的代码,常规情况下如果追加后的长度大于原有容量的2倍,那么lencap都为新长度:

反之若追加后的元素小于容量的2倍,则直接进行2倍扩容即可:

若新的长度大于容量的2倍则判断旧的容量是否超256,则会按照累加(256*3+(大于256的新长度))/4得到一个值,这一点我们不妨使用极限思维来分析,假设我们追加后的长度为257,按照这个公式我们得出:

1. 旧的容量为256
2. 追加后的长度为257
3. 由公式得新容量等于(256*3+257)/4即找到区域256的最大值,最终得到256.25+旧容量256取整后为512
4. 基于此计算方式我们不断进行推算,得到10w扩容为125192无限趋近于1.25倍,由此可以得出大于256后的扩容会由2倍扩容逐步转为1.25倍扩容的稳定过度。。

这一点我们也可以从growslice的源码中得以印证:

func growslice(oldPtr unsafe.Pointer, newLen, oldCap, num int, et *_type) slice {

 //......

 //默认新容量为oldCap即旧的容量值
 newcap := oldCap
 //获取当前容量的2倍
 doublecap := newcap + newcap
 //如果添加新元素后的len大于当前容量2倍则取newLen
 if newLen > doublecap {
  newcap = newLen
 } else {
  const threshold = 256
  //旧容量小于256则两倍扩容
  if oldCap < threshold {
   newcap = doublecap
  } else {
   //通过该公式实现随着容量不断递增,扩容逐渐递减,得到一个从2倍到1.25倍扩容的一个过渡
   for 0 < newcap && newcap < newLen {
    newcap += (newcap + 3*threshold) / 4
   }
   
   if newcap <= 0 {
    newcap = newLen
   }
  }
 }

 //.......
}

小结

本文通过切片的创建结合汇编码了解的切片底层数据结构和创建过程,再通过代码示例结合源码的方式了解了切片的动态扩容机制,了解切片在扩容时如何在空间和时间上实现折中,希望对你有帮助。

我是 sharkchiliCSDN Java 领域博客专家开源项目—JavaGuide contributor,我想写一些有意思的东西,希望对你有帮助,如果你想实时收到我写的硬核的文章也欢迎你关注我的公众号: 写代码的SharkChili 。 因为近期收到很多读者的私信,所以也专门创建了一个交流群,感兴趣的读者可以通过上方的公众号获取笔者的联系方式完成好友添加,点击备注 “加群” 即可和笔者和笔者的朋友们进行深入交流。

本文使用 markdown.com.cn 排版