教程集 www.jiaochengji.com
教程集 >  Golang编程  >  golang教程  >  正文 年度最佳【golang】内存分配详解

年度最佳【golang】内存分配详解

发布时间:2022-03-10   编辑:jiaochengji.com
教程集为您提供年度最佳【golang】内存分配详解等资源,欢迎您收藏本站,我们将为您提供最新的年度最佳【golang】内存分配详解资源

这篇文章主要介绍Go内存分配和Go内存管理,会轻微涉及内存申请和释放,以及Go垃圾回收。

从非常宏观的角度看,Go的内存管理就是下图这个样子,我们今天主要关注其中标红的部分。

<span class="img-wrap"></span>

<blockquote>友情提醒:

文章有点长,建议先收藏,后阅读,绝对是学习内存管理的好资料。

</blockquote>

本文基于go1.11.2,不同版本Go的内存管理可能存在差别,比如1.9与1.11的mheap定义就是差别比较大的,后续看源码的时候,请注意你的go版本,但无论你用哪个go版本,这都是一个优秀的资料,因为内存管理的思想和框架始终未变。

Go这门语言抛弃了C/C 中的开发者管理内存的方式:主动申请与主动释放,增加了逃逸分析和GC,将开发者从内存管理中释放出来,让开发者有更多的精力去关注软件设计,而不是底层的内存问题。这是Go语言成为高生产力语言的原因之一。

我们不需要精通内存的管理,因为它确实很复杂,但掌握内存的管理,可以让你写出更高质量的代码,另外,还能助你定位Bug。

这篇文章采用层层递进的方式,依次会介绍关于存储的基本知识,Go内存管理的“前辈”TCMalloc,然后是Go的内存管理和分配,最后是总结。这么做的目的是,希望各位能通过全局的认识和思考,拥有更好的编码思维和架构思维。

最后,这不是一篇源码分析文章,因为Go源码分析的文章已经有很多了,这些源码文章能够帮助你去学习具体的工程实践和奇淫巧计了,文章的末尾会推荐一些优秀文章,如果你对内存感兴趣,建议每一篇都去看一下,挑出自己喜欢的,多花时间研究下。

<h2>1. 存储基础知识回顾</h2>

这部分我们简单回顾一下计算机存储体系、虚拟内存、栈和堆,以及堆内存的管理,这部分内容对理解和掌握Go内存管理比较重要,建议忘记或不熟悉的朋友不要跳过。

<h3>存储金字塔</h3>

<span class="img-wrap"></span>

这幅图表达了计算机的存储体系,从上至下依次是:

<ul><li>CPU寄存器</li><li>Cache</li><li>内存</li><li>硬盘等辅助存储设备</li><li>鼠标等外接设备</li></ul>

从上至下,访问速度越来越慢,访问时间越来越长。

你有没有思考过下面2个简单的问题,如果没有不妨想想:

<ol><li>如果CPU直接访问硬盘,CPU能充分利用吗?</li><li>如果CPU直接访问内存,CPU能充分利用吗?</li></ol>

CPU速度很快,但硬盘等持久存储很慢,如果CPU直接访问磁盘,磁盘可以拉低CPU的速度,机器整体性能就会低下,为了弥补这2个硬件之间的速率差异,所以在CPU和磁盘之间增加了比磁盘快很多的内存。

<span class="img-wrap"></span>

然而,CPU跟内存的速率也不是相同的,从上图可以看到,CPU的速率提高的很快(摩尔定律),然而内存速率增长的很慢,_虽然CPU的速率现在增加的很慢了,但是内存的速率也没增加多少,速率差距很大_,从1980年开始CPU和内存速率差距在不断拉大,为了弥补这2个硬件之间的速率差异,所以在CPU跟内存之间增加了比内存更快的Cache,Cache是内存数据的缓存,可以降低CPU访问内存的时间。

不要以为有了Cache就万事大吉了,CPU的速率还在不断增大,Cache也在不断改变,从最初的1级,到后来的2级,到当代的3级Cache,_(有兴趣看cache历史)_。

<span class="img-wrap"></span>

