前提必读: 由于文章篇幅原因,引用的代码块都是不完整的,但是所有的函数名都列出来了,强烈建议读者看一遍源码
在讲golang的内存分配之前,我们先来看看一般程序的内存分配:
栈区(stack):由程序系统调用向操作系统申请,由操作系统善后,每个线程有自己的栈区,速度快,使用方便,程序员无法控制。
堆区(heap):程序运行期间动态的分配任意大小的内存,一般由程序员分配释放,若程序员不释放,程序结束后可能由OS回收。
全局区(静态区static):此内存区在编译的时候就已经分配好,速度快,程序结束后由操作系统释放
文字常量区:常量字符串存放在这一区域。程序结束后由系统释放。
程序代码区:存放函数体的二进制代码。
分配栈内存时栈空间是由高向低地址增长的,其中高地址的部分保存着进程的环境变量和命令行参数,低地址的部分保存函数栈帧,
在堆中分配内存时,是正常的由低到高分配
golang的内存分配基本策略:
- 每次从操作系统申请一大块内存(比如1MB)以减少系统调用
- 将申请到的大块内存按照特定大小预先切分成小块,构成链表
- 为对象分配内存时,只需从大小合适的链表提取一个小块即可
- 回收对象内存时,将该小块重新归还到原链表,以便复用
- 如果闲置内存过多,则尝试归还部分内存给操作系统,降低整体开销
内存分配器只管理内存块,并不关心对象状态。它不会主动回收内存,垃圾回收器在完成清理操作后,触发内存分配器的回收操作
分配器将其管理的内存分为两种:
- span:由多个地址连续的页(每一页的大小为8kb) 组成的大块内存
- object:将span按特定大小切分成多个小块,每个小块存储一个对象 按照其用途,span面向内部管理,object面向对象分配
分配器按页数来区分不同大小的span,以页数为单位将span存放到管理数组中,需要时就以页数的索引来查找。
span大小并非不变,在没有获取到合适大小的闲置span时,返回页数更多的span,然后进行剪裁,多余的页数构成新的span,放回管理数组。
mspan是go内存管理的最基本单元,但是内存的使用最终还是要落到“对象”上。mspan和对象是什么关系呢?其实“对象”肯定也放到page中,毕竟page是内存存储的基本单元。
我们来看下面这个图:
当要分配p4时,已经没有连续的内存块了,该如何分配?这种会出现内存碎片的分配情况,go是如何解决的呢?
对于上面的问题,go语言用mspan来解决。
我们知道mspan是由一片连续的8KB的页组成的大块内存。 注意,这里的页和操作系统本身的页并不是一回事,它一般是操作系统页大小的几倍。一句话概括:mspan是一个包含起始地址、mspan规格、页的数量等内容的双端链表。
// path: runtime/mheap.go
type mspan struct {
next *mspan //链表前向指针,用于将span链接起来
prev *mspan //链表前向指针,用于将span链接起来
startAddr uintptr // 起始地址,也即所管理页的地址
npages uintptr // 管理的页数
manualFreeList gclinkptr //待分配的object链表
nelems uintptr // 块个数,表示有多少个块可供分配
allocBits *gcBits //分配位图,每一位代表一个块是否已分配
allocCount uint16 // 已分配块的个数
spanclass spanClass // class表中的class ID,和sizeClasss相关
elems // class表中的对象大小,也即块大小
...
}
mspan的自身属性spanclass是由sizeClass组成,sizeClass决定object大小。每个mspan按照sizeClass的大小分割成若干个object,每个object可存储一个对象。 并且会使用一个位图来标记其尚未使用的object。mspan只会分配给和object尺寸大小接近的对象,当然,对象的大小要小于object大小。
如下图,mspan由一组连续的页组成,按照一定大小划分成object。
mspan的sizeClass共有67种,每种mspan分割的object大小是8的倍数。
var class_to_size = [_NumSizeClasses]uint16{0, 8, 16, 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}
比如说大小为32B的object可用来存储范围在17~32字节的对象,这种方式虽然会造成一些内存浪费,但分配器只需要面对几种有限的小块内存,优化来分配和复用策略。
对象分配的时候,根据对象的大小选择大小相近的span,这样,碎片问题就解决了。
值得一提的是数组里最大的数是32768,也就是32KB,超过此大小就是大对象了,它会被特别对待。实际上sizeClass为0就表示大对象,它直接由堆内存分配,小对象都通过mspan来分配。
对于mspan来说,它所能分到的页数,这也是写死在代码里的:
var class_to_allocnpages = [_NumSizeClasses]uint8{0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 2, 1, 2, 1, 3, 2, 3, 1, 3, 2, 3, 4, 5, 6, 1, 7, 6, 5, 4, 3, 5, 7, 2, 9, 7, 5, 8, 3, 10, 7, 4}
比如当我们要申请一个object大小为32B的mspan的时候,在class_to_size里对应的索引是3,而索引3在class_to_allocnpages数组里对应的页数就是1。
管理组件
优秀的内存分配器必须要在性能和内存利用率之间做到平衡,Go的起点很高,直接采用了tcmalloc的成熟架构。
Go分配器由三种组件组成:
- cache:每个运行期工作线程都会绑定一个cache,用于无锁object分配。
- central:为所有cache提供切分好的后备span资源。
- heap:管理闲置span,需要时向操作系统申请新内存。
type mheap struct {
...
largefree uint64 // bytes freed for large objects (>maxsmallsize)
nlargefree uint64 // number of frees for large objects (>maxsmallsize)
nsmallfree [_NumSizeClasses]uint64 //32kb以内的闲置span链表数组
central [numSpanClasses]struct {
mcentral mcentral
pad [cpu.CacheLinePadSize - unsafe.Sizeof(mcentral{})%cpu.CacheLinePadSize]byte
} //每个central对应一个sizeclass
...
}
type mcentral struct {
lock mutex
spanclass spanClass //规格
nonempty mSpanList // 链表,尚有空闲object的span
empty mSpanList // 链表,没有空闲object,或已被cache取走的span
...
}
type mcache struct {
...
alloc [numSpanClasses]*mspan // 以spanClass 为索引管理多个用于分配的span
...
}
分配器的分配流程大致分为:
- 计算待分配对象的对应规格(size class)
- 从 mcache.alloc 数组中找到规格相同的span
- 从 mspan.manualFreeList 链表中提取可用的object
- 如果 mspan.manualFreeList 为空,从central中获取新span
- 如果 mcentral.nonempty为空,从 nsmallfree 获取,并切分成object链表
- 如果 heap 没有合适的闲置span,向操作系统申请新内存
释放内存流程如下:
- 把标记为可回收的object交还给所属 mspan.manualFreeList
- 该span放回central,可供任意cache重新获取使用
- 如果span已回收完全部object,则将其交还给heap,已便重新切分复用
- 定期扫描heap 里长时间闲置的span,释放其占用的内存
以上不包含大对象,大对象直接从heap分配和回收
作为工作线程私有且不被共享的cache是实现高性能无锁分配的核心,为了避免多线程申请内存时不断的加锁,goroutine为每个线程分配了span内存块的缓存,这个缓存就是mcache,每个goroutine都会绑定的一个mcache,各个goroutine申请内存时不存在锁竞争的情况。
golang的并发调度模型MPG,运行期间一个goroutine只能和一个P关联,而mcache就在P上,所以,不可能有锁的竞争。
central的作用是在多个cache间提高object的利用率,避免内存浪费。
假如一个cache1获取一个span后,仅仅使用了一部分object,那么剩余的空间就可能被浪费。而回收操作将该span交还给central后,该span完全可以被cache2,cache3获取使用,此时,cache1已不再持有该span,完全不会造成问题。
初始化
讲内存初始化之前先来看一张图:
arena区域,就是我们所谓的堆区,Go动态分配的内存都是在这个区域,它把内存分割成8KB大小的页,一些页组合起来就是上面讲的mspan。
bitmap区域,作用是标记标记arena(即heap)中的对象。一是的标记对应地址中是否存在对象,另外是标记此对象是否被gc标记过。一个功能一个bit位,所以 bitmaps用两个bit位,bitmap中一个byte大小的内存对应arena区域中4个指针大小的内存。
bitmap的地址是由高地址向低地址增长的。
spans区域,存放mspan的指针,每个指针对应一页,所以spans区域的大小就是512GB/8KB*8B=512MB,除以8KB是计算有多少页,而最后乘以8是计算spans区域所有指针的大小(一个指针1byte)创建mspan的时候,按页填充对应的spans区域, 在回收object时,根据地址很容易就能找到它所属的mspan。
上图可以看到有两个S指向了同一个mspan,因为这两个S指向的P是同属一个mspan的。所以,通过arena上的地址可以快速找到指向它的S,通过S就能找到mspan,回忆一下前面我们说的mspan区域的每个指针对应一页。
假设最左边第一个mspan的Size Class等于10,根据前面的class_to_size数组,得出这个msapn分割的object大小是144B(数组中第10个),算出每页可分配的对象个数是8KB/144B=56.89个,取整56个,所以会有一些内存浪费掉了,Go的源码里有所有Size Class的mspan浪费的内存的大小;再根据class_to_allocnpages数组,得到这个mspan只由1个page组成;假设这个mspan是分配给无指针对象的,那么spanClass等于20。
startAddr直接指向arena区域的某个位置,表示这个mspan的起始地址,allocBits指向一个位图,每位代表一个块是否被分配了对象;allocCount则表示总共已分配的对象个数。
简单的说,就是使用三个数组组成一个高性能内存管理结构。
- 使用arena地址向操作系统申请内存(虚拟内存,并不少真正的分配内存),其大小决定了可分配用户内存的上限
- 位图bitmap为每个对象提供2bit标记位,用以保存指针,GC信息
- 创建span时,按照页填充对应的spans区域。在回收object时,只需将其地址按照页对齐后就可以找到所属的span。分配器还用此访问相邻的span,做合并操作。
内存分配
为对象分配内存须在栈上还是堆上完成,通常情况下,编译器有责任尽可能使用寄存器和栈来存储对象,这有助于提升性能,减少垃圾回收器的压力。
千万不要以为用了new 函数就一定会分配在堆上,即使是相同源码也有不同结果
package main
func main() {
println(test())
}
func test() *int {
x := new(int)
*x = 0xAABB
return x
}
当编译器禁用内联优化时
go build -gcflags "-l" -o test main.go //关闭内联优化
go tool objdump -s "main\.test" test //解析可执行文件test,将其中的 main 包的 test 方法转成汇编代码。
TEXT main.test(SB) /Users/xxx/test/main.go
main.go:7 0x105c660 65488b0c2530000000 MOVQ GS:0x30, CX
main.go:7 0x105c669 483b6110 CMPQ 0x10(CX), SP
main.go:7 0x105c66d 7639 JBE 0x105c6a8
main.go:7 0x105c66f 4883ec18 SUBQ $0x18, SP
main.go:7 0x105c673 48896c2410 MOVQ BP, 0x10(SP)
main.go:7 0x105c678 488d6c2410 LEAQ 0x10(SP), BP
main.go:8 0x105c67d 488d059c6d0000 LEAQ runtime.rodata+28000(SB), AX
main.go:8 0x105c684 48890424 MOVQ AX, 0(SP)
main.go:8 0x105c688 e8b3eefaff CALL runtime.newobject(SB) //在堆上分配
main.go:8 0x105c68d 488b442408 MOVQ 0x8(SP), AX
main.go:9 0x105c692 48c700bbaa0000 MOVQ $0xaabb, 0(AX)
main.go:10 0x105c699 4889442420 MOVQ AX, 0x20(SP)
main.go:10 0x105c69e 488b6c2410 MOVQ 0x10(SP), BP
main.go:10 0x105c6a3 4883c418 ADDQ $0x18, SP
main.go:10 0x105c6a7 c3 RET
main.go:7 0x105c6a8 e8d3b1ffff CALL runtime.morestack_noctxt(SB)
main.go:7 0x105c6ad ebb1 JMP main.test(SB)
当使用默认参数时,函数test会被main内联
go build -o test main.go
go tool objdump -s "main\.main" test
TEXT main.main(SB) /Users/xxx/test/main.go
main.go:3 0x105c5e0 65488b0c2530000000 MOVQ GS:0x30, CX
main.go:3 0x105c5e9 483b6110 CMPQ 0x10(CX), SP
main.go:3 0x105c5ed 764a JBE 0x105c639
main.go:3 0x105c5ef 4883ec18 SUBQ $0x18, SP
main.go:3 0x105c5f3 48896c2410 MOVQ BP, 0x10(SP)
main.go:3 0x105c5f8 488d6c2410 LEAQ 0x10(SP), BP
main.go:4 0x105c5fd 48c744240800000000 MOVQ $0x0, 0x8(SP)
main.go:9 0x105c606 48c7442408bbaa0000 MOVQ $0xaabb, 0x8(SP)
main.go:4 0x105c60f e80c17fdff CALL runtime.printlock(SB)
main.go:4 0x105c614 488d442408 LEAQ 0x8(SP), AX
main.go:4 0x105c619 48890424 MOVQ AX, 0(SP)
main.go:4 0x105c61d 0f1f00 NOPL 0(AX)
main.go:4 0x105c620 e8bb20fdff CALL runtime.printpointer(SB)
main.go:4 0x105c625 e8b619fdff CALL runtime.printnl(SB)
main.go:4 0x105c62a e87117fdff CALL runtime.printunlock(SB)
main.go:5 0x105c62f 488b6c2410 MOVQ 0x10(SP), BP
main.go:5 0x105c634 4883c418 ADDQ $0x18, SP
main.go:5 0x105c638 c3 RET
main.go:3 0x105c639 e842b2ffff CALL runtime.morestack_noctxt(SB)
main.go:3 0x105c63e 6690 NOPW
main.go:3 0x105c640 eb9e JMP main.main(SB)
内联优化之后的代码,没有调用newobject在堆上分配内存,编译器这么做道理很简单,没有内联时,需要在两个栈帧之间传递对象,因此会在堆上分配而不是返回一个失效栈帧里的数据。而当内联后,它实际上就成了main栈帧内的局部变量, 无须去堆上操作。
go编译器支持逃逸分析,它会在编译期间调用图来分析局部变量是否会被外部引用,从而决定是否分配在栈上。编译参数 -gcflags "-m" 可输出编译优化信息,其中包括内联和逃逸分析。
// 内置函数new的实现
func newobject(typ *_type) unsafe.Pointer {
return mallocgc(typ.size, typ, true)
}
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
...
// 当前线程所绑定的cache
if mp.p != 0 {
c = mp.p.ptr().mcache
} else {
// 在没有P的情况下被调用,这时我们使用mcache0,它是在mallocinit中设置的。当引导完成时,通过proclesize清除mcache0。
c = mcache0
if c == nil {
throw("malloc called with no P")
}
}
// 小对象
if size <= maxSmallSize {
// 无需扫描非指针微小对象(小于16)
if noscan && size < maxTinySize {
...
} else {
...
size = uintptr(class_to_size[sizeclass])
spc := makeSpanClass(sizeclass, noscan)
span = c.alloc[spc] //小对象从cache.alloc获取内存
}
} else {
// 大对象
shouldhelpgc = true
systemstack(func() {
span = largeAlloc(size, needzero, noscan) //大对象直接从heap获取内存
})
}
...
}
整理 mallocgc 代码的基本思路:
- 大对象直接从heap获取span
- 小对象从cache.alloc[sizeclass]中获取span,从span的manualFreeList获取object
- 微小对象组合使用cache.tiny.object 分配算法不算复杂,接下来关注一下资源不足时如何扩张(central) 在聊central之前,得先了解一下sweepgen这个概念。垃圾回收每次都会累加这个类似代领的计数值,每个等待处理的span也有该属性:
type mheap struct {
sweepgen uint32 // sweep generation, see comment in mspan; written during STW
sweepdone uint32 // all spans are swept
sweepers uint32 // number of active sweepone calls
}
type mspan struct {
// sweep generation:
// if sweepgen == h->sweepgen - 2, the span needs sweeping
// if sweepgen == h->sweepgen - 1, the span is currently being swept
// if sweepgen == h->sweepgen, the span is swept and ready to use
// if sweepgen == h->sweepgen + 1, the span was cached before sweep began and is still cached, and needs sweeping
// if sweepgen == h->sweepgen + 3, the span was swept and then cached and is still cached
// h->sweepgen is incremented by 2 after every GC
sweepgen uint32
}
在heap里闲置的span不会被垃圾回收器关注,但central里的span却有可能正在被清理。所以当cache从central提取span时,该属性就非常重要。
func (c *mcentral) oldCacheSpan() *mspan {
// 清理(sweep)垃圾
sg := mheap_.sweepgen
retry:
var s *mspan
//遍历nonempty(尚有空闲object的链表)
for s = c.nonempty.first; s != nil; s = s.next {
if s.sweepgen == sg-2 && atomic.Cas(&s.sweepgen, sg-2, sg-1) {
//要交给cache使用,转移到empty
c.nonempty.remove(s)
c.empty.insertBack(s)
unlock(&c.lock)
s.sweep(true)
goto havespan
}
if s.sweepgen == sg-1 {
// 该span已经被后台sweeper扫描了,则跳过
continue
}
// 当拥有没有被扫描的 nonempty span,从中分配
c.nonempty.remove(s)
c.empty.insertBack(s)
unlock(&c.lock)
goto havespan
}
//遍历empty(没有空闲object,或已被cache取走)
for s = c.empty.first; s != nil; s = s.next {
if s.sweepgen == sg-2 && atomic.Cas(&s.sweepgen, sg-2, sg-1) {
// 扫描empty,看能否在里面腾出一些空间
c.empty.remove(s)
c.empty.insertBack(s)
freeIndex := s.nextFreeIndex()
//清理后有可用的object
if freeIndex != s.nelems {
s.freeindex = freeIndex
goto havespan
}
// 清理后依然没有可用的object,重试
goto retry
}
if s.sweepgen == sg-1 {
// 跳过正在被清理的span
continue
}
// 已经扫描清理完 empty span,
// 所有后续的也必须清扫或在清扫过程中,循环已没有意义,跳出
break
}
//如果为空,补充central
s = c.grow()
if s == nil {
return nil
}
lock(&c.lock)
//新补充的span将被cache使用,所以放到empty链表尾部
c.empty.insertBack(s)
unlock(&c.lock)
...
}
可以看出,从central里获取span时,优先取用已有资源,哪怕是要执行清理操作。只有当现有资源都无法满足时,才去heap获取span,并重新切分成object对象。
从heap获取span的核心算法是找到大小最合适的块。首先从页数相等的链表查找,没有结果则从页数更多的链表提取,直至超大块或申请新块。为了避免浪费,会将多余部分切出来重新放回heap链表,同时尝试合并相邻闲置的span空间,减少碎片。
至此,内存分配操作流程结束
内存回收
内存回收的源头是垃圾清理操作。整个内存分配器的核心是内存复用,不再使用的内存会被放回合适的位置,等下次分配时再使用,只有当空闲内存资源过多时,才会考虑释放。
基于效率考虑,回收操作自然不会直接盯着单个对象,而是以span为基本单位。通过比对bitmap里的扫描标记,逐步将object收回原span,最终上交central或heap复用。
调用mspan的sweep方法,来引发内存分配器回收流程。
总的来说,针对大小不同的object,golang采用了不同的内存回收策略。小对象使用频繁,故而将回收的内存块归还给central以便其他cache复用,而大对象相对来说分配频率较少,直接从heap分配,回收时直接归还给heap。
至此,回收操作结束,被回收的span并不会释放,而是等待复用,在运行时入口函数 main.main 里,会专门启动一个监控任务 sysmon,它每隔一段时间就会检查 heap 里的闲置内存块,当闲置时间超过阈值,则释放其关联的物理内存。