golang数组是怎么扩容的?

说到这个问题,大部分人第一反应都是

数组cap小于1024,是double扩容,如果超出1024,就每次增加1/4扩容

但是,真的是这样吗?

试验

从0开始

首先上代码,大家猜一下输出的是什么?

package main

import (
    "fmt"
)

func main() {
    fmt.Println("------------------int test-----------------")
    intCap()
    fmt.Println("------------------bool test----------------")
    boolCap()
    fmt.Println("------------------struct test--------------")
    structCap()
}

func intCap() {
    arr := make([]int, 0)
    fmt.Println("len:", len(arr), ", cap:", cap(arr))
    arr = append(arr, 1)
    fmt.Println("len:", len(arr), ", cap:", cap(arr))
    arr = append(arr, 2)
    fmt.Println("len:", len(arr), ", cap:", cap(arr))
    arr = append(arr, 3)
    fmt.Println("len:", len(arr), ", cap:", cap(arr))
    arr = append(arr, 4)
    fmt.Println("len:", len(arr), ", cap:", cap(arr))
    arr = append(arr, 5)
    fmt.Println("len:", len(arr), ", cap:", cap(arr))

}

func boolCap() {
    arr := make([]bool, 0)
    fmt.Println("len:", len(arr), ", cap:", cap(arr))
    arr = append(arr, false)
    fmt.Println("len:", len(arr), ", cap:", cap(arr))
    arr = append(arr, false)
    fmt.Println("len:", len(arr), ", cap:", cap(arr))
    arr = append(arr, false)
    fmt.Println("len:", len(arr), ", cap:", cap(arr))
    arr = append(arr, false)
    fmt.Println("len:", len(arr), ", cap:", cap(arr))
    arr = append(arr, false)
    fmt.Println("len:", len(arr), ", cap:", cap(arr))
}

func structCap()  {
    arr := make([]struct{}, 0)
    fmt.Println("len:", len(arr), ", cap:", cap(arr))
    arr = append(arr, struct {}{})
    fmt.Println("len:", len(arr), ", cap:", cap(arr))
    arr = append(arr, struct {}{})
    fmt.Println("len:", len(arr), ", cap:", cap(arr))
    arr = append(arr, struct {}{})
    fmt.Println("len:", len(arr), ", cap:", cap(arr))
    arr = append(arr, struct {}{})
    fmt.Println("len:", len(arr), ", cap:", cap(arr))
    arr = append(arr, struct {}{})
    fmt.Println("len:", len(arr), ", cap:", cap(arr))
}

大家猜好后,看看实际的输出,是否是一样呢?

------------------int test-----------------
len: 0 , cap: 0
len: 1 , cap: 1
len: 2 , cap: 2
len: 3 , cap: 4
len: 4 , cap: 4
len: 5 , cap: 8
------------------bool test----------------
len: 0 , cap: 0
len: 1 , cap: 8
len: 2 , cap: 8
len: 3 , cap: 8
len: 4 , cap: 8
len: 5 , cap: 8
------------------struct test--------------
len: 0 , cap: 0
len: 1 , cap: 1
len: 2 , cap: 2
len: 3 , cap: 3
len: 4 , cap: 4
len: 5 , cap: 5

从1024开始

上下代码,我们再猜测下输出是什么?

package main

import (
    "fmt"
)

func main() {
    fmt.Println(1024*1.25, 1024*1.25*1.25, 1024*1.25*1.25*1.25, 1024*1.25*1.25*1.25*1.25)
  // 备注:以上结果输出 1280 1600 2000 2500
    fmt.Println("------------------int test-----------------")
    intCap()
    fmt.Println("------------------bool test----------------")
    boolCap()
    fmt.Println("------------------struct test--------------")
    structCap()
}

func intCap() {
    arr := make([]int, 1024, 1024)
    fmt.Println("len:", len(arr), ", cap:", cap(arr))
    newArr := make([]int, 1280-1024, 1280-1024)
    arr = append(arr, newArr...)
    fmt.Println("len:", len(arr), ", cap:", cap(arr))
    newArr = make([]int, 1600-1280, 1600-1280)
    arr = append(arr, newArr...)
    fmt.Println("len:", len(arr), ", cap:", cap(arr))
    newArr = make([]int, 2000-1600, 2000-1600)
    arr = append(arr, newArr...)
    fmt.Println("len:", len(arr), ", cap:", cap(arr))
}

func boolCap() {
    arr := make([]bool, 1024, 1024)
    fmt.Println("len:", len(arr), ", cap:", cap(arr))
    newArr := make([]bool, 1280-1024, 1280-1024)
    arr = append(arr, newArr...)
    fmt.Println("len:", len(arr), ", cap:", cap(arr))
    newArr = make([]bool, 1600-1280, 1600-1280)
    arr = append(arr, newArr...)
    fmt.Println("len:", len(arr), ", cap:", cap(arr))
    newArr = make([]bool, 2000-1600, 2000-1600)
    arr = append(arr, newArr...)
    fmt.Println("len:", len(arr), ", cap:", cap(arr))
}