三级Cache分别是L1、L2、L3,它们的速率是三个不同的层级,L1速率最快,与CPU速率最接近,是RAM速率的100倍,L2速率就降到了RAM的25倍,L3的速率更靠近RAM的速率。

看到这了,你有没有Get到整个存储体系的分层设计自顶向下,速率越来越低,访问时间越来越长,从磁盘到CPU寄存器,上一层都可以看做是下一层的缓存。

看了分层设计,我们看一下内存,毕竟我们是介绍内存管理的文章。

<h3>虚拟内存</h3>

虚拟内存是当代操作系统必备的一项重要功能了,它向进程屏蔽了底层了RAM和磁盘,并向进程提供了远超物理内存大小的内存空间。我们看一下虚拟内存的分层设计

<span class="img-wrap"></span>

上图展示了某进程访问数据,当Cache没有命中的时候,访问虚拟内存获取数据的过程。

访问内存,实际访问的是虚拟内存,虚拟内存通过页表查看,当前要访问的虚拟内存地址,是否已经加载到了物理内存,如果已经在物理内存,则取物理内存数据,如果没有对应的物理内存,则从磁盘加载数据到物理内存,并把物理内存地址和虚拟内存地址更新到页表。

有没有Get到:物理内存就是磁盘存储缓存层

另外,在没有虚拟内存的时代,物理内存对所有进程是共享的,多进程同时访问同一个物理内存存在并发访问问题。引入虚拟内存后,每个进程都要各自的虚拟内存,内存的并发访问问题的粒度从多进程级别,可以降低到多线程级别

<h3>栈和堆</h3>

我们现在从虚拟内存,再进一层,看虚拟内存中的栈和堆,也就是进程对内存的管理。

<span class="img-wrap"></span>

上图展示了一个进程的虚拟内存划分,代码中使用的内存地址都是虚拟内存地址,而不是实际的物理内存地址。栈和堆只是虚拟内存上2块不同功能的内存区域:

<ul><li>栈在高地址,从高地址向低地址增长。</li><li>堆在低地址,从低地址向高地址增长。</li></ul>

栈和堆相比有这么几个好处

<ol><li>栈的内存管理简单,分配比堆上快。</li><li>栈的内存不需要回收,而堆需要,无论是主动free,还是被动的垃圾回收,这都需要花费额外的CPU。</li><li>栈上的内存有更好的局部性,堆上内存访问就不那么友好了,CPU访问的2块数据可能在不同的页上,CPU访问数据的时间可能就上去了。</li></ol><h3>堆内存管理</h3>

<span class="img-wrap"></span>

我们再进一层,当我们说内存管理的时候,主要是指堆内存的管理,因为栈的内存管理不需要程序去操心。这小节看下堆内存管理干的是啥,如上图所示主要是3部分:分配内存块,回收内存块和组织内存块

在一个最简单的内存管理中,堆内存最初会是一个完整的大块,即未分配内存,当来申请的时候,就会从未分配内存,分割出一个小内存块(block),然后用链表把所有内存块连接起来。需要一些信息描述每个内存块的基本信息,比如大小(size)、是否使用中(used)和下一个内存块的地址(next),内存块实际数据存储在data中。

<span class="img-wrap"></span>

一个内存块包含了3类信息,如下图所示,元数据、用户数据和对齐字段,内存对齐是为了提高访问效率。下图申请5Byte内存的时候,就需要进行内存对齐。

<span class="img-wrap"></span>

释放内存实质是把使用的内存块从链表中取出来,然后标记为未使用,当分配内存块的时候,可以从未使用内存块中有先查找大小相近的内存块,如果找不到,再从未分配的内存中分配内存。

上面这个简单的设计中还没考虑内存碎片的问题,因为随着内存不断的申请和释放,内存上会存在大量的碎片,降低内存的使用率。为了解决内存碎片,可以将2个连续的未使用的内存块合并,减少碎片。

以上就是内存管理的基本思路,关于基本的内存管理,想了解更多,可以阅读这篇文章《Writing a Memory Allocator》,本节的3张图片也是来自这片文章。

<h2>2. TCMalloc</h2>

