1、概述
内存分配的基本策略:
- 每次从操作系统申请一大块内存(比如1MB),以减少系统调用。
- 将申请到的大块内存按照特定大小预先切分成小块,构成链表。
- 为对象分配内存时,只须从大小合适的链表提取一小块即可。
- 回收对象内存时,将该小块内存重新归还原链表,以便复用。
- 如闲置内存过多,则尝试归还部分内存给操作系统,降低整体开销。
内存块
分配器将其管理的内存块分为两种。
- span: 将多个地址连续的页(page)组成的大块内存。
- object: 将 span 按特定大小切分成多个小块,没个小块可存储一个对象。
分配器按页数来区分不同大小的 span。比如,以页数为单位将 span 存放到管理数组中,需要时就以页数为索引进行查找。当然,span大小并非固定不变。在获取闲置span时,如果没找到大小合适的,那就返回页数更多的,此时会引发裁剪操作,多余部分将构成新的 span 被释放回管理数组。分配器还会尝试将地址相邻的空闲 span 合并,已构建更大的内存块,减少碎片,提高更灵活的分配策略。
malloc.go
_PageShift = 13
_PageSize = 1 << _PageShift // 8KB
用于存储对象的 object,按8字节倍数分为n种。
若对象超出特定阈值限制,会被当做大对象(large object)特别对待。
malloc.go
_MaxSmallSize = 32768 // 32KB
管理组件
分配器由三种组件组成。
- cache: 每个运行期工作线程都会绑定一个 cache,用于无锁 object 分配。
- central:为所有cache 提供切分好的后备 span 资源。
- heap:管理闲置 span,需要时间向操作系统申请新内存。
2、初始化
因为内存分配器和垃圾回收算法都依赖连续地址,所以在初始化阶段,预先保留了很大的一段虚拟地址空间。
注意:保留地址空间,并不会分配内存。
该段空间被划分成三个区域:
简单点说,就是用三个数组组成一个高性能内存管理结构。
- 使用 area 地址向操作系统申请内存,其大小决定了可分配用户内存的上限。
- 位图 bitmap 为每个对象提供 4bit 标记位,用以保存指针、GC 标记等信息。
- 创建 span 时,按页填充对应 spans 空间。在回收 object 时,只续将其地址按页对齐后就可找到所属 span。分配器还用此访问相邻 span,做合并操作。
注意:
操作系统大多采取机会主义分配策略,申请内存时,仅承诺但不立即分配物理内存。
3、分配
为对象分配内存须区分是在栈上还是在堆上完成。通常情况下,编译器有责任尽可能使用寄存器和栈来存储对象,这有助于提升性能,减少垃圾回收器的压力。
但千万不要以为用了 new 函数就一定会分配在堆上,即使是相同的源码也有不同的结果。
package main
func test() *int {
x := new(int)
*x = 0xAABB
return x
}
func main() {
println(*test())
}
go build -gcflags “-l” -o test test.go // 关闭内联优化
go tool objdump -s “main.test” test
test.go:3 0x104e580 65488b0c2530000000 MOVQ GS:0x30, CX
test.go:3 0x104e589 483b6110 CMPQ 0x10(CX), SP
test.go:3 0x104e58d 7639 JBE 0x104e5c8
test.go:3 0x104e58f 4883ec18 SUBQ $0x18, SP
test.go:3 0x104e593 48896c2410 MOVQ BP, 0x10(SP)
test.go:3 0x104e598 488d6c2410 LEAQ 0x10(SP), BP
test.go:4 0x104e59d 488d05fca50000 LEAQ runtime.types+42272(SB), AX
test.go:4 0x104e5a4 48890424 MOVQ AX, 0(SP)
test.go:4 0x104e5a8 e8c3befbff CALL runtime.newobject(SB) // 在堆上分配
test.go:4 0x104e5ad 488b442408 MOVQ 0x8(SP), AX
test.go:5 0x104e5b2 48c700bbaa0000 MOVQ $0xaabb, 0(AX)
test.go:6 0x104e5b9 4889442420 MOVQ AX, 0x20(SP)
test.go:6 0x104e5be 488b6c2410 MOVQ 0x10(SP), BP
test.go:6 0x104e5c3 4883c418 ADDQ $0x18, SP
test.go:6 0x104e5c7 c3 RET
test.go:3 0x104e5c8 e8f384ffff CALL runtime.morestack_noctxt(SB)
test.go:3 0x104e5cd ebb1 JMP main.test(SB)
:-1 0x104e5cf cc INT $0x3
当使用默认参数时,函数 test 会被 main 内联,此时结果就变得不同了。
go build -o ./test ./test.go // 默认优化
go tool objdump -s “main.main” test
test.go:9 0x104e580 65488b0c2530000000 MOVQ GS:0x30, CX
test.go:9 0x104e589 483b6110 CMPQ 0x10(CX), SP
test.go:9 0x104e58d 7635 JBE 0x104e5c4
test.go:9 0x104e58f 4883ec10 SUBQ $0x10, SP
test.go:9 0x104e593 48896c2408 MOVQ BP, 0x8(SP)
test.go:9 0x104e598 488d6c2408 LEAQ 0x8(SP), BP
test.go:10 0x104e59d 90 NOPL
test.go:10 0x104e59e e89d50fdff CALL runtime.printlock(SB)
test.go:10 0x104e5a3 48c70424bbaa0000 MOVQ $0xaabb, 0(SP)
test.go:10 0x104e5ab e81058fdff CALL runtime.printint(SB)
test.go:10 0x104e5b0 e81b53fdff CALL runtime.printnl(SB)
test.go:10 0x104e5b5 e80651fdff CALL runtime.printunlock(SB)
test.go:11 0x104e5ba 488b6c2408 MOVQ 0x8(SP), BP
test.go:11 0x104e5bf 4883c410 ADDQ $0x10, SP
test.go:11 0x104e5c3 c3 RET
test.go:9 0x104e5c4 e8f784ffff CALL runtime.morestack_noctxt(SB)
test.go:9 0x104e5c9 ebb5 JMP main.main(SB)
:-1 0x104e5cb cc INT $0x3
:-1 0x104e5cc cc INT $0x3
:-1 0x104e5cd cc INT $0x3
:-1 0x104e5ce cc INT $0x3
:-1 0x104e5cf cc INT $0x3
从编译的结果来看,内联优化后的代码没有调用 newobject 在堆上分配内存。
编译器这么做,道理很简单。没有内联时,需要在两个栈帧间传递对象,因此会在堆上分配而不是返回一个失效栈帧里的数据。而当内联时,它实际就成了 main 栈帧内的局部变量,无须去堆上操作。
Go 编译器支持逃逸分析,它会在编译器通过构建调用图来分析局部变量是否会被外部引用,从而决定是否可直接分配在栈上。
编译参数 -gcflags “-m” 可输出编译优化信息,其中包括内联和逃逸分析。
对象分配策略:
- 大对象直接从 heap 获取 span
- 小对象从 cache.alloc[sizeclass].freelist 获取 object。
- 微小对象组合使用 cache.tiny object。
4、回收
之所以说回收而非释放,是因为整个内存分配器的核心是内存复用,不再使用的内存会被放回合适位置,等下次分配时再次使用。只有当空闲内存资源过多时,才会考虑释放。
基于效率考虑,回收操作自然不会直接盯着单个对象,而是以 span 为基本单位。通过比对 bitmap 里的扫描标记,逐步将 object 收归原 span,最终上交 central 或 heap 复用。
在回收器遍历span 时,将收集到的不可达 object 合并到 freelist 链表。如该 span 已收回全部 object,那么将这块完全自由的内存还给 heap,以便后续复用。
无论是向操作系统申请内存,还是清理回收内存,只要往 heap 里放 span,都会尝试合并左右相邻的闲置 span,已构成更大的自由块。