func structCap() {
    arr := make([]struct{}, 1024, 1024)
    fmt.Println("len:", len(arr), ", cap:", cap(arr))
    newArr := make([]struct{}, 1280-1024, 1280-1024)
    arr = append(arr, newArr...)
    fmt.Println("len:", len(arr), ", cap:", cap(arr))
    newArr = make([]struct{}, 1600-1280, 1600-1280)
    arr = append(arr, newArr...)
    fmt.Println("len:", len(arr), ", cap:", cap(arr))
    newArr = make([]struct{}, 2000-1600, 2000-1600)
    arr = append(arr, newArr...)
    fmt.Println("len:", len(arr), ", cap:", cap(arr))
}

猜好后,我们再看下结果是什么?

------------------int test-----------------
len: 1024 , cap: 1024
len: 1280 , cap: 1280
len: 1600 , cap: 1696
len: 2000 , cap: 2304
------------------bool test----------------
len: 1024 , cap: 1024
len: 1280 , cap: 1280
len: 1600 , cap: 1792
len: 2000 , cap: 2304
------------------struct test--------------
len: 1024 , cap: 1024
len: 1280 , cap: 1280
len: 1600 , cap: 1600
len: 2000 , cap: 2000

是不是发现,这个输出完全不按照预想的来呀!

接下来,我们就一点一点的探究,golang的slice到底是怎么扩容的

源码分析

首先,我们要知道,golang的append不是一个函数实现,可以简单的理解为一个语法糖(不准确,感受意思就好)。在程序编译期间,会将append进行语法分析,最终会调用到runtime/slice.go/growslice函数。具体编译可参见参考[1]和[2],此处不再赘述。

我们来看下程序的代码实现。这段代码比较长,我们分段来看。

空值扩容

# https://github.com/golang/go/blob/go1.16.10/src/runtime/slice.go#L125
// et是slice的类型,old是原slice,cap是新的slice的总长度
func growslice(et *_type, old slice, cap int) slice {
    ......
  // 如果新容量小于原容量,直接报错
  // 可能导致的原因:比如数据长度溢出
  // 在amd64机器上,原数组cap是math.MaxInt64,再增加一个数据,会直接变成1<<63,成为负数
    if cap < old.cap {
        panic(errorString("growslice: cap out of range"))
    }

  // 注意这一句,如果类型的size为0,直接返回一个slice类型,cap是新的slice的总长度
    if et.size == 0 {
        return slice{unsafe.Pointer(&zerobase), old.len, cap}
    }

实际上,到这个地方,我们就能理解,为什么我们前文所有关于struct{}(空类型)的测试,长度只会递增,而不会扩容了!

是因为struct{}的大小为0,golang直接就在原数据基础上增加了1个长度,然后就返回了

熟悉的方案

我们继续往后面看代码

newcap := old.cap
doublecap := newcap + newcap
if cap > doublecap {
  newcap = cap
} else {
  if old.cap < 1024 {
    newcap = doublecap
  } else {
    for 0 < newcap && newcap < cap {
      newcap += newcap / 4
    }
    if newcap <= 0 {
      newcap = cap
    }
  }
}

这段代码就是我们比较熟悉的扩容方案了,扩容按照以下方案进行

  • 如果期望cap超出原cap的两倍,扩容大小就是期望cap

  • 否则

    • 原cap不超过1024,扩容大小是原cap的2倍

    • 超过1024,按照那个1.25的倍率逐渐扩容

    • 1.25倍率如果数值溢出,扩容大小就是期望的cap

可以发现,这个方案跟我们熟悉的扩容机制很相似了,但是跟实际情况并不一样呀!

内存对齐

继续往后面读代码,后面还有处理逻辑

    var overflow bool
    var lenmem, newlenmem, capmem uintptr
    // Specialize for common values of et.size.
    // For 1 we don't need any division/multiplication.
    // For sys.PtrSize, compiler will optimize division/multiplication into a shift by a constant.
    // For powers of 2, use a variable shift.
    switch {
  // slice类型为1
    case et.size == 1:
        lenmem = uintptr(old.len)
        newlenmem = uintptr(cap)
        capmem = roundupsize(uintptr(newcap))
        overflow = uintptr(newcap) > maxAlloc
        newcap = int(capmem)
  // slice类型大小为指针大小
    case et.size == sys.PtrSize:
        lenmem = uintptr(old.len) * sys.PtrSize
        newlenmem = uintptr(cap) * sys.PtrSize
        capmem = roundupsize(uintptr(newcap) * sys.PtrSize)
        overflow = uintptr(newcap) > maxAlloc/sys.PtrSize
        newcap = int(capmem / sys.PtrSize)
  // slice类型大小为2的指数
    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)
    }

这段代码有点长,我们一点一点来解读。

slice大小为1

  • slice已有数据占用的内存为数据长度(因为size为1,所以内存就等于长度)

  • 新长度所占内存为预期容量大小

  • 总分配内存大小是待分配大小的向上取整

  • 总分配内存大小如果大于可分配内存空间,则数据溢出

  • 最终要分配的cap就是向上取整后的大小