TCMalloc是Thread Cache Malloc的简称,是Go内存管理的起源,Go的内存管理是借鉴了TCMalloc,随着Go的迭代,Go的内存管理与TCMalloc不一致地方在不断扩大,但其主要思想、原理和概念都是和TCMalloc一致的,如果跳过TCMalloc直接去看Go的内存管理,也许你会似懂非懂。

掌握TCMalloc的理念,_无需去关注过多的源码细节_,就可以为掌握Go的内存管理打好基础,基础打好了,后面知识才扎实。

在Linux里,其实有不少的内存管理库,比如glibc的ptmalloc,FreeBSD的jemalloc,Google的tcmalloc等等,为何会出现这么多的内存管理库?本质都是在多线程编程下,追求更高内存管理效率:更快的分配是主要目的。

那如何更快的分配内存?

我们前面提到:

<blockquote>引入虚拟内存后,让内存的并发访问问题的粒度从多进程级别,降低到多线程级别。</blockquote>

这是更快分配内存的第一个层次

同一进程的所有线程共享相同的内存空间,他们申请内存时需要加锁,如果不加锁就存在同一块内存被2个线程同时访问的问题。

TCMalloc的做法是什么呢?为每个线程预分配一块缓存,线程申请小内存时,可以从缓存分配内存,这样有2个好处:

<ol><li>为线程预分配缓存需要进行1次系统调用,后续线程申请小内存时,从缓存分配,都是在用户态执行,没有系统调用,缩短了内存总体的分配和释放时间,这是快速分配内存的第二个层次。</li><li>多个线程同时申请小内存时,从各自的缓存分配,访问的是不同的地址空间,无需加锁,把内存并发访问的粒度进一步降低了,这是快速分配内存的第三个层次。</li></ol><h3>基本原理</h3>

下面就简单介绍下TCMalloc,细致程度够我们理解Go的内存管理即可。

<blockquote>声明:我没有研究过TCMalloc,以下介绍根据TCMalloc官方资料和其他博主资料总结而来,错误之处请朋友告知我。</blockquote>

<span class="img-wrap"></span>

结合上图,介绍TCMalloc的几个重要概念:

<ol><li>Page:操作系统对内存管理以页为单位,TCMalloc也是这样,只不过TCMalloc里的Page大小与操作系统里的大小并不一定相等,而是倍数关系。《TCMalloc解密》里称x64下Page大小是8KB。</li><li>Span:一组连续的Page被称为Span,比如可以有2个页大小的Span,也可以有16页大小的Span,Span比Page高一个层级,是为了方便管理一定大小的内存区域,Span是TCMalloc中内存管理的基本单位。</li><li>ThreadCache:每个线程各自的Cache,一个Cache包含多个空闲内存块链表,每个链表连接的都是内存块,同一个链表上内存块的大小是相同的,也可以说按内存块大小,给内存块分了个类,这样可以根据申请的内存大小,快速从合适的链表选择空闲内存块。由于每个线程有自己的ThreadCache,所以ThreadCache访问是无锁的。</li><li>CentralCache:是所有线程共享的缓存,也是保存的空闲内存块链表,链表的数量与ThreadCache中链表数量相同,当ThreadCache内存块不足时,可以从CentralCache取,当ThreadCache内存块多时,可以放回CentralCache。由于CentralCache是共享的,所以它的访问是要加锁的。</li><li>PageHeap:PageHeap是堆内存的抽象,PageHeap存的也是若干链表,链表保存的是Span,当CentralCache没有内存的时,会从PageHeap取,把1个Span拆成若干内存块,添加到对应大小的链表中,当CentralCache内存多的时候,会放回PageHeap。如下图,分别是1页Page的Span链表,2页Page的Span链表等,最后是large span set,这个是用来保存中大对象的。毫无疑问,PageHeap也是要加锁的。</li></ol>

<span class="img-wrap"></span>

上文提到了小、中、大对象,Go内存管理中也有类似的概念,我们瞄一眼TCMalloc的定义:

<ol><li>小对象大小:0~256KB</li><li>中对象大小:257~1MB</li><li>大对象大小:>1MB</li></ol>

小对象的分配流程:ThreadCache -> CentralCache -> HeapPage,大部分时候,ThreadCache缓存都是足够的,不需要去访问CentralCache和HeapPage,无锁分配加无系统调用,分配效率是非常高的。

