内存分配篇
前言
程序中的数据和变量都会被分配到程序所在的虚拟内存中,内存空间包含两个重要区域:栈区(Stack)和堆区(Heap).
函数调用的参数,返回值以及局部变量大都会被分配到栈上,这部分内存会由编译器进行管理;不同编程语言使用不同的方法管理堆区的内存, 例如C++ 等编程语言会由工程师主动申请和释放内存, Go 以及 Java 等编程语言会由工程师和编译器共同管理, 堆中的对象由内存分配器分配并由垃圾收集器回收.
本文是深入Golang内存管理的第二篇,在第一篇中我们提到,Golang的内存分配模式与TCMalloc是极其相似的,这一篇我们将继续探究Golang的内存分配.
如果对于Golang的内存管理并不是那么熟悉可以先从TCMalloc篇入手.
内存分配基础概念
在了解Golang语言的内存布局发展前,我们需要知道两种内存分配方式.
线性分配
线性分配是一种高效的内存分配方法。当我们使用线性分配器时,只需要在内存中维护一个指向内存特定位置的指针,如果用户程序向分配器申请内存,分配器只需要检查剩余的空闲内存、返回分配的内存区域并修改指针在内存中的位置,即移动下图中的指针:
但是这种方法也有很大的局限性,比如说当上图中第二个object被回收以后,单靠一个指针是没有办法继续分配已经空闲的区域(下图中红色区域)
但是办法总是人想出来的,熟悉Java语言的同学们肯定清楚,基于这种线性分配方法衍生很多种垃圾回收算法,来解决线性分配法的缺陷.
例如: 标记-压缩算法,标记-复制算法等算法, 它们可以通过拷贝的方式整理存活对象的碎片, 将空闲内存定期合并, 这样就能利用线性分配器的效率提升内存分配器的性能了.
空闲链表分配
空闲链表分配法可以重用已经被释放的内存,它在内部会维护一个类似链表的数据结构。当用户程序申请内存时,空闲链表分配器会依次遍历空闲的内存块,找到足够大的内存,然后申请新的资源并修改链表.
常见的空闲链表分配法有四种
- 首次适应(First-Fit)— 从链表头开始遍历,选择第一个大小大于申请内存的内存块;
- 循环首次适应(Next-Fit)— 从上次遍历的结束位置开始遍历,选择第一个大小大于申请内存的内存块;
- 最优适应(Best-Fit)— 从链表头遍历整个链表,选择最合适的内存块;
- 隔离适应(Segregated-Fit)— 将内存分割成多个链表,每个链表中的内存块大小相同,申请内存时先找到满足条件的链表,再从链表中选择合适的内存块;
Golang的内存分配方法与隔离适应算法比较类似,同时在上篇中我们也介绍过, Go 语言的内存分配器借鉴了 TCMalloc 的设计实现高速的内存分配,它的核心理念是使用多级缓存将对象根据大小分类,并按照类别实施不同的分配策略。
在Go语言中,对象被划分为三种大小
- 小于16字节 – 微对象
- 大于16字节,小于32KB – 小对象
- 大于32KB – 大对象
在划分对象的同时,Go语言也采用了与TCMalloc同样的多级缓存,并且使用的是类似Pre-Thread模式.
G-P-M 并发模型:
- G:表示Goroutine,也就是我们说的协程,由P进行调度。
- P:是 G-M 的中间层,是逻辑处理器,每一个P组织多个goroutine 跑在同一个 OS 线程上。
- M:是对内核级线程的封装,真正干活的对象。
P作为协调器将G队列动态的绑在不同的M上,根据负载情况动态调整,从而均衡的发挥出多核最大并行处理计算能力
Go语言中的每个处理器拥有一个对应的线程缓存,这个缓存中缓存的就是类似于TCMalloc的Span对象,同时也有Size-class的概念,每个Size-class对应不同的对象大小.
因为不涉及多线程,所以也不需要使用互斥锁来保护内存,这能够减少锁竞争带来的性能损耗。当线程缓存不能满足需求时,运行时会使用中心缓存作为补充解决小对象的内存分配,在遇到 32KB 以上的对象时(即大对象),内存分配器会选择页堆直接分配大内存。
内存布局
在介绍完Go语言的一些基础概念以后,可以先了解一些Go语言内存布局的布局.
Go 1.10 及以前
在Go语言1.10版本以前采用的方式是线性内存.
如上图, 所有被程序申请到的内存被以Page的方式分割开来,每个Span可以管理一个或者多个Page.
对于任意一个地址,都可以通过图中arena的基地址映射到对应的Span上(通过计算页数对应到Span数组上)
在Golang进行垃圾回收的时候,会通过判断指针的地址判断对象是否在堆中.
注:线性内存的管理方式在1.10及以后就已经弃用了.主要原因有两点.并且都与C,Go混用有关.
1. 在C,Go混用时,分配的内存地址会发生冲突,导致堆得初始化和扩容失败.
2. 没有被预留的大块内存可能会被分配给 C 语言,导致扩容后的堆不连续
Go 1.11 及以后
线性内存的管理方式虽然简单,但是会造成C,Go混用导致程序崩溃.在1.11版本以后,Go的内存方式由线性内存管理转变为了稀疏内存管理.同时解决了上述C,Go混用的问题.
在上图中我们可以看到,在1.11版本以后,Go语言并没有再简单的使用线性分配的方式.直接由低地址到高地址的管理内存,而是通过一个heapArena数组对内存进行管理.
让我们看看heapArena这个结构是怎么定义的.
type heapArena struct {
// 前两者与线性分配中的bitmap以及spans对应,用于映射内存地址与Span.
bitmap [heapArenaBitmapBytes]byte
spans [pagesPerArena]*mspan
// 一个位图用于表示在当前heapArena管理的Span中,哪些Span是正在被使用的.
// 只使用了每个Span的第一个Page(Span可以由多页构成) 并且读写是原子性的
pageInUse [pagesPerArena / 8]uint8
// 与pageInUse类似,也是一个位图,用于表示哪些Span上有被标记的对象,用于垃圾回收.
pageMarks [pagesPerArena / 8]uint8
// 这个指针指向的是整个heapArena结构管理的首地址,通过CAS的方式进行修改
zeroedBase uintptr
}
需要注意的是,管理heapArena结构体的数组并不是一维的,它是一个二维的数组,并且在不同的平台每个维度管理的大小不同.
例如在Linux的x86-64架构运行,这个二维数组的一维大小是1,二维大小是4,194,304. 由于每个指针占8字节的内存空间,所以原信息一共占据32MB(32MB = 32 * 1024 * 1024 = 4,194,304 * 8),而每一个heapArena可以管理64MB的数据,所以在1.11版本以后,Go语言可以管理最大256TB(32MB * 64MB)的数据.
但是使用稀疏内存管理也是有代价的,在进行垃圾回收时,会造成额外百分1的开销.
内存管理组件
到现在,我们可以来正式介绍一下构成Golang内存管理的组件了.
与TCMalloc非常类似.Golang内存分配由mspan,mcache,mcentral,mheap组成.
可以说基本对应了TCMalloc中的Span,Pre-Thread,Central Free List,以及Page Heap.
在Golang的程序中,每个处理器都会分配一个线程缓存mcache用于处理微对象以及小对象的内存分配,mcache管理的单位就是mspan.
如果mcache中缓存的对象数量不够了,也就是alloc数组中缓存的对象不足,会向mheap持有的136个mcentral获取新的内存单元(这里的136也是mcache中的136,对应了68个无指针的Size-class和有指针的68个Szie-class).
mcentral中心缓存是属于全局结构mheap的,mheap就是用来管理Golang所申请的所有内存,如果mheap的内存也不够,则会向操作系统申请内存.
这么看是不是很像TCMalloc中依次向前端,中端,后端请求内存呢.
内存管理单元
spanClass
首先我们从spanClass开始,spanClass主要是为了中小对象的分配,还记得我们在开篇就提到过的根据大小分类对象. 他的概念与TCMalloc中的Size-class基本一致.
- 小于16字节 – 微对象
- 大于16字节,小于32KB – 小对象
- 大于32KB – 大对象
在Golang中有68种不同大小的spanClass,用于对小对象的分配.
type spanClass uint8
const (
// _NumSizeClasses = 68 ,左移即乘2,分表代表有无指针
numSpanClasses = _NumSizeClasses << 1
// tinySizeClass = 2,对应图上的小于16字节的两种
tinySpanClass = spanClass(tinySizeClass<<1 | 1)
)
// 前七位用于登记类型,最后一位用于记录有无指针
func makeSpanClass(sizeclass uint8, noscan bool) spanClass {
return spanClass(sizeclass<<1) | spanClass(bool2int(noscan))
}
// 获取前七位,也就是得到对应的spanClass类型
func (sc spanClass) sizeclass() int8 {
return int8(sc >> 1)
}
// 判断最后一位是否为1, 1为无指针,0为有指针
func (sc spanClass) noscan() bool {
return sc&1 != 0
}
其实spanClass的载体就是一个8位的数据,他的前七位用于存储当前spanClass属于68种的哪一种,最后一位代表当前spanClass(当前对象)是否存储了指针,这个非常重要,因为是否存在指针意味着是否需要在垃圾回收的时候进行扫描.
不同大小的对象在需要mcache分配内存时,会通过映射对应到不同的spanClass上,比如一个12字节的对象,会被向上取整到上表中的16字节的spanClass. 也就是spanClass的class为2,但是这个2代表的是前七位的值,如果这个12字节的对象不存在指针,那么最后一位就是1.
由此可得这个12字节的对象对应spanClass的值就是0000010 1也就是5.
mspan
再说mspan, mspan的概念也与TCMalloc中Span概念基本一致,一个Span可以管理一个或多个操作系统分配给程序的内存页(Page).同时spanClass也是由mspan分割而来,还是回到上面的表格
操作系统通常页面大小为8KB也就是8192字节. 如果按照序号为1的spanClass分配,那么管理1个Page的mspan会被分割为1024份.
下面就先来看看mspan的数据结构 (下述mspan均简写为Span)
type mspan struct {
// 前后指针,分别指向了前后的Span
next *mspan
prev *mspan
// 当前Span的起始地址
startAddr uintptr // address of first byte of span aka s.base()
// 代表当前Span是由多少Page构成的
npages uintptr // number of pages in span
// 扫描页中空闲对象的初始索引
freeindex uintptr
// 与freeindex结合使用可以快速定位空闲内存
allocCache uint64
// 记录这个Span被spanClass切割成了多少份,即可以存放多少个对象
nelems
// 位图,记录已经分配的对象
allocBits *gcBits
// 位图,记录内存的回收情况,用于垃圾回收
gcmarkBits *gcBits
// 记录已经分配对象个数
allocCount uint16
// 记录Span信息
spanclass spanClass
// 记录内存使用情况
1. mSpanDead 已经被回收
2. mSpanInUse 已经分配
3. mSpanManual 已经分配
4. mSpanFree 空闲
state mSpanStateBox
}
在mspan结构体中,startAddr对应了这个Span对应于内存中的地址,npages对应了当前Span包含的Page个数,如图所示就是一个包含两个Page的Span.
并且在mspan的结构中还有用于快速查找空闲内存的allocCache以及用于定位空闲内存的指针freeindex.
需要注意的是,spanClass只是划分mspan的方式,真正的内存管理单元只有mspan一个.
mcache
在介绍完基本的内存单元mspan以后,就可以正式进入内存分配环节了,当一个线程为对象申请内存时,就会使用mcache进行申请.
mcache会被绑定在并发模型中的P上.也就是说每一个P(处理器)都会有一个mcache,用于给对应的协程的对象分配内存,
type mcache struct {
next_sample uintptr
local_scan uintptr // 在当前mcache中已经分配的可以扫描的字节数
*** 重要
// 微对象分配器,在后文中会有详细的介绍.
tiny uintptr
tinyoffset uintptr
local_tinyallocs uintptr // 微对象的分配数量
*** 重要
// numSpanClasses = 136 = _NumSizeClasses * 2
// 可以被分配的mspan,数量为(spanClass) * 2
alloc [numSpanClasses]*mspan
// 栈缓存
stackcache [_NumStackOrders]stackfreelist
// 本地分配的缓存信息,在GC时被刷新
local_largefree uintptr // 大对象被回收后会增加该对象大小
local_nlargefree uintptr // 大对象被回收的数量
local_nsmallfree [_NumSizeClasses]uintptr // 小对象被回收的数量
// 用于垃圾回收,本篇不讨论.
flushGen uint32
}
在代码中,用重要两字标明了两段,第一段是由三个字段组成的未分配器,它是线程专门用于分配微小对象的分配器(小于16字节),还有一段就是一个mspan的数组.
存储在mcache中的缓存对象数组一共有(68) * 2个,其中*2是将spanClass分成了有指针和没有指针两种,方便与垃圾回收.
接下来让我们看看mcache的初始化流程.
// 空占位符
var emptymspan mspan
func allocmcache() *mcache {
var c *mcache
systemstack(func() {
// 通过全局的mheap上锁
lock(&mheap_.lock)
c = (*mcache)(mheap_.cachealloc.alloc())
// 更新当前mcache的状态为sweepgen(用于垃圾回收)
c.flushGen = mheap_.sweepgen
unlock(&mheap_.lock)
})
// 将mcache中alloc数组使用空占位符代替(即emptymspan)
for i := range c.alloc {
c.alloc[i] = &emptymspan
}
// 获取堆分析的下一个采样点
c.nextSample = nextSample()
return c
}
初始化完成并没有真正的分配内存到mcache中,当初始化完成并且需要内存时,由于并没有真正的持有空闲的mspan,mcache首先会调用mcache.refill方法获取对应的mspan.
func (c *mcache) refill(spc spanClass) {
s := c.alloc[spc]
if uintptr(s.allocCount) != s.nelems {
throw("refill of span with free space remaining")
}
if s != &emptymspan {
if s.sweepgen != mheap_.sweepgen+3 {
throw("bad sweepgen in refill")
}
atomic.Store(&s.sweepgen, mheap_.sweepgen)
}
// 调用mcentral的cacheSpan()获取对应spanClass的mspan.
s = mheap_.central[spc].mcentral.cacheSpan()
if s == nil {
throw("out of memory")
}
// 如果已经分配的对象等于这个mspan能分配的最大数
if uintptr(s.allocCount) == s.nelems {
throw("span has no free space")
}
// 将这个mspan缓存,下次垃圾回收的时候会扫描
s.sweepgen = mheap_.sweepgen + 3
// 把获得的mspan放到mcache中.
c.alloc[spc] = s
}
值得注意的是这是唯一一个向线程缓存mcache中插入内存管理单元mspan的唯一方法.
接下来还有一个比较重要的部分就是微对象分配器,前面的流程主要是针对于spanClass由需要3开始到序号68分配是使用的,而微对象分配器主要是针对于16字节以下的对象:
type mcache struct {
...
// 指向分配微对象的首地址
tiny uintptr
// 指向当前已经分配对象的尾地址,即下一个可以分配空间的首地址
tinyoffset uintptr
// 记录微对象分配器中已分配对象的个数
local_tinyallocs uintptr
...
}
至于微对象分配器的分配过程在后文中的内存分配中会提到.
mcentral
接下来就要说到mcentral,他是一个中央缓存.在前文中也有提到,全局变量mheap负责管理所有被Go程序申请到的内存,而mheap持有了136个mcentral,现在看到这个136,我们不难分析出来,每一个mcentral只负责管理一种spanClass类型的mspan,并且管理的单元就是mspan,如果mcache中缓存的某种spanClass类型的Span没有空闲的了,就会向对应spanClass类型的mcentral申请.
为了更方便的理解内存分配的过程,我们可以将处理器申请内存的过程类比为我们去超市购物,其中我们就代表处理器,超市就是mcache这层缓存
在超市中有非常多类型的商品,我们每次都回去商城买一个类型的商品,但是商城里的东西也会缺货呀,那这时候,商城就回去供货商那,一次进货一批同一类型的商品.这个供货商就代表mcentral,因为一般供货商只会提供一种类型的商品.所以商城也就是mcache他负责缓存多种商品,也就是spanClass类型的Span供我们(处理器)使用,而每个供货商(mcentral)只会提供一种类型的商品(spanClass类型的Span).
接下来就是mcentral的数据结构:
Go 1.14 版本
type mcentral struct {
// 用于线程缓存申请内存时使用的锁
lock mutex
// mcentral对应的spanClass
spanclass spanClass
nonempty mSpanList // 空闲的span列表
empty mSpanList // 已经被使用的span列表
// 这个mcentral分配mspan的累积计数
nmalloc uint64
}
截至文章到此,文章中使用的Go版本均是基于Go 1.14版本.但是在向Go 1.16过度的过程中,mcentral的结构发生了一点变化.(其他内存组件基本没有大变动)
Go 1.16 版本
type mcentral struct {
// mcentral对应的spanClass
spanclass spanClass
partial [2]spanSet // 储存空闲的Span的列表
full [2]spanSet // 储存不包含空闲空间的列表
}
虽然结构有所改变,但是mcache向 mcentral申请空间的方法mheap_.central[spc].mcentral.cacheSpan()的流程却基本没变,主要分为以下几个部分:
- 从清理过的、包含空闲空间的列表的(nonempty或partial)结构中中查找可以使用的内存管理单元;
- 从未被清理过的、有空闲对象的 (nonempty或partial)结构中查找可以使用的内存管理单元;
- 获取未被清理的、不包含空闲空间的(empty或full)结构中获取内存管理单元并通过sweep()函数清理它的内存空间;
- 调用 runtime.mcentral.grow 从堆中申请新的内存管理单元;
- 更新内存管理单元的 allocCache 等字段帮助快速分配内存;
下面的代码主要就由Go 1.14 举例
func (c *mcentral) cacheSpan() *mspan {
// 部分代码省略
lock(&c.lock)
retry:
var s *mspan
// 前两步都在这个循环中
for s = c.nonempty.first; s != nil; s = s.next {
//2. 从未被清理过的、有空闲对象的 (nonempty或partial)结构中查找可以使用的内存管理单元;
if s.sweepgen == sg-2 && atomic.Cas(&s.sweepgen, sg-2, sg-1) {
c.nonempty.remove(s)
c.empty.insertBack(s)
unlock(&c.lock)
s.sweep(true)
goto havespan
}
if s.sweepgen == sg-1 {
// 当前mspan正在清理,继续下一轮
continue
}
//1. 从清理过的、包含空闲空间的列表的(nonempty或partial)结构中中查找可以使用的内存管理单元
c.nonempty.remove(s)
c.empty.insertBack(s)
unlock(&c.lock)
goto havespan
}
//3. 获取未被清理的、不包含空闲空间的(empty或full)结构中获取内存管理单元并通过sweep()函数清理它的内存空间;
for s = c.empty.first; s != nil; s = s.next {
if s.sweepgen == sg-2 && atomic.Cas(&s.sweepgen, sg-2, sg-1) {
c.empty.remove(s)
c.empty.insertBack(s)
unlock(&c.lock)
s.sweep(true)
// 清理过后获取了空间,直接跳到分配
freeIndex := s.nextFreeIndex()
if freeIndex != s.nelems {
s.freeindex = freeIndex
goto havespan
}
lock(&c.lock)
goto retry
}
if s.sweepgen == sg-1 {
continue
}
break
}
unlock(&c.lock)
// 省略部分代码
// 调用runtime.mcentral.grow 从堆中申请新的内存管理单元;
s = c.grow()
if s == nil {
return nil
}
lock(&c.lock)
c.empty.insertBack(s)
unlock(&c.lock)
havespan:
//省略部分代码
//调整已分配mspan的个数
atomic.Xadd64(&c.nmalloc, int64(n))
usedBytes := uintptr(s.allocCount) * s.elemsize
atomic.Xadd64(&memstats.heap_live, int64(spanBytes)-int64(usedBytes))
//调整allocBits和 allocCache等字段,让运行时在分配内存时能够快速找到空闲的对象
freeByteBase := s.freeindex &^ (64 - 1)
whichByte := freeByteBase / 8
s.refillAllocCache(whichByte)
s.allocCache >>= s.freeindex % 64
return s
}
这是在mcache某个spanClass类型的Span不足时向mcentral申请内存所调用的方法.其中还涉及到了mcentral的扩容.
func (c *mcentral) grow() *mspan {
// 通过spanClass获取所需要的Page页数,以及大小.
npages := uintptr(class_to_allocnpages[c.spanclass.sizeclass()])
size := uintptr(class_to_size[c.spanclass.sizeclass()])
// 向mheap申请对应页数的mspan下来
s := mheap_.alloc(npages, c.spanclass, true)
if s == nil {
return nil
}
// 更新mspan中的一些字段,以及初始化mspan
// n := (npages << _PageShift) / size
n := (npages << _PageShift) >> s.divShift * uintptr(s.divMul) >> s.divShift2
s.limit = s.base() + size*n
heapBitsForAddr(s.base()).initSpan(s)
return s
}
mheap
最后介绍的就是mheap,作为一个全局变量管理整个申请到的堆内存.
type mheap struct {
lock mutex
free mTreap // 空闲的并且没被os收回的二叉树堆,大对象用
scav mTreap // 空闲的并且已经被os收回的二叉树堆,大对象用
sweepgen uint32 // 扫描计数值,每次gc后会自增2
sweepdone uint32 // 扫描状态,用于判断是否可以进行一次扫描
sweepers uint32 // number of active sweepone calls
allspans []*mspan // 所有的spans
// sweepSpans的长度是2,sweepSpans[h.sweepgen/2%2]保存当前正在使用的span列表
// sweepSpans[1-h.sweepgen/2%2]保存等待sweep的span列表,由于sweepgen每次gc+2,因此
// sweepSpans [0],sweepSpans [1]每次身份互相交换
sweepSpans [2]gcSweepBuf
_ uint32 // align uint64 fields on 32-bit for atomics
pagesInUse uint64 // 有多少页正在被使用
pagesSwept uint64 // 扫描的页面数量
pagesSweptBasis uint64 // 用做扫描比例的初始基点
sweepHeapLiveBasis uint64 // 用做扫描比例的初始处于存活状态的初始基点
sweepPagesPerByte float64 // 扫描比
// 是一个全局page页要被回收的位置,通过它/单个heapArena的总页数可以判断出是第几个heapArena
// 通过它%单个heapArena总page数,可以确定在这个heapArena的位置
reclaimIndex uint64
reclaimCredit uintptr // 多归还的pages,是回收对象在heapArena释放的
scavengeCredit uintptr // 多回收给os的字节,下次回收可先扣减这个值,不足再回收真正的空间
//
largealloc uint64 // 大对象分配的字节数
nlargealloc uint64 // 大对象分配的数量
largefree uint64 // 大对象释放的字节数
nlargefree uint64 // 大对象释放的数量
nsmallfree [_NumSizeClasses]uint64 // 小对象释放的数量
*** 重点
arenas [1 << arenaL1Bits]*[1 << arenaL2Bits]*heapArena//arenas数组集合,管理各个heapArena
allArenas []arenaIdx //所有arena序号集合,可以根据arenaIdx算出对应arenas中的哪一个heapArena
sweepArenas []arenaIdx //扫描周期开始时allArenas的一个快照
*** 重点
//各个规格的mcentral集合
central [numSpanClasses]struct {
mcentral mcentral
pad [cpu.CacheLinePadSize - unsafe.Sizeof(mcentral{})%cpu.CacheLinePadSize]byte
}
spanalloc fixalloc // span*的内存分配器(只是分配空结构)
cachealloc fixalloc // mcache*的内存分配器
treapalloc fixalloc // treapNodes*的内存分配器
specialfinalizeralloc fixalloc // specialfinalizer*的内存分配器
specialprofilealloc fixalloc // specialprofile*的内存分配器
speciallock mutex // lock for special record allocators.
arenaHintAlloc fixalloc // allocator for arenaHints
}
mheap在内存分配篇中比较重点的部分就是一个heapArena的二维数组,以及持有的136个mcentral,到这为止,整个内存分配体系就已经比较清晰了.
heapArena用于管理真实的内存.
先来看看mheap的初始化,这部分比较清晰,主要就是初始化一些mheap中的一些空闲链表分配器,以及mcentral中心缓存.
func (h *mheap) init() {
//初始化分配器
h.spanalloc.init(unsafe.Sizeof(mspan{}), recordspan, unsafe.Pointer(h), &memstats.mspan_sys)
h.cachealloc.init(unsafe.Sizeof(mcache{}), nil, nil, &memstats.mcache_sys)
h.specialfinalizeralloc.init(unsafe.Sizeof(specialfinalizer{}), nil, nil, &memstats.other_sys)
h.specialprofilealloc.init(unsafe.Sizeof(specialprofile{}), nil, nil, &memstats.other_sys)
h.arenaHintAlloc.init(unsafe.Sizeof(arenaHint{}), nil, nil, &memstats.other_sys)
h.spanalloc.zero = false
//初始化mcentral中心缓存
for i := range h.central {
h.central[i].mcentral.init(spanClass(i))
}
h.pages.init(&h.lock, &memstats.gc_sys)
}
这些空闲链表分配器其实就是在开头提到的空闲链表分配,他们都是fixalloc类型的分配器,而fixalloc有几个方法用于内存的分配以及释放: fixalloc.init() , fixalloc.alloc() , fixalloc.free()
最后再来简单的看看mcentral中扩容方法是怎么向mheap申请内存的.
func (h *mheap) alloc(npages uintptr, spanclass spanClass, needzero bool) *mspan {
var s *mspan
systemstack(func() {
//为了阻止堆内存的大量增长,在分配对应页数的内存之前,会调用reclaim方法来回收一部分内存.
if h.sweepdone == 0 {
h.reclaim(npages)
}
//在分配内存以后,会先初始化mspan然后返回
//如果内存不够了,会进行扩容,即通过sysAlloc方法向操作系统申请内存,此处不展开细说.
s = h.allocSpan(npages, spanAllocHeap, spanclass)
})
if s != nil {
if needzero && s.needzero != 0 {
memclrNoHeapPointers(unsafe.Pointer(s.base()), s.npages<<_PageShift)
}
s.needzero = 0
}
return s
}
由于篇幅原因,简单介绍了向mheap申请内存的过程,并省略了mheap的扩容过程.
内存分配
在聊完了内存管理组件以后,终于可以进入最后的重头戏,内存分配了.
在前文中我们知道,Go语言以内存大小将内存分为了三类:
- 微对象 (0, 16B) — 先使用微型分配器,再依次尝试线程缓存、中心缓存和堆分配内存;
- 小对象 [16B, 32KB] — 依次尝试使用线程缓存、中心缓存和堆分配内存;
- 大对象 (32KB, +∞) — 直接在堆上分配内存;
所以我们也将分配内存的函数mallocgc函数分为三大部分:
微对象的内存分配:
Go 语言运行时将小于 16 字节的对象划分为微对象,它会使用线程缓存上的微分配器提高微对象分配的性能,我们主要使用它来分配较小的字符串以及逃逸的临时变量。微分配器可以将多个较小的内存分配请求合入同一个内存块中,只有当内存块中的所有对象都需要被回收时,整片内存才可能被回收。
//指向分配微对象的首地址
tiny uintptr
//指向当前已经分配对象的尾地址,即下一个可以分配空间的首地址
tinyoffset uintptr
//记录微对象分配器中已分配对象的个数
local_tinyallocs uintptr
微分配器管理的对象不可以是指针类型,管理多个对象的内存块大小 maxTinySize 是可以调整的.
在默认情况下,内存块的大小为 16 字节。
如上图中,微对象分配器中已经被分配了12B的内存,现在仅剩下4B空闲, 如果此时有小于等于4B的对象需要被分配内存,那么这个对象会直接使用tinyoffset之后剩余的空间.
还是以上的情况,如果需要被分配的对象大小大于4B那么会被算在小对象分配的过程中,不使用微对象分配器.
要注意的是,分配在微对象分配器中的对象只有在微对象分配器中所有对象都标记为垃圾才会被整块回收.
如果微对象分配器一开始没有被初始化,但是又有微对象需要被分配,就会走小对象分配的过程,但是申请到的空间会作为微对象分配器的空间,剩下的空间可以用于分配另外的微对象.
让我们看看微对象分配部分的代码:
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
...
if size <= maxSmallSize {
if noscan && size < maxTinySize {
off := c.tinyoffset
// 省略将off对齐的代码
if off+size <= maxTinySize && c.tiny != 0 {
// 将对象分配到微对象分配器中,实际是将对应的内存作为指针返回
x = unsafe.Pointer(c.tiny + off)
// 更新微对象分配器中的状态
c.tinyoffset = off + size
c.tinyAllocs++
mp.mallocing = 0
releasem(mp)
return x
}
// 如果微对象分配器中的内存不足时,使用span进行分配.
span = c.alloc[tinySpanClass]
//调用mcache中缓存的mspan获取内存.
v := nextFreeFast(span)
if v == 0 {
// 同样是获取mcache中的缓存,但是更加耗时
// 如果mcache中没获取到则获取mcentral中的mspan用于分配(调用refill方法)
// 如果mcentral也没有则去找mheap.
// 这里的tinySpanClass,是序号为2的spanClass,即大小为16字节.同时也等于macTinySize
v, span, shouldhelpgc = c.nextFree(tinySpanClass)
}
// 返回对应内存的指针
x = unsafe.Pointer(v)
(*[2]uint64)(x)[0] = 0
(*[2]uint64)(x)[1] = 0
// 如果微对象分配器没有初始化,则将当前对象申请的空间作为微对象分配器的空间
if size < c.tinyoffset || c.tiny == 0 {
c.tiny = uintptr(x)
c.tinyoffset = size
}
size = maxTinySize
}
...
}
小对象的内存分配
小对象是指大小为 16 字节到 32,768 字节的对象以及所有小于 16 字节的指针类型的对象,小对象的分配可以被分成以下的三个步骤:
- 确定分配对象的大小以及跨度类 runtime.spanClass;
- 从线程缓存、中心缓存或者堆中获取内存管理单元并从内存管理单元找到空闲的内存空间;
- 调用 runtime.memclrNoHeapPointers 清空空闲内存中的所有数据;
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
...
if size <= maxSmallSize {
if noscan && size < maxTinySize {
//微对象分配
...
} else {
//小对象分配
var sizeclass uint8
// smallSizeMax = 1024
// size_to_class8与size_to_class128都是用于将内存向上对齐到spanClass类型的序号
if size <= smallSizeMax-8 {
sizeclass = size_to_class8[(size+smallSizeDiv-1)/smallSizeDiv]
} else {
sizeclass = size_to_class128[(size-smallSizeMax+largeSizeDiv-1)/largeSizeDiv]
}
// 通过对应的spanClass类型再反向获取内存大小
size = uintptr(class_to_size[sizeclass])
// 通过对应大小以及有无指针获取到spanClass
spc := makeSpanClass(sizeclass, noscan)
// 最后通过spanClass获取mcache中缓存的mspan
span := c.alloc[spc]
// 调用mcache中缓存的mspan获取内存.
v := nextFreeFast(span)
if v == 0 {
// 同样是获取mcache中的缓存,但是更加耗时
// 如果mcache中没获取到则获取mcentral中的mspan用于分配(调用refill方法)
// 如果mcentral也没有则去找mheap.
v, span, _ = c.nextFree(spc)
}
// 转为指针返回
x = unsafe.Pointer(v)
if needzero && span.needzero != 0 {
// 清空空闲内存中的所有数据;
memclrNoHeapPointers(unsafe.Pointer(v), size)
}
}
} else {
// 大对象分配
...
}
...
return x
}
不管是在微对象分配的过程中,还是在小对象分配的过程中,我们都有看见两个函数,第一个是nextFreeFast函数以及nextFree方法,来看看这两个方法.
首先是nextFreeFast函数,会先在mcache线程缓存中的span中寻找空间,如果没有找到则返回空.
func nextFreeFast(s *mspan) gclinkptr {
// allocCache前文中提到,是通过每一位对应到span中的内存
// Ctz64用于判断第几位开始不是0,从低位(右边)开始,如果是64,则所有都是0.
theBit := sys.Ctz64(s.allocCache)
if theBit < 64 {
// 在从右往左找到一个空闲的单元(spanClass大小)时,会与freeindex相加,如果超出了就是没有空间了
// 如果找到的结果比mspan能存储的最大对象少,即还有空闲内存
// 这块的freeindex不一定是第一个空闲的空间,可以前面还有被回收的内存.
result := s.freeindex + uintptr(theBit)
if result < s.nelems {
freeidx := result + 1
if freeidx%64 == 0 && freeidx != s.nelems {
return 0
}
// 找到了空闲的对象后更新内存管理单元的allocCache、freeindex等字段并返回该片内存
s.allocCache >>= uint(theBit + 1)
// 这里的freeindex是在不断后移的
s.freeindex = freeidx
s.allocCount++
return gclinkptr(result*s.elemsize + s.base())
}
}
return 0
}
在nextFreeFast返回0,即没有找到空闲空间以后,会调用nextFree方法:
func (c *mcache) nextFree(spc spanClass) (v gclinkptr, s *mspan, shouldhelpgc bool) {
s = c.alloc[spc]
shouldhelpgc = false
// 获取mspan中下一个空闲对象.
// 这里获取到的freeindex就是准确的第一个空闲空间,并且在nextFreeIndex中会更新值.
freeIndex := s.nextFreeIndex()
// 如果获取到的freeindex等于nelems,则这个mspan已经满了,需要去mcentral中获取空间.
if freeIndex == s.nelems {
// The span is full.
if uintptr(s.allocCount) != s.nelems {
println("runtime: s.allocCount=", s.allocCount, "s.nelems=", s.nelems)
throw("s.allocCount != s.nelems && freeIndex == s.nelems")
}
c.refill(spc)
shouldhelpgc = true
s = c.alloc[spc]
freeIndex = s.nextFreeIndex()
}
if freeIndex >= s.nelems {
throw("freeIndex is not valid")
}
// 获取指针并且返回
v = gclinkptr(freeIndex*s.elemsize + s.base())
// 更新mspan的状态
s.allocCount++
if uintptr(s.allocCount) > s.nelems {
println("s.allocCount=", s.allocCount, "s.nelems=", s.nelems)
throw("s.allocCount > s.nelems")
}
return
}
总结上述两个方法就是在mcache中如果没有找到空闲的内存,则会调用refill方法去mcentral中找到内存填充.
大对象的内存分配
运行时对于大于 32KB 的大对象会单独处理,我们不会从线程缓存或者中心缓存中获取内存管理单元,而是直接调用 allocLarge 分配大片内存:
...
if size <= maxSmallSize {
if noscan && size < maxTinySize {
//微对象分配
...
} else {
//小对象分配
...
}
} else {
// 大对象分配
var s *mspan
span = c.allocLarge(size, needzero, noscan)
span.freeindex = 1
span.allocCount = 1
x = unsafe.Pointer(span.base())
size = span.elemsize
}
...
return x
}
大对象的分配会调用allocLarge方法分配内存,而在allocLarge中会调用mheap直接进行分配,并生成一个spanClass为0的对象.
func (c *mcache) allocLarge(size uintptr, needzero bool, noscan bool) *mspan {
//通过大小获取需要分配的Page数量
npages := size >> _PageShift
if size&_PageMask != 0 {
npages++
}
...
//直接调用mheap进行分配.
spc := makeSpanClass(0, noscan)
s := mheap_.alloc(npages, spc, needzero)
...
mheap_.central[spc].mcentral.fullSwept(mheap_.sweepgen).push(s)
s.limit = s.base() + size
heapBitsForAddr(s.base()).initSpan(s)
return s
}
小结
内存分配是 Go 语言运行时内存管理的核心逻辑,运行时的内存分配器使用类似 TCMalloc 的分配策略将对象根据大小分类,并设计多层级的组件提高内存分配器的性能。
关于Go语言堆上内存的分配到这就基本结束了,期间由于篇幅原因,关于mheap还有一些扩容等内容没有进行具体的介绍,大家有兴趣的可以去文末中参考的文章里细看.
下一个章节将介绍有关Go语言在栈上进行的内存分配,有兴趣的话可以期待一下.
参考: