“ golang的内存分析工具怎么用?内存和回收原理,这一篇就够了”
1. 目录
2. 由一个问题展开
3. 名字说明
4. 内存怎么采样?
4.1 编译期间逃逸分析
4.2 采样的简单实现
4.3 内存采样的时机
4.4 内存采样的入口
4.5 内存采样的信息
4.6 golang的类型反射
5. 内存分配
5.1 C语言你分配和释放内存怎么做?
5.2 内存分配设计考虑的几个问题
5.3 golang的内存分配
6. 内存回收
6.1 golang协程抢占执行
6.2 STW是怎么回事?
6.3 垃圾回收要求
6.4 golang版本迭代历史
6.5 GC触发条件
6.6 三色定义
6.7 GC流程
6.8 写屏障
6.9 内存可见性
6.10 注意问题
golang从语言级别,就提供了完整的采样和分析的机制。大家经常使用 pprof 分析内存占用。
但是不清楚怎么实现?不清楚怎么看指标?不清楚 flat,cum的区别?我们就从这个问题展开。
内存分析的时候,有四个输入选项:
1 . alloc_objects : 历史总分配的累计
2 . alloc_space :历史总分配累计
3 . inuse_objects:当前正在使用的对象数
a . 堆上分配出来,业务正在使用的,也包括业务没有使用但是还没有垃圾回收掉的对象。
4 . inuse_space:当前正在使用的内存
两个输出选项:
思考几个问题:
1 . 上面说的对象是什么概念?
2 . 经常使用内存分析,这个内存分析是否是精确的?性能消耗大不大
3 . 为啥显示的是堆栈?不是说分配的对象吗?为啥不直接显示分配的对象结构名?
说明下,golang pprof是分析从堆上分配的内存。golang的内存在堆上,还是在栈上?这个不是我们决定的,就算你调用new这个关键字,也不一定是在堆上分配。
逃逸分析是golang的一个非常重要的一个点。对于内存分配,垃圾回收的设计都有非常重要的影响。
采样的实现非常简单。简单描述流程:
累计分配:就是alloc 当前在用 inuse:就是 alloc-free
采样的时机说3个点:
但是注意一点:并不是每一次分配内存都会被采样。也就是说这里其实是有个权衡的。现在是每满512KB才会采样一次。这里的考虑是性能和采样效果的权衡。因为采样是要耗费性能的,是要取堆栈的。
怎么理解?举个例子
理想情况下(不考虑其他任何影响):
那么有人会想,这样岂不是会漏掉了很多内存?统计还能用来排查问题吗?
这个是性能和效果的一个考虑,一般来讲,我们是用pprof分析内存占用的时候,在整个golang程序跑起来后,时时刻刻都在分配释放内存,每累计分配512KB,打点一次。虽然会漏掉一些内存分配释放,但是对每个结构都是公平的。如果有一个内存泄露分配行为,那么累计下来一定会被抓住的,并且是非常容易被抓住。
内存采样的入口,这个非常简单理解。肯定是一个在分配内存的函数位置,一个是释放内存的位置。这里要特意提下上下文环境。因为golang是垃圾回收类型的语言,内存分配是完全交由golang自己管理,自己不能管理内存。
两个入口函数:
这两个是配套使用的采样打点函数。而且一定是配套的。简单说:
mallocgc
分配内存,如果到了采样点,那么会调用 mProf_Malloc
采样。这里问你的是,golang采样是采样啥?类型信息?这里也说过一点,内存这里和类型系统是没啥关系的。这里采样的是分配栈,也就是分配路径。
看个例子:
大家可以先猜下,我们看alloc_space。这个内存会是怎么累计到的。实际统计如下:
和大家猜的一样吗?这些是怎么看。
首先说几个结论:
重点提示:这个要理解这个,首先要知道,内存采样的是什么,内存采样的是分配栈。
解释说明
(图中140M我们当150M看哈,这里采样少了第一次,细节原因可以看代码,这里提一下,不做阐述。):
1 . main函数里,A函数调用了5次,B函数 5次,C函数5次。其中B会调用A,C会调用B。
2 . 调用一次A会分配10M内存,调用一次B会分配20M,调用一次C会分配30M。总累计分配内存是300M
3 . A函数实际调用次数是 15次;这个和flat的值是一致的:150M
a. (A) * 5
b. (B -> A) * 5
c. (C -> B -> A) * 5
4 . B函数函数实际调用10次;这个和flat的值也是一致的:100M
a . B * 5
b . (C -> B) * 5
5 . C函数5次:这个和flat的值是一致的:50M
a . C * 5
6 . main函数300M,也是一致的。
图示
记住一句话:采样是记录分配堆栈,而不是类型信息。
思考几个问题:
先说结论:golang里面,内存块是没有携带对象类型信息的,这个跟C是一样的。但是golang又有反射,golang的反射一定要基于interface使用。这个要仔细理解下。
因为,golang里面interface的结构变量,是会记录type类型的。
反射定律一:反射一定是基于接口的。是从接口到反射类型。
反射定律二:反射一定是基于接口的。是从反射类型到接口。
还是那句话,golang的反射一定是依赖接口类型的,一定是经过接口倒腾过的。
因为当前接口这个类型对应了两个内部结构:struct iface
,struct eface
,这两个结构都是会存储type类型。以后的一切都是基于这个类型的。
思考一个问题,在C语言里,我们分配内存:
分配内存的时候,传入大小,拿到一个指针。
ptr = malloc(1024);
释放内存的时候,直接传入ptr,没有任何其他参数:
free (ptr);
释放的时候,怎么确定释放哪些位置?如果要你自己实现,有很多简单的思路,说一个最简单的:分配的时候,不止分配1024字节,还分配了其他的信息,带head了。
这种分配方式有什么问题:
1 . 性能 a . 局部性
2 . 碎片率 a . 内部碎片率 b . 外部碎片率
golang大方向的考虑就是基于局部性和碎片率来考虑的。使用的是和tcmalloc一致的设计。
首先,内存块是不带类型信息的。像我们在C语言里面,有时候实现的简单的内存池,在不考虑一些开销的时候,会把业务类型放到meta信息里,为的是排查问题方便。golang内存管理作为一个通用模块,不会这么搞。
很多时候,你查golang的资料,会看到这张图:
这张图有几个信息比较重要:
1 . 为什么spans区域是512M,bitmap区是16G,arena是512G?先不要纠结值,我们先说这个比例关系: a . spans区域,一个指针大小(8Byte)对应arena的一个page(8KB),倍数是1024 b . bitmap区域,一个字节(8bit)对应arena的32Bytes,倍数是32倍
2 . 我们给用户分配的内存就是arena区域的内存,spans区,bitmap区均为其他用途的元数据信息。
a . bitmap这个实现我们这次不谈,不同通过这个你得知道一点:并不是所有的内存空间都会扫描一把,是有挑选判断的。
b . spans区域是一般用来根据一个内存地址查询mspan结构的。调用函数:spanOf。
c . bitmap是用来辅助垃圾回收用的区域。有这个bitmap信息可以提高回收效率和精度。注意一点,这个不是标识object是否分配的位图,标识是否分配object的问题是mspan.allocBits
结构。这个可以理解为提高垃圾回收效率的实现。
注意几个点:
1 . 很多文章都提到golang内存512GB这个事情。512GB说的是内存虚拟地址空间的限制,是最大能力,是最大的规划利用。golang之前最大可以使用的内存地址空间。
2 . golang1.11 之后已经没有512GB的限制了。基本上和系统的虚拟地址空间一致
a . 这个比例还是一样的,1:1024,1:32
3 . 就算golang1.11之前,也不是说golang的程序上来就向系统申请这么大块虚拟地址。也是每64M的申请,管理对象单元是heapArea结构。
4 . 三个区域看着连续结在一起,但是其实不是连续的地址。
a . 实际的实现中都是以64M(heapArena)的小单位进行的。
物理偏向概念:
逻辑偏向概念:
管理结构层次概念:
mcache:每个M上的,管理内存用的。我们都知道GMP架构,每个M都有自己的内存cache管理,这样是为了局部性。只是一个cache管理。mcentral:mheap结构所有,也只是一个cache管理,但是是为所有人服务的。mheap:是真正负责分配和释放物理内存的。
这个思路很简单,就是设计成局部性的一个层次设计。
mcache由于只归属自己的M,span一旦在这个结构管理下,其他人是不可见,不会去操作的。只有这个m会操作。所以自然就不需要加锁。
mcentral是所有人可见的。所以操作自然要互斥,这个的作用也是一个cache的统一管理。
这个是负责真实内存分配和释放的的一个结构。
golang的内存设计目标:碎片率平均12.5%左右。
说明:
1 . tail wast实际是浪费的外部碎片
a . 比如说,第一种size,8字节。一个page 8KB,8字节刚好对齐。外部碎片为0.
2 . max waste说的是最大的内部碎片率
a . 怎么算的?每一个放进该span的对象大小都是最小值的情况
b . 比如说,第一种size,8字节。最小的对象是1字节,浪费7字节,最大碎片率为 1-1/8 = 87.5%
怎么的出来的这些值?经验值吧,可能。
首先,golang没有真正的抢占。golang调度单位为协程,所谓抢占,也就是强行剥夺执行权。但是有一点,golang本质上是非抢占的,不像操作系统那样,有时钟中断和时间片的概念。golang虽然里面是有一个抢占的概念,但是注意了,这个抢占是建议性质的抢占,也就是说,如果有协程不听话,那是没有办法的,实现抢占的效果是要对方协程自己配合的。
一句话:系统想让某个goroutine自己放弃执行权,会给这个协程设置一个魔数,协程在切调度,或者其他时机检查到了的时候,会感知到这一个行为。
当前的抢占实现是:
所以,在golang里面,只要有函数调用,就会有感知抢占的时机。stw就是基于这个实现的。
思考一个问题:
如果有一个猥琐的函数:非常耗时,一直在做cpu操作,并且完全没有函数调用。这种情况下,golang是没有一点办法的。那么这种情况会影响到整个程序的能力。
所以,我们平时写函数,一定要短小精悍,功能拆分合理。
STW:stop the world,也就是说暂停说由协程的调度和执行。stw是怎么实现?stw的基础就是上面提到的抢占实现。stw调用的目的是为了让整个程序(赋值器停止),那么就需要剥夺每一个协程的执行。
stw在垃圾回收的几个关键操作里是需要的,比如开启垃圾回收,需要stw,做好准备工作。如果stw的时候,出现了猥琐的函数,那么会导致整个系统的能力降低。因为大家都在等你一个人。
黑色对象不允许指向白色对象。
黑色对象可以指向白色对象,但是前提是,该白色对象一定是处于灰色保护链中。
这里不详细阐述了。贴一张go1.8之前的图:
当下GC大概分为四个阶段:
如果标记和回收不用和应用程序并发,在标记和回收整个过程直接stw,那么就简单了。golang为了提供低时延,就必须让赋值器和回收器并发起来。但是在并发的过程中,赋值器和回收器对于引用树的理解就会出现不一致,这里就一定要配合写屏障技术。
写屏障技术,是动态捕捉写操作,维持回收正确性的技术。写屏障就是一段 hook 代码,编译期间生成,运行期间跟进情况会调用到 hook 的代码段,也就是写屏障的代码;
下面系统整体的讨论下写屏障的技术。
(Dijkstra '78)
writePointer ( slot, ptr ):
// 无脑保护插入的新值
shade ( ptr )
*slot = ptr
这个是另外一个通用的屏障技术。这个维护的是强三色不变式来保证正确性,保证黑色对象一定不能指向白色对象。golang使用的是这个屏障,插入屏障。按照道理,是几乎完全不需要stw的。但是golang有一个处理,由于栈上面使用屏障会导致处理非常复杂,并且开销会非常大。所以当前golang只针对堆上的写操作做了屏障。
那么就会带来一个问题:所以当一轮扫描完了之后,在标记结束的阶段,还需要重新扫描一遍goroutine栈,并且栈引用到的所有对象也要扫描。因为goroutine有可能直接指向了白色对象。在扫描goroutine栈过程中,需要stw。这个也是go1.8以前的一个非常大的延迟来源。
(开始的时候,stw扫描栈,得到灰色对象)
图表演示
堆上路径赋值:
step1:堆上对象赋值的时候,插入写屏障,保护强三色不变式
step2:删除的时候,没啥问题
栈上对象赋值:
step3:栈上对象赋值的时候,没有写屏障。白色对象直接被黑色对象引用。
step4:删除灰色保护路径。
所以才需要在mark terminato阶段,重新扫描栈。
(Yuasa '90)
writePointer ( slot, ptr ):
// 删除之前,保护原先白色或者灰色指向的数据块
if ( isGery ( slot ) || isWhite ( slot ) )
shade ( *slot )
*slot = ptr
这个是通用的一种写屏障技术。golang并没有实现,而是实现了插入写屏障。原因就在于:这个在垃圾回收之前,必须做一个快照扫描,这个就会对用户时延有比较严重的影响。下面详述。
主要流程:
2 . 扫描堆对象的时候,可以和应用程序并发的。此后根一直保持黑色(黑色赋值器),不用再扫描栈。 3 . 对象被删除的时候,删除写屏障会捕捉到。置灰。 a . 上面的伪代码显示有条件,其实第一版的时候是没有条件的。 b . 这里加上条件是为了回收精度:当上游之前是白色或者灰色才需要把这个置灰色。如果是黑?那么一定是处于灰色保护状态,因为这个是前提(理解这个非常重要)。
(开始的时候,stw扫描栈,得到灰色对象)
图表演示
初始扫描快照后:
step1: 赋值。这里赋值是允许的,虽然是破坏了强三色不变式。但是还是符合弱三色不变式。
step2:删除。这里就拦截了,必须置灰色。保证弱三色不变式。
回收精度:
删除写屏障的精度比插入写屏障的精度更低。删除的即使是最后一个指针,也会保留到下一轮,属于一个浮动垃圾。这个比插入屏障精度还低。因为,对于插入屏障所保留的对象,回收器至少可以确定曾在其中执行了某些回收相关的操作(获取或写入对象的引用),但删除屏障所保留的对象却不一定被赋值器操作过。
为什么需要打快照?
删除写屏障,又叫快照屏障增量技术(或者说,一定要配合这个来做)。
golang为啥没有用这个?
1 . 一个是精度问题,这个精度要比插入写屏障低; 2 . 考虑goroutine可能非常多,不适合上来就stw,扫描所有的内存栈。这个适合小内存的场景。 a . 思考一个问题:这个和混合写屏障有没有区别?还是有区别的,这里是要锁整个栈,混合写屏障是并发的,每次只需要锁单个栈。
混合屏障是结合插入屏障和删除屏障。
伪代码:
writePointer (slot, ptr) :
// 保护原来的(被删除的)
shade ( *slot )
if current stack is grey:
// 如果对象为灰色,则还需要保护新指向的对象
shade ( ptr )
*slot = ptr
(开始的时候,stw扫描栈,得到黑色对象)
golang实际情况:
伪代码如上。但是这里提出来一点,golang根本不是和伪代码说的这样。没有做条件判断,所以现在的回收精度很低。这个算是一个TodoList。
注意:使用了混合屏障,还是针对堆上的,栈上对象写入还是没有barrier。golang之前只使用插入屏障,关键在于栈对象没有,导致栈上黑对象可能指向白对象。所以要rescan。因为如果不rescan,而且又破坏了弱三色不变式(没有处于灰色保护链中),那么就丢数据了。
混合屏障,就是结合删除屏障,保护这一个前提,代价就是进一步降低回收精度。
图表示例:
混合屏障就是要解决:栈指向白色对象,stw重新扫描栈的问题。
step1:赋值白对象到黑对象引用,这个不会阻止这个,也不会有写屏障。就是一个正常的赋值。
step2:删除指针的时候,意图破坏弱三色不变式的时候,写屏障就会把这个对象置灰色。
问题一:如果有个还会想?由于栈上没有写屏障,这个删除的对象式根指向的呢?如果存在以下场景?
step1:堆上的白色对象引用赋值给黑色栈对象。
step2:如果删除指针,岂不是连弱三色不变式也破坏了?
这个怎么办呢?
答案是:其实根本就不可能出现这个场景的引用图。第一个图就不会出现。因为虽然没有stw,但是扫描某个g的时候,这个g是暂停的。相当于这个g栈是一个快照状态。
混合写屏障的栈,要么全黑,要么全白(单个栈)
那么这个暂停g这个是怎么做到的?
问题二:如果是多个栈呢,那么就不是原子的快照了。比如下图?那么就可能导致这种情况。
如果说A和前面的黑色对象不属于同一个g栈。那么是否可能会导致这种场景出现?分析下:
答案是:这里的关键在于第三步。G1的栈对象接受赋值,这个并不是凭空来的。那么一定是G1自己找来的,可达的对象。这个是一个前提。所以,如果能接受这样的赋值,那么这个白色对象一定是处于G1栈的灰色保护下,因为G1一定是可访问这个对象的。否则,根本就不能完成这个赋值。
混合写屏障的场景,白色对象处于灰色保护下,但是只由堆上的灰色对象保护。注意理解这点;
屏障生成示例:
runtime.gcWriteBarrier :
1 . 计算出wbBuf的next位置 2 . record ptr a . ptr指针放到wbBuf队列中。
3 . 把 *(slot)
存到wbBuf队列中 ( 置灰色,flush了就是灰色 )
a . shade( *slot )
4 . 如果队列没有满
a . 那么就赋值写(*(slot) = ptr
); 则返回
5 . 如果队列满了,那么跳到flush
a . wbBufFlush就是把wbBufFlush里的元属flush到灰色队列中。
b . 调用完了 runtime.wbBufFlush 处理之后,返回赋值ret(*(slot) = ptr
)
这么看起来,就不存在 判断stack是否为灰色的条件?
writePointer(slot, ptr):
shade(*slot)
shade(ptr)
*slot = ptr
优点:
shade(*slot)
这个指针,就保护了一条路径:这个来路一定是灰色的,下游的白色都会收到保护。并且,我们知道,栈上得到的白色指针一定是可达的,那么一定是有堆上灰色对象保护的。5 . 任何一个白色对象(被黑色栈对象指向的)一定是被堆上灰色对象保护可达的。
缺点:
这种屏障会导致比较多的屏障,两倍。所以针对这个考虑权衡,会加一个stack条件判断,就是我们看到的混合屏障的样子。
提一下golang的内存可见性。在c里面,如果是在多线程环境,并发操作一些变量,需要考虑一些可见性的问题。比如赋值一个变量,这个线程还有可能在寄存器里没有刷下去,或者编译器帮你优化到寄存器中,不去内存读。所以有一个volatile关键字,强制去内存读。
golang是否有这个内存可见性的问题?
一句话,golang里面,只要你保证顺序性,那么内存一致性就没有问题。具体可以搜索happen-before的机制。
千万不要尝试绕过golang的类型系统。golang官方在提到uintptr类型的时候,都说不要产生uintptr的临时变量,因为很有可能会导致gc的错误回收(这个做过一个简单的验证,1.13本的uintptr类型是不作为指针标记的)。
举一个极端的例子,如果你new了一个对象,然后把这个对象的地址保存在8个不连续的byte类型里,那就等着coredump吧。
比如现在你分配一个大内存出来(1G的[ ]byte类型空间)。这是一个大内存块。并且golang没有任何标识这个地方标识指针。
// 分配一个大内存数组(1GB),数组元素是byte。那么自然每个元素都是不含指针的。
begin := make([]byte, 1024*1024*1024)
那么扫描是不会扫描这个内部的。
内存池分配器接口:func (ac *Allocator) Alloc (size int) unsafe.Pointer
用来分配对象,使用可能会导致莫名其妙的内存错误。假设用来分配对象T:
type T struct {
s *S
}
t := (*T) (ac.Alloc(sizeT))
t.s = &S{}
T对象是从一个大数组里划出来的,垃圾回收其实并不知道T这个对象。不过只要1G内存池本身不被回收,T对象还是安全的。但是T里面的S,是golang走类型系统分配出来的,就会有问题。
假设发生垃圾回收了,GC会认为这个内存空间是一个Byte数组,而不会扫描,那么t.s指向的对象认为未被任何对象引用到,它会被清理掉。最后t.s就成了一个悬挂指针。
golang里面实现内存分配器,适用处理两种情况:
其实,没必要自己搞通用内存池。一旦绕过了golang的类型系统,就会出现坑。
Copyright© 2013-2020
All Rights Reserved 京ICP备2023019179号-8