本文是对 Go slice扩容分析之 不是double或1.25那么简单的学习与记录
1.18版本新的扩容策略
图片来自 Go slice新的扩容机制 - 你背的八股文过时啦
其实就是为了 让容量增长更加平滑
相关讨论发生在 2021年9月(看起来是go101作者老貘发起的): https://groups.google.com/g/golang-nuts/c/UaVlMQ8Nz3o
代码改动其实很少
runtime: make slice growth formula a bit smoother
https://go-review.googlesource.com/c/go/+/347917
从Go 1.18版本开始采用了新的策略。1.17及以前还是老的策略
可以这样记忆,新的扩容策略,是和泛型一个版本引入的,都是1.18~
之前是:
元素数量小于 1024,直接双倍;大于1024,1.25倍
1.18之后:
如果原容量小于 256,直接取原容量的两倍作为新容量如果原容量大于等于 256,在原容量 n 的基础上循环执行 n += (n+3*256)/4 的操作,直到 n 大于等于预期新容量,并取 n 作为新容量
另外还要考虑元素类型及内存对齐(之前也要考虑,只是被我们忽略了)
以上截图来自 Go 1.18 全新的切片扩容机制
在2022年9月份,cmd/compile,runtime: redo growslice calling convention,修改了growslice时元素类型对最终容量的影响
问题
依据大多数资料,slice的扩容机制是当切片的容量小于1024时,进行双倍扩容;当大于1024时,进行1.25倍扩容,见如下代码:
1 | var sli = []int{} |
输出为:
1 | 1 |
又见如下代码:
1 | var sli2 = []int{} |
输出为:
1 | [0 1 2 3 4 5 6 7 8 9] |
用更”准确”的话描述,是当cap<1024时,cap的值一定是2的n次方,且cap>=len;每当发生append使len增加,如果导致len>cap,此时cap会先于append操作进行double
即有
1 | var sli3 = []int{} |
结果为:
1 | 512 |
对于
1 | var sli4 = []int{} |
结果为:
1 | 1024 |
1024*1.25 = 1280
看似无懈可击的结果, 不过, 果真确凿如此吗?
上面的操作是每次append一个元素,考虑另一种情形,一次性append很多元素,会发生什么呢?
当同时append进多个元素时,如下:
1 | package main |
结果为:
1 | len of old a is 2 |
匪夷所思?
其实是因为内存对齐
简化以上代码:
1 | package main |
输出为:
1 | len of old m is 2 |
为什么一次性append多个,最后切片的长度为6; 而多次append单个元素,最后切片的长度为8?
切片扩容的源码,是/src/runtime/slice.go中的growslice方法:
1 |
|
原理
使用gdb调试工具进行调试
强烈建议先阅读此文,学习GDB基本用法
使用l 10可查看第10行附近的源码
使用 b 12 在第12行处设置断点 (可以设置多个断点)
这样会在运行到第12行时停止,可查看变量的值、堆栈情况等;
使用info b 查看断点处情况
可使用 s, 跳入断点,并看执行情况
可使用 r运行代码
可使用 p 变量名,显示变量值
如果调试过程中出现value optimized out,说明编译器进行了内联优化。
可通过go build -gcflags "-N -l" -o 自定义的二进制文件名称 原始Go文件.go命令,禁用编译器优化
gdb调试 出现value optimized out解决方法
可使用n单步运行. 非常好用,可以看到调用链
使用c, 使程序继续往下运行,直到再次遇到断点或程序结束
使用q, 退出gdb
调用roundupsize()函数,进行(向上)内存对齐
roundupsize()位于go/src/runtime/msize.go,更具体介绍与使用可参考
1 | // Copyright 2009 The Go Authors. All rights reserved. |
1 | // Code generated by mksizeclasses.go; DO NOT EDIT. |
当创建一个对象时,需要分配一块内存。假设创建的对象需要52 byte,系统是不会真就给分配52byte大小的内存,首先会根据上面代码中第6行注释部分,来计算需要分配的内存大小。
可参考[go/src/runtime/msize.go] 中的 roundupsize 函数。也就是需要向上取整,即 48<52<64,所以申请52byte,实际会分配64byte大小的内存。
另外还需要了解: 如果每次创建一个对象,Go runtime都向计算机中申请一块内存,而在程序运行时是会频繁的创建对象的,这样效率将会大大降低。 所以程序会预先申请好一些内存块,其大小就是 8、16、32、48 等等,这样在程序申请内存时序就可以把申请好的内存选一块给我们,也就提高了效率
growslice的三个参数: 第一个是类型(上例中为int64),第二个是扩容前切片a(元素为2和3),第三个参数是预估容量5(原有切片容量加上新加元素个数,即2+3=5)
对容量进行计算:
1 | ... |
又因为int64 类型大小为8字节,故而下面会走到*et.size == sys.PtrSize:*代码块
对于const PtrSize = 4 << (^uintptr(0) >> 63):
1 | package main |
执行结果为:
1 | uintptr(0)值为: 0 |
1 | case et.size == sys.PtrSize: |
关于maxAlloc:
1 | package main |
输出为:
1 | _64bit值为: 1 |
更多参考:
深度解密Go语言之Slice 搜关键字 roundupsize
Linux内核中有rounddown方法,取数的最高二进制阶数
取数的最高二进制阶数rounddown_pow_of_two
原文链接: https://dashen.tech/2019/06/28/golang中slice扩容一定是double或1-25倍吗/
版权声明: 转载请注明出处.