操作系统内存管理
物理内存:
linux系统中通过分段和分页机制,把物理内存划分为4k大小的内存页(page),但直接分配物理内存会出现几种问题:
1)外部碎片
系统分配物理内存页的时候会尽量分配连续的页面,频繁分配和回收导致大量的小块内存在中间,形成外部碎片
2)内部碎片
物理内存是按页来分配的,实际中只需要很小内存时,也会分配至少4k,这样除去用掉的字节,剩下的就形成了内部碎片
虚拟内存:
linux通过虚拟内存,让每个进程认为自己独占了内存空间,进程看到的实际是虚拟地址
此时分配的虚拟内存还没有映射到物理内存,只有访问时才会真正的申请物理内存
golang内存管理
从参与内存管理过程的角色来看,我们可以拆成三个不同部分,用户程序(Mutator)、分配器(Allocator)及回收器(Collector)。
当用户程序申请内存时,它会通过内存分配器申请内存。
分配器负责从堆中初始化相应的内存区域。 回收器负责回收内存。
内存分配
Tcmalloc,全称Thread-Caching Malloc,是google推出的内存分配器,用来替代传统的malloc内存分配函数。它有减少内存碎片,适用于多核,更好的并行性支持等特性。
Golang的分配器就借鉴了 TCMalloc 的设计实现高速的内存分配,它的核心理念是使用多级缓存将对象根据大小分类,并按照类别实施不同的分配策略。
内存管理单元
我们这里先介绍下Golang内存分配的基本单位。
- Page:与操作系统管理内存的方式类似,Golang将虚拟内存空间以page作为单位进行划分,每个page默认8k。
- Span:连续的N个Page称为一个Span。
- Object:应用程序以Object作为整体在Page上分配内存。大对象甚至会横跨多个Page。
Golang以Span为单位向系统申请内存,申请到的Span可能只有一个Page,也可能有N个Page。Span中的Page可以被划分为一系列小对象,也可以整体当做中对象或者大对象分配。
因此三者的关系,可以用下面的图来描述。
对于不同大小的Object,Golang也按大小进行了分级,根据分级来制定不同的分配策略。
- 微对象:小于16B。
- 小对象:大于16B,小于32KB。(针对小对象,Golang还更细致的将对象大小分成了68级,称为Size Class)
- 大对象:大于32KB。
程序中绝大多数都是小对象,分级处理有利于提升效率。
SpanClass: Size Class在Golang中的体现是SpanClass,定义在mheap.go/spanClass。 spanClass是一个int8,前面7位存储size class的级数信息,最后1位存储noscan,用于记录有无指针,1为无指针,0为有指针,有指针的时候需要参与到内存回收扫描过程。
例如我们要分配一个9B的对象,他就会被取整为16B,级数信息是2,如果这个对象没有指针,那noscan就是1,这个对象就会被分配到SpanClass为 0000010 1 的span中。
| class | 字节数(B) | 对应span的大小(B) | 占用page数 | 每个span可分配数 | 最大浪费 |
|---|---|---|---|---|---|
| 1 | 8 | 8192 | 1 | 1024 | 87.50% |
| 2 | 16 | 8192 | 1 | 512 | 43.75% |
| 3 | 24 | 8192 | 1 | 341 | 29.24% |
| 4 | 32 | 8192 | 1 | 256 | ... |
| ... | ... | ... | ... | ... | ... |
| 66 | 28672 | 57344 | 7 | 2 | 4.91% |
| 67 | 32768 | 32768 | 4 | 1 | 12.50% |
Span:Span是由1个或多个连续Page组成,每个Span对象都有一个起始Page地址以及包含的Page数量。同时Span还有prev和next两个指针,方便组成双向链表。这里的spanClass就是我们上面所说的分级,每个span都只会服务于一种SpanClass。
Span定义在golang中是runtime/mheap.go。
type mspan struct {
// 第一部分:关联性字段
next *mspan // 下个span的指针
prev *mspan // 上个span的指针
// 第二部分:span标识、统计性字段
startAddr uintptr // span首地址
npages uintptr // 该span占用了多少page
spanclass spanClass // size class定义
state mSpanStateBox // 当前span的状态:使用中,空闲,回收。
nelems uintptr // 记录这个Span被spanClass切割成了多少份,即可以存放多少个对象
allocCount uint16 // 记录已经分配对象个数
// 第三部分:对象分配字段
allocBits *gcBits // 位图,记录已经分配的对象
gcmarkBits *gcBits // 位图,记录内存的回收情况,用于垃圾回收
freeindex uintptr // span中空闲对象扫描的初始index,与allocCache配置使用
allocCache uint64 // allocBits的缓存
}
多级缓存
我们先看看tcmalloc的整体框架。
这里的User Code就是我们日常写的用户程序,OS是操作系统。Tcmalloc作为中间层又分了三级,Front-End(前端)、Middle-End(中端)、Back-End(后端) 。简单来说三者主要的角色分工是:
- 前端是一个高速缓存,为应用程序提供快速的内存分配和内存释放;
- 中端负责填充前端缓存;
- 后端负责从操作系统中申请内存;
Golang基本复用了tcmalloc的三层框架,对应的三层实现分别是 mcache(前端),mcentral(中端),mheap(后端)。
mcache:mcache作为一个缓存,里面存放了大量已分配或未分配的Span。当用户程序申请小对象内存时,mcache会查找自己管理的内存块,如果有符合条件的就直接返回,否则向中端请求一批内存来重新填充。当内存被释放后,将会重新加入到缓存中。大部分情况下,前端缓存都能满足用户程序的需求。
mcache有个很重要的优化点,就是它只会让一个线程 (os线程) 访问,所以不需要加锁,避免了加锁带来的性能损耗。对应到Golang的GMP模型,就是每个mcache都会与一个P进行绑定,只有这个P能访问mcache中的对象。
mcache在Golang中的定义在mcache.go/mcache。
type mcache struct {
alloc [numSpanClasses]*mspan // mspan的数组
}
numSpanClasses = _NumSizeClasses << 1 // size Class数目的两倍=136.
这里mcache是基于各种SpanClass维护了一个span的数组。前面我们讲到SpanClass由两部分组成,前七位是size class的级数,共有68个,最后一位是noscan表示是否有指针。因此这里mcache实际上是将有指针和无指针的span也进行了区分,主要是方便进行内存回收(noscan的span就不会进行GC标记了),共136个。示意图如下。
当mcache资源不足时,会从中端缓存mcentral中获取Span,加入到对应的Span列表中。
mcentral: mcentral的定义在mcentral.go/mcentral中。
type mcentral struct {
spanclass spanClass
partial [2]spanSet // list of spans with a free object
full [2]spanSet // list of spans with no free objects
}
我们可以从代码中看到,一个mcentral只会有一个spanClass,系统中也有136个mcentral在维护136种span。
每一个mcentral只负责管理一种spanClass类型的mspan,并且管理的单元就是mspan,如果mcache中缓存的某种spanClass类型的Span没有空闲的了,就会向对应spanClass类型的mcentral申请。
partial和full是实际存储span的数组,这里设置为数组是将 GC清理过 和 GC未清理过 分拆成两份,便于管理。
mheap:golang启动之初,会一次性从操作系统申请一块大的内存(虚拟地址空间)作为内存池,切成小块自己管理。
由mheap的struct来管理,mheap负责将一整块内存切割成不同的区域,将其中一部分内存切割成合适的大小。
mheap主要完成三个工作:
- 作为一个全局变量,管理所有从系统中申请到的堆内存。
- 当无内存可用时,向操作系统申请内存
- 将不需要的内存返还给操作系统
mheap的定义,在 runtime/mheap.go 中。 可以看到定义了heapArena数组集合,同时维护了136个SpanClass的mcentral。每个heapArena在64位机器上是64M大小。
type mheap struct {
lock mutex
pages pageAlloc // page分配的结构
allspans []*mspan // 所有的span
arenas [1 << arenaL1Bits]*[1 << arenaL2Bits]*heapArena // arenas数组集合,管理各个heapArena
allArenas []arenaIdx // 所有arena的序号集合,可以根据arenaIdx算出对应于arenas中的哪个heapArena
central [numSpanClasses]struct { // 各个mspanClass的central对象 numSpanClasses=136
mcentral mcentral
pad [cpu.CacheLinePadSize - unsafe.Sizeof(mcentral{})%cpu.CacheLinePadSize]byte
}
}
type heapArena struct {
bitmap [heapArenaBitmapBytes]byte // 映射内存地址中是否有对象,以及是否被gc标记
spans [pagesPerArena]*mspan // 该heapArena管理的span
pageInUse [pagesPerArena / 8]uint8 // 正在使用的page的位图
pageMarks [pagesPerArena / 8]uint8 // GC标记页的位图
}
我们接下来用一个大图来描述我们上面讲到的三级缓存的关系。
分配逻辑
前面我们讲到,Golang将对象按大小分成了三类:
- 微对象,size < 16B
- 小对象,16B < size < 32K (正常分配,首先计算使用的大小规格,然后使用mcache分配)
- 大对象,size > 32K (直接通过mheap分配)
每一类的分配逻辑,又不太相同。
// 分配对象内存入口
func newobject(typ *_type) unsafe.Pointer {
return mallocgc(typ.size, typ, true)
}
微对象分配器:
微对象分配器主要是处理 小于16B,且没有指针 的小对象。
微对象分配器处于mcache上,主要用来分配较小的字符串和逃逸的临时变量。
他可以将多个较小的内存分配合入同一个内存块中,只有当内存块中所有对象都要被回收时,整片内存才会被回收。
逻辑比较简单,就是先从微对象分配器中进行分配,如果微对象分配器中内存不足时,则走小对象分配器进行分配。比如我们下面的一个微对象16B中已经分配了12B,如果接下来要申请5B的内存,那就无法分配,只能走小对象分配了。
小对象分配:
小对象的分配方式我们前面也讨论了很多,整体可以分为三个步骤:
- 确定分配对象的大小和对应的SpanClass
- 从线程缓存mcache,中心缓存mcentral中获取Span,并从Span中通过allocCache和freeindex找到空闲的内存空间。
- 调用memclrNoHeapPointers方法清空空闲内存中的所有数据。
大对象分配:
对于大于32KB的大对象,Golang会单独处理,不会经过mcache和mcentral这两层,而是直接从heap上分配对象。

