一、内存分配器
程序中的数据和变量都会被分配到程序所在的虚拟内存中,内存才能空间包含两个重要区域: 栈区(Stack)和堆区(Heap)。
函数调用的参数、返回值以及局部变量大都会被分配到栈上、这部分内存会有编译器进行管理。
不同编程语言使用不同方法管理堆区的内存,c++、等编程语言会由使用者主动申请和释放内存;
Go和Java等编程语言会由使用者和编译器共同管理。
堆中的对象有内存分配器分配并由垃圾收集器回收。
栈区速度>>堆区速度,堆区分配繁琐,栈区只需要push和pop
Golang的内存分配器借鉴了tcmalloc的思想,尽量减少在多线程模型下,锁的竞争开销,开提高内存分配的效率。
## 锁的必要性:
在内存空间中被划分为了多个内存单元格,当两个线程来申请内存空间时,如果不进行加锁,可能会给两个线程划分到一个内存空间,那整个内存空间到底谁使用呢?这就会起冲突。
所以需要引用锁的概念,在第一个线程申请这块空间后,给这块内存空间上锁,给其他线程分配其他内存空间就能解决这个问题。
二、TCMalloc
tcmalloc,其实就是thread cache malloc的缩写,线程缓存内存分配机制。
1.tcmalloc的实现:
tcmalloc内存分配分为了ThreadCache (线程缓存层)、CentralCache (中心缓存)、PageHeap (内存页堆)三个层次。
在一个进程申请到的内存空间上,用PageHeap的概念分成了多个 span ,每一个span都是一个用来管理内存的逻辑概念,我把它理解为单元格单位;
上一层时CentralCache,里面分为了从8byte到32kb的不同大小 (size class)的一堆链表,每个链表管理着一堆的span。在这一层仍然是被线程共享内存的,所以在这一层上还是需要加锁的;
再上一层的ThreadCache,每个线程都会独享一个ThreadCache空间,每个空间都会缓存着一些空闲链表,每个线程申请内存空间是只会在ThreadCache中查找。
因为ThreadCache是独享内存的,所以在这一层上不需要锁竞争,减少了开销。
ThreadCache是每一个线程的缓存,分配是不需要加锁,速度比较快。ThreadCache中对于每一个size class维护一个单独的FreeList,缓存还没有分配的空闲对象 (占位)。
CentralCache也同样为每一个size class维护一个Freelist,但是这一层是所有线程公用的,分配时需要加锁。
CentralCache中内存不足时,会从PageHeap中申请。CentralCache从PageHeap中申请的内存,可能来自于PageHeap的缓存,也可能时PageHeap从操作系统中申请的新的内存。
PageHeap内部,对于128个Page以内的span,会都用一个链表来缓存,超过了128个page的span,则存储于一个有序的set。
每一层都可能会出现内存不够用的情况,这时候就会向下一层申请内存空间
2.tcmalloc的优势:
· 减少锁的争用
· 事先一次性向操作系统申请了一大片的内存空间,再由内存分配器进行管理,提高性能。
## 为什么事先申请空间再分配会性能会更好?
每一次向操作系统申请内存,需要经过系统调用,需要上下文切换,比较耗费性能;
先申请大量内存再分配,只需要指针的操作就能分配出去。
三、Golang的内存分配器
Golang语言的内存分配器借鉴了tcmalloc的思想;
Go语言的内存分配器包含内存管理单元、线程缓存、中心缓存和页堆几个重要组件;
下面会详细介绍它们对应的数据结构 mspan、 mcache、 mcentral、 mheap以及它们再内存分配器中的作用以及实现。
每一个处理器 P都会分配一个线程缓存mcache用于处理微对象和小对象的分配,它们会持有内存管理单元mspan;
每个size class的 mspan都会管理特定大小的对象,当mspan中不存在空闲对象时,它们会从mcentral中获取新的mspan;
mcentral属于全局的堆结构体 mheap,mheap会从操作系统中申请内存
mheap中包含多个heap arena,每一个heap arena是一段连续的内存,对应的是操作系统的页page
四、内存管理组件 (mspan)
mspan是一个内存管理单元的基本单元
每个mspan都对应一个大小等级(67个),小对象类型的堆对象会根据其大小,分配到相应设定好大小等级的mspan上分配内存
微对象:(0,16B) 先使用微型分配器,在依次尝试mcache、mcentral、mheap分配内存;
小对象:[16B,32KB] 依次尝试使用mcache、mcentral、mheap分配内存;
大对象:(32KB,+∞) 直接再mheap上分配内存。
mspan: 是Go语言内存管理的基本单元,该结构体包含next和prev两个字段,分别指向了前一个和后一个的mspan;
串联后形成一个双向链表,mspanlist存储双向链表的头节点和尾节点并再mcache和mcentral中使用。
span--跨度
可以理解为一段连续的内存空间
每个mspan都管理 npages个大小为8KB的页,这里的页不是操作系统中的内存页,它们是操作系统中内存页的整数倍;
mspan使用 startAddr、npages、freeindex、allocBits、gcmarkBits、allocCache 字段来管理内存页的分配和回收;
当mspan管理的内存不足时,会以页为单位向mheap申请内存,更改 startAddr和npages ;
当线程向mspan申请内存时,会使用startAddr和allocCache快速查找空闲对象;
如果能在内存中找到空闲的内存单元会直接返回,当内存中不包含空闲对象时,mcache会调用 mcache.refill更新mspan以满足内存需求。
spanclass--跨度类
用于表示span的对象大小和span类型
Go语言的一共有67种spanclass,每一个spanclass都标shi存储特定大小的对象并且包含特定数量的页数以及对象;
所有的数据都会被虚线计算好并存储在runtime.class_to_size和runtime.class_to_allocnpages中。
spanclass所能表示span中对象大小从8btye到32768(32kb)一共67种span
能标示span的大小、spen的页数、span中对象大小、对象个数等信息
spanclass为5的span:
对象大小上限为48byte,页数为1页。
所以span大小为8*1024byte,最大对象数量为170个,尾部浪费为32byte;
最小存储对象为33btye,最多会浪费31.52%
spanclass除了存储类别的id之外,还会存储一个noscan标记位,该标记位表示对象是否包含指针,垃圾回收会对包含指针的mpan进行扫描
spanclass是一个uint整数,前7位存储class id,最后一位存储noscan标识
type mspan struct {
next *mspan // list中的下一个span,没有则为nil
prev *mspan // list中的上一个span,没有则为nil
startAddr uintptr // 确定span管理页的偏移量
npages uintptr // 确定span管理的页数
freeindex uintptr // 页中空闲对象的索引
allocBits *gcBits // 标记内存的占用情况
gcmarkBits *gcBits // 标记内存的回收情况
allocCache uint64 // allocBits的补码,用于快速查找内存中未被使用的内存
spanclass spanClass // 用于表示span的类型
...
}
五、线程缓存 (mcache)
mcache是Go语言中的线程缓存,它会与线程上的处理器(GMP-P)绑定;
每一个线程分配一个mcache用于处理微对象和小对象的内存分配;
因为是每个线程独有的,所以不需要加锁。
mcache刚被初始化的时候是不包含mspan的,只有当用户申请内存时才从上一级组件mcentral获取性的mspan满足内存分配的需求
mcache会持有tiny相关字段用于微对象内存分配
mcache会持有mspan用于小对象的内存分配
alloc:
用于分配内存的mspan数组;
数组大小位span类型总数的两倍(67*2),即每种span类型都有两个mspan,一个表示noscan,一个是scan。
为了提高GC扫描性能,对于noscan没必要去扫描,而scan则需要GC进行扫描。
mcache在刚刚被初始化是alloc中mspan是空的占位符emptymspan。当mcache中mspan的空闲内存不足时,会向mcentral组件请求获取mspan。
· 微分配器 TinyAllocator
线程缓存中还包括几个分配微对象的字段,tiny、tinyoffset、tinyAllocs三个字段组成了微对象分配器,专门管理16byte以下的对象。
微分配器只会用于分配非指针类型的内存。
type mcache struct {
tiny uintptr // 指向堆中的一片内存,即当前偏移量
tinyoffset uintptr // 下一个空闲内存所在的偏移量
tinyAllocs uintptr //会记录内存分配器中分配的对象个数
...
}
六、中心缓存 (mcentral)
mcentral时内存分配器的中心缓存,与线程缓存不同,访问中心缓存中的mspan需要使用互斥锁
每个mcentral都会管理某个spanclass的内存管理单元;
他会持有两个runtime.spanSet,分别存储包含空闲对象和不包含空闲对象的mspan。
partial:有空闲空间的span列表
full:没有空闲空间的span列表
与mcache不同,mcentral是公共资源,会有多个线程的mcache向mcentral申请mspan,依次访问mcentral中的mspan时需要使用互斥锁。
mcache会通过mcentra的cacheSpan方法获取mspan,并更新mcache的alloc字段中相应span class对应的mspan
从mcentral中申请资源的时候,会优先向有空闲空间的span列表申请mspan,再向无空闲空间的span列表申请mspan,如果获取失败,会再从mheap中申请mspan。
## 为什么再向mheap中申请span之前还要先尝试向full列表申请呢?
因为再向mheap申请之前会进行一次GC,会把一些已经满的但是不使用的mspan的空间回收掉,可能会出现一些空的mspan
如果申请到了mspan,会更新mspan中的 allocBits和allocCache字段。
runtime.refill会为线程缓存获取一个指定span class的mspan;
被替换的(old)mspan不能包含空闲的内存空间,而获取的mspan中需要至少包含一个空闲对象。 (旧的一定要非常旧,新的不一定要非常新)
type mcentral struct {
spanclass spanClass
partial [2]spanSet
full [2]spanSet
}
七、页堆 (mheap)
内存分配的核心组件,包含mcentral和heapArena,堆上所有mspan都是mheap结构分配来的
allspans: 已经分配的所有mspan
arenas: heapArena数组,用于管理一个个内存块
central: mcentral数组,用于管理对应spanClass的mspan
heapArena:
Go 1.11后采用稀疏内存管理。堆区的内存可以不连续,将堆区内存分成一个个内存块(arena),通过heapArena管理
在mheap中维护一个heapArena数组,记录所有内存块:
bitmap:标记heapArena内存中没饿过地址空间的使用情况
spans:记录page id映射到的mspan
pageInUse、pageMarks:标记状态为mSpanInUse或在GC扫描中被标记的page,用于加速内存回收
zeroedBase:标记此arena中尚未使用的第一个page的首地址
mcentral从mheap中申请新的mspan时:
1. 从heap的pages中获取内存,并生成mspan
2. heap中无法获取到mspan时,需要对堆增长,调用sysAlloc从操作系统中申请的内存
3. 从已经保留的heapArena中获取内存
4. 无法获取到合适的内存时,会向操作系统申请,并进行heapArena初始化,将heapArena加入到arena列表中
八、内存分配
堆上所有的对象都会通过调用 rentime.newobject 函数分配内存;
该函数会调用 runtime.mallocgc 分配指定大小的内存空间,这也是程序向堆上申请内存空间的必经函数。
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
mp := acquirem()
mp.mallocing = 1
c := gomcache()
var x unsafe.Pointer
noscan := typ == nil || typ.ptrdata == 0
if size <= maxSmallSize {
if noscan && size < maxTinySize {
// 微对象分配
} else {
// 小对象分配
}
} else {
// 大对象分配
}
publicationBarrier()
mp.mallocing = 0
releasem(mp)
return x
}
上述代码使用runtime.gomcache 获取线程缓存并判断申请内存的类型是否为指针。
而且还可以看出runtime.mallocgc 会根据对象的大小执行不同的分配逻辑,在前面也提到过根据对象大小将他们分成微对象、小对象、大对象
· 微对象:(0,16byte) 先使用微型分配器,在依次尝试mcache、mcentral、mheap分配内存;
会使用微型分配器提高微对象分配性能,我们主要使用它来分配较小的字符串和逃逸的临时变量;
微分配器可以将多个较小的内存分配请求何如同一个内存块中,只有当内存块中的所有对象都需要被回收时,整片内存才可能被回收。
微分配器管理的对象不可以是指针类型,管理多个对象的内存块大小 maxTinySize是可以调整的,默认情况是16字节。
maxTinySize越大,组合多个对象的可能性就越高,内存浪费就越严重;
maxTinySize越小,内存浪费越少,但是提供了越少的组合可能;
最好的方案是8的倍数
还记得微分配器的结构吗?
tiny uintptr // 指向推中的一片内存,即当前偏移量
tinyoffset uintptr // 下一个空闲内存所在的偏移量
假设tiny标记的16byte的微分配器已经被分配了12byte,tinyoffset标记了12byte之后;
如果下一个待分配对象小于16-12=4byte,他会直接使用这个内存块的剩余部分,减少内存碎片;
不过该内存块只有所有对象都被标记为垃圾时,才会回收。
代码:
mcache中的tiny字段指向了maxTinySize大小的块;
如果当前块中还包含大小合适的空闲内存,运行时会通过这tiny和tinyoffset直接获取并返回这块内存
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
...
if size <= maxSmallSize {
if noscan && size < maxTinySize {
off := c.tinyoffset
if off+size <= maxTinySize && c.tiny != 0 {
x = unsafe.Pointer(c.tiny + off)
c.tinyoffset = off + size
c.local_tinyallocs++
releasem(mp)
return x
}
...
}
...
}
...
}
如果内存块的空闲内存不够时,会先从mcache中找到跨度对应的mspan,,调用runtime.nextFreeFast获取空闲的内存;
当不存在空闲内存时,调用runtime.mcache.nextFree从mcentral或mheap中获取可分配的内存块
代码:
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
...
if size <= maxSmallSize {
if noscan && size < maxTinySize {
...
span := c.alloc[tinySpanClass]
v := nextFreeFast(span)
if v == 0 {
v, _, _ = 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
}
...
}
...
return x
}
获取新的空闲内存块之后,上述代码会清空空闲内存中的数据、更新构成微对象分配器的几个字段并返回空闲内存
· 小对象:
大小为16byte到32kb的对象以及所有小于16byte的指针对象,小对象的分配可以分为三个步骤:
1. 确定分配对象的大小以及spanclass
2. 从mcache、mcentral、mheap中获取mspan并从mspan中找到空闲的内存空间
3. 调用 runtime.memclrNoHeapPointers 清空空闲内存中的所有数据
确定待分配对象的大小以及spanclass需要使用预先计算好的size_to_class8、size_to_class128、class_to_divmagic字典,这些常量能帮助快速获取对应的值并构建。
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
...
if size <= maxSmallSize {
...
} else {
var sizeclass uint8
if size <= smallSizeMax-8 {
sizeclass = size_to_class8[(size+smallSizeDiv-1)/smallSizeDiv]
} else {
sizeclass = size_to_class128[(size-smallSizeMax+largeSizeDiv-1)/largeSizeDiv]
}
size = uintptr(class_to_size[sizeclass])
spc := makeSpanClass(sizeclass, noscan)
span := c.alloc[spc]
v := nextFreeFast(span)
if v == 0 {
v, span, _ = c.nextFree(spc)
}
x = unsafe.Pointer(v)
if needzero && span.needzero != 0 {
memclrNoHeapPointers(unsafe.Pointer(v), size)
}
}
} else {
...
}
...
return x
}
重点分析两个方法的实现原理,它们分别是 runtime.nextFreeFast和runtime.mcache.nextFree,这两个方法会帮助我们获取空闲的内存空间。
runtime.nextFreeFast会利用mspan中的allocCache字段 (占用位图的补码),快速找到该字段为1的位数,
找到了空闲对象后,我们就可以更新mspan的allocCache、freeindex等字段并返回该片内存
代码:
func nextFreeFast(s *mspan) gclinkptr {
theBit := sys.Ctz64(s.allocCache)
if theBit < 64 {
result := s.freeindex + uintptr(theBit)
if result < s.nelems {
freeidx := result + 1
if freeidx%64 == 0 && freeidx != s.nelems {
return 0
}
s.allocCache >>= uint(theBit + 1)
s.freeindex = freeidx
s.allocCount++
return gclinkptr(result*s.elemsize + s.base())
}
}
return 0
}
如果通过runtime.nextFreeFast没有找到空闲的内存,会通过runtime.mcache.nextFree找到新的内存管理单元
代码:
func (c *mcache) nextFree(spc spanClass) (v gclinkptr, s *mspan, shouldhelpgc bool) {
s = c.alloc[spc]
freeIndex := s.nextFreeIndex()
if freeIndex == s.nelems {
c.refill(spc)
s = c.alloc[spc]
freeIndex = s.nextFreeIndex()
}
v = gclinkptr(freeIndex*s.elemsize + s.base())
s.allocCount++
return
}
如果在mcache中没有找到可用的mspan,会通过前面介绍的mcache.refill使用mcentral中的mspan替换已经不存在可用对象的数据;
该方法会调用新结构体的mspan.nextFreeIndex获取空闲的内存并返回
· 大对象:
运行时对于大于32KB的大对象会单独处理,我们不会从mcache或者mcentral中获取mspan而是直接调用mcache.allocLarge:
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
...
if size <= maxSmallSize {
...
} else {
var s *mspan
span = c.allocLarge(size, needzero, noscan)
span.freeindex = 1
span.allocCount = 1
x = unsafe.Pointer(span.base())
size = span.elemsize
}
publicationBarrier()
mp.mallocing = 0
releasem(mp)
return x
}
mcache.allocLarge会计算分配给该对象所需要的页数,它按照8KB的背书在堆上申请内存。
申请内存时会创建一个spanclass为0的特殊spancalss并调用mheap.alloc分配一个管理对应内存的mspan
代码:
func (c *mcache) allocLarge(size uintptr, needzero bool, noscan bool) *mspan {
npages := size >> _PageShift
if size&_PageMask != 0 {
npages++
}
...
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的内存分配器在给对象分配内存时,根据对象的大小,分成三类微对象(0,16btye]、小对象(16btye,32KB]、大对象(32KB,+∞)。
大体上的分配流程:
1. 大对象直接从mheap上分配;
2. 微对象使用mcache上的tiny分配器分配;
3. 小对象首先计算对象的规格大小,然后使用mcache中相应规格大小的mspan分配;
4.如果mcache没有相应规格大小的mspan,指向mcentral申请;
4.如果mcentral没有相应规格大小的mspan,指向mheap申请;
4.如果mheap没有相应规格大小的mspan,指向操作系统申请;
十、逃逸分析:
逃逸分析是一种静态分析,在编译阶段执行。
每当函数中申请新对象,编译器会根据该对象是否被函数外部引用来分析,决定变量应该在栈上非,还是在堆上分配。
· 两个不变性:
1. 指向栈对象的指针不能存在于堆中
2. 指向栈对象的指针不能在栈对象收回后存活
· 主要策略:
1. 如果函数外部没有引用,则优先放到栈中;
2. 如果函数外部存在引用,则必定放到堆中;
· 场景:
1. 函数返回局部变量,则会引起内存逃逸
2. 动态类型引起的逃逸
3. 栈空间不足导致逃逸
4. 闭包函数中没有定义变量i的,而是引用了他所在函数f中的变量i,变量i发生逃逸
十一、如何利用逃逸分析提升性能
传值 VS 传指针:
传值会拷贝整个对象,而传指针只会拷贝指针地址,指向的对象是同一个。
传指针可以减少值的拷贝,但是导致内存逃逸到堆中,增加垃圾回收(GC)的负担。
在对象频繁创建和删除的场景下,传递指针导致的GC开销可能会严重影响性能。
一般情况下,对于需要修改原对象值,或占用内存比较大的结构体,选择传指针。
对于只读的占用内存比较小的结构体,直接传值能够获得更好的性能。
一、内存分配器
程序中的数据和变量都会被分配到程序所在的虚拟内存中,内存才能空间包含两个重要区域: 栈区(Stack)和堆区(Heap)。
函数调用的参数、返回值以及局部变量大都会被分配到栈上、这部分内存会有编译器进行管理。
不同编程语言使用不同方法管理堆区的内存,c++、等编程语言会由使用者主动申请和释放内存;
Go和Java等编程语言会由使用者和编译器共同管理。
堆中的对象有内存分配器分配并由垃圾收集器回收。
栈区速度>>堆区速度,堆区分配繁琐,栈区只需要push和pop
Golang的内存分配器借鉴了tcmalloc的思想,尽量减少在多线程模型下,锁的竞争开销,开提高内存分配的效率。
## 锁的必要性:
在内存空间中被划分为了多个内存单元格,当两个线程来申请内存空间时,如果不进行加锁,可能会给两个线程划分到一个内存空间,那整个内存空间到底谁使用呢?这就会起冲突。
所以需要引用锁的概念,在第一个线程申请这块空间后,给这块内存空间上锁,给其他线程分配其他内存空间就能解决这个问题。
二、TCMalloc
tcmalloc,其实就是thread cache malloc的缩写,线程缓存内存分配机制。
1.tcmalloc的实现:
tcmalloc内存分配分为了ThreadCache (线程缓存层)、CentralCache (中心缓存)、PageHeap (内存页堆)三个层次。
在一个进程申请到的内存空间上,用PageHeap的概念分成了多个 span ,每一个span都是一个用来管理内存的逻辑概念,我把它理解为单元格单位;
上一层时CentralCache,里面分为了从8byte到32kb的不同大小 (size class)的一堆链表,每个链表管理着一堆的span。在这一层仍然是被线程共享内存的,所以在这一层上还是需要加锁的;
再上一层的ThreadCache,每个线程都会独享一个ThreadCache空间,每个空间都会缓存着一些空闲链表,每个线程申请内存空间是只会在ThreadCache中查找。
因为ThreadCache是独享内存的,所以在这一层上不需要锁竞争,减少了开销。
ThreadCache是每一个线程的缓存,分配是不需要加锁,速度比较快。ThreadCache中对于每一个size class维护一个单独的FreeList,缓存还没有分配的空闲对象 (占位)。
CentralCache也同样为每一个size class维护一个Freelist,但是这一层是所有线程公用的,分配时需要加锁。
CentralCache中内存不足时,会从PageHeap中申请。CentralCache从PageHeap中申请的内存,可能来自于PageHeap的缓存,也可能时PageHeap从操作系统中申请的新的内存。
PageHeap内部,对于128个Page以内的span,会都用一个链表来缓存,超过了128个page的span,则存储于一个有序的set。
每一层都可能会出现内存不够用的情况,这时候就会向下一层申请内存空间
2.tcmalloc的优势:
· 减少锁的争用
· 事先一次性向操作系统申请了一大片的内存空间,再由内存分配器进行管理,提高性能。
## 为什么事先申请空间再分配会性能会更好?
每一次向操作系统申请内存,需要经过系统调用,需要上下文切换,比较耗费性能;
先申请大量内存再分配,只需要指针的操作就能分配出去。
三、Golang的内存分配器
Golang语言的内存分配器借鉴了tcmalloc的思想;
Go语言的内存分配器包含内存管理单元、线程缓存、中心缓存和页堆几个重要组件;
下面会详细介绍它们对应的数据结构 mspan、 mcache、 mcentral、 mheap以及它们再内存分配器中的作用以及实现。
每一个处理器 P都会分配一个线程缓存mcache用于处理微对象和小对象的分配,它们会持有内存管理单元mspan;
每个size class的 mspan都会管理特定大小的对象,当mspan中不存在空闲对象时,它们会从mcentral中获取新的mspan;
mcentral属于全局的堆结构体 mheap,mheap会从操作系统中申请内存
mheap中包含多个heap arena,每一个heap arena是一段连续的内存,对应的是操作系统的页page
四、内存管理组件 (mspan)
mspan是一个内存管理单元的基本单元
每个mspan都对应一个大小等级(67个),小对象类型的堆对象会根据其大小,分配到相应设定好大小等级的mspan上分配内存
微对象:(0,16B) 先使用微型分配器,在依次尝试mcache、mcentral、mheap分配内存;
小对象:[16B,32KB] 依次尝试使用mcache、mcentral、mheap分配内存;
大对象:(32KB,+∞) 直接再mheap上分配内存。
mspan: 是Go语言内存管理的基本单元,该结构体包含next和prev两个字段,分别指向了前一个和后一个的mspan;
串联后形成一个双向链表,mspanlist存储双向链表的头节点和尾节点并再mcache和mcentral中使用。
span--跨度
可以理解为一段连续的内存空间
每个mspan都管理 npages个大小为8KB的页,这里的页不是操作系统中的内存页,它们是操作系统中内存页的整数倍;
mspan使用 startAddr、npages、freeindex、allocBits、gcmarkBits、allocCache 字段来管理内存页的分配和回收;
当mspan管理的内存不足时,会以页为单位向mheap申请内存,更改 startAddr和npages ;
当线程向mspan申请内存时,会使用startAddr和allocCache快速查找空闲对象;
如果能在内存中找到空闲的内存单元会直接返回,当内存中不包含空闲对象时,mcache会调用 mcache.refill更新mspan以满足内存需求。
spanclass--跨度类
用于表示span的对象大小和span类型
Go语言的一共有67种spanclass,每一个spanclass都标shi存储特定大小的对象并且包含特定数量的页数以及对象;
所有的数据都会被虚线计算好并存储在runtime.class_to_size和runtime.class_to_allocnpages中。
spanclass所能表示span中对象大小从8btye到32768(32kb)一共67种span
能标示span的大小、spen的页数、span中对象大小、对象个数等信息
spanclass为5的span:
对象大小上限为48byte,页数为1页。
所以span大小为8*1024byte,最大对象数量为170个,尾部浪费为32byte;
最小存储对象为33btye,最多会浪费31.52%
spanclass除了存储类别的id之外,还会存储一个noscan标记位,该标记位表示对象是否包含指针,垃圾回收会对包含指针的mpan进行扫描
spanclass是一个uint整数,前7位存储class id,最后一位存储noscan标识
type mspan struct {
next *mspan // list中的下一个span,没有则为nil
prev *mspan // list中的上一个span,没有则为nil
startAddr uintptr // 确定span管理页的偏移量
npages uintptr // 确定span管理的页数
freeindex uintptr // 页中空闲对象的索引
allocBits *gcBits // 标记内存的占用情况
gcmarkBits *gcBits // 标记内存的回收情况
allocCache uint64 // allocBits的补码,用于快速查找内存中未被使用的内存
spanclass spanClass // 用于表示span的类型
...
}
五、线程缓存 (mcache)
mcache是Go语言中的线程缓存,它会与线程上的处理器(GMP-P)绑定;
每一个线程分配一个mcache用于处理微对象和小对象的内存分配;
因为是每个线程独有的,所以不需要加锁。
mcache刚被初始化的时候是不包含mspan的,只有当用户申请内存时才从上一级组件mcentral获取性的mspan满足内存分配的需求
mcache会持有tiny相关字段用于微对象内存分配
mcache会持有mspan用于小对象的内存分配
alloc:
用于分配内存的mspan数组;
数组大小位span类型总数的两倍(67*2),即每种span类型都有两个mspan,一个表示noscan,一个是scan。
为了提高GC扫描性能,对于noscan没必要去扫描,而scan则需要GC进行扫描。
mcache在刚刚被初始化是alloc中mspan是空的占位符emptymspan。当mcache中mspan的空闲内存不足时,会向mcentral组件请求获取mspan。
· 微分配器 TinyAllocator
线程缓存中还包括几个分配微对象的字段,tiny、tinyoffset、tinyAllocs三个字段组成了微对象分配器,专门管理16byte以下的对象。
微分配器只会用于分配非指针类型的内存。
type mcache struct {
tiny uintptr // 指向堆中的一片内存,即当前偏移量
tinyoffset uintptr // 下一个空闲内存所在的偏移量
tinyAllocs uintptr //会记录内存分配器中分配的对象个数
...
}
六、中心缓存 (mcentral)
mcentral时内存分配器的中心缓存,与线程缓存不同,访问中心缓存中的mspan需要使用互斥锁
每个mcentral都会管理某个spanclass的内存管理单元;
他会持有两个runtime.spanSet,分别存储包含空闲对象和不包含空闲对象的mspan。
partial:有空闲空间的span列表
full:没有空闲空间的span列表
与mcache不同,mcentral是公共资源,会有多个线程的mcache向mcentral申请mspan,依次访问mcentral中的mspan时需要使用互斥锁。
mcache会通过mcentra的cacheSpan方法获取mspan,并更新mcache的alloc字段中相应span class对应的mspan
从mcentral中申请资源的时候,会优先向有空闲空间的span列表申请mspan,再向无空闲空间的span列表申请mspan,如果获取失败,会再从mheap中申请mspan。
## 为什么再向mheap中申请span之前还要先尝试向full列表申请呢?
因为再向mheap申请之前会进行一次GC,会把一些已经满的但是不使用的mspan的空间回收掉,可能会出现一些空的mspan
如果申请到了mspan,会更新mspan中的 allocBits和allocCache字段。
runtime.refill会为线程缓存获取一个指定span class的mspan;
被替换的(old)mspan不能包含空闲的内存空间,而获取的mspan中需要至少包含一个空闲对象。 (旧的一定要非常旧,新的不一定要非常新)
type mcentral struct {
spanclass spanClass
partial [2]spanSet
full [2]spanSet
}
七、页堆 (mheap)
内存分配的核心组件,包含mcentral和heapArena,堆上所有mspan都是mheap结构分配来的
allspans: 已经分配的所有mspan
arenas: heapArena数组,用于管理一个个内存块
central: mcentral数组,用于管理对应spanClass的mspan
heapArena:
Go 1.11后采用稀疏内存管理。堆区的内存可以不连续,将堆区内存分成一个个内存块(arena),通过heapArena管理
在mheap中维护一个heapArena数组,记录所有内存块:
bitmap:标记heapArena内存中没饿过地址空间的使用情况
spans:记录page id映射到的mspan
pageInUse、pageMarks:标记状态为mSpanInUse或在GC扫描中被标记的page,用于加速内存回收
zeroedBase:标记此arena中尚未使用的第一个page的首地址
mcentral从mheap中申请新的mspan时:
1. 从heap的pages中获取内存,并生成mspan
2. heap中无法获取到mspan时,需要对堆增长,调用sysAlloc从操作系统中申请的内存
3. 从已经保留的heapArena中获取内存
4. 无法获取到合适的内存时,会向操作系统申请,并进行heapArena初始化,将heapArena加入到arena列表中
八、内存分配
堆上所有的对象都会通过调用 rentime.newobject 函数分配内存;
该函数会调用 runtime.mallocgc 分配指定大小的内存空间,这也是程序向堆上申请内存空间的必经函数。
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
mp := acquirem()
mp.mallocing = 1
c := gomcache()
var x unsafe.Pointer
noscan := typ == nil || typ.ptrdata == 0
if size <= maxSmallSize {
if noscan && size < maxTinySize {
// 微对象分配
} else {
// 小对象分配
}
} else {
// 大对象分配
}
publicationBarrier()
mp.mallocing = 0
releasem(mp)
return x
}
上述代码使用runtime.gomcache 获取线程缓存并判断申请内存的类型是否为指针。
而且还可以看出runtime.mallocgc 会根据对象的大小执行不同的分配逻辑,在前面也提到过根据对象大小将他们分成微对象、小对象、大对象
· 微对象:(0,16byte) 先使用微型分配器,在依次尝试mcache、mcentral、mheap分配内存;
会使用微型分配器提高微对象分配性能,我们主要使用它来分配较小的字符串和逃逸的临时变量;
微分配器可以将多个较小的内存分配请求何如同一个内存块中,只有当内存块中的所有对象都需要被回收时,整片内存才可能被回收。
微分配器管理的对象不可以是指针类型,管理多个对象的内存块大小 maxTinySize是可以调整的,默认情况是16字节。
maxTinySize越大,组合多个对象的可能性就越高,内存浪费就越严重;
maxTinySize越小,内存浪费越少,但是提供了越少的组合可能;
最好的方案是8的倍数
还记得微分配器的结构吗?
tiny uintptr // 指向推中的一片内存,即当前偏移量
tinyoffset uintptr // 下一个空闲内存所在的偏移量
假设tiny标记的16byte的微分配器已经被分配了12byte,tinyoffset标记了12byte之后;
如果下一个待分配对象小于16-12=4byte,他会直接使用这个内存块的剩余部分,减少内存碎片;
不过该内存块只有所有对象都被标记为垃圾时,才会回收。
代码:
mcache中的tiny字段指向了maxTinySize大小的块;
如果当前块中还包含大小合适的空闲内存,运行时会通过这tiny和tinyoffset直接获取并返回这块内存
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
...
if size <= maxSmallSize {
if noscan && size < maxTinySize {
off := c.tinyoffset
if off+size <= maxTinySize && c.tiny != 0 {
x = unsafe.Pointer(c.tiny + off)
c.tinyoffset = off + size
c.local_tinyallocs++
releasem(mp)
return x
}
...
}
...
}
...
}
如果内存块的空闲内存不够时,会先从mcache中找到跨度对应的mspan,,调用runtime.nextFreeFast获取空闲的内存;
当不存在空闲内存时,调用runtime.mcache.nextFree从mcentral或mheap中获取可分配的内存块
代码:
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
...
if size <= maxSmallSize {
if noscan && size < maxTinySize {
...
span := c.alloc[tinySpanClass]
v := nextFreeFast(span)
if v == 0 {
v, _, _ = 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
}
...
}
...
return x
}
获取新的空闲内存块之后,上述代码会清空空闲内存中的数据、更新构成微对象分配器的几个字段并返回空闲内存
· 小对象:
大小为16byte到32kb的对象以及所有小于16byte的指针对象,小对象的分配可以分为三个步骤:
1. 确定分配对象的大小以及spanclass
2. 从mcache、mcentral、mheap中获取mspan并从mspan中找到空闲的内存空间
3. 调用 runtime.memclrNoHeapPointers 清空空闲内存中的所有数据
确定待分配对象的大小以及spanclass需要使用预先计算好的size_to_class8、size_to_class128、class_to_divmagic字典,这些常量能帮助快速获取对应的值并构建。
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
...
if size <= maxSmallSize {
...
} else {
var sizeclass uint8
if size <= smallSizeMax-8 {
sizeclass = size_to_class8[(size+smallSizeDiv-1)/smallSizeDiv]
} else {
sizeclass = size_to_class128[(size-smallSizeMax+largeSizeDiv-1)/largeSizeDiv]
}
size = uintptr(class_to_size[sizeclass])
spc := makeSpanClass(sizeclass, noscan)
span := c.alloc[spc]
v := nextFreeFast(span)
if v == 0 {
v, span, _ = c.nextFree(spc)
}
x = unsafe.Pointer(v)
if needzero && span.needzero != 0 {
memclrNoHeapPointers(unsafe.Pointer(v), size)
}
}
} else {
...
}
...
return x
}
重点分析两个方法的实现原理,它们分别是 runtime.nextFreeFast和runtime.mcache.nextFree,这两个方法会帮助我们获取空闲的内存空间。
runtime.nextFreeFast会利用mspan中的allocCache字段 (占用位图的补码),快速找到该字段为1的位数,
找到了空闲对象后,我们就可以更新mspan的allocCache、freeindex等字段并返回该片内存
代码:
func nextFreeFast(s *mspan) gclinkptr {
theBit := sys.Ctz64(s.allocCache)
if theBit < 64 {
result := s.freeindex + uintptr(theBit)
if result < s.nelems {
freeidx := result + 1
if freeidx%64 == 0 && freeidx != s.nelems {
return 0
}
s.allocCache >>= uint(theBit + 1)
s.freeindex = freeidx
s.allocCount++
return gclinkptr(result*s.elemsize + s.base())
}
}
return 0
}
如果通过runtime.nextFreeFast没有找到空闲的内存,会通过runtime.mcache.nextFree找到新的内存管理单元
代码:
func (c *mcache) nextFree(spc spanClass) (v gclinkptr, s *mspan, shouldhelpgc bool) {
s = c.alloc[spc]
freeIndex := s.nextFreeIndex()
if freeIndex == s.nelems {
c.refill(spc)
s = c.alloc[spc]
freeIndex = s.nextFreeIndex()
}
v = gclinkptr(freeIndex*s.elemsize + s.base())
s.allocCount++
return
}
如果在mcache中没有找到可用的mspan,会通过前面介绍的mcache.refill使用mcentral中的mspan替换已经不存在可用对象的数据;
该方法会调用新结构体的mspan.nextFreeIndex获取空闲的内存并返回
· 大对象:
运行时对于大于32KB的大对象会单独处理,我们不会从mcache或者mcentral中获取mspan而是直接调用mcache.allocLarge:
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
...
if size <= maxSmallSize {
...
} else {
var s *mspan
span = c.allocLarge(size, needzero, noscan)
span.freeindex = 1
span.allocCount = 1
x = unsafe.Pointer(span.base())
size = span.elemsize
}
publicationBarrier()
mp.mallocing = 0
releasem(mp)
return x
}
mcache.allocLarge会计算分配给该对象所需要的页数,它按照8KB的背书在堆上申请内存。
申请内存时会创建一个spanclass为0的特殊spancalss并调用mheap.alloc分配一个管理对应内存的mspan
代码:
func (c *mcache) allocLarge(size uintptr, needzero bool, noscan bool) *mspan {
npages := size >> _PageShift
if size&_PageMask != 0 {
npages++
}
...
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的内存分配器在给对象分配内存时,根据对象的大小,分成三类微对象(0,16btye]、小对象(16btye,32KB]、大对象(32KB,+∞)。
大体上的分配流程:
1. 大对象直接从mheap上分配;
2. 微对象使用mcache上的tiny分配器分配;
3. 小对象首先计算对象的规格大小,然后使用mcache中相应规格大小的mspan分配;
4.如果mcache没有相应规格大小的mspan,指向mcentral申请;
4.如果mcentral没有相应规格大小的mspan,指向mheap申请;
4.如果mheap没有相应规格大小的mspan,指向操作系统申请;
十、逃逸分析:
逃逸分析是一种静态分析,在编译阶段执行。
每当函数中申请新对象,编译器会根据该对象是否被函数外部引用来分析,决定变量应该在栈上非,还是在堆上分配。
· 两个不变性:
1. 指向栈对象的指针不能存在于堆中
2. 指向栈对象的指针不能在栈对象收回后存活
· 主要策略:
1. 如果函数外部没有引用,则优先放到栈中;
2. 如果函数外部存在引用,则必定放到堆中;
· 场景:
1. 函数返回局部变量,则会引起内存逃逸
2. 动态类型引起的逃逸
3. 栈空间不足导致逃逸
4. 闭包函数中没有定义变量i的,而是引用了他所在函数f中的变量i,变量i发生逃逸
十一、如何利用逃逸分析提升性能
传值 VS 传指针:
传值会拷贝整个对象,而传指针只会拷贝指针地址,指向的对象是同一个。
传指针可以减少值的拷贝,但是导致内存逃逸到堆中,增加垃圾回收(GC)的负担。
在对象频繁创建和删除的场景下,传递指针导致的GC开销可能会严重影响性能。
一般情况下,对于需要修改原对象值,或占用内存比较大的结构体,选择传指针。
对于只读的占用内存比较小的结构体,直接传值能够获得更好的性能。