Go语言切片扩容原理和过程

 更新时间:2025年02月17日 09:42:20   作者:飞川001  
切片(Slice)在 Go 语言中,有一个很常用的数据结构,切片是一个拥有相同类型元素的可变长度的序列,它是基于数组类型做的一层封装,它非常灵活,支持自动扩容,并发不安全,本文给大家介绍了Go 语言切片如何扩容,需要的朋友可以参考下

一、结构介绍

切片(Slice)在 Go 语言中,有一个很常用的数据结构,切片是一个拥有相同类型元素的可变长度的序列,它是基于数组类型做的一层封装。它非常灵活,支持自动扩容。并发不安全。

切片是一种引用类型,它有三个属性:指针,长度和容量

底层源码定义:

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

1.指针: 指向 slice 可以访问到的第一个元素。

2.长度: slice 中元素个数。

3.容量: slice 起始元素到底层数组最后一个元素间的元素个数。

比如使用 make([]byte, 5) 创建一个切片,它看起来是这样的:

二、扩容时机与过程

Go 中切片的扩容机制是基于动态数组的,这意味着切片的底层数组会动态调整大小以适应元素的增加。下面是 Go 切片扩容的一般过程:

1.初始分配:

当使用 make 创建一个切片时,Go 会为其分配一块初始的底层数组,并将切片的长度和容量都设置为相同的值。

2.追加元素:

当你使用 append 向切片追加元素时,Go 会检查是否有足够的容量来容纳新的元素。如果有足够的容量,新元素会被添加到底层数组的末尾,切片的长度会增加。如果没有足够的容量,就需要进行扩容。

3.扩容:

当切片需要扩容时,Go 会创建一个新的更大的底层数组(具体的扩容策略看下面扩容原理)。然后,原数组的元素会被复制到新数组中,新元素会被添加到新数组的末尾。最后,切片的引用会指向新的底层数组,原数组会被垃圾回收。
这个扩容的过程保证了在大多数情况下,append 操作都是高效的。由于每次扩容都会涉及元素的复制,因此在涉及大量元素的情况下可能会导致一些性能开销。如果你知道切片需要存储的元素数量,可以使用 make 函数make([]T, length, capacity)的第三个参数显式指定容量,以减少扩容的次数。

三、扩容原理

Go1.18之前切片的扩容是以容量1024为临界点,当旧容量 < 1024个元素,扩容变成2倍;当旧容量 > 1024个元素,那么会进入一个循环,每次增加25%直到大于期望容量。
然而这个扩容机制已经被Go 1.18弃用了,官方说新的扩容机制能更平滑地过渡。
具体扩容原理分别如下:

Go 1.18版本 之前扩容原理

在分配内存空间之前需要先确定新的切片容量,运行时根据切片的当前容量选择不同的策略进行扩容:

1. 如果期望容量大于当前容量的两倍就会使用期望容量;

2. 如果当前切片的长度小于 1024 就会将容量翻倍;

3. 如果当前切片的长度大于等于 1024 就会每次增加 25% 的容量,直到新容量大于期望容量;

注:解释一下第一条:
比如 nums := []int{1, 2} nums = append(nums, 2, 3, 4),这样期望容量为2+3 = 5,而5 > 2*2,故使用期望容量(这只是不考虑内存对齐的情况下)

记录容量变化如下:

[0 ->   -1] cap = 0     |  after append 0     cap = 1   
[0 ->    0] cap = 1     |  after append 1     cap = 2   
[0 ->    1] cap = 2     |  after append 2     cap = 4   
[0 ->    3] cap = 4     |  after append 4     cap = 8   
[0 ->    7] cap = 8     |  after append 8     cap = 16  
[0 ->   15] cap = 16    |  after append 16    cap = 32  
[0 ->   31] cap = 32    |  after append 32    cap = 64  
[0 ->   63] cap = 64    |  after append 64    cap = 128 
[0 ->  127] cap = 128   |  after append 128   cap = 256 
[0 ->  255] cap = 256   |  after append 256   cap = 512 
[0 ->  511] cap = 512   |  after append 512   cap = 1024
[0 -> 1023] cap = 1024  |  after append 1024  cap = 1280
[0 -> 1279] cap = 1280  |  after append 1280  cap = 1696
[0 -> 1695] cap = 1696  |  after append 1696  cap = 2304

Go 1.18版本 之后扩容原理

和之前版本的区别,主要在扩容阈值,以及这行源码:newcap += (newcap + 3*threshold) / 4。

