需要说明:本文基于 Golang 1.18 进行解析讲解,不同版本对于扩容的策略可能有小的调整。

主要搞清楚一个问题:Golang 的 slice 发生扩容时,究竟做了什么。

先说结论:详细可以看我之前的文章:🧐万万没想到!Golang Slice扩容还能这么玩!

  • 原切片容量小于 256:如果原切片容量小于 256,新容量会直接翻倍。例如原切片容量为 128,扩容后的新容量就是 256。

  • 原切片容量大于等于 256:当原切片容量大于等于 256 时,扩容会采用更复杂的策略。新容量会在原容量的基础上增加一个 0 到 25% 之间的 “额外” 容量,具体增加的比例会根据情况动态调整。这种策略旨在平衡内存使用效率和性能。比如原切片容量为 512,扩容后的新容量可能会大于 512 但小于 640(512 + 512 * 25%)。

在进行上述两个步骤计算出新的容量之后,Golang 会进行内存对齐,调整出最适容量。

之前的文章我没有细聊 slice 内存对齐怎么做,所以现在领着大家来探寻真相!

1. 下面的例子输出什么?

从现象出发,探寻问题的本质。

我会先给出几个 Golang slice 扩容的例子,读者可以先思考,再看输出结果。

感兴趣的读者可以在线验证下述输出

1.1. 例1

package main

import "fmt"

func main() {
	a := make([]bool, 9)
	fmt.Printf("cap: %d\n", cap(a))
	a = append(a, true)
	fmt.Printf("cap: %d\n", cap(a))
}

输出:

cap: 9

cap: 24

1.2. 例2

package main

import "fmt"

func main() {
	a := make([]uint32, 9)
	fmt.Printf("cap: %d\n", cap(a))
	a = append(a, 1)
	fmt.Printf("cap: %d\n", cap(a))
}

输出:

cap: 9

cap: 20

1.3. 例3

package main

import "fmt"

func main() {
	a := make([]uint64, 9)
	fmt.Printf("cap: %d\n", cap(a))
	a = append(a, 1)
	fmt.Printf("cap: %d\n", cap(a))
}

输出:

cap: 9

cap: 18

1.4. 例4

package main

import (
	"fmt"
)

type A struct {
	a bool
	b bool
	c bool
	d bool
	e bool
}

func main() {
	a := make([]A, 9)
	fmt.Printf("cap: %d\n", cap(a))
	a = append(a, A{})
	fmt.Printf("cap: %d\n", cap(a))
}
cap: 9
cap: 19

哈哈哈哈,是不是懵了?

说好的2倍扩容呢?怎么一扩一个样!

别急,在探寻问题之前,我先交代一下 Golang 的堆内存分配逻辑,然后在一个一个的解释原因!

2. 神奇的 Mheap

2.1. 什么是 mheap?

MHeap是 Go 运行时中的全局堆管理器(位于runtime/mheap.go),主要包含以下关键组件:

2.1.1. Arena 区域

  • 堆内存的主体部分,被划分为多个 64MB 的块(称为arena chunks

  • 最大可管理 16EB(2^64)的虚拟地址空间(实际受系统限制)

  • 通过arena_startarena_end指针界定范围

2.1.2. Span 管理

  • MSpan:内存管理的基本单元,每个 span 包含多个相同大小的对象

  • Span Class:根据对象大小分为 67 种 class(如 class 1 对应 8 字节,class 2 对应 16 字节...)

  • Span Lists:维护多种状态的 span 链表(空闲、部分使用、已满)

2.1.3. 中心缓存

  • MCentral:每个 size class 对应一个 MCentral,管理全局的 span 列表

  • Cache Lines:每个 MCentral 包含两个 span 链表(空闲 / 非空闲),支持并发访问

2.1.4. 位图与元数据

  • bitmap:记录每个对象的 GC 标记位和类型信息

  • spanmap:映射虚拟地址到对应的 MSpan

  • arenas:三级索引结构,快速定位内存块

2.2. mheap 如何分配内存的?

当 Go 程序请求内存时,MHeap 的分配逻辑如下:

1. 小对象分配(≤32KB)

plaintext

请求分配对象 → MCache本地缓存 → 有可用块? → 直接分配(无锁,极快)
                            ↓
                        无可用块 → 从MCentral获取span → 有空闲span? → 分割并分配
                                                      ↓
                                                  无空闲span → 从MHeap申请新span
                                                               ↓
                                                           MHeap有足够空间? → 分配并分割
                                                                         ↓
                                                                     触发系统调用(mmap)

2. 大对象分配(>32KB)

  • 直接从 MHeap 分配独立 span

  • 跳过 MCache 和 MCentral,减少中间层开销

  • 使用专门的大对象管理逻辑(避免与小对象混存)

  • 每个 mspan 大小, N页, 每页 8KB,如: 33KB 的对象,申请的 span 有5页,40KB大小。

总结:

mheap 在进行内存分配时,是以 mspan 为结构分配的。

  • 大对象分配(>32KB): 分配 n*8kB大小的mspan。

  • 小对象分配(<=32KB): 从 mcache或mcentral,或直接申请新的mspan,mspan依据大小分为67类,在源码runtime/sizaclasses.go

// class  bytes/obj  bytes/span  objects  tail waste  max waste  min align
//     1          8        8192     1024           0     87.50%          8
//     2         16        8192      512           0     43.75%         16
//     3         24        8192      341           8     29.24%          8
//     4         32        8192      256           0     21.88%         32
//     5         48        8192      170          32     31.52%         16
//     6         64        8192      128           0     23.44%         64
//     7         80        8192      102          32     19.07%         16
//     8         96        8192       85          32     15.95%         32
//     9        112        8192       73          16     13.56%         16
//    10        128        8192       64           0     11.72%        128
//    11        144        8192       56         128     11.82%         16
//    12        160        8192       51          32      9.73%         32
//    13        176        8192       46          96      9.59%         16
//    14        192        8192       42         128      9.25%         64
//    15        208        8192       39          80      8.12%         16
//    16        224        8192       36         128      8.15%         32
//    17        240        8192       34          32      6.62%         16
//    18        256        8192       32           0      5.86%        256
//    19        288        8192       28         128     12.16%         32
//    20        320        8192       25         192     11.80%         64
//    21        352        8192       23          96      9.88%         32
//    22        384        8192       21         128      9.51%        128
//    23        416        8192       19         288     10.71%         32
//    24        448        8192       18         128      8.37%         64
//    25        480        8192       17          32      6.82%         32
//    26        512        8192       16           0      6.05%        512
//    27        576        8192       14         128     12.33%         64
//    28        640        8192       12         512     15.48%        128
//    29        704        8192       11         448     13.93%         64
//    30        768        8192       10         512     13.94%        256
//    31        896        8192        9         128     15.52%        128
//    32       1024        8192        8           0     12.40%       1024
//    33       1152        8192        7         128     12.41%        128
//    34       1280        8192        6         512     15.55%        256
//    35       1408       16384       11         896     14.00%        128
//    36       1536        8192        5         512     14.00%        512
//    37       1792       16384        9         256     15.57%        256
//    38       2048        8192        4           0     12.45%       2048
//    39       2304       16384        7         256     12.46%        256
//    40       2688        8192        3         128     15.59%        128
//    41       3072       24576        8           0     12.47%       1024
//    42       3200       16384        5         384      6.22%        128
//    43       3456       24576        7         384      8.83%        128
//    44       4096        8192        2           0     15.60%       4096
//    45       4864       24576        5         256     16.65%        256
//    46       5376       16384        3         256     10.92%        256
//    47       6144       24576        4           0     12.48%       2048
//    48       6528       32768        5         128      6.23%        128
//    49       6784       40960        6         256      4.36%        128
//    50       6912       49152        7         768      3.37%        256
//    51       8192        8192        1           0     15.61%       8192
//    52       9472       57344        6         512     14.28%        256
//    53       9728       49152        5         512      3.64%        512
//    54      10240       40960        4           0      4.99%       2048
//    55      10880       32768        3         128      6.24%        128
//    56      12288       24576        2           0     11.45%       4096
//    57      13568       40960        3         256      9.99%        256
//    58      14336       57344        4           0      5.35%       2048
//    59      16384       16384        1           0     12.49%       8192
//    60      18432       73728        4           0     11.11%       2048
//    61      19072       57344        3         128      3.57%        128
//    62      20480       40960        2           0      6.87%       4096
//    63      21760       65536        3         256      6.25%        256
//    64      24576       24576        1           0     11.45%       8192
//    65      27264       81920        3         128     10.00%        128
//    66      28672       57344        2           0      4.91%       4096
//    67      32768       32768        1           0     12.50%       8192

依据上述的 mspan 表,我们就知道了 1 中的对象扩容具体是怎么分配的了。接下来,我一一带大家结合内存分配来解释。

3. 基于 mheap 内存分配,解释问题

3.1. 例1

package main

import "fmt"

func main() {
	a := make([]bool, 9)
	fmt.Printf("cap: %d\n", cap(a))
	a = append(a, true)
	fmt.Printf("cap: %d\n", cap(a))
}

解释:

  • bool 类型占用 1 字节,初始申请 9 个长度大小切片,占用 9 字节,分配在 class2(obj: 16字节) 的mspan上。

  • 发生扩容时,首先,原容量为 9,小于 256,进行 2 倍扩容,容量变为 18。此时进行分配,obj 为 18 字节。分配时,放在 class3 (obj: 24 字节) 的 mspan 上。

  • 为了节约内存效率,Golang 会自适应内存上线,以 24 字节去分配我们的容量,最多可以分配 24 个。

这就是为什么我们例1,9 的容量扩容后容量变为 24 的原因啦!

3.2. 例2

package main

import "fmt"

func main() {
	a := make([]uint32, 9)
	fmt.Printf("cap: %d\n", cap(a))
	a = append(a, 1)
	fmt.Printf("cap: %d\n", cap(a))
}

解释:

  • uint32 类型占用 4 字节,初始申请 9 个长度大小切片,占用 36 字节,分配在 class5(obj: 48 字节) 的 mspan 上。

  • 发生扩容,原容量为 9,小于 256,进行 2 倍扩容,容量变为 18。此时进行分配,obj 为 4*18=72 字节。分配时,放在 class7 (obj: 80 字节) 的 mspan 上。

  • 为了节约内存效率,Golang 再次调整了扩容数组的内存上限,以 80 字节去分配容量,容量变为80/4=20

3.3. 例3

package main

import "fmt"

func main() {
	a := make([]uint64, 9)
	fmt.Printf("cap: %d\n", cap(a))
	a = append(a, 1)
	fmt.Printf("cap: %d\n", cap(a))
}

解释:

  • uint64 是 8 字节, 初始 9 个长度, 总容量 9,占用空间,72 字节,分配在 class7(ojb: 80 字节) 的 mspan。

  • 扩容后,预估容量 18,占用空间 8 * 18 = 144 字节。刚好分配在 class11(obj: 144 字节)的mspan上。

  • 已经占满,无需在向上分配,所以扩容之后刚好是 18。

3.4. 例4

package main

import (
	"fmt"
)

type A struct {
	a bool
	b bool
	c bool
	d bool
	e bool
}

func main() {
	a := make([]A, 9)
	fmt.Printf("cap: %d\n", cap(a))
	a = append(a, A{})
	fmt.Printf("cap: %d\n", cap(a))
}

解释:

  • 我们构造了一个结构体A,他的大小占用 5 字节。

  • 初始分配 9 个容量切片,占用大小 40 字节。分配在 class5(obj: 48 字节) mspan 上。

  • 扩容时,先 2 倍调整为 18 容量,占用大小:5*18=90 字节。分配在 class8 (obj:96字节) mspan上。

  • 向上扩容,占满对象分配空间:96/5 可以分配 19 个对象。所以最终扩容后,长度变为了 19。

4. 思考

上述由 Golang 的 slice 扩容策略出发,清晰介绍了 mheap 的分配逻辑,详细介绍了 mspan 的外部内存对齐策略。

基于此,我们平时在设计对象大小,设计切片等动作时,可以尽量的将数据大小像 mspan 靠齐。这样不仅可以最大程度的利用上内存空间,还能让 Golang 的内存分配尽量完整,提高 GC 的回收性能,减少内存碎片的产生。

题外话:

得益于开发者对功能的优秀封装,Golang展现出极易上手的特点,使得许多人在初次接触时将其视为一门简单的语言。

然而,Golang从来都不是一门真正意义上的简单语言,就像很多人拿着 “1024 以下2倍扩容,1024以上1/4扩容“,当 slice 扩容的真理 一样。要对技术由敬畏,不要当半桶水的“专家”。

共勉!