这时出现了一个新的概念向上取整(roundupsize),我们先记住,在这段处理逻辑中,最终分配的cap需要进行向上取整,后面再看具体怎么向上取整的。

slice大小为PtrSize

首先我们知道,PtrSize的定义如下

const PtrSize = 4 << (^uintptr(0) >> 63) 

可知,在32位机器上,PtrSize就是4,64位是8。

我们可以发现这块的计算逻辑和slice元素size为1的没有太大区别,就是计算各种容量的时候乘上了PtrSize

slice大小为2的n次方

这块的计算比较底层一些,或者说是为了加快计算,而使用了比较底层的计算方式。

...
shift = uintptr(sys.Ctz64(uint64(et.size))) & 63
lenmem = uintptr(old.len) << shift
...

解释完这两句,就都能理解了。

首先Ctz64是计算64位机器上,et.Size(比如为4),二进制序列有几个0(相关解析可参考[3])

可知后面有两个0,那么与63进行下位与运算

   1 1 1 1 1 1 # 63
&  0 0 0 0 1 0 # 4
=  0 0 0 0 1 0 # 2

可知shift值是2

我们又知道,计算机中,一个数m左移n位=m * 2^n

对于slice元素容量为2的n次方的类型,分配的容量大小一定是

len * et.size = len * 2^n = len << n

所以,我们就能理解前面代码段的含义了,实质就是一个乘法计算总大小,但是改成了位移计算。

为啥要改呢?

因为计算机计算位移的效率高于乘法

slice大小不满足以上条件

好了,现在slice的大小不满足以上条件,那就也没什么骚操作了,直接乘吧,计算最终的大小。

内存对齐探究

看完这一段代码,我们大体知道了,slice的扩容容量就是之前理解的[1.5倍增长、2倍扩容]这种值,再进行向上取整,也就是内存对齐的操作。

具体怎么计算的呢?

func roundupsize(size uintptr) uintptr {
    if size < _MaxSmallSize {
        if size <= smallSizeMax-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)
}

首先,我们知道一些前置知识,golang内存管理基于tcmalloc,将不同对象按照内存大小分为以下几种(更多可见[4]、[5]、[6])

  • 微小对象(size< 16byte)

  • 小对象 (16byte <= size <=32k )

  • 大对象 (size > 32k)

小对象

如果需要分配内存的是小对象,

  • 且size小于1024,会通过size_to_class8选取出最后分配多少

  • 否则会通过size_to_class128选取分配大小

比如,我们需要分配的内存大小为100,可知divRoundUP计算结果为12

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, 14, 14, 15, 15, 16, 16, 17, 17, 18, 18, 19, 19, 19, 19, 20, 20, 20, 20, 21, 21, 21, 21, 22, 22, 22, 22, 23, 23, 23, 23, 24, 24, 24, 24, 25, 25, 25, 25, 26, 26, 26, 26, 27, 27, 27, 27, 27, 27, 27, 27, 28, 28, 28, 28, 28, 28, 28, 28, 29, 29, 29, 29, 29, 29, 29, 29, 30, 30, 30, 30, 30, 30, 30, 30, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32}

var class_to_size = [_NumSizeClasses]uint16{0, 8, 16, 24, 32, 48, 64, 80, 96, 112, 128, 144, 160, 176, 192, 208, 224, 240, 256, 288, 320, 352, 384, 416, 448, 480, 512, 576, 640, 704, 768, 896, 1024, 1152, 1280, 1408, 1536, 1792, 2048, 2304, 2688, 3072, 3200, 3456, 4096, 4864, 5376, 6144, 6528, 6784, 6912, 8192, 9472, 9728, 10240, 10880, 12288, 13568, 14336, 16384, 18432, 19072, 20480, 21760, 24576, 27264, 28672, 32768}

最后分配的大小为

size_to_class8[12] == 9
class_to_size[9] == 112

表示分配的内存大小是112,假设et.size为2,则实际cap为112/2=56

大对象

如果是需要分配大对象,就需要进行其他处理了。

首先假设需要的size非常大,加上一个新的_PageSize(8k)溢出了,那就没办法了,申请多少就是多少吧。

否则,就按照_PageSize递增的方式来分配内存

return alignUp(size, _PageSize)
...
func alignUp(n, a uintptr) uintptr {
    return (n + a - 1) &^ (a - 1)
}

又是一个二进制运算,他的含义是,把size提到_PageSize的倍数。

比如,申请的size是2.5 * _PageSize ,那么实际分配 3 *_PageSize

分配内存

终于计算好了分配多少内存,接下来就是执行具体分配内存的策略了。

由于这块是内存管理的范畴了,我们就直接跳过了。

结论

通过上面大量的分析,我们终于知道了go到底如何扩容的数组了

  • 首先小于1024,则double

  • 大于1024,则按照1.25的倍率增加

  • 根据请求的内存大小,进行内存对齐

  • cap就是内存对齐的大小/元素大小

参考

  1. append implemention1

  2. append implemention2

  3. golang 神奇函数

  4. golang内存堆栈管理

  5. golang内存管理

  6. golang内存分配