在 C/C++ 这类语言中,内存是由开发者自己管理的,需要主动申请和释放,而在 Go 语言中则是由该语言自己管理的,开发者不用做太多干涉,只需要声明变量,Go 语言就会根据变量的类型自动分配相应的内存。

    Go 语言程序所管理的虚拟内存空间会被分为两部分:堆内存和栈内存。栈内存主要由 Go 语言来管理,开发者无法干涉太多,堆内存才是我们开发者发挥能力的舞台,因为程序的数据大部分分配在堆内存上,一个程序的大部分内存占用也是在堆内存上。

    Go 语言的内存垃圾回收是针对堆内存的垃圾回收。

    go内存结构

    1. 运行时内存

    Go的运行时内存就是操作系统分配给进程空间的堆、栈内存,在堆内存的使用上不像JVM分代管理,而是采用分层级的内存管理模式。

    1. 分层级的堆内存

    Go的堆内存直接采用了TCMalloc库的内存管理模型。TCMalloc库是Google开发的现代内存分配器,其基本特征是内存分层级、对抗内存碎片以及快速分配等。Go语言根据自身需求对TCMalloc做了很多优化,但仍保留了其基本架构。
    Go的内存分配器是分层级的,由mcache/mcentral/mheap 三个组件构成。因此整个堆内存结构可以看成是三层级的内存模型,其结构如下图所示:

    image.png

    缓存组件mcache与工作线程(goroutine)绑定,是goroutine私有的内存空间。在mcache中为对象分配内存时,无需竞争,性能很高。
    · 中间组件mcentral 只负责一种规格(size class)的内存块,为mcache缓存组件提供备用的特定规格的可用空间。mcache的内存扩容请求会被分散到不同的mcentral 组件上,以减小共享内存的竞争锁粒度。
    · 堆组件mheap负责管理用户程序的所有可用堆内存空间以及为大对象直接分配内存。它为上层组件提供扩容支持。当空间不足时,mheap组件向操作系统申请内存。

    中间组件mcentral、堆组件mheap为所有工作线程(goroutine)所共享,所以在内存分配时通常存在同步竞争的情况。

    Go内存分配器管理span以及object两种类型的内存块。span是Go内存管理的基本单元,由多个地址连续的页(8k大小的page内存块)组成的⼤块内存。object则是将span按照特定规格(size class)切分成的多个⼩块,每个⼩块都可以用来存储⼀个对象。

    Go的object内存块大小为8字节的整倍数,被划分为67种规格。Go对象的内存分配就是从有限的67种规格中找出与对象大小最合适的一块可用内存块的过程。Go使用“空闲列表”方式管理可分配的内存空间,相同规格的内存块连接成一个双向链表。以下是Go object的size class对应表,其中包含66种规格,另外一种规格是大于32KB的大对象:

    image.png

    根据不同对象的规格大小,Go内存分配器有不同的内存分配逻辑。比如零长度对象由于没有可读写内容,在分配时不同类型可能指向同一位置,如struct{}与[0]int。内存分配器会将小于16字节且不包含指针(noscan)的微小对象组合起来,并尝试用单个object内存块存储以减少内存浪费。32KB以内大小的小对象使用mcache组件进行分配,大于32KB的大对象直接在mheap组件管理的堆上分配。

    用户程序中创建的对象大部分是小对象,这也是内存分配器的重心所在,小对象分配在每个goroutine mcache中避免了竞争锁提升了分配效率。如前所述,Go内存分配器采用分层级组件的方式来管理应用的堆内存,为小对象分配内存需要多级组件相互协作完成。分配过程如下图所示:

    image.png

    · mcache缓存是goroutine的私有内存空间,直接为当前goroutine无锁分配小对象的内存块,速度很快。内存分配器首先根据对象大小获取mcache中对应size class的span链表,并从表头span中提取object块进行分配。

    · 如果分配器发现mcache下没有对应规格的可用span资源,则会尝试从堆区相应class的mcentral区域中申请扩容(mcentral是所有线程共享,在为mcache扩容时总是会先lock)。分配器将申请到的span资源链接到mcache链表,继续为对象分配object块空间。

    · 如果mcentral中没有找到可用的内存块,分配器会向mheap申请扩容,扩容成功后继续为对象分配内存。

    对于大对象,Go的内存分配器直接在mheap分配内存,如果没有找到合适的span内存块,分配器将向操作系统申请扩容后继续分配。提取的span内存块如果超过了对象规格所需的页数,分配器将尝试分割该span合适大小分配给对象,并合并剩余的空间归还给mheap管理,以减少堆内存碎片。

    由上可知,小对象内存分配,Go mcache方式与Java TLAB方式相似,都是从堆内存中为线程划分私有空间以便进行快速分配,但是仍然会有所差异:

    image.png

    内存结构划分、对象分配方式与垃圾收集策略密切相关,而且自动GC很大程度上会成为影响系统性能的瓶颈。Go与Java的GC策略是判断对象是否存活,并对其进行标记,或采用“复制”算法、“清除”算法、“整理”算法等完成不可引用对象的内存回收操作。目前垃圾收集过程中总是会遇到STW(stop the world)的问题。

    Go只有一种垃圾收集器,其基于优化改进的“标记-清除”算法,特征为“非分代、非紧缩、写屏障、三色标记、并发标记清理”。Go的“非分代”内存管理使得Go并不需要实现多种GC算法策略,“非紧缩”的特征使得回收的内存块非常容易的复用,较少产生内存碎片,基本上不需要压缩整理。Go的垃圾收集器与JVM中的CMS垃圾收集器原理上是非常相似的。

    1. · GC中的STW问题
    2. 垃圾收集器在回收对象内存的过程中,总是需要挂起所有的用户线程(即STW,stop the world),以避免GC线程在回收时对象的引用关系还在不断变化导致回收结果不准确。但是STW可能会因GC时间过长而使得用户线程长时间的停顿,这对追求响应速度的程序来说将是令人难以接收的。Go GC的目标就是尽量减小STW的时间,以使得程序能够获取最大限度的响应速度。

    Go GC线程与用户线程是并发的,其过程可以分为如下四个阶段:

    ■ Sweep termination:清理掉意外遗留的span内存块,只有上一次的GC清除工作完成了才能开始下一次GC。

    ■ Mark:
    1)初始标记,需要STW。准备GC Roots对象的扫描、开启写屏障等。
    2)并发标记,GC Roots到所有的对象的可达性分析,采用三色标记法。

    ■ Mark termination:
    重新标记,需要STW。重新扫描部分GC Roots对象,修正并发标记期间因用户线程继续运行而导致标记产生变动的那一部分对象的标记记录。

    ■ Sweep:
    根据标记结果并发清除,回收内存。
    可见,在初始标记以及重新标记期间仍然存在STW问题。Go GC虽然没有完全消除STW,但在整个GC回收周期中,已将STW局限在有限的2个阶段,这让程序实时性有了很大改善。

    1. ·GC触发时机一览
    2. Go的堆内存没有分代,每次GC时都要回收整个堆中的对象。

    image.png

    参考

    简析Go与Java内存管理的差异

    https://juejin.cn/post/6844903795739082760