Go语言成为高生产力语言的原因之一自己管理内存:Go抛弃了C/C++中的开发者管理内存的方式,实现了主动申请与主动释放管理,增加了逃逸分析和GC,将开发者从内存管理中释放出来,让开发者有更多的精力去关注软件设计,而不是底层的内存问题。
我们无须精通复杂的内存管理,但掌握内存的管理,可以让你写出更高质量的代码和高效快速还定位问题,这是要求具备本质思维,从问题表象逐层深入本质的过程。
首先声明这篇原文并非完全原创,是对网上相关文章进行思考和总结:
https://zhuanlan.zhihu.com/p/76802887
http://goog-perftools.sourceforge.net/doc/tcmalloc.html学习的本质是知识搬迁,通过不断的探索、实践和思考总结来增强认知能力。
一、内存和内存管理
1、内存:计算机的存储结构
计算机系统中有几类存储设备:cache、内存、外存。从上至下的访问速度越来越慢。
2、虚拟内存和物理内存
具体可以了解《Linux内存管理》https://guisu.blog.csdn.net/article/details/6152921
1)物理内存:
真实存在的插在主板内存槽上的内存条的容量的大小.
物理内存是由若干个存储单元组成的,每个存储单元有一个编号,这种编号可唯一标识一个存储单元,称为内存地址(或物理地址)。我们可以把内存看成一个从0字节一直到内存最大容量逐字节编号的存储单元数组,即每个存储单元与内存地址的编号相对应。
2) 虚拟内存(Virtual memory)(也叫虚拟存储器)
虚拟内存地址就是每个进程可以直接寻址的地址空间,不受其他进程干扰。每个指令或数据单元都在这个虚拟空间中拥有确定的地址。 虚拟内存就是进程中的目标代码,数据等虚拟地址组成的虚拟空间
虚拟内存不考虑物理内存的大小和信息存放的实际位置,只规定进程中相互关联信息的相对位置。每个进程都拥有自己的虚拟内存,且虚拟内存的大小由处理机的地址结构和寻址方式决定。如直接寻址,如果cpu的有效地址长度为16位,则其寻址范围0 -64k。32位机器可以直接寻址4G空间,意思是每个应用程序都有4G内存空间可用。
虚拟内存一般分为以下4大块:
1,栈空间:特点是内存地址连续,先进后出,里面放了局部变量、函数形参、自动变量。编译器自动分配和释放进行管理。
2,堆空间:特点是内存地址是不连续,一般是链表结构,先进后出,用户自己管理申请malloc,分配calloc,释放realloc 。
3,数据段:数据段里面又分三块,
第一块是bss,保存未初始化的全局变量;
第二块是rodata,保存了常量;
第三块 是.data(静态数据区)保存了初始化的全局变量还有static修饰的变量。
4,代码段:存放了源代码。
一个可执行程序在存储(没有调入内存)时主要分为代码段,数据段,未初始化数据段三部分。
可执行程序在运行时又多出了两个区域:栈段(Stack)和堆段(Heap)。
3、内存管理
操作系统有内存管理、linux有内存管理、jvm也有内存管理等,GO也有内存管理。
操作系统内存管理主要包括物理内存管理和虚拟内存管理,具体可以了解:《操作系统内存管理》https://guisu.blog.csdn.net/article/details/5713164。
程序中的数据和变量都会被分配到程序所在的虚拟内存中,内存空间包含两个重要区域:栈区(Stack)和堆区(Heap)。函数调用的参数、返回值以及局部变量大都会被分配到栈上,这部分内存会由编译器进行管理;
当我们说应用程序内存管理的时候,主要是指堆内存的管理,因为栈的内存管理不需要程序去操心。
不同编程语言使用不同的方法管理堆区的内存,C++ 等编程语言会由工程师主动申请和释放内存,Go 以及 Java 等编程语言会由工程师和编译器共同管理,堆中的对象由内存分配器分配并由垃圾收集器回收。
Java的jvm内存管理:运行JVM虚机,通过参数配置jvm内存大小,系统将分配给它一块内存区域(运行数据区),这一内存区域由JVM自己来管理。JVM内存可以划分为5大块:Java栈、程序计数寄存器(PC寄存器)、本地方法栈(Native Method Stack)、Java堆、方法区。
GO内存管理:GO应用程序的内存一般也会分成堆区和栈区,程序在运行期间可以主动从堆区申请内存空间,这些内存由内存分配器分配并由垃圾收集器负责回收。
二、堆内存如何分配
在一个最简单的内存管理中,堆内存最初会是一个完整的大块,即未分配任何内存。
内存申请:当发现内存申请的时候,堆内存就会从未分配内存分割出一个小内存块(block),然后用链表把所有内存块连接起来。
内存释放:释放内存实质是把使用的内存块从链表中取出来,然后标记为未使用,当分配内存块的时候,可以从未使用内存块中优先查找大小相近的内存块,如果找不到,再从未分配的内存中分配内存。
要掌握内存的分配过程,先了解有哪些内存分配器很分配方法:
编程语言的内存分配器一般包含两种分配方法,一种是线性分配器(Sequential Allocator,Bump Allocator),另一种是空闲链表分配器(Free-List Allocator),这两种分配方法有着不同的实现机制和特性。
1、线性分配器
线性分配(Bump Allocator)是一种高效的内存分配方法,但是有较大的局限性。当我们使用线性分配器时,只需要在内存中维护一个指向内存特定位置的指针,如果用户程序向分配器申请内存,分配器只需要检查剩余的空闲内存、返回分配的内存区域并修改指针在内存中的位置,即移动下图中的指针:
虽然线性分配器实现为它带来了较快的执行速度以及较低的实现复杂度,但是线性分配器无法在内存被释放时重用内存。如下图所示,如果已经分配的内存被回收,线性分配器无法重新利用红色的内存:
因为线性分配器具有上述特性,所以需要与合适的垃圾回收算法配合使用,例如:标记压缩(Mark-Compact)、复制回收(Copying GC)和分代回收(Generational GC)等算法,它们可以通过拷贝的方式整理存活对象的碎片,将空闲内存定期合并,这样就能利用线性分配器的效率提升内存分配器的性能了。
因为线性分配器需要与具有拷贝特性的垃圾回收算法配合,所以 C 和 C++ 等需要直接对外暴露指针的语言就无法使用该策略。
2、空闲链表分配器
空闲链表分配器(Free-List Allocator)可以重用已经被释放的内存,它在内部会维护一个类似链表的数据结构。当用户程序申请内存时,空闲链表分配器会依次遍历空闲的内存块,找到足够大的内存,然后申请新的资源并修改链表:
因为不同的内存块通过指针构成了链表,所以使用这种方式的分配器可以重新利用回收的资源,但是因为分配内存时需要遍历链表,所以它的时间复杂度是 𝑂(𝑛)。
空闲链表分配器可以选择不同的策略在链表中的内存块中进行选择,最常见的是以下四种:
- 首次适应(First-Fit)— 从链表头开始遍历,选择第一个大小大于申请内存的内存块;
- 循环首次适应(Next-Fit)— 从上次遍历的结束位置开始遍历,选择第一个大小大于申请内存的内存块;
- 最优适应(Best-Fit)— 从链表头遍历整个链表,选择最合适的内存块;
- 隔离适应(Segregated-Fit)— 将内存分割成多个链表,每个链表中的内存块大小相同,申请内存时先找到满足条件的链表,再从链表中选择合适的内存块;
Go 语言使用的内存分配策略与第四种策略有些相似,我们通过下图了解该策略的原理:
如上图所示,该策略会将内存分割成由 4、8、16、32 字节的内存块组成的链表,当我们向内存分配器申请 8 字节的内存时,它会在上图中找到满足条件的空闲内存块并返回。隔离适应的分配策略减少了需要遍历的内存块数量,提高了内存分配的效率。
三、分级分配TCMalloc
1、TCMalloc分配器概述
TCMalloc(线程缓存分配Thread-Caching Malloc)就是一个内存分配器,管理堆内存,主要影响malloc和free,用于降低频繁分配、释放内存造成的性能损耗,并且有效地控制内存碎片。
在Linux操作系统中,其实有不少的内存管理库,比如glibc的ptmalloc,FreeBSD的jemalloc。glibc中的内存分配器是ptmalloc2,TCMalloc号称要比它快。一次malloc和free操作,ptmalloc需要300ns,而tcmalloc只要50ns。
-
Front-end:
它是一个内存缓存,提供了快速分配和重分配内存给应用的功能。它主要有2部分组成:Per-thread cache 和 Per-CPU cache。 -
Middle-end:
职责是给Front-end提供缓存。也就是说当Front-end缓存内存不够用时,从Middle-end申请内存。它主要是 Central free list 这部分内容。 -
Back-end:
这一块是负责从操作系统获取内存,并给Middle-end提供缓存使用。它主要涉及 Page Heap 内容。
Go 语言的内存分配器就借鉴了 TCMalloc 的设计实现高速的内存分配,它的核心理念是使用多级缓存将对象根据大小分类,并按照类别实施不同的分配策略。
随着Go的迭代,Go的内存管理与TCMalloc不一致地方在不断扩大,但其主要思想、原理和概念都是和TCMalloc一致的。
同一进程下的所有线程共享相同的内存空间,它们申请内存时需要加锁,如果不加锁就存在同一块内存被2个线程同时访问的问题。
TCMalloc的做法是什么呢?为每个线程预分配一块缓存,线程申请小内存时,可以从缓存分配内存,这样有3个好处:
- 减少系统调用速度快:为线程预分配缓存需要进行1次系统调用,后续线程申请小内存时直接从缓存分配,都是在用户态执行的,没有了系统调用,缩短了内存总体的分配和释放时间,这是快速分配内存的主要原因。
- 降低了锁竞争: 对于小对象,多个线程同时申请小内存,从各自的缓存分配,访问的是不同的地址空间,从而无需加锁,把内存并发访问的粒度进一步降低了。对于大对象,TCMalloc尝试使用粒度较好和有效的自旋锁。
-
节省内存:分配N个8字节对象使用大约8N * 1.01字节的空间。而ptmalloc2中每个对象都使用了一个四字节的头。
要使用TCMalloc,只要将tcmalloc通过“-ltcmalloc”链接器标志接入你的应用即可。
也可以通过LD_PRELOAD动态加载:$ LD_PRELOAD=”/usr/lib/libtcmalloc.so”。
例如Mysql要使用TCMalloc,可以把TCMalloc动态库加到mysqld_safe中启动;
也可也静态编译,要依次编译libunwind,TCMalloc,然后编译mysql,configure中加入–with-mysqld-ldflags=-ltcmalloc选项。
2、TCMalloc基本原理
TCMalloc将整个虚拟内存空间划分为n个同等大小的Page。将n个连续的page连接在一起组成一个Span。
PageHeap向OS申请内存,申请的span可能只有一个page,也可能有n个page。
ThreadCache内存不够用会向CentralCache申请,CentralCache内存不够用时会向PageHeap申请,PageHeap不够用就会向OS操作系统申请。
TCMalloc的几个重要概念:
Page
操作系统对内存管理以页为单位,默认大小是8KB,TCMalloc也是这样,只不过TCMalloc里的Page大小与操作系统里的大小并不一定相等,而是倍数关系。《TCMalloc解密》里称x64下Page大小是8KB。
Span
一组连续的Page被称为Span,比如可以有2个页大小的Span,也可以有16页大小的Span,多个这样的span就用链表来管理。Span比Page高一个层级,是为了方便管理一定大小的内存区域,TCMolloc以span为单位向系统申请内存。申请内存,分裂span;回收内存,合并span。
ThreadCache线程缓存
ThreadCache是每个线程各自的Cache,一个Cache包含多个空闲内存块链表(size classes),每个链表(size-class)连接的都是内存块(object),同一个链表上内存块(object)的大小是相同的,也可以说按内存块大小,给内存块分了个类,这样可以根据申请的内存大小,快速从合适的链表选择空闲内存块。由于每个线程有自己的ThreadCache,所以ThreadCache访问是无锁的。
size class:每一个size class都对应着不同空闲内存块的大小 ,例如8字节、16字节等。共有(1B~256KB)分为85个类别。
CentralCache中心缓存
CentralCache是所有线程共享的缓存,也是保存的空闲内存块链表,链表数量与ThreadCache中链表数量相同:
当ThreadCache的内存块不足时,可以从CentralCache获取内存块;
当ThreadCache内存块过多时,可以放回CentralCache。
由于CentralCache是共享的,所以它的访问是要加锁(自旋锁)的。
PageHeap页堆
PageHeap是对堆内存的抽象,PageHeap存的也是若干链表,链表保存的是Span。当CentralCache的内存不足时,会从PageHeap获取空闲的内存Span,然后把1个Span拆成若干内存块,添加到对应大小的链表中并分配内存;当CentralCache的内存过多时,会把空闲的内存块放回PageHeap中。
3、对象大小类别和内存分配回收流程
TCMalloc对象的大小将对象分成小对象、中对象、大对象三种:
- 小对象大小:0~256KB
- 中对象大小:257~1MB
- 大对象大小:>1MB
小对象的分配流程:ThreadCache -> CentralCache -> HeapPage,
分配:
当一个线程申请内存的时候,将要分配的内存大小映射到对应的size class:
1)ThreadCache获取:(无需加锁)查看ThreadCache中size class对应的FreeList。若ThreadCache的FreeList有空闲对象,则返回一个空闲对象,分配结束;
2)CentralCache获取:若ThreadCache没有空闲对象的时候,向CentralCache中对应的class size获取对象 ,CentralCache是线程共享的,所以需要自旋锁,若有可用对象,将分配的class size放到ThreadCache的FreeList中,返回对象,分配结束;
3)PageHeap申请:如果CentralCache也没有可用的对象,向PageHeap申请一个span,将span拆分成class size放到CentralCache的freeList中。
大部分时候,ThreadCache缓存都是足够的,不需要去访问CentralCache和HeapPage,无系统调用配合无锁分配,分配效率是非常高的。
回收:
根据申请内存地址计算页号,通过页号找到对应的span,通过span知道对应的size class,若没超过ThreadCache的阈值(2MB),则使用垃圾回收机制移动到CentralCache
中对象分配流程(中对象大小:(256KB, 1MB]):
直接在PageHeap中选择适当的大小即可,128 Page的Span所保存的最大内存就是1MB。
分配:
在PageHeap中的span list顺序选择一个非空链表M(n个page),然后按照内存大小将M分成2类,一种是满足大小的k个page,返回对象,分配结束。另外一种的n-k的page会继续放在n-kpage的span list中。 若PageHeap没有合适的空闲块时,就按照大对象内存分配进行分配。
回收:
根据申请内存地址计算页号,通过页号找到对应的span,寻找到对应的span大小,进行回收
大对象分配流程:
从large span set选择合适数量的页面组成span,用来存储数据。
分配:
在PageHeap中的span set,选取最新的span进行分配(n个page),也是分成2类,一种是满足大小的k个page,返回对象,分配结束。另外一种的n-k的,若n-k>128,将剩下的page放在span set中,其他会继续放在n-k个page的span list中。
回收:
根据申请内存地址计算页号,通过页号找到对应的span,寻找到对应的span大小,进行回收,若没有对应的大小,则继续放在span set中
因为程序中的绝大多数对象的大小都在 32KB 以下,而申请的内存大小影响 Go 语言运行时分配内存的过程和开销,所以分别处理大对象和小对象有利于提高内存分配器的性能。
五、go具体内存管理组件
Go内存管理的许多概念在TCMalloc中已经有了,含义是相同的,只是名字有一些变化。Go 语言的内存分配器包含内存管理单元、线程缓存、中心缓存和页堆几个重要组件,这几种最重要组件对应的数据结构分别是 runtime.mspan、runtime.mcache、runtime.mcentral 和 runtime.mheap。
所有的 Go 语言程序都会在启动时初始化如上图所示的内存布局,每一个处理器都会分配一个线程缓存 runtime.mcache 用于处理微对象和小对象的分配,它们会持有内存管理单元 runtime.mspan。
- Page
与TCMalloc中的Page相同,x64架构下1个Page的大小是8KB。上图的最下方,1个浅蓝色的长方形代表1个Page。
- Span
Span与TCMalloc中的Span相同,Span是内存管理的基本单位,代码中为mspan,一组连续的Page组成1个Span,所以上图一组连续的浅蓝色长方形代表的是一组Page组成的1个Span,另外,1个淡紫色长方形为1个Span。
- mcache
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刚刚好。
- mcentral
mcentral与TCMalloc中的CentralCache类似,是所有线程共享的缓存,需要加锁访问。它按Span级别对Span分类,然后串联成链表,当mcache的某个级别Span的内存被分配光时,它会向mcentral申请1个当前级别的Span。
但是mcentral与CentralCache也有不同点,CentralCache是每个级别的Span有1个链表,mcache是每个级别的Span有2个链表,这和mcache申请内存有关,稍后我们再解释。
- mheap
mheap与TCMalloc中的PageHeap类似,它是堆内存的抽象,把从OS申请出的内存页组织成Span,并保存起来。当mcentral的Span不够用时会向mheap申请内存,而mheap的Span不够用时会向OS申请内存。mheap向OS的内存申请是按页来的,然后把申请来的内存页生成Span组织起来,同样也是需要加锁访问的。
但是mheap与PageHeap也有不同点:mheap把Span组织成了树结构,而不是链表,并且还是2棵树,然后把Span分配到heapArena进行管理,它包含地址映射和span是否包含指针等位图,这样做的主要原因是为了更高效的利用内存:分配、回收和再利用。
- object size:代码里简称size,指申请内存的对象大小。
- size class:代码里简称class,它是size的级别,相当于把size归类到一定大小的区间段,比如size[1,8]属于size class 1,size(8,16]属于size class 2。
- span class:指span的级别,但span class的大小与span的大小并没有正比关系。span class主要用来和size class做对应,1个size class对应2个span class,2个span class的span大小相同,只是功能不同,1个用来存放包含指针的对象,一个用来存放不包含指针的对象,不包含指针对象的Span就无需GC扫描了。
- num of page:代码里简称npage,代表Page的数量,其实就是Span包含的页数,用来分配内存。
1、Go内存分配
Go 语言的内存分配器会根据申请分配的内存大小选择不同的处理逻辑,运行时根据对象的大小将对象分成微对象、小对象和大对象三种:
类别 | 大小 |
---|---|
微对象 | (0, 16B) |
小对象 | [16B, 32KB] |
大对象 | (32KB, +∞) |
- 微对象
(0, 16B)
— 先使用微型分配器,再依次尝试线程缓存mcache、中心缓存mcentral和堆mheap分配内存; - 小对象
[16B, 32KB]
— 依次尝试使用线程缓存mcache、中心缓存mcentral和堆mheap分配内存; - 大对象
(32KB, +∞)
— 直接在堆mheap上分配内存;
2、微对象内存分配
Go语言运行时将小于 16 字节的对象划分为微对象,它会使用线程缓存上的微分配器提高微对象分配的性能,我们主要使用它来分配较小的字符串以及逃逸的临时变量。微分配器可以将多个较小的内存分配请求合入同一个内存块中,只有当内存块中的所有对象都需要被回收时,整片内存才可能被回收。
微分配器管理的对象不可以是指针类型,管理多个对象的内存块大小 maxTinySize
是可以调整的,在默认情况下,内存块的大小为 16 字节。maxTinySize
的值越大,组合多个对象的可能性就越高,内存浪费也就越严重;maxTinySize
越小,内存浪费就会越少,不过无论如何调整,8 的倍数都是一个很好的选择。
如上图所示,微分配器已经在 16 字节的内存块中分配了 12 字节的对象,如果下一个待分配的对象小于 4 字节,它会直接使用上述内存块的剩余部分,减少内存碎片,不过该内存块只有所有对象都被标记为垃圾时才会回收。
线程缓存 runtime.mcache 中的 tiny
字段指向了 maxTinySize
大小的块,如果当前块中还包含大小合适的空闲内存,运行时会通过基地址和偏移量获取并返回这块内存:
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
...
if size <= maxSmallSize {
if noscan && size < maxTinySize {
off := c.tinyoffset
if off+size <= maxTinySize && c.tiny != 0 {
x = unsafe.Pointer(c.tiny + off)
c.tinyoffset = off + size
c.local_tinyallocs++
releasem(mp)
return x
}
...
}
...
}
...
}
当内存块中不包含空闲的内存时,下面的这段代码会先从线程缓存找到跨度类对应的内存管理单元 runtime.mspan,调用 runtime.nextFreeFast 获取空闲的内存;当不存在空闲内存时,我们会调用 runtime.mcache.nextFree 从中心缓存或者页堆中获取可分配的内存块:
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
...
if size <= maxSmallSize {
if noscan && size < maxTinySize {
...
span := c.alloc[tinySpanClass]
v := nextFreeFast(span)
if v == 0 {
v, _, _ = c.nextFree(tinySpanClass)
}
x = unsafe.Pointer(v)
(*[2]uint64)(x)[0] = 0
(*[2]uint64)(x)[1] = 0
if size < c.tinyoffset || c.tiny == 0 {
c.tiny = uintptr(x)
c.tinyoffset = size
}
size = maxTinySize
}
...
}
...
return x
}
获取新的空闲内存块之后,上述代码会清空空闲内存中的数据、更新构成微对象分配器的几个字段 tiny
和 tinyoffset
并返回新的空闲内存。
3、小对象内存分配
小对象是指大小为 16 字节到 32,768 字节的对象以及所有小于 16 字节的指针类型的对象,小对象的分配可以被分成以下的三个步骤:
- 确定分配对象的大小以及跨度类runtime.spanClass ;
- 从线程缓存、中心缓存或者堆中获取内存管理单元并从内存管理单元找到空闲的内存空间;
- 调用 runtime.memclrNoHeapPointers清空空闲内存中的所有数据;
确定待分配的对象大小以及跨度类需要使用预先计算好的 size_to_class8
、size_to_class128
以及 class_to_size
字典,这些字典能够帮助我们快速获取对应的值并构建runtime.spanClass :
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
...
if size <= maxSmallSize {
...
} else {
var sizeclass uint8
if size <= smallSizeMax-8 {
sizeclass = size_to_class8[(size+smallSizeDiv-1)/smallSizeDiv]
} else {
sizeclass = size_to_class128[(size-smallSizeMax+largeSizeDiv-1)/largeSizeDiv]
}
size = uintptr(class_to_size[sizeclass])
spc := makeSpanClass(sizeclass, noscan)
span := c.alloc[spc]
v := nextFreeFast(span)
if v == 0 {
v, span, _ = c.nextFree(spc)
}
x = unsafe.Pointer(v)
if needzero && span.needzero != 0 {
memclrNoHeapPointers(unsafe.Pointer(v), size)
}
}
} else {
...
}
...
return x
}
在上述代码片段中,我们会重点分析两个方法的实现原理,它们分别是 runtime.nextFreeFast 和 runtime.mcache.nextFree,这两个方法会帮助我们获取空闲的内存空间。runtime.nextFreeFast 会利用内存管理单元中的 allocCache 字段,快速找到该字段为 1 的位数,我们在上面介绍过 1 表示该位对应的内存空间是空闲的:
func nextFreeFast(s *mspan) gclinkptr {
theBit := sys.Ctz64(s.allocCache)
if theBit < 64 {
result := s.freeindex + uintptr(theBit)
if result < s.nelems {
freeidx := result + 1
if freeidx%64 == 0 && freeidx != s.nelems {
return 0
}
s.allocCache >>= uint(theBit + 1)
s.freeindex = freeidx
s.allocCount++
return gclinkptr(result*s.elemsize + s.base())
}
}
return 0
}
找到了空闲的对象后,我们就可以更新内存管理单元的 allocCache
、freeindex
等字段并返回该片内存;如果我们没有找到空闲的内存,运行时会通过 runtime.mcache.nextFree 找到新的内存管理单元:
func (c *mcache) nextFree(spc spanClass) (v gclinkptr, s *mspan, shouldhelpgc bool) {
s = c.alloc[spc]
freeIndex := s.nextFreeIndex()
if freeIndex == s.nelems {
c.refill(spc)
s = c.alloc[spc]
freeIndex = s.nextFreeIndex()
}
v = gclinkptr(freeIndex*s.elemsize + s.base())
s.allocCount++
return
}
在上述方法中,如果我们在线程缓存中没有找到可用的内存管理单元,会通过前面介绍的 runtime.mcache.refill 使用中心缓存中的内存管理单元替换已经不存在可用对象的结构体,该方法会调用新结构体的 runtime.mspan.nextFreeIndex 获取空闲的内存并返回。
4、大对象
运行时对于大于 32KB 的大对象会单独处理,我们不会从线程缓存或者中心缓存中获取内存管理单元,而是直接调用 runtime.mcache.allocLarge 分配大片内存:
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
...
if size <= maxSmallSize {
...
} else {
var s *mspan
span = c.allocLarge(size, needzero, noscan)
span.freeindex = 1
span.allocCount = 1
x = unsafe.Pointer(span.base())
size = span.elemsize
}
publicationBarrier()
mp.mallocing = 0
releasem(mp)
return x
}
runtime.mcache.allocLarge 会计算分配该对象所需要的页数,它按照 8KB 的倍数在堆上申请内存:
func (c *mcache) allocLarge(size uintptr, needzero bool, noscan bool) *mspan {
npages := size >> _PageShift
if size&_PageMask != 0 {
npages++
}
...
s := mheap_.alloc(npages, spc, needzero)
mheap_.central[spc].mcentral.fullSwept(mheap_.sweepgen).push(s)
s.limit = s.base() + size
heapBitsForAddr(s.base()).initSpan(s)
return s
}
申请内存时会创建一个跨度类为 0 的 runtime.spanClass 并调用 runtime.mheap.alloc 分配一个管理对应内存的管理单元。
五、Go变量的内存位置
1、Go变量的位置由编译器决定
我们在写C、PHP、Java的时候,可以很容易的知道,所写的变量所在的位置:带new
、malloc
等字段的,那一定是在堆上分配了,至于后续GC怎么处理,有没有引用继续关联,堆有没与释放,程序是否存在内存泄露…这都是后续处理的问题了;变量的存储位置是肯定是在堆上了。
但是在用Go的时候要注意,new
、make
等等关键字都不好使,Go变量的位置不是由写程序的程序员来决定的,而是Go自行处理;所以可能你的变量是new
出来的,但是,最终也不一定分配到堆上,很可能是分配在栈上。
Go把变量的位置在哪儿这件事对程序员“隐藏”了,Go自行处理;因为Go认为:变量的存储位置,会对程序的性能有一定影响,而Go是计划打造对性能有极致要求的程序,因而自己管了。
Go是这么管的:
首先,栈stack
上的效率肯定是比堆要高的,这算是常识;
Go在编译期会对每一个函数变量做判断,如果不能够判断此函数中的变量在返回之后是否仍被引用到,就给把变量扔堆heap
上,否则,就扔栈stack
上。但是注意:如果变量非常大,还是会扔到堆heap
上。
2、栈内存空间
每个goroutine都有自己的栈,栈的初始大小是2KB,100万的goroutine会占用2G,但goroutine的栈会在2KB不够用时自动扩容,当扩容为4KB的时候,百万goroutine会占用4GB。
栈区的内存一般由编译器自动分配和释放,其中存储着函数的入参以及局部变量,这些参数会随着函数的创建而创建,函数的返回而消亡,一般不会在程序中长期存在,这种线性的内存分配策略有着极高地效率,但是用户也往往不能控制栈内存的分配,这部分工作基本都是由编译器完成的。
Go 语言使用用户态线程 Goroutine 作为执行上下文,它的额外开销和默认栈大小都比线程小很多,然而 Goroutine 的栈内存空间和栈结构也在早期几个版本中发生过一些变化:
- v1.0 ~ v1.1 — 最小栈内存空间为 4KB;
- v1.2 — 将最小栈内存提升到了 8KB;
- v1.3 — 使用连续栈替换之前版本的分段栈8;
- v1.4 — 将最小栈内存降低到了 2KB;
Goroutine 的初始栈内存在最初的几个版本中多次修改,从 4KB 提升到 8KB 是临时的解决方案,其目的是为了减轻分段栈中的栈分裂对程序的性能影响;在 v1.3 版本引入连续栈之后,Goroutine 的初始栈大小降低到了 2KB,进一步减少了 Goroutine 占用的内存空间。
3、内存逃逸机制
在 C 语言和 C++ 这类需要手动管理内存的编程语言中,将对象或者结构体分配到栈上或者堆上是由工程师自主决定的,这也为工程师的工作带来的挑战,如果工程师能够精准地为每一个变量分配合理的空间,那么整个程序的运行效率和内存使用效率一定是最高的,但是手动分配内存会导致如下的两个问题:
- 不需要分配到堆上的对象分配到了堆上 — 浪费内存空间;
- 需要分配到堆上的对象分配到了栈上 — 悬挂指针、影响内存安全;
与悬挂指针相比,浪费内存空间反而是小问题。在 C 语言中,栈上的变量被函数作为返回值返回给调用方是一个常见的错误,在如下所示的代码中,栈上的变量 i
被错误返回:
int *dangling_pointer() {
int i = 2;
return &i;
}
当 dangling_pointer
函数返回后,它的本地变量会被编译器回收,调用方获取的是危险的悬挂指针,我们不确定当前指针指向的值是否合法时,这种问题在大型项目中是比较难以发现和定位的。
应用程序的每一个函数都会有自己的内存区域存放自己的局部变量、返回地址等,这些内存会由编译器在栈中进行分配,每一个函数都会分配一个栈桢,在函数运行结束后进行销毁,但是有些变量我们想在函数运行结束后仍然使用它,那么就需要把这个变量在堆上分配,这种从"栈"上逃逸到"堆""上的现象就成为内存逃逸。
在编译器优化中,逃逸分析是用来决定指针动态作用域的方法。Go 语言的编译器使用逃逸分析决定哪些变量应该在栈上分配,哪些变量应该在堆上分配,其中包括使用 new
、make
和字面量等方法隐式分配的内存,Go 语言的逃逸分析遵循以下两个不变性:
- 指向栈对象的指针不能存在于堆中;
- 指向栈对象的指针不能在栈对象回收后存活;
在栈上分配的地址,一般由系统申请和释放,不会有额外性能的开销,比如函数的入参、局部变量、返回值等。在堆上分配的内存,如果要回收掉,需要进行GC,那么GC一定会带来额外的性能开销。编程语言不断优化GC算法,主要目的都是为了减少GC带来的额外性能开销,变量一旦逃逸会导致性能开销变大。
逃逸机制:
编译器会根据变量是否被外部引用来决定是否逃逸:
1.如果函数外部没有引用,则优先放到栈中;
2.如果函数外部存在引用,则必定放到堆中;
3.如果栈上放不下,则必定放到堆上;
4、逃逸分析的工具
我们是否有办法知道我们写的Go程序中变量的位置呢?
答案是有的,Go向开发者提供了变量逃逸分析的工具:
go build -gcflags '-m -l' main.go
这里的main.go也可以是某个具体的二进制应用程序
下面对如下代码进行逃逸分析:
import (
"fmt"
)
func main(){
a:= 3
b := 5
ret := add(a, b)
fmt.Println(ret)
}
func add(x,y int)int {
sum := x + y
return sum
}
分析结果:
./main.go:11:16: main ... argument does not escape
./main.go:11:16: ret escapes to heap
5、总结
1.栈上分配内存比在堆中分配内存效率更高
2.栈上分配的内存不需要GC处理,而堆需要
3.逃逸分析目的是决定内分配地址是栈还是堆
4.逃逸分析在编译阶段完成因为无论变量的大小,只要是指针变量都会在堆上分配,所以对于小变量我们还是使用传值效率(而不是传指针)更高一点