go语言Slice的扩容机制

我们都知道在go语言中,slice的底层是数组,当我们对slice增加元素时,实际添加的是底层的数组,但是数组的长度是固定的。如果在新增的元素个数超过底层数组的容量,底层数组就会进行扩容,那么go语言多个版本依赖,它的扩容机制是什么样的呢?网上关于扩容机制有很多说法,但是都是比较笼统,禁不住面试官深入提问,所以这篇文章带你深入了解slice的扩容,让你明白go语言的源码也不难读。

目前go语言的切片扩容机制主要经历了两个大的思路改动,我把它分成1.7版本之前和1.8版本及之后,同时也在其他版本中对某个思路进行优化。那我们先来看看1.7版本及之前的扩容机制

1.7版本

假如你现在是go语言的开发大佬,团队将slice的扩容的机制交给你设计。那么你会怎么思考呢?

让我们从简单的开始,给一个slice扩容,最简单的就是直接在原来的容量上增加一定的数量,你可能会写出如下代码

go 复制代码
 func growslice(old int) int {
   return old + 一个常量  
 }

但是身为大佬的你会想到,不管旧切片的容量大小,每次增加一个固定长度的容量都不合适,比如现在常量比较小,但是旧容量非常大,那你需要调用很多次growslice才能满足,每次调用都是要开辟新的空间的,这样性能不就下去了

聪明的你想到了直接乘以一个倍数,于是你有了新的思路:

go 复制代码
 func growslice(old int) int{
   return old * 2 
 }

那这是不是太简单了,假如old比较小,这样也可以,但是如果old非常大,那么2倍的old岂不是更大,如果内存分配更大地址,但是实际没有那么多的元素填满,既不是浪费了宝贵的内存,这样不用多久就会报 内存溢出的问题了

那解决上面的问题非常简单,设置个阈值,比如1024,只要在旧容量较少的时候扩展2倍,在旧容量比较大的时候,扩展比较少的倍数就行啦,比如1.25倍,然后再简单粗暴些,要是大于2倍还不够,直接设置成所需的容量,于是你写出了这个代码

go 复制代码
 func growslice(old slice,cap int) int {   // cap 需要的容量  old 旧切片结构体
     newcap := old.cap //定义新的容量,初始值为旧容量
     doublecap := newcap + newcap // 定义旧容量的2倍
     
     if cap > doublecap { // 如果需要的容量大于旧容量2倍
       newcap = cap // 直接设置新容量位需要的容量
     } else {
       if old.cap < 1024 {  //如果旧容量少于1024
         newcap = doublecap  // 新容量是原来的2倍
       } else {
         for 0 < newcap && newcap < cap {  //如果大于1024,就每次增加1.25倍
           newcap += newcap / 4
         }
         // Set newcap to the requested cap when
         // the newcap calculation overflowed.
         if newcap <= 0 {
           newcap = cap
         }
       }
     }
 }

恭喜你,你设计了golang 1.17版本之前的切片扩容机制!!!

源码路径: src/runtime/slice.go

1.8版本

在1.18版本之后,扩容机制设计思路发生了一些变化,主要是取消了以一些简单粗暴的直接乘以1.25倍的操作,但是整体的设计思想还是变化不大的,我们先来看看源码

go 复制代码
   newcap := old.cap
   doublecap := newcap + newcap
 ​
   if cap > doublecap {  // 需要的容量大于旧切片的容量
     newcap = cap  //可以看到这一步是没有变化的,如果需要的容量大于旧的容量的2倍,直接扩容到新的容量
   } else {  // 需要的容量小于旧切片的2倍容量
     const threshold = 256 // 这里设置了一个阈值  256
     
     // 接下来判断旧容量的阈值的关系 
     if old.cap < threshold { // 旧容量小于阈值
       newcap = doublecap  // 直接翻倍
     } else {  // 旧容量大于阈值
       // 这是一个循环,直到新容量大于等于需要的容量才退出 
       for 0 < newcap && newcap < cap {
         // 这里取两个极限值看,当newcap无限接近256时,
         // 等价于 newcap += (newcap + 3 * newcap) /4   也就是 newcap += newcap  取两倍
         // 当newcap远大于256时,threshold就可以忽略不看
         // 等价于 newcap += newcap / 4 也就是 newcap = 1.25 * newcap
         newcap += (newcap + 3*threshold) / 4
       }
       // Set newcap to the requested cap when
       // the newcap calculation overflowed.
       if newcap <= 0 {
         newcap = cap
       }
     }
   }

