笔者在经过了前期基础学习后,用 go 语言来实现自己面临的业务问题已经不再是问题,所以拥有了另一方面的求知欲--go 语言自身的各种包,各种机制是如何实现的,本章主要在探究 go 语言的内存分配器,希望能用本文讲清楚 go 语言内存分配器的机制,帮助大家更好地理解 go 语言的运行机制。
不同于 c 语言使用 malloc 和 free 来主动管理内存,golang 让程序员避免了这一切繁杂的操作,它通过 escape analysis 来分配内存,通过 garbage collection(gc)来回收内存,本文主要介绍内存分配部分的工作。
程序有两种内存,一种是堆栈(stack),一种是堆(heap),所有的堆内数据都被 GC 管理。
我们要明白什么时候程序会分配内存,在某些语言中是程序员主动申请的,在 go 语言中则依赖 escape analysis,越多的值在堆栈,程序运行越快(存取速度比堆要快,仅次于直接位于 CPU 中的寄存器),以下是内存分配的一些时机
1. goloang 只会把函数中确定不在函数结束后使用的变量放到堆栈,否则就会放到堆:一个值可能在构造该值的函数之后被引用-->变量上传
package main
func main(){ n:=answer() println(*n/2)}
func answer() *int{ x:=42 return &x}
复制代码
使用命令 go build -gcflags="-m -l"得到结果
./main.go:10:2: moved to heap: x
复制代码
2.编译器确定值太大而无法放入堆栈
3.编译器在编译的时候无法得知这个值的具体大小
ps:将变量下传,变量还会留在堆栈中
type Reader interface{ Read(p []byte) (n int,err error)}//better than type Reader interface{ Read(n int) (b []byte,err error)}//因为从上面传参数下去用的是堆栈,从下面往上传,则会escape到堆,导致程序更慢
复制代码
学习 go 语言的内存分配方式之前,我们先来看看另一个内存分配器-->TCMalloc,全称Thread-Caching Malloc
。
TCMalloc 有两个重要组成部分:线程内存(thread cache)和页堆(page heap)
每一个内存页都被分为多个固定分配大小规格的空闲列表(free list
) 用于减少碎片化。这样每一个线程都可以获得一个用于无锁分配小对象的缓存,这样可以让并行程序分配小对象(<=32KB)非常高效。
如图所示,第一行就是长度为 8 字节的内存块,在 thread cache 内最大的为 256 字节的内存块
TCMalloc 管理的堆由一组页组成(page 一般大小为 4kb),一组连续的页面被表示为 span。当分配的对象大于 32KB,将使用页堆(Page Heap)进行内存分配,分配对象时,大的对象直接分配 Span,小的对象从 Span 中分配。
当没有足够的空间分配小对象则会到页堆获取内存。如果页堆页没有足够的内存,则页堆会向操作系统申请更多的内存。
将基于 Page 的对象分配,和 Page 本身的管理串联
每种规格的对象,都从不同的 Span 进行分配;每种规则的对象都有一个独立的内存分配单元:CentralCache。在一个 CentralCache 内存,我们用链表把所有 Span 组织起来,每次需要分配时就找一个 Span 从中分配一个 Object;当没有空闲的 Span 时,就从 PageHeap 申请 Span。
最终我们得到结构图如下:
TCMalloc 针对不同的对象分配采用了不同的形式
每个线程都一个线程局部的 ThreadCache,按照不同的规格,维护了对象的链表;如果 ThreadCache 的对象不够了,就从 CentralCache 进行批量分配;如果 CentralCache 依然没有,就从 PageHeap 申请 Span;如果 PageHeap 没有合适的 Page,就只能从操作系统申请了。
在释放内存的时候,ThreadCache 依然遵循批量释放的策略,对象积累到一定程度就释放给 CentralCache;CentralCache 发现一个 Span 的内存完全释放了,就可以把这个 Span 归还给 PageHeap;PageHeap 发现一批连续的 Page 都释放了,就可以归还给操作系统。
由此,TCMalloc 的核心思路即:
把内存分为多级管理,从而降低锁的粒度。它将可用的堆内存采用二级分配的方式进行管理:每个线程都会自行维护一个独立的内存池,进行内存分配时优先从该内存池中分配,当内存池不足时才会向全局内存池申请,以避免不同线程对全局内存池的频繁竞争。
Go 在程序启动的时候,会先向操作系统申请一块内存(注意这时还只是一段虚拟的地址空间,并不会真正地分配内存),切成小块后自己进行管理。
申请到的内存块被分配了三个区域,在 X64 上分别是 512MB,16GB,512GB 大小。
arena区域
就是我们所谓的堆区,Go 动态分配的内存都是在这个区域,它把内存分割成8KB
大小的页,一些页组合起来称为mspan
。
bitmap区域
标识arena
区域哪些地址保存了对象,并且用4bit
标志位表示对象是否包含指针、GC
标记信息。bitmap
中一个byte
大小的内存对应arena
区域中 4 个指针大小(指针大小为 8B )的内存,所以bitmap
区域的大小是512GB/(4*8B)=16GB
。
spans区域
存放mspan
(也就是一些arena
分割的页组合起来的内存管理基本单元,后文会再讲)的指针,每个指针对应一页,所以spans
区域的大小就是512GB/8KB*8B=512MB
。除以 8KB 是计算arena
区域的页数,而最后乘以 8 是计算spans
区域所有指针的大小。创建mspan
的时候,按页填充对应的spans
区域,在回收object
时,根据地址很容易就能找到它所属的mspan
。
spans区域
存放mspan
(也就是一些arena
分割的页组合起来的内存管理基本单元,后文会再讲)的指针,每个指针对应一页,所以spans
区域的大小就是512GB/8KB*8B=512MB
。除以 8KB 是计算arena
区域的页数,而最后乘以 8 是计算spans
区域所有指针的大小。创建mspan
的时候,按页填充对应的spans
区域,在回收object
时,根据地址很容易就能找到它所属的mspan
。
go 初始化的时候会将内存页分为如下 67 个不同大小的内存块,最大到 32kb
Go 的内存分配器在分配对象时,根据对象的大小,分成三类:小对象(小于等于 16B)、一般对象(大于 16B,小于等于 32KB)、大对象(大于 32KB)。
大体上的分配流程:
1.32KB 的对象,直接从 mheap 上分配;
2.<=16B 的对象使用 mcache 的 tiny 分配器分配;
3.(16B,32KB] 的对象,首先计算对象的规格大小,然后使用 mcache 中相应规格大小的 mspan 分配;
4.如果 mcache 没有相应规格大小的 mspan,则向 mcentral 申请
5.如果 mcentral 没有相应规格大小的 mspan,则向 mheap 申请
6.如果 mheap 中也没有合适大小的 mspan,则向操作系统申请
mspan:Go 中内存管理的基本单元,是由一片连续的 8KB 的页组成的大块内存。是一个包含起始地址、mspan 规格、页的数量等内容的双端链表,mspan 由一组连续的页组成,按照一定大小划分成object
。
结构图
mcache:Go 像 TCMalloc 一样为每一个 逻辑处理器(P)(Logical Processors) 提供一个本地线程缓存(Local Thread Cache)称作 mcache,所以如果 Goroutine 需要内存可以直接从 mcache 中获取,由于在同一时间只有一个 Goroutine 运行在 逻辑处理器(P)(Logical Processors) 上,所以中间不需要任何锁的参与。
对于每一种大小规格都有两个类型:
采用这种方法的好处之一就是进行垃圾回收时 noscan 对象无需进一步扫描是否引用其他活跃的对象。
(<=16B 的对象使用 mcache 的 tiny 分配器分配)
结构体
//path: /usr/local/go/src/runtime/mcache.go
type mcache struct { alloc [numSpanClasses]*mspan}
numSpanClasses = _NumSizeClasses << 1
复制代码
结构图
central(mcentral):为所有mcache
提供切分好的mspan
资源。每个central
保存一种特定大小的全局mspan
列表,包括已分配出去的和未分配出去的。 每个mcentral
对应一种mspan
,而mspan
的种类导致它分割的object
大小不同。当工作线程的mcache
中没有合适(也就是特定大小的)的mspan
时就会从mcentral
获取。mcentral
被所有的工作线程共同享有,存在多个 Goroutine 竞争的情况,因此会消耗锁资源。
//path: /usr/local/go/src/runtime/mcentral.go
type mcentral struct { // 互斥锁 lock mutex // 规格 sizeclass int32 // 尚有空闲object的mspan链表 nonempty mSpanList // 没有空闲object的mspan链表,或者是已被mcache取走的msapn链表 empty mSpanList // 已累计分配的对象个数 nmalloc uint64 }
复制代码
结构图
mheap:代表 Go 程序持有的所有堆空间,Go 程序使用一个mheap
的全局对象_mheap
来管理堆内存。
当mcentral
没有空闲的mspan
时,会向mheap
申请。而mheap
没有资源时,会向操作系统申请新内存。mheap
主要用于大对象的内存分配,以及管理未切割的mspan
,用于给mcentral
切割成小对象。
同时我们也看到,mheap
中含有所有规格的mcentral
,所以,当一个mcache
从mcentral
申请mspan
时,只需要在独立的mcentral
中使用锁,并不会影响申请其他规格的mspan
。
//path: /usr/local/go/src/runtime/mheap.go
type mheap struct { lock mutex // spans: 指向mspans区域,用于映射mspan和page的关系 spans []*mspan // 指向bitmap首地址,bitmap是从高地址向低地址增长的 bitmap uintptr
// 指示arena区首地址 arena_start uintptr // 指示arena区已使用地址位置 arena_used uintptr // 指示arena区末地址 arena_end uintptr
central [67*2]struct { mcentral mcentral pad [sys.CacheLineSize - unsafe.Sizeof(mcentral{})%sys.CacheLineSize]byte }}
复制代码
结构图:
arena:golang 中所有堆区的统称,以 x64 为例子就是 512GB 的虚拟地址空间。
1.go 语言的垃圾回收
2.进程调度,线程调度,协程调度
3.虚拟内存
https://www.youtube.com/watch?v=ZMZpH4yT7M0
https://www.linuxzen.com/go-memory-allocator-visual-guide.html
领取专属 10元无门槛券
私享最新 技术干货