【Golang】关于Go语言中Slice扩容策略

一、概述

当切片的容量不足时,我们会调用 runtime.growslice 函数为切片扩容,扩容是为切片分配新的内存空间并拷贝原切片中元素的过程,我们先来看新切片的容量是如何确定的,使用的是 growslice 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
func growslice(et *_type, old slice, cap int) slice {
    if raceenabled {
        callerpc := getcallerpc()
        racereadrangepc(old.array, uintptr(old.len*int(et.size)), callerpc, funcPC(growslice))
    }
    if msanenabled {
        msanread(old.array, uintptr(old.len*int(et.size)))
    }
 
    if cap < old.cap {
        panic(errorString("growslice: cap out of range"))
    }
 
    if et.size == 0 {
        // append should not create a slice with nil pointer but non-zero len.
        // We assume that append doesn't need to preserve old.array in this case.
        return slice{unsafe.Pointer(&zerobase), old.len, cap}
    }
 
    newcap := old.cap
    doublecap := newcap + newcap
    if cap > doublecap {
        newcap = cap
    } else {
        if old.cap < 1024 {
            newcap = doublecap
        } else {
            // Check 0 < newcap to detect overflow
            // and prevent an infinite loop.
            for 0 < newcap && newcap < cap {
                newcap += newcap / 4
            }
            // Set newcap to the requested cap when
            // the newcap calculation overflowed.
            if newcap <= 0 {
                newcap = 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 {
    case et.size == 1:
        lenmem = uintptr(old.len)
        newlenmem = uintptr(cap)
        capmem = roundupsize(uintptr(newcap))
        overflow = uintptr(newcap) > maxAlloc
        newcap = int(capmem)
    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)
    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)
    }
 
    // The check of overflow in addition to capmem > maxAlloc is needed
    // to prevent an overflow which can be used to trigger a segfault
    // on 32bit architectures with this example program:
    //
    // type T [1<<27 + 1]int64
    //
    // var d T
    // var s []T
    //
    // func main() {
    //   s = append(s, d, d, d, d)
    //   print(len(s), "\n")
    // }
    if overflow || capmem > maxAlloc {
        panic(errorString("growslice: cap out of range"))
    }
 
    var p unsafe.Pointer
    if et.ptrdata == 0 {
        p = mallocgc(capmem, nil, false)
        // The append() that calls growslice is going to overwrite from old.len to cap (which will be the new length).
        // Only clear the part that will not be overwritten.
        memclrNoHeapPointers(add(p, newlenmem), capmem-newlenmem)
    } else {
        // Note: can't use rawmem (which avoids zeroing of memory), because then GC can scan uninitialized memory.
        p = mallocgc(capmem, et, true)
        if lenmem > 0 && writeBarrier.enabled {
            // Only shade the pointers in old.array since we know the destination slice p
            // only contains nil pointers because it has been cleared during alloc.
            bulkBarrierPreWriteSrcOnly(uintptr(p), uintptr(old.array), lenmem-et.size+et.ptrdata)
        }
    }
    memmove(p, old.array, lenmem)
 
    return slice{p, old.len, newcap}
}

二、分配策略

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

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

三、代码验证

1、测试代码1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main
 
import (
    "fmt"
    "time"
)
 
func main() {
    vals := []string{"a", "b", "c"}
    oldCapacity := cap(vals)
    //避免结果输出过快
    tick := time.NewTicker(1 * time.Millisecond)
    for {
        <-tick.C
        vals = append(vals, "a")
        if capacity := cap(vals); capacity != oldCapacity {
            multiplier := float64(capacity) / float64(oldCapacity)
            fmt.Printf("len(vals) is %d => cap(vals) is %d , oldCap is %d ; multiplier is %.2f\n", len(vals), capacity, oldCapacity, multiplier)
            oldCapacity = capacity
        }
    }
}

验证结果1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
len(vals) is 4 => cap(vals) is 6 , oldCap is 3 ; multiplier is 2.00
len(vals) is 7 => cap(vals) is 12 , oldCap is 6 ; multiplier is 2.00
len(vals) is 13 => cap(vals) is 24 , oldCap is 12 ; multiplier is 2.00
len(vals) is 25 => cap(vals) is 48 , oldCap is 24 ; multiplier is 2.00
len(vals) is 49 => cap(vals) is 96 , oldCap is 48 ; multiplier is 2.00
len(vals) is 97 => cap(vals) is 192 , oldCap is 96 ; multiplier is 2.00
len(vals) is 193 => cap(vals) is 384 , oldCap is 192 ; multiplier is 2.00
len(vals) is 385 => cap(vals) is 768 , oldCap is 384 ; multiplier is 2.00
len(vals) is 769 => cap(vals) is 1536 , oldCap is 768 ; multiplier is 2.00
len(vals) is 1537 => cap(vals) is 2048 , oldCap is 1536 ; multiplier is 1.33
len(vals) is 2049 => cap(vals) is 2560 , oldCap is 2048 ; multiplier is 1.25
len(vals) is 2561 => cap(vals) is 3584 , oldCap is 2560 ; multiplier is 1.40
len(vals) is 3585 => cap(vals) is 4608 , oldCap is 3584 ; multiplier is 1.29
len(vals) is 4609 => cap(vals) is 6144 , oldCap is 4608 ; multiplier is 1.33

通过输出结果我们可以看到,刚开始slice容量为3,之后每次扩容都是遵循之前的扩容策略,但是当容量为1536时,下次扩容按照我们的扩容策略计算应该是1920(1536+1536*1/4),但是实际上的容量却是2048,那么2048是怎么得到的呢?当执行到代码片一之后仅会确定切片的大致容量,之后还需要根据切片中的元素大小对齐内存,当数组中元素所占的字节大小为 1、8 或者 2 的倍数时,运行时会使用其他代码对齐内存。

因为string类型占用16个字节,是2的倍数,在确定大致容量后,之后会进行内存对齐,会执行下面的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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
        }
        //当数据类型size为16时,shift=4
        lenmem = uintptr(old.len) << shift
        newlenmem = uintptr(cap) << shift
        //执行代码片一之后,newcap为1920
        capmem = roundupsize(uintptr(newcap) << shift)
        overflow = uintptr(newcap) > (maxAlloc >> shift)
        newcap = int(capmem >> shift)

上面的代码执行后

1
2
3
//shift=4
newcap=1920(1536+1536*1/4)
capmem=1920*16

之后执行 runtime.roundupsize 函数会将待申请的内存向上取整,取整时会使用 runtime.class_to_size 数组,使用该数组中的整数可以提高内存的分配效率并减少碎片,这可能会导致容量的变化与上面的扩容策略有所出入,我们再看一下roundupsize方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//  _MaxSmallSize   = 32768 //代表小对象的最大字节数也就是32kb
//  smallSizeDiv    = 8    
//  smallSizeMax    = 1024 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)
}

返回的值为

1
2
3
4
5
class_to_size[size_to_class128[divRoundUp(size-smallSizeMax, largeSizeDiv]]
divRoundUp(size-smallSizeMax, largeSizeDiv=(29696+128-1)/128=232
 
size_to_class128[32]=66
class_to_size[66]=32768

即返回的capmem为32768,之后会执行下面这段代码

1
newcap = int(capmem >> shift)

最终的容量为newcap=32768/16=2048

2、测试代码2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main
 
import (
    "fmt"
    "time"
)
 
func main() {
    vals := []byte{'a', 'b', 'c'}
    oldCapacity := cap(vals)
    //避免结果输出过快
    tick := time.NewTicker(1 * time.Millisecond)
    for {
        <-tick.C
        vals = append(vals, 'a')
        if capacity := cap(vals); capacity != oldCapacity {
            multiplier := float64(capacity) / float64(oldCapacity)
            fmt.Printf("len(vals) is %d => cap(vals) is %d , oldCap is %d ; multiplier is %.2f\n", len(vals), capacity, oldCapacity, multiplier)
            oldCapacity = capacity
        }
    }
}

验证结果2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
len(vals) is 3 => cap(vals) is 8 , oldCap is 2 ; multiplier is 4.00
len(vals) is 9 => cap(vals) is 16 , oldCap is 8 ; multiplier is 2.00
len(vals) is 17 => cap(vals) is 32 , oldCap is 16 ; multiplier is 2.00
len(vals) is 33 => cap(vals) is 64 , oldCap is 32 ; multiplier is 2.00
len(vals) is 65 => cap(vals) is 128 , oldCap is 64 ; multiplier is 2.00
len(vals) is 129 => cap(vals) is 256 , oldCap is 128 ; multiplier is 2.00
len(vals) is 257 => cap(vals) is 512 , oldCap is 256 ; multiplier is 2.00
len(vals) is 513 => cap(vals) is 1024 , oldCap is 512 ; multiplier is 2.00
len(vals) is 1025 => cap(vals) is 1280 , oldCap is 1024 ; multiplier is 1.25
len(vals) is 1281 => cap(vals) is 1792 , oldCap is 1280 ; multiplier is 1.40
len(vals) is 1793 => cap(vals) is 2304 , oldCap is 1792 ; multiplier is 1.29
len(vals) is 2305 => cap(vals) is 3072 , oldCap is 2304 ; multiplier is 1.33
len(vals) is 3073 => cap(vals) is 4096 , oldCap is 3072 ; multiplier is 1.33
len(vals) is 4097 => cap(vals) is 5376 , oldCap is 4096 ; multiplier is 1.31
len(vals) is 5377 => cap(vals) is 6784 , oldCap is 5376 ; multiplier is 1.26
len(vals) is 6785 => cap(vals) is 9472 , oldCap is 6784 ; multiplier is 1.40
len(vals) is 9473 => cap(vals) is 12288 , oldCap is 9472 ; multiplier is 1.30

从输出结果我们可以看出,刚开始slice容量为21,在第一次扩容时就不满足代码片一中的扩容策略,按照代码片一中的扩容策略,此次扩容,slice的容量应该是42(21*2),我们从代码中分析一下原因,当数据类型size为1时,在执行代码片一之后会执行以下代码:

1
2
3
4
5
6
7
case et.size == 1:
    lenmem = uintptr(old.len)
    newlenmem = uintptr(cap)
    //执行过代码片一之后,newcap=42
    capmem = roundupsize(uintptr(newcap))
    overflow = uintptr(newcap) > maxAlloc
    newcap = int(capmem)

同样会使用roundupsize向上取整,会执行以下代码

1
2
3
4
5
6
7
8
9
if size < _MaxSmallSize {
    //smallSizeMax 每个span中能存放的最多的小对象个数
    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)]])
    }
}

返回的值为

1
2
3
4
5
class_to_size[size_to_class8[divRoundUp(size, smallSizeDiv)]]
divRoundUp(size, smallSizeDiv)=(42+8-1)/8=6
 
size_to_class8[6]=4
class_to_size[4]=48

即返回的capmem=48,因此newcap=48

3、内存对齐

计算出了新容量之后,还没有完,出于内存的高效利用考虑,还要进行内存对齐

1
capmem := roundupsize(uintptr(newcap) * uintptr(et.size))

newcap就是前文中计算出的newcap,et.size代表slice中一个元素的大小,capmem计算出来的就是此次扩容需要申请的内存大小。roundupsize函数就是处理内存对齐的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
func roundupsize(size uintptr) uintptr {
    if size < _MaxSmallSize {
        if size <= 1024-8 {
            return uintptr(class_to_size[size_to_class8[(size+7)>>3]])
        } else {
            return uintptr(class_to_size[size_to_class128[(size-1024+127)>>7]])
        }
    }
    if size+_PageSize < size {
        return size
    }
    return round(size, _PageSize)
}

 _MaxSmallSize的值在64位macos上是32«10,也就是2的15次方,32k。golang事先生成了一个内存对齐表。通过查找(size+7) » 3,也就是需要多少个8字节,然后到class_to_size中寻找最小内存的大小。承接上文的size,应该是40,size_to_class8的内容是这样的:

1
size_to_class8:1 1 2 3 3 4 4 5 5 6 6 7 7 8 8 9 9 10 10 11 11...

查表得到的数字是4,而class_to_size的内容是这样的:

1
class_to_size:0 8 16 32 48 64 80 96 112 128 144 160 176 192 208 224 240 256...

因此得到最小的对齐内存是48字节。完成内存对齐计算后,重新计算应有的容量,也就是48/8 = 6。扩容得到的容量就是6了。

4、向上取整

之前说到了slice扩容时可能会将容量向上取整,为什么需要向上取整?

这与golang的内存分配机制有关,因为我没有深入太多细节,所以这里大概说一下, 目前,当我们创建一个切片或增长一个切片时,我们使用 malloc为该底层数组 (目前golang应该是用Tmalloc来分配内存)分配内存。 golang中分配内存的原理大概是把内存分成大小不等的块(8,16,32,48......)。然后在需要时,找个一个大小合适的内存块,分配给这个对象(只是大概的原理,其真实的分配流程必然更加复杂),比如说我们创建一个容量为42的字符型slice([]byte),那么malloc会为其底层数组分配大小为48B的内存块。

假设我们的slice容量不向上取整,那么我们的slice指向该大小为42字节的数组(其实际分配内存为48字节),len为22,cap=42,那么就有6字节的内存被浪费了,也无法分配给其他的对象。

所以我们在slice 扩容时对其容量向上取整,比如说我们创建有一个容量为21的字符型slice([]byte),之后apped操作触发slice扩容,那么在确定其容量时向上取整48,那么同样会为其底层数组分配大小为48的内存块,但是不会造成内存的浪费了。

posted @   踏雪无痕SS  阅读(587)  评论(1编辑  收藏  举报
相关博文:
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
历史上的今天:
2021-10-02 【Golang】Go 通过结构(struct) 实现接口(interface)
点击右上角即可分享
微信分享提示