在分配内存空间之前需要先确定新的切片容量,运行时根据切片的当前容量选择不同的策略进行扩容:

1. 如果期望容量大于当前容量的两倍就会使用期望容量;

2. 如果当前切片的长度小于阈值(默认 256)就会将容量翻倍;

3. 如果当前切片的长度大于等于阈值(默认 256),就会每次增加 25% 的容量,基准是 newcap + 3*threshold,直到新容量大于期望容量;

记录容量变化如下:

[0 ->   -1] cap = 0     |  after append 0     cap = 1
[0 ->    0] cap = 1     |  after append 1     cap = 2   
[0 ->    1] cap = 2     |  after append 2     cap = 4   
[0 ->    3] cap = 4     |  after append 4     cap = 8   
[0 ->    7] cap = 8     |  after append 8     cap = 16  
[0 ->   15] cap = 16    |  after append 16    cap = 32  
[0 ->   31] cap = 32    |  after append 32    cap = 64  
[0 ->   63] cap = 64    |  after append 64    cap = 128 
[0 ->  127] cap = 128   |  after append 128   cap = 256 
[0 ->  255] cap = 256   |  after append 256   cap = 512 
[0 ->  511] cap = 512   |  after append 512   cap = 848 
[0 ->  847] cap = 848   |  after append 848   cap = 1280
[0 -> 1279] cap = 1280  |  after append 1280  cap = 1792
[0 -> 1791] cap = 1792  |  after append 1792  cap = 2560

大致规则如下:

其中,当扩容前容量 >= 256时,会按照公式进行扩容

newcap += (newcap + 3*threshold) / 4

这样得到的预估容量并不是最终结果,还有内存对齐,进一步调整newcap

在1.18中,优化了切片扩容的策略,让底层数组大小的增长更加平滑:通过减小阈值并固定增加一个常数,使得优化后的扩容的系数在阈值前后不再会出现从2到1.25的突变,该commit作者给出了几种原始容量下对应的“扩容系数”:

oldcap扩容系数
2562.0
5121.63
10241.44
20481.35
40961.30

可以看到,Go1.18的扩容策略中,随着容量的增大,其扩容系数是越来越小的,可以更好地节省内存。

可以试着求一个极限,当oldcap远大于256的时候,扩容系数将会变成1.25。

四、内存对齐

扩容之后的容量并不是严格按照这个策略的。那是为什么呢?

实际上,growslice​ 的后半部分还有更进一步的优化(内存对齐等),靠的是 roundupsize​ 函数,在计算完 newcap 值之后,还会有一个步骤计算最终的容量:

capmem = roundupsize(uintptr(newcap) * ptrSize)
newcap = int(capmem / ptrSize)

举例:

还是上面的例子:

nums := []int{1, 2}
nums = append(nums, 2, 3, 4)
fmt.Printf("len:%v  cap:%v", len(nums), cap(nums))

按照上述策略的结果,应该是 len:5,cap:5。但是最终结果为 len:5,cap:6
解释:容量计算完了后还要考虑到内存的高效利用,进行内存对齐,则会调用这个函数 roundupsize 。

func roundupsize(size uintptr) uintptr {
    if size < _MaxSmallSize {
        if size <= smallSizeMax-8 {
            return uintptr(class_to_size[size_to_class8[(size+smallSizeDiv-1)/smallSizeDiv]])
        } else {
        return uintptr(class_to_size[size_to_class128[(size-smallSizeMax+largeSizeDiv-1)/largeSizeDiv]])
        }
    }
    if size+_PageSize < size {
        return size
    }
    return alignUp(size, _PageSize)
}

size 表示新切片需要的内存大小 我们传入的 int 类型,每个占用 8 字节 (可以调用 unsafe.Sizeof() 函数查看占用的大小),一共 5 个 所以是 40,size 小于_MaxSmallSize 并且小于 smallSizeMax-8 ,那么使用通用公式 (size+smallSizeDiv-1)/smallSizeDiv 计算得到 5,然后到 size_to_class8 找到第五号元素 为 4,再从 class_to_size 找到 第四号元素 为 48,这就是新切片占用的内存大小,每个 int 占用 8 字节,所以最终切片的容量为 6 。所以说切片的扩容有它基本的扩容规则,在规则之后还要考虑内存对齐,这就代表不同数据类型的切片扩容的容量大小是会不一致。

五、总结

切片扩容通常是在进行切片的 append​ 操作时触发的。在进行 append​ 操作时,如果切片容量不足以容纳新的元素,就需要对切片进行扩容,此时就会调用 growslice 函数进行扩容。
切片扩容分两个阶段,分为 go1.18 之前和之后:

