内存分配
基础概念
为了方便自主管理内存,会先向系统申请一块内存,然后将内存切割成小块,通过一定的内存分配算法管理内 存。以64位系统为例,Golang程序启动时会向系统申请的内存如下图所示:
- arena为堆区,应用中需要的内存从这里分配,大小为512G,为了方便管理把arena区域划分成一个个的page,每个page为8KB,一共有512GB/8KB个页。
- spans区域存放span的指针,每个指针对应一个page,所以span区域的大小为(512GB/8KB)*指针大小8byte = 512M
- bitmap区域大小也是通过arena计算出来,不过主要用于GC。
span
span是内存管理的基本单位, 是用于管理arena页的关键数据结构,每个span中包含1个或多个连续页,为了满足小对象分配,span中的一页会划分更小的粒度,而对于大对象比如超过页大小,则通过多页实现。
class
跟据对象大小,划分了一系列class,每个class都代表一个固定大小的对象,以及每个span的大小。如下表所示:
1 | // class bytes/obj bytes/span objects waste bytes |
- class ID,每个span结构中都有一个class ID, 表示该span可处理的对象类型。
- bytes/obj:该class代表对象的字节数。
- bytes/span:每个span占用堆的字节数,也即页数*页大小。
- objects: 每个span可分配的对象个数,也即(bytes/spans)/(bytes/obj)。
- waste bytes: 每个span产生的内存碎片,也即(bytes/spans)%(bytes/obj)。
注:上表可见最大的对象是32K(32*1024=32768)大小,超过32K大小的由特殊的class表示,该class ID为0,每个class只包含一个对象。
span数据结构
每个span用于管理特定的class对象, 跟据对象大小,span将一个或多个页拆分成多个块进行管理。
src/runtime/mheap.go:mspan
定义了其数据结构:
1 | type mspan struct { |
以class 10为例,span和管理的内存如下图所示:
spanclass为10,参照class表可得出npages=1,nelems=56,elemsize为144。其中startAddr是在span初始化时就指定了某个页的地址。allocBits指向一个位图,每位代表一个块是否被分配,本例中有两个块已经被分配,其allocCount也为2。next和prev用于将多个span链接起来,这有利于管理多个span。
central
全局管理span的数据结构,各线程需要内存时从 central管理的span中申请内存,当某个线程释放内存时又会回收进central。 为了避免多线程申请内存时不断的加锁,Go为每个线程分配了span的缓存,这个缓存即是cache。
src/runtime/mcentral.go:mcentral
定义了central数据结构:
1 | type mcentral struct { |
线程从central获取span步骤:
- 加锁
- 从nonempty列表获取一个可用span,并将其从链表中删除
- 将取出的span放入empty链表
- 将span返回给线程
- 解锁
- 线程将该span缓存进cache
线程将span归还步骤:
- 加锁
- 将span从empty列表删除
- 将span加入noneempty列表
- 解锁
cache
runtime/mcache.go:mcache
定义了cache的数据结构:
1 | type mcache struct { |
alloc
为mspan
的指针数组,数组大小为class总数的2倍。数组中每个元素代表了一种class类型的span列表,每种class类型都有两组span列表,第一组列表中所表示的对象中包含了指针,第二组列表中所表示的对象不含有指针,这么做是为了提高GC扫描性能,对于不包含指针的span列表,没必要去扫描。根据对象是否包含指针,将对象分为noscan
和scan
两类,分别代表没有指针和有指针。
mcache
和span
的对应关系如下图所示:
mcache在初始化时是没有任何span的,在使用过程中会动态地从central中获取并缓存下来,根据使用情况,每种class的span个数也不相同。上图所示,class 0的span数比class1的要多,说明本线程中分配的小对象要多一些。
heap
从mcentral数据结构可见,每个mcentral对象只管理特定的class规格的span。事实上每种class都会对应一个mcentral,这个mcentral的集合存放于mheap数据结构中。mheap管理着全部的内存,事实上Go就是通过一个mheap类型的全局变量进行内存管理的。
src/runtime/mheap.go:mheap
定义了heap的数据结构:
1 | type mheap struct { |
mheap内存管理示意图如下:
系统预分配的内存分为spans、bitmap、arean三个区域,通过mheap管理起来。
内存分配过程
分配步骤如下:
- 获取当前线程的私有缓存mcache 。
- 跟据size计算出适合的class的ID 。
- 从mcache的alloc[class]链表中查询可用的span 。
- 如果mcache没有可用的span则从mcentral申请一个新的span加入mcache中 。
- 如果mcentral中也没有可用的span则从mheap中申请一个新的span加入mcentral 。
- 从该span中获取到空闲对象地址并返回。
垃圾回收
由于span的数据结构可知,span中维护了一个位图gcmarkBits用于标记内存块被引用情况。allocBits和gcmarkBits数据结构是完全一样的,标记结束后就是内存回收,回收时将allocBits指向gcmarkBits,则代表标记过的才是存活的,gcmarkBits则会在下次标记时重新分配内存,非常的巧妙。
三色标记法
前面介绍了对象标记状态的存储方式,还需要有一个标记队列来存放待标记的对象,可以简单想象成把对象从标记队列中取出,将对象的引用状态标记在span的gcmarkBits,把对象引用到的其他对象再放入队列中。三色只是为了叙述上方便抽象出来的一种说法,实际上对象并没有颜色之分。这里的三色,对应了垃圾回收过程中对象的三种状态:
- 灰色:对象还在标记队列中等待
- 黑色:对象已被标记,gcmarkBits对应的位为1(该对象不会在本次GC中被清理)
- 白色:对象未被标记,gcmarkBits对应的位为0(该对象将会在本次GC中被清理)
例如,当前内存中有A~F一共6个对象,根对象a,b本身为栈上分配的局部变量,根对象a、b分别引用了对象A、B, 而B对象又引用了对象D,则内存回收前各对象的状态如下图所示:
标记过程
- 把所有的未对象都放到白色的集合中。
- 从根节点开始遍历对象,遍历到的白色对象从白色集合中放到灰色集合中。
- 遍历灰色集合中的对象,把灰色对象引用的白色集合的对象放入到灰色集合中,同时把遍历过的灰色集合中的对象放到黑色的集合中。
- 循环步骤3,直到灰色集合中没有对象。
注:在GC的过程中所有新分配的对象都会立刻变为黑色。
Stop The World
由于垃圾回收过程中需要控制住内存的变化,回收过程中指针传递会引起内存引用关系变化。Go的垃圾回收也会STW就是停掉所有的goroutine,专心做垃圾回收,待垃圾回收结束后再恢复goroutine。
垃圾回收优化
写屏障(Write Barrier)
STW目的是防止GC扫描时内存变化而停掉goroutine,Go在进行三色标记的时候并没有STW,而写屏障就是让goroutine与GC同时运行的手段。虽然写屏障不能完全消除STW,但是可以大大减少Mark Termination中STW的时间。写屏障类似一种开关,在GC的特定时机开启,开启后指针传递时会把指针标记,即本轮不回收,下次GC时再确定。
GC过程中新分配的内存会被立即标记,用的并不是写屏障技术,也即GC过程中分配的内存不会在本轮GC中回收。
举例解释:
如:用户代码可能会修改已经标记为黑色的对象,让它引用白色对象。
stack -> A.ref -> B ,A是从栈对象直接可达,将它标记为灰色。此时B是白色对象。假设这个时候用户代码执行: localRef = A.ref,A.ref = NULL, localRef是栈上面的一个黑色对象,前一行赋值语句使得它引用到B对象。后一行A.ref被置为空之后,A将不再引用到B。A是灰色但是不再引用到B了,B不会着色。localRef是黑色,处理完毕的对象,引用了B但是不会被再次处理。于是B将永远不再有机会被标记,它会被误当作垃圾清理掉!而写屏障会保证B不会被回收。
辅助GC(Mutator Assist)
为了防止内存分配过快,在GC执行过程中,如果goroutine需要分配内存,那么这个goroutine会参与一部分GC的工作,即帮助GC做一部分工作,这个机制叫作Mutator Assist。
GC的几个阶段:
Mark阶段该阶段又分为两个部分:
- Mark Prepare:初始化GC任务,包括开启写屏障(write barrier)和辅助GC(mutator assist),统计root对象的任务数量等,这个过程需要STW。
- GC Drains: 扫描所有root对象,包括全局指针和goroutine(G)栈上的指针(扫描对应G栈时需停止该G),将其加入标记队列(灰色队列),并循环处理灰色队列的对象,直到灰色队列为空。该过程后台并行执行。
Mark Termination阶段:该阶段主要是完成标记工作,重新扫描(re-scan)全局指针和栈。因为Mark和用户程序是并行的,所以在Mark过程中可能会有新的对象分配和指针赋值,这个时候就需要通过写屏障(write barrier)记录下来,re-scan 再检查一下,这个过程也是会STW的。
Sweep: 按照标记结果回收所有的白色对象,该过程后台并行执行。
Sweep Termination: 对未清扫的span进行清扫, 只有上一轮的GC的清扫工作完成才可以开始新一轮的GC。
注:GC过程有两次STW:第一次STW会准备根对象的扫描, 启动写屏障(Write Barrier)和辅助GC(mutator assist)。第二次STW会重新扫描部分根对象, 禁用写屏障(Write Barrier)和辅助GC(mutator assist)。
垃圾回收触发时机
内存分配量达到阀值触发GC
每次内存分配时都会检查当前内存分配量是否已达到阀值,如果达到阀值则立即启动GC。
1 | 阀值 = 上次GC内存分配量 * 内存增长率 |
内存增长率由环境变量GOGC
控制,默认为100,即每当内存扩大一倍时启动GC。
定期触发GC
默认情况下,最长2分钟触发一次GC,这个间隔在src/runtime/proc.go:forcegcperiod
变量中被声明:
1 | // forcegcperiod is the maximum time in nanoseconds between garbage |
手动触发
程序代码中也可以使用runtime.GC()
来手动触发GC。这主要用于GC性能测试和统计。
GC总时间
Tgc = Tseq + Tmark + Tsweep
( T 表示 time)
Tseq
表示是停止用户的 goroutine 和做一些准备活动(通常很小)需要的时间。Tmark
是堆标记时间,标记发生在所有用户 goroutine 停止时,因此可以显著地影响处理的延迟。Tsweep
是堆清除时间,清除通常与正常的程序并发运行,所以对延迟来说是不太关键的。
各版本垃圾回收器
go语言垃圾回收总体采用的是经典的mark and sweep算法。
- 1.3版本以前,go的垃圾回收算法都非常简陋,然后其性能也广被诟病:go runtime在一定条件下(内存超过阈值或定期如2min),暂停所有任务的执行,进行mark&sweep操作,操作完成后启动所有任务的执 行。在内存使用较多的场景下,go程序在进行垃圾回收时会发生非常明显的卡顿现象(Stop The World)。在对响应速度要求较高的后台服务进程中,这种延迟简直是不能忍受的!这个时期国内外很多在生产环境实践go语言的团队都或多或少踩过gc的 坑。当时解决这个问题比较常用的方法是尽快控制自动分配内存的内存数量以减少gc负荷,同时采用手动管理内存的方法处理需要大量及高频分配内存的场景。
- 1.3版本开始go team开始对gc性能进行持续的改进和优化,每个新版本的go发布时gc改进都成为大家备受关注的要点。1.3版本中,go runtime分离了mark和sweep操作,和以前一样,也是先暂停所有任务执行并启动mark,mark完成后马上就重新启动被暂停的任务了,而是 让sweep任务和普通协程任务一样并行的和其他任务一起执行。如果运行在多核处理器上,go会试图将gc任务放到单独的核心上运行而尽量不影响业务代码 的执行。go team自己的说法是减少了50%-70%的暂停时间。
- 1.4版本(当前最新稳定版本)对gc的性能改动并不多。1.4版本中runtime很多代码取代了原生c语言实现而采用了go语言实现,对 gc带来的一大改变是可以是实现精确的gc。c语言实现在gc时无法获取到内存的对象信息,因此无法准确区分普通变量和指针,只能将普通变量当做指针,如 果碰巧这个普通变量指向的空间有其他对象,那这个对象就不会被回收。而go语言实现是完全知道对象的类型信息,在标记时只会遍历指针指向的对象,这样就避免了C实现时的堆内存浪费(解决约10-30%)。
- 1.5版本go team对gc又进行了比较大的改进(1.4中已经埋下伏笔如write barrier的引入),官方的主要目标是减少延迟。go 1.5正在实现的垃圾回收器是“非分代的、非移动的、并发的、三色的标记清除垃圾收集器”。分代算法上文已经提及,是一种比较好的垃圾回收管理策略,然 1.5版本中并未考虑实现;我猜测的原因是步子不能迈太大,得逐步改进,go官方也表示会在1.6版本的gc优化中考虑。同时引入了上文介绍的三色标记 法,这种方法的mark操作是可以渐进执行的而不需每次都扫描整个内存空间,可以减少stop the world的时间。
方法逃逸
每当函数中申请新的对象,编译器会跟据该对象是否被函数外部引用来决定是否逃逸:
- 如果函数外部没有引用,则优先放到栈中;
- 如果函数外部存在引用,则必定放到堆中;
注意,对于函数外部没有引用的对象,也有可能放到堆中,比如内存过大超过栈的存储能力。
通过编译参数-gcflag=-m可以查看编译过程中的逃逸分析:
1 | go build -gcflags=-m |
根对象
根对象在垃圾回收的术语中又叫做根集合,它是垃圾回收器在标记过程时最先检查的对象,包括:
- 全局变量:程序在编译期就能确定的那些存在于程序整个生命周期的变量。
- 执行栈:每个 goroutine 都包含自己的执行栈,这些执行栈上包含栈上的变量及指向分配的堆内存区块的指针。
- 寄存器:寄存器的值可能表示一个指针,参与计算的这些指针可能指向某些赋值器分配的堆内存区块。
观察GC
GODEBUG=gctrace=1
1
2build ./main.go
GODEBUG=gctrace=1 ./maingo tool trace
debug.ReadGCStats
runtime.ReadMemStats