Go内存管理和分配策略

2023-04-10 09:33:03 浏览数 (1)

前言

开始了解Go内存分配之前我们来简单了解下虚拟内存技术。

虚拟内存技术

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

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

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

TCMalloc内存分配算法简述

TCMalloc全称是Thread Cache Malloc,是google为C语言开发的内存分配算法,是Go内存分配的起源。我们对它做个简单的了解,看看它的核心思想和几个重要概念,更能帮助我们理解Go内存分配和TCMalloc的相似和不同的地方。

核心思想

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

TCMalloc重要概念

  1. Page: 操作系统对内存的管理同样是以页为单位,但TCMalloc中的Page和操作系统的中页是倍数关系,x64下Page大小为8KB
  2. Span: 一组连续的Page被叫做Span,是TCMalloc内存管理的基本单位,有不同大小的Span,比如2个Page大的Span,16个Page大的Span
  3. ThreadCache: 每个线程各自的Cache,每个ThreadCache包含多个不同规格的Span链表,叫做SpanList, 内存分配的时候,可以根据要分配的内存大小,快速选择不同大小的SpanList,在SpanList上选择合适的Span,每个线程都有自己的ThreadCache,所以ThreadCache是无锁访问的
  4. CentralCache: 中心Cache,所有线程共享的Cache,也是保存的SpanList,数量和ThreadCache中数量相同 当ThreadCache中内存不足时,可以从CentralCache中获取 当ThreadCache中内存太多时,可以放回CentralCache 由于CentralCache是线程共享的,所以它的访问需要加锁
  5. PageHeap: 堆内存的抽象,同样当CentealCache中内存太多或太少时,都可从PageHeap中放回或获取,同样,PageHeap的访问也是需要加锁的

Go的内存分配算法是基于TCMalloc(Thread Cache malloc,线程缓存分配器)内存分配算法实现的,通过借鉴了TCmalloc的思想,开发出Go的内存分配器,核心实现在内置运行时(就是runtime)。

Go内存分配

核心思想

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

  1. 每次从操作系统申请一大块内存, 以减少系统调用。
  2. 将申请到的大块内存按照特定大小预先切分成小块, 构成链表。
  3. 为对象分配内存时, 只需从大小合适的链表提取一个小块即可。
  4. 回收对象内存时, 将该小块内存重新归还到原链表, 以便复用。
  5. 如闲置内存过多, 则尝试归还部分内存给操作系统, 降低整体开销

内存管理图

先看图,我们先在脑中构造一个基础的概念图,然后再一个个解释,我觉得这种方式比只读枯燥的文字更有效。

我们从Go内存管理结构图中可以看出内存管理由mcache、mcentral、mheap组成一个三级管理结构,本质上都是对mspan的管理,三者之间没有严格的包含关系,只是用于不同的目的来共同配合管理所有mspan。

mspan其实就是Go中内存管理的基本单元,是由一片连续的 8kB 的页(page)组成的内存块。小对象和大对象分配的位置不用,大对象在mheap上分配,小对象使用mcache的tiny分配器分配。而文章开始我们为什么要去了解虚拟内存技术呢,可以看到mheap向操作系统申请新内存时,是向虚拟内存申请。

内存管理单元 mspan

Span是go内存管理的基本单位,代码中为mspan,一组连续的Page组成1个Span,所以上图一组连续的浅蓝色长方形代表的是一组Page组成的1个Span,另外,1个淡紫色长方形为1个Span。go把内存分为67个大小不同的span(SizeClass有67种),并且大小是不固定的。

sizeclasses.go对span数量67写死在代码中:

代码语言:javascript复制
// src/runtime/mheap.go 的mspan结构体

type mSpanList struct {
 first *mspan // first span in list, or nil if none
 last  *mspan // last span in list, or nil if none
}

type mspan struct {
        next *mspan     // 链表后向指针,用于将span链接起来
        prev *mspan     // 链表前向指针,用于将span链接起来
        list *mSpanList // 双端队列的head

        startAddr uintptr // 起始地址,也即所管理页的地址
        npages    uintptr // 块个数,表示有多少个块可供分配
        ...
}

内存管理组件

内存管理器由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切割成小对象

把这些概念结合起来,可以用下面图进行概述三者之间的联系和对mspan的不同处理。

分配流程

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

大体上的分配流程:

  • 32KB 的对象,直接从mheap上分配;
  • <=16B 的对象使用mcache的tiny分配器分配;
  • (16B,32KB] 的对象,首先计算对象的规格大小,然后使用mcache中相应规格大小的mspan分配;
  • 如果mcache没有相应规格大小的mspan,则向mcentral申请
  • 如果mcentral没有相应规格大小的mspan,则向mheap申请
  • 如果mheap中也没有合适大小的mspan,则向操作系统申请
go

0 人点赞