一、go1.18 之前:

1.如果期望容量大于当前容量的两倍就会使用期望容量;
2.如果当前切片的长度小于 1024 就会将容量翻倍;
3.如果当前切片的长度大于 1024 就会每次增加 25% 的容量,直到新容量大于期望容量;

二、go1.18 之后:

1.如果期望容量大于当前容量的两倍就会使用期望容量;

2.如果当前切片的长度小于阈值(默认 256)就会将容量翻倍;

3.如果当前切片的长度大于等于阈值(默认 256),就会每次增加 25% 的容量,基准是 newcap + 3*threshold,直到新容量大于期望容量;

总的来说,Go的设计者不断优化切片扩容的机制,其目的只有一个:就是控制让小的切片容量增长速度快一点,减少内存分配次数,而让大切片容量增长率小一点,更好地节省内存。

  • 如果只选择翻倍的扩容策略,那么对于较大的切片来说,现有的方法可以更好的节省内存。
  • 如果只选择每次系数为1.25的扩容策略,那么对于较小的切片来说扩容会很低效。
  • 之所以选择一个小于2的系数,在扩容时被释放的内存块会在下一次扩容时更容易被重新利用。

以上就是Go语言切片扩容原理和过程的详细内容,更多关于Go切片扩容的资料请关注脚本之家其它相关文章!

相关文章

  • Go Fiber快速搭建一个HTTP服务器

    Go Fiber快速搭建一个HTTP服务器

    Fiber 是一个 Express 启发 web 框架基于 fasthttp ,最快 Go 的 http 引擎,这篇文章主要介绍了Go Fiber快速搭建一个HTTP服务器,需要的朋友可以参考下
    2023-06-06
  • 一文带你了解Golang中interface的设计与实现

    一文带你了解Golang中interface的设计与实现

    本文就来详细说说为什么说 接口本质是一种自定义类型,以及这种自定义类型是如何构建起 go 的 interface 系统的,感兴趣的小伙伴可以跟随小编一起学习一下
    2023-01-01
  • Go 实现 Nginx 加权轮询算法的方法步骤

    Go 实现 Nginx 加权轮询算法的方法步骤

    本文主要介绍了Go 实现 Nginx 加权轮询算法的方法步骤,文中通过示例代码介绍的非常详细,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
    2021-12-12
  • Go语言中使用flag包对命令行进行参数解析的方法

    Go语言中使用flag包对命令行进行参数解析的方法

    这篇文章主要介绍了Go语言中使用flag包对命令行进行参数解析的方法,文中举了一个实现flag.Value接口来自定义flag的例子,需要的朋友可以参考下
    2016-04-04
  • 详解go程序如何在windows服务中开启和关闭

    详解go程序如何在windows服务中开启和关闭

    这篇文章主要介绍了一个go程序,如何在windows服务中优雅开启和关闭,文中通过代码示例和图文讲解的非常详细,对大家的学习或工作有一定的帮助,需要的朋友可以参考下
    2024-07-07
  • Goland支持泛型了(上机实操)

    Goland支持泛型了(上机实操)

    Go的泛型不是还在设计草图吗?最乐观估计也要2021年8月份。你说Go语言现在都没开发好泛型,你支持这个特性有什么用呢?感兴趣的朋友跟随小编一起看看吧
    2020-12-12
  • 探究gRPC 客户端调用服务端需要连接池吗?

    探究gRPC 客户端调用服务端需要连接池吗?

    这篇文章主要为大家介绍了gRPC 客户端调用服务端需要连接池吗的问题探究,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2023-08-08
  • Golang 中的 unsafe.Pointer 和 uintptr详解

    Golang 中的 unsafe.Pointer 和 uintptr详解

    这篇文章主要介绍了Golang中的unsafe.Pointer和uintptr详解,文章围绕主题展开详细的内容介绍,具有一定的参考价值,需要的朋友可以参考一下
    2022-08-08
  • Go压缩位图库roaring安装使用详解

    Go压缩位图库roaring安装使用详解

    这篇文章主要为大家介绍了Go压缩位图库roaring安装使用详解,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2022-07-07
  • Go语言实现常见限流算法的示例代码

    Go语言实现常见限流算法的示例代码

    计数器、滑动窗口、漏斗算法、令牌桶算法是我们常见的几个限流算法,本文将依次用Go语言实现这几个限流算法,感兴趣的可以了解一下
    2023-05-05

最新评论