go内存分配器详解-摘自go语言设计与实现
go设计与实现把go内存分配器介绍的很详细,起始一般情况下程序员不怎么会用到。需要简单了解下即可。如果没时间看,看看下述内容即可。
栈区堆区概念要理解。分配方法其实就是基于算法中的数组和链表,优缺点都类似。
go采用的空闲链表分配。并采取了隔离适应来规避链表的缺陷。
通过将对象大小分成微对象[<16B]小对象[16-32KB] 大对象[>32KB],并且通过多级缓存提高分配效率。
通过将对象大小分成微对象[<16B]小对象[16-32KB] 大对象[>32KB],并且通过多级缓存提高分配效率。
通过将对象大小分成微对象[<16B]小对象[16-32KB] 大对象[>32KB],并且通过多级缓存提高分配效率。(重要的事情说三遍,将对象分类并使用多级缓存是go内存管理的重要思想)
最重要的内存管理组件。内存管理单元mspan、线程缓存mcache、中心缓存mcentral、页堆mheap简单的了解下。
这些组件的源码都在runtime包中
go以页为单位管理内存(跟操作系统的页不同),每个mspan会持有多个页。
mspan是基础,mcache是为每个goroutine各自分配的缓存,mcentral可以理解成是内存池,mheap是更大的池。
当mspan不足,会向mcentral申请,mcentral不足向mheap申请,mheap不足向操作系统申请。
一般了解到这些内容就可以了。有时间可以详细往下看。
一、内存管理的基础概念
内存空间分为两个重要区域。栈区Stack和堆区Heap。函数调用的参数,返回值以及局部遍历大都分配到栈上,由编译器管理。不同编程语言使用不同的方法管理堆区的内存,C++ 等编程语言会由工程师主动申请和释放内存,Go 以及 Java 等编程语言会由工程师和编译器共同管理,堆中的对象由内存分配器分配并由垃圾收集器回收。
设计原理
内存管理一般包含三个不同的组件,分别是用户程序(Mutator)、分配器(Allocator)和收集器(Collector),当用户程序申请内存时,它会通过内存分配器申请新的内存,而分配器会负责从堆中初始化相应的内存区域。
分配方法
编程语言的内存分配器一般包含两种分配方法,一种是线性分配器(Sequential Allocator,Bump Allocator),另一种是空闲链表分配器(Free-List Allocator)
线性分配器
线性分配(Bump Allocator)是一种高效的内存分配方法,但是有较大的局限性。当我们在编程语言中使用线性分配器,我们只需要在内存中维护一个指向内存特定位置的指针,当用户程序申请内存时,分配器只需要检查剩余的空闲内存、返回分配的内存区域并修改指针在内存中的位置,即移动下图中的指针:
优点:执行快,容易实现
缺点:无法重用内存,需要搭配合适的垃圾回收算法
回收中产生的内存碎片,需要搭配合适的垃圾回收算法。标记压缩(Mark-Compact)、复制回收(Copying GC)和分代回收(Generational GC)等算法可以通过拷贝的方式整理存活对象的碎片,将空闲内存定期合并,这样就能利用线性分配器的效率提升内存分配器的性能了。
空闲链表分配器
空闲链表分配器(Free-List Allocator)可以重用已经被释放的内存,它在内部会维护一个类似链表的数据结构。当用户程序申请内存时,空闲链表分配器会依次遍历空闲的内存块,找到足够大的内存,然后申请新的资源并修改链表:
优点:内存复用方便
缺点:分配内存需要遍历整个链表,耗时长。
空闲链表分配器可以选择不同的策略在链表中的内存块中进行选择,最常见的就是以下四种方式:
首次适应(First-Fit)— 从链表头开始遍历,选择第一个大小大于申请内存的内存块;
- 循环首次适应(Next-Fit)— 从上次遍历的结束位置开始遍历,选择第一个大小大于申请内存的内存块;
- 最优适应(Best-Fit)— 从链表头遍历整个链表,选择最合适的内存块;
- 隔离适应(Segregated-Fit)— 将内存分割成多个链表,每个链表中的内存块大小相同,申请内存时先找到满足条件的链表,再从链表中选择合适的内存
Go 语言使用的内存分配策略与第四种策略有些相似,我们通过下图了解一下该策略的原理:
如上图所示,该策略会将内存分割成由 4、8、16、32 字节的内存块组成的链表,当我们向内存分配器申请 8 字节的内存时,我们会在上图中的第二个链表找到空闲的内存块并返回。隔离适应的分配策略减少了需要遍历的内存块数量,提高了内存分配的效率。
分级分配
线程缓存分配(Thread-Caching Malloc,TCMalloc)是用于分配内存的的机制,它比 glibc 中的 malloc
函数还要快很多。Go 语言的内存分配器就借鉴了 TCMalloc 的设计实现高速的内存分配,它的核心理念是使用多级缓存根据将对象根据大小分类,并按照类别实施不同的分配策略。
对象大小
Go 语言的内存分配器会根据申请分配的内存大小选择不同的处理逻辑,运行时根据对象的大小将对象分成微对象、小对象和大对象三种:
类别 | 大小 |
---|---|
微对象 | (0, 16B) |
小对象 | [16B, 32KB] |
大对象 | (32KB, +∞) |
多级缓存
内存分配器不仅会区别对待大小不同的对象,还会将内存分成不同的级别分别管理,TCMalloc 和 Go 运行时分配器都会引入线程缓存(Thread Cache)、中心缓存(Central Cache)和页堆(Page Heap)三个组件分级管理内存:
线程缓存属于每一个独立的线程,它能够满足线程上绝大多数的内存分配需求,因为不涉及多线程,所以也不需要使用互斥锁来保护内存,这能够减少锁竞争带来的性能损耗。当线程缓存不能满足需求时,就会使用中心缓存作为补充解决小对象的内存分配问题;在遇到 32KB 以上的对象时,内存分配器就会选择页堆直接分配大量的内存。
这种多层级的内存分配设计与计算机操作系统中的多级缓存也有些类似,因为多数的对象都是小对象,我们可以通过线程缓存和中心缓存提供足够的内存空间,发现资源不足时就从上一级组件中获取更多的内存资源。
内存管理组件
Go 语言的内存分配器包含内存管理单元、线程缓存、中心缓存和页堆几个重要组件,这几种最重要组件对应的数据结构 runtime.mspan
、runtime.mcache
、runtime.mcentral
和 runtime.mheap
。
所有的 Go 语言程序都会在启动时初始化如上图所示的内存布局,每一个处理器都会被分配一个线程缓存 runtime.mcache
用于处理微对象和小对象的分配,它们会持有内存管理单元 runtime.mspan
。
每个类型的内存管理单元都会管理特定大小的对象,当内存管理单元中不存在空闲对象时,它们会从 runtime.mheap
持有的 134 个中心缓存 runtime.mcentral
中获取新的内存单元,中心缓存属于全局的堆结构体 runtime.mheap
,它会从操作系统中申请内存。
在 amd64 的 Linux 操作系统上,runtime.mheap
会持有 4,194,304=4*1024*1024 runtime.heapArena
,每一个 runtime.heapArena
都会管理 64MB 的内存,单个 Go 语言程序的内存上限也就是 256TB。
内存管理单元
runtime.mspan
是 Go 语言内存管理的基本单元,该结构体中包含 next
和 prev
两个字段,它们分别指向了前一个和后一个 runtime.mspan
type mspan struct { next *mspan prev *mspan ... }
串联后的上述结构体会构成如下双向链表,运行时会使用 runtime.mSpanList
存储双向链表的头结点和尾节点并在线程缓存以及中心缓存中使用。
页和内存
每个 runtime.mspan
都管理 npages
个大小为 8KB 的页,这里的页不是操作系统中的内存页,它们是操作系统内存页的整数倍,该结构体会使用下面的这些字段来管理内存页的分配和回收:
type mspan struct { startAddr uintptr // 起始地址 npages uintptr // 页数 freeindex uintptr allocBits *gcBits gcmarkBits *gcBits allocCache uint64 ... }
startAddr
和npages
— 确定该结构体管理的多个页所在的内存,每个页的大小都是 8KB;freeindex
— 扫描页中空闲对象的初始索引;allocBits
和gcmarkBits
— 分别用于标记内存的占用和回收情况;allocCache
—allocBits
的补码,可以用于快速查找内存中未被使用的内存;
runtime.mspan
会以两种不同的视角看待管理的内存,当结构体管理的内存不足时,运行时会以页为单位向堆申请内存:
图 7-12 内存管理单元与页
当用户程序或者线程向 runtime.mspan
申请内存时,该结构会使用 allocCache
字段以对象为单位在管理的内存中快速查找待分配的空间:
如果我们能在内存中找到空闲的内存单元,就会直接返回,当内存中不包含空闲的内存时,上一级的组件 runtime.mcache
可能会为该结构体添加更多的内存页以满足为更多对象分配内存的需求。
跨度类
runtime.spanClass
是 runtime.mspan
结构体的跨度类,它决定了内存管理单元中存储的对象大小和个数:
type mspan struct { ... spanclass spanClass ... }
Go 语言的内存管理模块中一共包含 67 种跨度类,每一个跨度类都会存储特定大小的对象并且包含特定数量的页数以及对象,所有的数据都会被预选计算好并存储在 runtime.class_to_size
和 runtime.class_to_allocnpages
等变量中:
class | bytes/obj | bytes/span | objects | tail waste | max waste |
---|---|---|---|---|---|
1 | 8 | 8192 | 1024 | 0 | 87.50% |
2 | 16 | 8192 | 512 | 0 | 43.75% |
3 | 32 | 8192 | 256 | 0 | 46.88% |
4 | 48 | 8192 | 170 | 32 | 31.52% |
5 | 64 | 8192 | 128 | 0 | 23.44% |
6 | 80 | 8192 | 102 | 32 | 19.07% |
… | … | … | … | … | … |
66 | 32768 | 32768 | 1 | 0 | 12.50% |
跨度类的数据
上表展示了对象大小从 8B 到 32KB,总共 66 种跨度类的大小、存储的对象数以及浪费的内存空间
除了上述 66 个跨度类之外,运行时中还包含 ID 为 0 的特殊跨度类,它能够管理大于 32KB 的特殊对象
线程缓存
runtime.mcache
是 Go 语言中的线程缓存,它会与线程上的处理器一一绑定,主要用来缓存用户程序申请的微小对象。每一个线程缓存都持有 67 * 2 个 runtime.mspan
,这些内存管理单元都存储在结构体的 alloc
字段中
线程缓存与内存管理单元
线程缓存在刚刚被初始化时是不包含 runtime.mspan
的,只有当用户程序申请内存时才会从上一级组件获取新的 runtime.mspan
满足内存分配的需求。
微分配器
线程缓存中还包含几个用于分配微对象的字段,下面的这三个字段组成了微对象分配器,专门为 16 字节以下的对象申请和管理内存:
type mcache struct { tiny uintptr tinyoffset uintptr local_tinyallocs uintptr }
微分配器只会用于分配非指针类型的内存,上述三个字段中 tiny
会指向堆中的一篇内存,tinyOffset
是下一个空闲内存所在的偏移量,最后的 local_tinyallocs
会记录内存分配器中分配的对象个数。
中心缓存
runtime.mcentral
是内存分配器的中心缓存,与线程缓存不同,访问中心缓存中的内存管理单元需要使用互斥锁:
type mcentral struct { lock mutex spanclass spanClass nonempty mSpanList empty mSpanList nmalloc uint64 }
每一个中心缓存都会管理某个跨度类的内存管理单元,它会同时持有两个 runtime.mSpanList
,分别存储包含空闲对象的列表和不包含空闲对象的链表:
中心缓存和内存管理单元
该结构体在初始化时,两个链表都不包含任何内存,程序运行时会扩容结构体持有的两个链表,nmalloc
字段也记录了该结构体中分配的对象个数。
内存管理单元
线程缓存会通过中心缓存的 runtime.mcentral.cacheSpan
方法获取新的内存管理单元,该方法的实现比较复杂,我们可以将其分成以下几个部分:
- 从有空闲对象的
runtime.mspan
链表中查找可以使用的内存管理单元; - 从没有空闲对象的
runtime.mspan
链表中查找可以使用的内存管理单元; - 调用
runtime.mcentral.grow
从堆中申请新的内存管理单元; - 更新内存管理单元的
allocCache
等字段帮助快速分配内存;
页堆
runtime.mheap
是内存分配的核心结构体,Go 语言程序只会存在一个全局的结构,而堆上初始化的所有对象都由该结构体统一管理,该结构体中包含两组非常重要的字段,其中一个是全局的中心缓存列表 central
,另一个是管理堆区内存区域的 arenas
以及相关字段。
页堆中包含一个长度为 134 的 runtime.mcentral
数组,其中 67 个为跨度类需要 scan
的中心缓存,另外的 67 个是 noscan
的中心缓存:
go语言设计与实现书中提到的东西远不止这些。还有很多更详细更深奥的东西。如果有兴趣可以买书或者看电子书。
问题:为什么分成67类对象?微对象,小对象,大对象分类的标准是什么,为什么这么分?