Golang

关注公众号 jb51net

关闭
首页 > 脚本专栏 > Golang > Go内存管理

详解Go是如何优雅的进行内存管理

作者:MysTic_Zhong

Go语言抛弃C/C++中的开发者管理内存的方式,实现了主动申请与主动释放管理,增加了逃逸分析和垃圾回收,将开发者从内存管理中释放出来,作为进阶的Go开发,了解掌握Go的内存管理还是很有必要的

前言

Go语言抛弃C/C++中的开发者管理内存的方式,实现了主动申请与主动释放管理,增加了逃逸分析和垃圾回收,将开发者从内存管理中释放出来。
所以我们在日常编写代码的时候不需要精通内存的管理,它确实很复杂。但是另一方面,如果你掌握了Go内存管理的基本概念和知识点,可以让你写出更高质量的,更压榨机器性能的代码;另外,还能帮助你更快更精准得定位Bug,快速解决问题。所以,作为进阶的Go开发,了解掌握Go的内存管理还是很有必要的。

相关背景

存储金字塔

冯·诺依曼计算机体系中的存储器,是用于存储程序和数据的。现代计算机系统中,一般都是采用“CPU寄存器-CPU高速缓存-内存-硬盘”的存储器结构。自上而下容量逐渐增大,速度逐渐减慢,单位价格逐渐降低。

1、CPU寄存器:存储CPU正在使用的数据或指令。

2、CPU高速缓存:存储CPU近期要用到的数据和指令。

3、内存:存储正在运行或者将要运行的程序和数据。

4、硬盘:存储暂时不使用或者不能直接使用的程序和数据

虚拟内存

物理内存:是指实际通过物理内存而获得的内存空间。

虚拟内存:与物理内存相反,是指根据系统需要从硬盘中虚拟的划出一部分存储空间。

虚拟内存技术就是对内存的一种抽象,有了这层抽象之后,程序运行进程的总大小可以超过实际可用的物理内存大小。每个进程都有自己的独立虚拟地址空间,然后通过CPU和MMU把虚拟内存地址转换为实际物理地址。

TCMalloc

TCMalloc全称是Thread Cache Malloc,是google为C语言开发的内存分配算法,是Go内存分配的起源。
TCMalloc内存分配算法的核心思想是把内存分为多级管理,从而降低锁的粒度,它将可用的堆内存采用二级分配的方式进行管理,每个线程都会自行维护一个独立的线程内存池,进行内存分配时优先从该线程内存池中分配, 当线程内存池不足时才会向全局内存池申请,以避免不同线程对全局内存池的频繁竞争 ,进一步的降低了内存并发访问的粒度。
Go的内存分配算法是基于TCMalloc内存分配算法实现的,借鉴了TCmalloc的思想。

几个重要概念
 

Page: 操作系统对内存的管理同样是以页为单位,但TCMalloc中的Page和操作系统的中页是倍数关系,x64下Page大小为8KB。

Span:一组连续的Page被叫做Span,是TCMalloc内存管理的基本单位,有不同大小的Span,比如2个Page大的Span,16个Page大的Span。

ThreadCache:每个线程各自的Cache,每个ThreadCache包含多个不同规格的Span链表,叫做SpanList,内存分配的时候,可以根据要分配的内存大小,快速选择不同大小的SpanList,在SpanList上选择合适的Span,每个线程都有自己的ThreadCache,所以ThreadCache是无锁访问的。

CentralCache:中心Cache,所有线程共享的Cache,也是保存的SpanList,数量和ThreadCache中数量相同,当ThreadCache中内存不足时,可以从CentralCache中获取,当ThreadCache中内存太多时,可以放回CentralCache,由于CentralCache是线程共享的,所以它的访问需要加锁。

PageHeap:堆内存的抽象,同样当CentealCache中内存太多或太少时,都可从PageHeap中放回或获取,同样,PageHeap的访问也是需要加锁的。

管理分配

核心思想

Go在程序启动的时候,会分配一块连续的内存(虚拟的地址空间,还没有真正地分配内存),切成小块后自己进行管理,对内存的分配遵循以下思想。

1.每次从操作系统申请一大块内存, 以减少系统调用。

2.将申请到的大块内存按照特定大小预先切分成小块, 构成链表。

3.为对象分配内存时, 只需从大小合适的链表提取一个小块即可。

4.回收对象内存时, 将该小块内存重新归还到原链表, 以便复用。

5.如闲置内存过多, 则尝试归还部分内存给操作系统, 降低整体开销。

内存管理由mcache、mcentral、mheap组成一个三级管理结构,本质上都是对mspan的管理,三者用于不同的目的来共同配合管理所有mspan。

mspan

mspan是Go中内存管理的基本单元,是由一片连续的8kB的page组成的内存块。但是小对象和大对象分配的位置不用,大对象在mheap上分配,mheap向操作系统申请新内存时,是向虚拟内存申请;小对象使用mcache的tiny分配器分配。
一组连续的Page组成1个Span,go把内存分为67个大小不同的span,并且大小是不固定的。

源码文件src/runtime/sizeclasses.go对67种span的定义(源码版本为go-1.17.1,本文下所有源码展示均为此版本)

延伸扩展:67种定义列表里面有一列的名称叫做"max waste",代表的是这个span下可能出现的最大内存浪费比例。举个例子解释,看第4个规格的情况:

class  bytes/obj  bytes/span  objects  tail waste  max waste  min align
    4         32        8192      256           0     21.88%         32

4的对象最小内存长度为25字节(因为小于25的只会申请3或3以下的,要不到4),所以如果每个object都被25字节的对象申请,此时内存浪费最大,对应浪费率为:(32-25)/32 = 21.88%。

再通过观察整个列表可以看到,"max waste"一列并非线性递减的,熟悉Linux的同学应该猜到原因了,没错,这个设计跟大名鼎鼎的伙伴算法是非常相似的。

伙伴算法(buddy算法),就是将内存分成若干块,然后以最适合的方式满足程序内存需求的一种内存管理算法,伙伴算法是尽可能地在提高内存利用率的同时减少内存碎片。但是算法中,一个很小的块往往会阻碍一个大块的合并,一个系统中,对内存块的分配,大小是随机的,一片内存中仅一个小的内存块没有释放,旁边两个大的就不能合并,这也是造成上面现象的根因。(完整解读伙伴算法需要非常大的篇幅和难度,本文就不展开了,文章最后有参考链接,读者可自行研究)

回来主题,上面说到的Spans有3种类型:
空闲-span,没有对象,可以释放回操作系统,或重用于堆分配,或重用于堆栈内存。
正在使用-span,至少有一个堆对象,可能有更多的空间。
栈-span,用于 goroutine 堆栈。此跨度可以存在于堆栈中或堆中,但不能同时存在。

源码文件src/runtime/mheap.go对mspan结构体的定义

type mspan struct {
	next *mspan     // 链表后向指针
	prev *mspan     // 链表前向指针
	list *mSpanList // 双端队列的head(已无实际用途)
	startAddr uintptr // span起始位置的地址指针
	npages    uintptr // 可供分配的页数
	...
	manualFreeList gclinkptr // 在mSpanManual的空闲对象
	allocCache uint64  // 在freeindex处的allocBits的缓存
	...
	allocBits  *gcBits // 标记span中的elem哪些是被使用的,哪些是未被使用的
	gcmarkBits *gcBits // 标记span中的elem哪些是被标记的,哪些是未被标记的
	speciallock mutex  // 互斥锁
}

管理组件说明

内存管理器由mcache, mcentral, mheap3种组件构成: 三级管理结构是为了方便对span进行管理,加速对span对象的访问和分配,这三个结构在runtime中分别有对应的mcache.go、mcentral.go、mheap.go文件。

mcache:保存的是各种大小的Span,并按Span class分类,小对象直接从mcache分配内存,它起到了缓存的作用,并且可以无锁访问Go中是每个P拥有1个mcache,因为在Go程序中,当前最多有GOMAXPROCS个线程在运行,所以最多需要GOMAXPROCS个mcache就可以保证各线程对mcache的无锁访问。
mcentral:是所有线程共享的缓存,需要加锁访问,它按Span class对Span分类,串联成链表,当mcache的某个级别Span的内存被分配光时,它会向mcentral申请1个当前级别的Span。
mheap:是堆内存的抽象,把从OS(系统)申请出的内存页组织成Span,并保存起来。当mcentral的Span不够用时会向mheap申请,mheap的Span不够用时会向OS申请,向OS的内存申请是按页来的,然后把申请来的内存页生成Span组织起来,同样也是需要加锁访问的。mheap主要用于大对象的内存分配,以及管理未切割的mspan,用于给mcentral切割成小对象。

熟悉的金字塔,熟悉的结构

通俗的理解:mcache, mcentral, mheap就是对ThreadCache, CentralCache, PageHeap的继承沿用和基于go体系的优化处理版本。

分配流程

Go的内存分配器在分配对象时,根据对象的大小,分成三类:小对象(<=16B)、一般对象(>16B && <=32KB)、大对象(>32KB)。

源码文件src/runtime/malloc.go根据分配对象的大小选择对应的空间申请

大体上的分配流程:
1.>32KB 的对象,直接从mheap上分配。
2.<=16B 的对象使用mcache的tiny分配器分配。
3.>16B && <=32KB 的对象,首先计算对象的规格大小,然后使用mcache中相应规格大小的mspan分配。
如果mcache没有相应规格大小的mspan,则向mcentral申请; 如果mcentral没有相应规格大小的mspan,则向mheap申请; 如果mheap中也没有合适大小的mspan,则向OS申请。

源码文件src/runtime/mheap.go内存分配初始化过程

小结

Go内存管理源自TCMalloc,优秀作品源于继承和优化(在这里我自己想到了一句话:如果说我比别人看得更远些,那是因为我站在了巨人的肩上--牛顿)。但它比TCMalloc还多了2件东西:逃逸分析(后面篇幅会提及)和垃圾回收。

总结一下它在底层设计上着重用到的2个重要观念:

使用缓存提高效率:在存储的整个体系中到处可见缓存的思想,利用缓存减少了系统调用的次数,降低了锁的粒度、减少加锁的次数,提高了管理效率。

以空间换时间:空间换时间是一种常用的性能优化思想,数据库的索引/许多数据结构的本质就是空间换时间。

关联知识点

逃逸分析

Go堆内存所使用的内存页与goroutine的栈所使用的内存页是交织在一起的,带GC(垃圾回收)功能的GO语言会对位于堆上的对象进行自动管理。当某个对象不可达时,即没有其对象引用它时,它将会被回收并被重用(三色标记)。但GC是会给程序带来性能损耗的,尤其是当堆内存上有大量待扫描的堆内存对象时,将会给GC带来过大的压力,从而消耗更多的计算和存储资源。于是开发者们都想尽量减少在堆上的内存分配,可以在栈上分配的变量尽量留在栈上。

逃逸分析(escape analysis)就是在程序编译阶段根据程序代码中的数据流,对代码中哪些变量需要在栈上分配,哪些变量需要在堆上分配进行静态分析的方法

分析准则: 逃逸分析是在编译器完成的,也就是只存在于编译阶段;如果变量在函数外部没有引用,则优先放到栈中;如果变量在函数外部存在引用,则必定放在堆中。

命令:go build -gcflags '-m -m -l' xxx.go

内存对齐

CPU访问内存时,并不是逐个字节访问,而是以字长为单位访问。这样是为了是减少CPU访问内存的次数,提升CPU访问内存的吞吐量。如果访问对象在内存的存储空间是对齐的话,CPU读取一次即可,否则就要读取两次甚至多次,如下图清晰可见。

对齐规则

1.第一个成员在与结构体变量偏移量为0的地址处;2.其他成员变量要对齐到对齐数的整数倍的地址处;3.结构体总大小为最大对齐数的整数倍。

下图是不同类型的对齐系数和占用字节数

所以我们在日常编码过程中,要尽量对结构体的变量类型做针对性的顺序调整,以符合对齐原则。

One More Thing

介绍完了Go的情况,最后来简单看下其他语言的,作者本人对Java不太熟悉,就不掺和了,就用最简单的描述来讲一下相对熟悉的php和python。

php:php也是有一个基本的分配单元叫chunk,chunk分配了512个page,page的大小为4KB。内存分配模式也是有3种,small(小于等于3KB),large(大于3KB小于等于2MB-4KB内存),huge(大于2MB-4KB内存),GC机制是引用计数方式,对堆区zend_mm_heap的管控就相对非常随意了(纯个人心得理解)。

python:py最大的特色是有个内存池,可以减少内存碎片化,提高执行效率。回收机制也是引用计数,但是它有标记/清除和分代回收两个辅助功能。

综合3种语言对比,可以看到既有共同交集的地方,也有各自的私有属性特色,各自的管理分配方式用到自己的语言环境下都能发挥最大的作用和效率。这也验证了一句至高的哲学:方案设计或者架构理念,是没有最优秀最完美的,但是会有最适合最贴近使用场景的。

以上就是详解Go是如何优雅进行内存管理的详细内容,更多关于Go内存管理的资料请关注脚本之家其它相关文章!

您可能感兴趣的文章:
阅读全文