关于Go的内存分配
在 Go
语言里,从内存的分配到不再使用后内存的回收等等这些内存管理工作都是由 Go
在底层完成的。虽然开发者在写代码时不必过度关心内存从分配到回收这个过程,但是 Go
的内存分配策略里有不少有意思的设计,通过了解他们有助于我们自身的提高,也让我们能写出更高效的 Go
程序。
Go内存管理的设计旨在在并发环境中快速运行,并与垃圾回收器集成在一起。让我们看一个简单的示例:
package main
type smallStruct struct {
a, b int64
c, d float64
}func main() {
smallAllocation()
}
//go:noinline
func smallAllocation() *smallStruct {
return &smallStruct{}
}
复制代码
函数上面的注释 //go:noinline
将禁止 Go
对该函数进行内联,这样 main
函数就会使用 smallAllocation
函数返回的指针变量,因为被多个函数使用,返回的这个变量将被分配到堆上。
关于内联的概念之前的文章有说过:
内联是一种手动或编译器优化,用于将简短函数的调用替换为函数体本身。这么做的原因是它可以消除函数调用本身的开销,也使得编译器能更高效地执行其他的优化策略。
所以如果上面的例子不干预编译器的话,编译器通过内联将 smallAllocation
函数体里的内容直接放到 main
函数里,这样就不会产生smallAllocation
这个函数的调用了,所有的变量都是 main
函数内这个范围使用的,也就不在需要将变量往堆上分配了。
继续说上面那个例子,通过逃逸分析命令 go tool compile -m main.go 可以确认我们上面的分析, &smallStruct{}
会被分配到堆上去。
➜ go tool compile -m main.go
main.go:12:6: can inline main
main.go:10:9: &smallStruct literal escapes to heap
复制代码
借助命令 go tool compile -S main.go ,可以显示该程序的汇编代码,也可以明确地向我们展示内存的分配:
0x001d 00029 (main.go:10) LEAQ type."".smallStruct(SB), AX
0x0024 00036 (main.go:10) PCDATA $2, $0
0x0024 00036 (main.go:10) MOVQ AX, (SP)
0x0028 00040 (main.go:10) CALL runtime.newobject(SB)
复制代码
内置函数 newobject
会通过调用另外一个内置函数 mallocgc
在堆上分配新内存。在Go里面有两种内存分配策略,一种适用于程序里小内存块的申请,另一种适用于大内存块的申请,大内存块指的是大于32KB。
下面我们来细聊一下这两种策略。
小于32KB内存块的分配策略
当程序里发生了 32kb
以下的小块内存申请时,Go会从一个叫做的mcache
的本地缓存给程序分配内存。这个本地缓存 mcache
持有一系列的大小为 32kb
的内存块,这样的一个内存块里叫做 mspan
,它是要给程序分配内存时的分配单元。
从mcache中给程序分配内存
在Go的调度器模型里,每个线程 M
会绑定给一个处理器 P
,在单一粒度的时间里只能做多处理运行一个 goroutine
,每个 P
都会绑定一个上面说的本地缓存 mcache
。当需要进行内存分配时,当前运行的 goroutine
会从 mcache
中查找可用的 mspan
。从本地 mcache
里分配内存时不需要加锁,这种分配策略效率更高。
那么有人就会问了,有的变量很小就是数字,有的却是一个复杂的结构体,申请内存时都分给他们一个 mspan
这样的单元会不会产生浪费。其实 mcache
持有的这一系列的 mspan
并不都是统一大小的,而是按照大小,从8字节到32KB分了大概70类的 msapn
。
按照大小分类的mspan
就文章开始的那个例子来说,那个结构体的大小是32字节,正好32字节的这种 mspan
能满足需求,那么分配内存的时候就会给它分配一个32字节大小的 mspan
。
alloc 分配内存
现在,我们可能会好奇,如果分配内存时 mcachce
里没有空闲的32字节的 mspan
了该怎么办? Go
里还为每种类别的 mspan
维护着一个 mcentral
。
mcentral
的作用是为所有 mcache
提供切分好的 mspan
资源。每个 central
会持有一种特定大小的全局 mspan
列表,包括已分配出去的和未分配出去的。每个 mcentral
对应一种 mspan
,当工作线程的 mcache
中没有合适(也就是特定大小的)的 mspan
时就会从 mcentral
去获取。 mcentral
被所有的工作线程共同享有,存在多个 goroutine
竞争的情况,因此从 mcentral
获取资源时需要加锁。
mcentral
的定义如下:
//runtime/mcentral.go
type mcentral struct {
// 互斥锁 lock mutex
// 规格 sizeclass int32
// 尚有空闲object的mspan链表
nonempty mSpanList
// 没有空闲object的mspan链表,或者是已被mcache取走的msapn链表
empty mSpanList
// 已累计分配的对象个数
nmalloc uint64
}
复制代码
mcentral
里维护着两个双向链表, nonempty 表示链表里还有空闲的 mspan
待分配。 empty表示这条链表里的 mspan
都被分配了 object
。
mcentral
如果上面我们那个程序申请内存的时候, mcache
里已经没有合适的空闲 mspan
了,那么工作线程就会像下图这样去 mcentral
里去申请。
简单说下 mcache
从 mcentral
获取和归还 mspan
的流程:
nonempty mspan nonempty mspan empty mspan
mspan empty mspan nonempty
从mcentral里申请mspan
当 mcentral
没有空闲的 mspan
时,会向 mheap
申请。而 mheap
没有资源时,会向操作系统申请新内存。 mheap
主要用于大对象的内存分配,以及管理未切割的 mspan
,用于给 mcentral
切割成小对象。
从heap上申请内存
同时我们也看到, mheap
中含有所有规格的 mcentral
,所以,当一个 mcache
从 mcentral
申请 mspan
时,只需要在独立的 mcentral
中使用锁,并不会影响申请其他规格的 mspan
。
上面说了每种尺寸的 mspan
都有一个全局的列表存放在 mcentral
里供所有线程使用,所有 mcentral
的集合则是存放于 mheap
中的。mheap
里的 arena
区域是真正的堆区,运行时会将 8KB
看做一页,这些内存页中存储了所有在堆上初始化的对象。运行时使用二维的 runtime.heapArena 数组管理所有的内存,每个 runtime.heapArena 都会管理 64MB 的内存。
如果 arena
区域没有足够的空间,会调用 runtime.mheap.sysAlloc 从操作系统中申请更多的内存。
大于32KB内存块的分配策略
Go没法使用工作线程的本地缓存 mcache
和全局中心缓存 mcentral
上管理超过32KB的内存分配,所以对于那些超过32KB的内存申请,会直接从堆上( mheap
)上分配对应的数量的内存页(每页大小是8KB)给程序。
直接从堆上分配内存
总结
我们把内存分配管理涉及的所有概念串起来,可以勾画出Go内存管理的一个全局视图:
Go内存分配的全局示意图
Go语言的内存分配非常复杂,这个文章从一个比较粗的角度来看Go的内存分配,并没有深入细节。一般而言,了解它的原理,到这个程度也就可以了(应付面试)。
总结起来关于Go内存分配管理的策略有如下几点:
Go在程序启动时,会向操作系统申请一大块内存,由
mheap
结构全局管理。mspan mspan object
mcache mcentral mheap Go mcache mspan mcentral mspan mheap Go
一般小对象通过
mspan
分配内存;大对象则直接由mheap
分配内存。
相关阅读
参考链接
Memory Management and Allocation [1]
图解Go语言内存分配 [2]
内存分配器 [3]
参考资料
[1]
Memory Management and Allocation: medium.com/a-journey-w…
[2]
图解Go语言内存分配: juejin.im/post/684490…
[3]
内存分配器: draveness.me/golang/docs…
- END -
有疑问加站长微信联系(非本文作者)