内存回收
内存回收,也叫垃圾收集(Garbage Collection,GC)。
内存分配是在堆上进行的,堆内存并非无穷尽的,那就需要合理使用堆内存。主要涉及几个点:
- 合理的申请内存,尽量减少内存;
- 及时清理掉不再使用的内存。
对于第一点,是由用户程序控制的。
对于第二点,不同语言的处理方式不太一样。C++等通过用户手动回收,GO/JAVA等从语言层面实现了垃圾收集器,无需用户手动回收。
垃圾识别
垃圾收集器首先需要判断出来,哪些数据还存活着,而哪些数据是不再被需要的,也就是垃圾。
Tracing GC(追踪式垃圾回收)是一种通过跟踪对象引用关系判定对象存活状态的垃圾回收算法,其核心在于从一组初始引用(GC Roots)出发,遍历整个对象图,最终确定哪些对象是“可达对象”,不可达对象需要被回收。
垃圾收集
基于Tracing GC的垃圾识别方法,常见的垃圾收集算法有标记-清除算法(Mark-Sweep) ,复制算法(Coping) ,标记-整理算法(Mark-Compact) ,分代清理(Generational Collection)。
Golang GC采用 Tracing 法来扫描对象,使用不分代(对象没有分代)、不整理(回收过程中不移动对象)、并发(与用户代码并发执行)的三色标记-清除算法来清理对象。
Golang采用标记-清除算法,内存碎片的问题主要是通过分配器分配方式来解决。
Golang发展史上,有几个版本的改动比较重要。
- go 1.3以前,Go采用串行式的标记-清除法,每次GC都需要STW(stop the world,也就是将所有程序暂停运行)进行标记和清除,程序很容易出现卡顿。
- go 1.5,为了降低GC延迟,采用了并发标记和并发清除的三色标记法,加入了write barrier写屏障,实现了更好的回收器调度。
- go 1.8,引入混合屏障以消除STW中的re-scan,降低了STW的最差耗时。
1. 三色标记法
Golang GC会从根对象开始扫描,三色标记法将对象分为三类,并以不同颜色相称。
- 白色对象:未被回收器访问到的对象。扫描开始时,所有对象均为白色;扫描结束后,所有白色对象均为不可达对象。
- 灰色对象:已被回收器访问到的对象,需要进一步对他们所指向的对象进行遍历。
- 黑色对象:已被回收器访问到的对象,并且该对象中所有的指针都已被扫描。黑色对象中任何一个指针都不可能直接指向白色对象。
标记过程如下:
-
初始状态下所有对象都是白色的;
-
从根对象出发扫描所有可达对象,标记为灰色,放入待处理队列;
-
从待处理队列中取出灰色对象,将其引用的对象标记为灰色放入待处理队列中,然后将自身标记为黑色;
-
重复步骤3,直到待处理对象为空。此时白色对象即为不可达对象,可以回收白色对象。
Golang中,根对象主要包括:
- 全局变量,编译器确定的那些存在于程序整个生命周期的变量
- 执行栈:每个goroutine的执行栈上的变量
- 寄存器:寄存器的值可能是个指向堆内存区块的指针
并发问题:
基于上述的三色标记法流程,我们可以发现,它有一个很重要的前提是,在标记过程中,对象之间的引用不能变,也就是说,需要在标记过程中启动STW,不然标记结果就可能错误。
在三色标记法中,同时出现下面两种情况时,就会出现对象丢失现象。
条件一:一个白色对象被黑色对象引用(白色被挂在黑色下)
条件二:灰色对象与它指向的白色对象的关系遭到破坏(灰色对象丢失了该白色对象)
当然,如果下图中的白色对象3, 如果他还有很多下游对象的话, 也会一并都清理掉。
为了防止这种现象,最简单的方式就是STW,直接禁止掉其他用户程序对对象引用关系的干扰,但是STW本身有明显的资源浪费,并且会影响所有的用户程序。所以我们要在保证对象不丢失的情况下,通过另一种机制来减少STW时间,这个机制就是屏障机制。
2. 屏障机制
2.1 三色不变式:
从对象丢失的两个条件来看,通过某些方式可以破坏这两个条件,就可以避免对象丢失。
两个条件在回顾一下:
- 条件一:一个白色对象被黑色对象引用(白色被挂在黑色下)
- 条件二:灰色对象与它指向的白色对象的关系遭到破坏(灰色对象丢失了该白色对象)
因此Golang提出了两种三色不变式。
- 强三色不变式:不允许存在黑色对象引用白色对象
- 弱三色不变式:所有被黑色对象引用的白色对象都处于灰色保护状态。换句话说,就是只有在白色对象存在其他灰色对象对他的引用的时候,黑色对象才可以引用白色对象。
2.2 插入屏障(Dijkstra屏障):
具体操作:在A对象引用B对象的时候,将B对象标记为灰色。
满足:强三色不变式。
伪代码:
添加下游对象(当前下游对象slot, 新下游对象ptr) {
标记灰色(新下游对象ptr)
当前下游对象slot = 新下游对象ptr
}
A.添加下游对象(nil, B) //A 之前没有下游, 新添加一个下游对象B, B被标记为灰色
A.添加下游对象(C, B) //A 将下游对象C 更换为B, B被标记为灰色
插入屏障主要是破坏了条件一。
接下来,我们用几张图, 更清晰的看一下整体流程。
但是如果栈不添加,当全部三色标记扫描之后,栈上有可能依然存在白色对象被引用的情况(如上图的对象9)。所以要对栈重新进行三色标记扫描, 但这次为了对象不丢失,要对本次标记扫描启动STW暂停,直到栈空间的三色标记结束。
最后将栈和堆空间 扫描剩余的全部 白色节点清除。这次STW大约的时间在10~100ms间。
每次进行赋值操作都需要引入写屏障,这会增加大量性能开销,考虑到栈函数操作频繁及对速度要求高的特点,就没有将插入屏障在栈空间的对象操作中使用,而仅仅用于堆空间对象中。
2.3 删除屏障(Yuasa屏障):
具体操作:被删除的对象,如果自身为灰色或者白色,那么被标记为灰色。
满足:弱三色不变式。(保护灰色对象到白色对象的路径不断)
伪代码:
添加下游对象(当前下游对象slot, 新下游对象ptr) {
//1
if (当前下游对象slot是灰色 || 当前下游对象slot是白色) {
标记灰色(当前下游对象slot) //slot为被删除对象, 标记为灰色
}
//2
当前下游对象slot = 新下游对象ptr
}
A.添加下游对象(B, nil) //A对象,删除B对象的引用。B被A删除,被标记为灰(如果B之前为白)
A.添加下游对象(B, C) //A对象,更换下游B变成C。B被A删除,被标记为灰(如果B之前为白)
上述伪代码中,B是被其它对象删除的对象,A要添加B这个对象。
下图中,对象1将指针从对象2转为指向对象3后,对象2被标记为灰色,原本可能丢失的白色对象3再次获得了灰色对象2的保护。
接下来,我们同样用几张图,更清晰的看一下整体流程。
如果下图中对象5被对象1删除,且不触发删除写屏障,并且对象5被一个黑色对象引用时,对象5、2、3都会被错误的回收掉。因此必须触发删除写屏障。
这种方式的回收精度低,一个对象即使被删除了最后一个指向它的指针也依旧可以活过这一轮,在下一轮GC中被清理掉。
2.4 混合写屏障:
插入写屏障和删除写屏障的短板:
- 插入写屏障:结束时需要STW来重新扫描栈,标记栈上引用的白色对象的存活;
- 删除写屏障:回收精度低,GC开始时STW扫描堆栈来记录初始快照,这个过程会保护开始时刻的所有存活对象。
Go V1.8版本引入了混合写屏障机制(hybrid write barrier),避免了对栈re-scan的过程,极大的减少了STW的时间。结合了两者的优点。
具体操作:
- GC开始将栈上的对象全部扫描并标记为黑色,这个过程不需要STW(之后不再进行第二次重复扫描)
- GC期间,任何在栈上创建的新对象,均为黑色
- 被删除的对象标记为灰色
- 被添加的对象标记为灰色
满足: 变形的弱三色不变式
伪代码:
添加下游对象(当前下游对象slot, 新下游对象ptr) {
//1 只要当前下游对象被移走,就标记灰色
标记灰色(当前下游对象slot)
//2
标记灰色(新下游对象ptr)
//3
当前下游对象slot = 新下游对象ptr
}
A.添加下游对象(nil, B) //A 之前没有下游。新添加一个下游对象B, B被标记为灰色
A.添加下游对象(C, B) //A 将下游对象C更换为B,B,C均被标记为灰色
需要注意的是,Golang只需要在标记初始阶段,将栈上所有对象都标记为黑色,这个过程也不需要STW,因为后续栈上生成的对象都会是黑色的;后续栈上对象的指针变更,均不会使用屏障技术,因为要保证栈的运行效率。
接下来,我们同样用几张图,更清晰的看一下整体流程。
GC开始:扫描栈区,将可达对象全部标记为黑
场景一: 对象被一个堆对象删除引用,成为栈对象的下游
场景二: 对象被一个栈对象删除引用,成为另一个栈对象的下游
场景三:对象被一个堆对象删除引用,成为另一个堆对象的下游
场景四:对象从一个栈对象删除引用,成为另一个堆对象的下游
Golang中的混合写屏障满足弱三色不变式,结合了删除写屏障和插入写屏障的优点,只需要在开始时并发扫描各个goroutine的栈,使其变黑并一直保持,这个过程不需要STW,而标记结束后,因为栈在扫描后始终是黑色的,也无需再进行re-scan操作了,减少了STW的时间。