需要说明:本文基于 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_start
和arena_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 扩容的真理 一样。要对技术由敬畏,不要当半桶水的“专家”。
共勉!
评论