中对象分配流程:直接在PageHeap中选择适当的大小即可,128 Page的Span所保存的最大内存就是1MB。

大对象分配流程:从large span set选择合适数量的页面组成span,用来存储数据。

通过本节的介绍,你应当对TCMalloc主要思想有一定了解了,我建议再回顾一下上面的内容。

<em>本节图片皆来自《TCMalloc解密》,图片版权归原作者所有。</em>

<h3>精彩文章推荐</h3>

本文对于TCMalloc的介绍并不多,重要的是3个快速分配内存的层次,如果想了解更多,可阅读下面文章。

<ol><li>TCMalloc</li></ol>

必读,通过这篇你能掌握TCMalloc的原理和性能,对掌握Go的内存管理有非常大的帮助,虽然如今Go的内存管理与TCMalloc已经相差很大,但是,这是Go内存管理的起源和“大道”,这篇文章顶看十几篇Go内存管理的文章。

<ol><li>TCMalloc解密</li></ol>

可选异常详细,包含大量精美图片,看完得花小时级别,理解就需要更多时间了,看完这篇不需要看其他TCMalloc的文章了。

<ol><li>TCMalloc介绍</li></ol>

可选,算是TCMalloc的文档的中文版,多数是从英文版翻译过来的,如果你英文不好,看看。

<h2>3. Go内存管理</h2>

前面铺垫了那么多,终于到了本文核心的地方。前面的铺垫不是不重要,相反它们很重要,Go语言内存管理源自前面的基础知识和内存管理思维,如果你跳过了前面的内容,建议你回头看一看,它可以帮助你更好的掌握Go内存管理。

前文提到Go内存管理源自TCMalloc,但它比TCMalloc还多了2件东西:逃逸分析和垃圾回收,这是2项提高生产力的绝佳武器。

这一大章节,我们先介绍Go内存管理和Go内存分配,最后涉及一点垃圾回收和内存释放。

<h3>Go内存管理的基本概念</h3>

前面计算机基础知识回顾,是一种自上而下,从宏观到微观的介绍方式,把目光引入到今天的主题。

Go内存管理的许多概念在TCMalloc中已经有了,含义是相同的,只是名字有一些变化。先给大家上一幅宏观的图,借助图一起来介绍。

<span class="img-wrap"></span>

<h4>Page</h4>

与TCMalloc中的Page相同,x64下1个Page的大小是8KB。上图的最下方,1个浅蓝色的长方形代表1个Page。

<h4>Span</h4>

与TCMalloc中的Span相同,Span是内存管理的基本单位,代码中为<code>mspan</code>,一组连续的Page组成1个Span,所以上图一组连续的浅蓝色长方形代表的是一组Page组成的1个Span,另外,1个淡紫色长方形为1个Span。

<h4>mcache</h4>

mcache与TCMalloc中的ThreadCache类似,mcache保存的是各种大小的Span,并按Span class分类,小对象直接从mcache分配内存,它起到了缓存的作用,并且可以无锁访问

但mcache与ThreadCache也有不同点,TCMalloc中是每个线程1个ThreadCache,Go中是每个P拥有1个mcache,因为在Go程序中,当前最多有GOMAXPROCS个线程在用户态运行,所以最多需要GOMAXPROCS个mcache就可以保证各线程对mcache的无锁访问,线程的运行又是与P绑定的,把mcache交给P刚刚好。

<h4>mcentral</h4>

mcentral与TCMalloc中的CentralCache类似,是所有线程共享的缓存,需要加锁访问,它按Span class对Span分类,串联成链表,当mcache的某个级别Span的内存被分配光时,它会向mcentral申请1个当前级别的Span。

但mcentral与CentralCache也有不同点,CentralCache是每个级别的Span有1个链表,mcache是每个级别的Span有2个链表,这和mcache申请内存有关,稍后我们再解释。

<h4>mheap</h4>

mheap与TCMalloc中的PageHeap类似,它是堆内存的抽象,把从OS申请出的内存页组织成Span,并保存起来。当mcentral的Span不够用时会向mheap申请,mheap的Span不够用时会向OS申请,向OS的内存申请是按页来的,然后把申请来的内存页生成Span组织起来,同样也是需要加锁访问的。