从上面的代码中可以看出,这次改动主要是改动了一个阈值和翻倍时扩容曲线的线性。不再是直接的直接线性,而是无限接近的取值。

而在后面的版本中,go团队将扩容单独写成了一个函数,并优化了代码,比如1.23中

go 复制代码
 func nextslicecap(newLen, oldCap int) int { //newLen 就是新的容量,oldCap就是旧的容量
   newcap := oldCap
   doublecap := newcap + newcap
   if newLen > doublecap {  
     return newLen  
   }
 ​
   const threshold = 256  
   if oldCap < threshold { 
     return doublecap   // 如果旧的容量小于阈值,直接返回两倍的容量
   }
   for {                // 这里是一个循环
     newcap += (newcap + 3*threshold) >> 2 
     
     if uint(newcap) >= uint(newLen) {
       break  // 一直到新的容量大于等于需要的容量才会退出
     }
   }
 ​
   if newcap <= 0 {
     return newLen
   }
   return newcap
 }

以上就是go语言的扩容机制。

总结

等等,你以为你结束了?不,前面只是介绍了新切片的容量,也就是新切片的元素个数,但是在代码中容量是体现在内存上的,所以如何为不同类型的切片设置合适的内存大小呢?

众所周知

内存大小 = 容量个数 * 元素类型大小

那难道直接申请乘积大小的内存就行了?不不不,没那么简单。

在许多编程语言中,申请内存不是直接和操作系统沟通,而是和语言自身实现的内存管理模块挂钩,由程序想内存管理模块申请,管理模块一般会向操作系统申请一批内存,然后分成不同大小的常用的内存并管理,然后按照需求选择合适(足够大且不浪费)的内存分配给程序

这里就需要匹配到合适的内存规格

go 复制代码
 switch {
   case et.Size_ == 1:  // 如果元素类型大小为 1 字节
     // ...
     capmem = roundupsize(uintptr(newcap), noscan)  // 返回内存大小
     newcap = int(capmem)
   case et.Size_ == goarch.PtrSize:  // 如果元素类型是默认指针大小(32位系统为4   64为系统为8)
      // ...
     capmem = roundupsize(uintptr(newcap)*goarch.PtrSize, noscan) // 返回内存大小
     // ...
     newcap = int(capmem / goarch.PtrSize) //新的容量为申请的内存大小 / 默认指针大小
   case isPowerOfTwo(et.Size_):  // 如果是类型大小是否是2的幂
     // ... 位运算获取cap容量 
     newcap = int(capmem >> shift)
     capmem = uintptr(newcap) << shift
   default: // 其他情况 
     // ...
     newcap = int(capmem / et.Size_)  // 直接相除
     capmem = uintptr(newcap) * et.Size_
   }

在获取了相应的内存大小之后,调用mallocgc方法申请对应的内存

scss 复制代码
  mallocgc(capmem, et, true)
 // capmem 内存大小    et 元素类型

最后调用memmove(p, oldPtr, lenmem)方法将旧的切片移到新的切片里

scss 复制代码
 memmove(p, oldPtr, lenmem)
 // p 移动目的地  oldPtr 从哪移动  lenmen 移动大小

总结

现在才算完成了go语言的切片扩容和内存的申请。我们可以看到,上面也有一些关于go语言内存管理的细节没有提及,因为这里面的内容比较多,就暂时不在这篇文章描述了。如果读者感兴趣,可以自行了解go的内存管理

相关推荐
caihuayuan55 小时前
升级element-ui步骤
java·大数据·spring boot·后端·课程设计
Kookoos6 小时前
ABP vNext + EF Core 实战性能调优指南
数据库·后端·c#·.net·.netcore
揣晓丹8 小时前
JAVA实战开源项目:健身房管理系统 (Vue+SpringBoot) 附源码
java·vue.js·spring boot·后端·开源
豌豆花下猫9 小时前
Python 3.14 新特性盘点,更新了些什么?
后端·python·ai
flying_13149 小时前
面试常问系列(一)-神经网络参数初始化-之-softmax
深度学习·神经网络·算法·机器学习·面试
caihuayuan510 小时前
Vue生命周期&脚手架工程&Element-UI
java·大数据·spring boot·后端·课程设计
蒟蒻小袁11 小时前
力扣面试150题-- 翻转二叉树
算法·leetcode·面试
明月与玄武12 小时前
Spring Boot中的拦截器!
java·spring boot·后端
菲兹园长13 小时前
SpringBoot统一功能处理
java·spring boot·后端
muxue17813 小时前
go语言封装、继承与多态:
开发语言·后端·golang