但mheap与PageHeap也有不同点:mheap把Span组织成了树结构,而不是链表,并且还是2棵树,然后把Span分配到heapArena进行管理,它包含地址映射和span是否包含指针等位图,这样做的主要原因是为了更高效的利用内存:分配、回收和再利用。

<h4>大小转换</h4>

除了以上内存块组织概念,还有几个重要的大小概念,一定要拿出来讲一下,不要忽视他们的重要性,他们是内存分配、组织和地址转换的基础。

<span class="img-wrap"></span>

<ol><li>object size:代码里简称<code>size</code>,指申请内存的对象大小。</li><li>size class:代码里简称<code>class</code>,它是size的级别,相当于把size归类到一定大小的区间段,比如size[1,8]属于size class 1,size(8,16]属于size class 2。</li><li>span class:指span的级别,但span class的大小与span的大小并没有正比关系。span class主要用来和size class做对应,1个size class对应2个span class,2个span class的span大小相同,只是功能不同,1个用来存放包含指针的对象,一个用来存放不包含指针的对象,不包含指针对象的Span就无需GC扫描了。</li><li>num of page:代码里简称<code>npage</code>,代表Page的数量,其实就是Span包含的页数,用来分配内存。</li></ol>

在介绍这几个大小之间的换算前,我们得先看下图这个表,这个表决定了映射关系。

最上面2行是我手动加的,前3列分别是size class,object size和span size,根据这3列做size、size class和num of page之间的转换。

仔细看一遍这个表,再向下看转换是如何实现的。

<span class="img-wrap"></span>

在Go内存大小转换那幅图中已经标记各大小之间的转换,分别是数组:<code>class_to_size</code>,<code>size_to_class*</code>和<code>class_to_allocnpages</code>,这3个数组内容,就是跟上表的映射关系匹配的。比如<code>class_to_size</code>,从上表看class 1对应的保存对象大小为8,所以<code>class_to_size[1]=8</code>,span大小为8192Byte,即8KB,为1页,所以<code>class_to_allocnpages[1]=1</code>。

<span class="img-wrap"></span>

为何不使用函数计算各种转换,而是写成数组?

有1个很重要的原因:空间换时间。你如果仔细观察了,上表中的转换,并不能通过简单的公式进行转换,比如size和size class的关系,并不是正比的。这些数据是使用较复杂的公式计算出来的,公式在<code>makesizeclass.go</code>中,这其中存在指数运算与for循环,造成每次大小转换的时间复杂度为O(N*2^N)。另外,对一个程序而言,内存的申请和管理操作是很多的,如果不能快速完成,就是非常的低效。把以上大小转换写死到数组里,做到了把大小转换的时间复杂度直接降到O(1)。

<h4>其他转换表字段</h4>

第4列num of objects代表是当前size class级别的Span可以保存多少对象数量,第5列tail waste是<code>span%obj</code>计算的结果,因为span的大小并不一定是对象大小的整数倍。

最后一列max waste代表最大浪费的内存百分比,计算方法在<code>printComment</code>函数中:

<pre><code>func printComment(w io.Writer, classes []class) { fmt.Fprintf(w, "// %-5s %-9s %-10s %-7s %-10s %-9s\n", "class", "bytes/obj", "bytes/span", "objects", "tail waste", "max waste") prevSize := 0 for i, c := range classes { if i == 0 { continue } spanSize := c.npages * pageSize objects := spanSize / c.size tailWaste := spanSize - c.size*(spanSize/c.size) maxWaste := float64((c.size-prevSize-1)*objects tailWaste) / float64(spanSize) prevSize = c.size fmt.Fprintf(w, "// ]

您可能感兴趣的文章:
为什么要学 Go
年度最佳【golang】内存分配详解
Go语言 几个亟待解决的Go语言问题
go 手动释放内存_深入理解golang:内存分配原理
基于Golang协程实现流量统计系统
Go 开发关键技术指南 | 为什么你要选择 Go?(内含超全知识大图)
Golang内存分配实现分析
php和golang怎么配合
佳能SX60 HS和尼康D7100性能对比解析
Golang简介

[关闭]
~ ~