Go内存管理(上)

前言

在进入本章内容学习之前,最好能够对虚拟内存以及一些基本的物理内存知识有一定了解,这样能帮助更好的深入理解一些概念。为了缩减内容篇幅,聚焦重点内容,就不一一去讲解了,这边提供几个相关内容的参考地址,如下:

虚拟内存知识可以参考该地址: https://blog.csdn.net/qqxjx/article/details/133852071

物理内存知识可以参考该地址:https://blog.csdn.net/qqxjx/article/details/134119042

虚拟内存与物理内存映射参考地址:https://mp.weixin.qq.com/s/FzTBx32ABR0Vtpq50pwNSA

另外本文后续讲解的内容和概念都基于golang 1.20源码以及后续版本来分析,早期版本不再兼顾。

设计原则

我们前面文章已经分析了 Go 程序如何启动、初始化需要进行的关键步骤、初始化结束后, 主 goroutine 如何被调度器进行调度,即GMP调度。现在我们来看 Go 中另一重要的关键组件:内存分配器。

我们知道,Go内存由**栈内存(Stack)**和 **堆内存(Heap)**两大部分组成:

  • 传统意义上栈内存Go的运行时(runtime)控制,一般不开放给用户态代码;
  • 堆内存被分为了两个部分:
    1. Go 运行时(runtime)自身所需的堆内存,即堆外内存;
    2. Go 用户态代码所使用的堆内存,也叫做 Go 堆,Go 堆负责了用户态对象的存放以及 goroutine 的执行栈。

作为内存分配器,最主要负责的是堆内存,特别是Go 堆部分的内存申请和管理。

Go 的内存分配器基于谷歌的 Thread-Cache Malloctcmalloc 的具体细节在此就不普及了,因为 Go 的内存分配器与 tcmalloc 存在一定差异。

Go内存分配器的核心设计思想是:多级内存分配模块,减少内存分配时锁的使用与系统调用;多尺度内存单元,减少内存分配产生碎片。大白话总结无外乎就是:时间换空间、空间换时间的处理方案罢了。

内存管理组件

Go语言内置运行时(就是runtime),抛弃了传统的内存分配方式,改为自主管理。这样可以自主地实现更好的内存使用模式,比如内存池、预分配等等。这样,不会每次内存分配都需要进行系统调用。

Go 的内存分配器主要包含以下几个核心管理组件:

  • heapArena: 操作系统在虚拟内存中分配给Go语言堆的一段连续内存区域;
  • mheap:分配的堆,负责管理堆内存的分配、回收;
  • mspan:运行时堆管理机制中管理内存的基本单元,表示一个内存区域,是 mheap 上管理的一连串的页;
  • mcentral:中心缓存,每个中心缓存都会管理某个跨度类的内存管理单元;
  • mcache:线程缓存,它会与线程上的P一一绑定,主要用来缓存用户程序申请的微小对象。

下面进行一一着重分析,分析每个管理组件的作用以及和其他组件的关系。

堆区域 — heapArena

runtime.heapArena

Go语言的runtimeGo 堆地址空间划分成了一个一个的arena(堆区域),而arena就是用于存储堆对象的一块连续内存区域。arena区域的起始地址被定义为常量arenaBaseOffset,且与 arena 的大小对齐。

不同平台下虚拟地址大小以及arena大小等都不一样,如下表:

       平台        地址有效位数   单个Arena大小     L1 entries   L2 entries
 --------------   ---------    ----------      ----------  -----------
       */64-bit         48        64MB           1        4M (32MB)
 windows/64-bit         48         4MB          64        1M  (8MB)
      ios/arm64         33         4MB           1       2048  (8KB)
       */32-bit         32         4MB           1       1024  (4KB)
     */mips(le)         31         4MB           1        512  (2KB)

amd64架构的Linux环境下,每个arena的大小是64MB,起始地址也对齐到64MB,而arenapage组成,每个page大小为8KB,可以得出每个arena下有 819264M/8K=8192)个page。相关定义如下:

//go 1.20.3 path: /src/runtime/sizeclasses.go
const (
	_PageShift      = 13
)

//go 1.20.3 path: /src/runtime/goarch.go
const PtrSize = 4 << (^uintptr(0) >> 63)    										// 单个指针大小,32系统下值为4, 64位系统下值为8

//go 1.20.3 path: /src/runtime/malloc.go

const{
  _PageSize = 1 << _PageShift							 											// 2的13次方,即8192字节 = 8K
  pageSize  = _PageSize
  arenaBaseOffset = 0xffff800000000000*goarch.IsAmd64 + 0x0a00000000000000*goos.IsAix  //系统不同值不同
  heapArenaBytes = 1 << logHeapArenaBytes												// 单个arena大小,64位下为 67108864字节 = 64M
  pagesPerArena = heapArenaBytes / pageSize											// 单个arena含page数量,即64m/8K = 8192 个 page
}

amd64架构的Linux环境下arena示意图如下:

image-20220527114751136

作为单个arena,则有runtime.heapArena结构体负责存储元数据和管理,runtime.heapArena结构对象自身存储在Go堆之外,其结构定义如下:

//go 1.20.3 path: /src/runtime/mheap.go

type heapArena struct {
	_            sys.NotInHeap              			// 不用于堆分配的字段标记
	bitmap       [heapArenaBitmapWords]uintptr 		// 位图数组,用于表示哪些内存块已经被分配给对象,哪些内存块是可用的。
	noMorePtrs   [heapArenaBitmapWords / 8]uint8 	// 位图数组,标记堆区域中每个字对应的内存页是否不再包含指针
	spans        [pagesPerArena]*mspan       			// 保存了指向 mspan 结构体的指针数组,表示堆区域的每个内存页的元信息
	pageInUse    [pagesPerArena / 8]uint8    			// 位图数组,标记堆区域中每个内存页是否在使用中
	pageMarks    [pagesPerArena / 8]uint8    			// 位图数组,标记堆区域中每个内存页是否被标记
	pageSpecials [pagesPerArena / 8]uint8    			// 位图数组,标记堆区域中每个内存页是否被特殊标记
	checkmarks   *checkmarksMap              			// 检查标记,用于跟踪堆区域中每个内存页的标记情况
	zeroedBase   uintptr                     			// 用于存储堆区域的基址,表示已清零的内存页的起始地址
}

根据结构体定义,我们可以得出如下示意图:

image-20231207094754905

接下来,分析分析runtime.heapArena结构体的一些重要字段。

  • heapArena.spans

    分析这个字段前,我们先初步了解下span以及对应的mspan结构体(后续会单独分析)。

    我们都知道,linuxpage作为基本的内存分配单元,但是在Go堆里面,span 被作为内存分配的基本单元,span 表示一组连续的page,基于不同需求,

    span将划分为很多不同规格类型,包含着不同连续数量的page(比如2Page组成的span,还可以有4Page组成的span等等)。 而mspan结构体正是负责管理span的结构体。

    了解完span,回到heapArena.spans, 它是个*mspan类型的数组,大小为8192,正好对应arena8192page,所以用于定位一个page对应的mspan在哪儿。

  • heapArena.bitmap & heapArena.noMorePtrs

    heapArena.bitmap 是一个位图数组,用于记录堆内存中的内存块分配情况。

    Go1.18以前,它用2bit位标记一个指针大小的内存单元:

    • 1bit(低4bit位)标记这个arena中一个指针大小的内存单元到底是指针还是标量;
    • 1bit(高4bit位)标记这块内存空间的后续单元是否包含指针,如果存在指针则需要继续扫描,否则终止扫描。

    Go1.18以及之前的heapArena.bitmap 如下图:

    image-20231207144238836

    但是Go1.20之后,heapArena.bitmap 就完全不是这么一回事了。我们先看看新版本heapArena.bitmap数组的长度heapArenaBitmapWords的定义的一些定义:

    //go 1.20.3 path: /src/runtime/malloc.go
    const{
      heapArenaBytes = 1 << logHeapArenaBytes												// 单个arena大小,64位下为 67108864字节 = 64M
      heapArenaWords = heapArenaBytes / goarch.PtrSize							// 单个arena字长数量; 64M/8=8M; 64位的Linux系统,一个heapArena有8M个word,一个word占8个字节
      heapArenaBitmapWords = heapArenaWords / (8 * goarch.PtrSize)	// 单个heapArena的bitmap占用,即8M/64=131072
    }
    

    Go1.20以及之后的版本中,用一个bit位指代一个字长(word size),而非之前的一个byte,在Linux 64系统中,一个字长(word size)为8Bytes,所以一个heapArena管理的内存大小是64MB,则需要64MB/(8*64)=128KBit位。

    除此之外,Go1.20以及之后的版本也取消了用2bit标记内存单元状态的方式,用heapArena.bitmap来表示堆内存中的内存块分配情况,如果某bit位为0,表示对应的内存块是未分配的;如果bit位为1,表示对应的内存块已经被分配给对象。

    而新启用了heapArena.noMorePtrs字段来表标记内存块中不再有指针的数组。它的作用是辅助垃圾回收(GC)过程,特别是在标记和清理阶段。

    heapArena.noMorePtrs数组的每个元素表示一组8个指针的状态。这是因为在Go语言的内存管理中,一个指针的大小通常为8字节(具体取决于特定的架构和编译器),因此一个内存块的长度通常也是8的倍数。将内存块的大小除以8可以得到一组指针的数量,这样每个元素可以表示这些指针的状态。通过这种方式,heapArena.noMorePtrs数组可以更高效地管理内存块的指针状态。

  • heapArena.pageInUse

    heapArena.pageInUse 同样也是一个位图数组,uint8类型,用于标记哪些页面被使用了。实际上,这个位图只标记处于使用状态(mSpanInUse)的span的第一个page

    例如arena中连续三个span分别包含123pagepageInUse位图标记情况如下图所示:

    image-20231207165623394

  • heapArena.pageMarks

    heapArena.pageMarks是一个用于标记内存页(page)是否被垃圾回收的数组。它的作用是在垃圾回收阶段,标记哪些内存页包含被标记的对象,以便在垃圾回收过程中进行特殊处理。

    heapArena.pageMarks数组与heapArena.bitmap数组一起使用。heapArena.bitmap用于记录每个内存页的分配情况,而heapArena.pageMarks用于记录每个内存页中对象的状态。当垃圾回收扫描到一个内存页时,它会检查heapArena.pageMarks数组中相应的元素,以确定该内存页中的对象是否已经被标记为可达。

    如果heapArena.pageMarks数组中相应的元素为true,表示该内存页中的对象已经被标记为可达,那么垃圾回收器会特殊处理该内存页中的对象。例如,在垃圾回收的清理阶段,只有被标记为可达的对象才会被保留,而未被标记的对象则会被释放。

    它的标记方法和heapArena.pageInUse一样,只标记每个span的第一个page

了解完arena的结构体runtime.heapArena,再来看看 arena们在堆中的管理。

arena在堆中,有一个二维数组 mheap.arenas 管理,每个数组单元存储一个 arena结构,并且通过mheap.arenas[index] 来访问。定义如下:

//go 1.20.3 path: /src/runtime/mheap.go
type mheap struct {
    ......
    arenas [1 << arenaL1Bits]*[1 << arenaL2Bits]*heapArena
    arenaHints *arenaHint
    ......
}

那为什么要把mheap.arenas 设计成二维数组呢?这个后面会细说。

arenaHint

在堆中,还有一个跟arena很有关系的字段:arenaHints,它是 arenaHint 链表的节点结构,保存了arena 的起始地址、是否为最后一个 arena,以及下一个 arenaHint 指针。其定义如下:

//go 1.20.3 path: /src/runtime/mheap.go
type arenaHint struct {  
    _    sys.NotInHeap // 标记该字段不会被放入堆中  
    addr uintptr       // 指向内存地址的指针  
    down bool           // 是否向下寻找内存  
    next *arenaHint     // 指向下一个arenaHint的指针  
}

Go的堆是动态按需增长的,初始化的时候并不会向操作系统预先申请一些内存备用,而是等到实际用到的时候才去分配。为避免随机地申请内存造成进程的虚拟地址空间混乱不堪,我们要让堆区从一个起始地址连续地增长,而arenaHint结构就是用来做这件事情的,它提示分配器从哪里分配内存来扩展堆,尽量使堆按照预期的方式增长。我们用一张图来展示arenaHint结构:

image-20231208095731422

arenaIdx

arenaIdx类型底层是个uint,它的主要作用是用来寻址对应的heapArena

给你一个地址p,你怎么定位到相关的heapArena呢?

这个要分两步走:

  1. 将地址p通过计算获取 arenaIdx
  2. arenaIdx通过计算转化为mheap.arenas数组的一维和二维索引:l1l2,从而通过索引定位到相关heapArena存储位置。
地址与arenaIdx 相互转换

amd64架构的Linux环境下,arena的大小和对齐边界都是64MB,所以整个虚拟地址空间都可以看作由一系列arena组成的。arena区域的起始地址被定义为常量arenaBaseOffset,且与 arena 的大小对齐。

用一个给定的地址p减去arenaBaseOffset,然后除以arena的大小heapArenaBytes,就可以得到p所在arena的编号。反之,给定arena的编号,也能由此计算出arena的地址。

相关代码定义如下:

//go 1.20.3 path: /src/runtime/mheap.go

//根据地址计算获取arenaIdx
func arenaIndex(p uintptr) arenaIdx {
	return arenaIdx((p - arenaBaseOffset) / heapArenaBytes)
}

//根据arenaIdx计算获取地址
func arenaBase(i arenaIdx) uintptr {
	return uintptr(i)*heapArenaBytes + arenaBaseOffset
}

地址与arenaIdx编号之间的换算如下图:

image-20231208105948599

arenaIdx寻址对应的heapArena

在使用arenaIdx寻址对应的heapArena前,我们要先了解为什么要将管理堆中arenamheap.arenas数组设计成二维数组?

mheap.arenas定义代码如下:

//go 1.20.3 path: /src/runtime/mheap.go
type mheap struct {
    ......
    arenas [1 << arenaL1Bits]*[1 << arenaL2Bits]*heapArena
    ......
}

我们回忆下,amd64架构上虚拟地址的有效位数是48位,而arena的大小是2^26 = 64MB,两者相差22位,也就是说整个地址空间对应4Marena

我们已经知道每个arena都有一个对应的heapArena结构, 如果用arena的编号作为下标,把所有heapArena的地址放到一个数组中,则这个数组将占用32MB空间。32MB还可以接受,但是在某些系统上就不止32MB了, 在amd64架构的Windows上,受系统原因影响, arena的大小是4MB, 缩小了16倍, 用来寻址heapArena的数组就会相应地变大16倍,那就无法接受了,所以Go的开发者把arenaIdx分成了两段, 把用来寻址heapArena的数组也做成了两级, 有点类似于两级页表, 这就是为什么要把 mheap.arenas数组设计成二维数组的原因。

知道了 mheap.arenas数组的设计原因后,借助下列函数可以将arenaIdx转为 mheap.arenas数组索引的,代码如下:

//go 1.20.3 path: /src/runtime/mheap.go

func (i arenaIdx) l1() uint {
	if arenaL1Bits == 0 {
		return 0
	} else {
		return uint(i) >> arenaL1Shift
	}
}

func (i arenaIdx) l2() uint {
	if arenaL1Bits == 0 {
		return uint(i)
	} else {
		return uint(i) & (1<<arenaL2Bits - 1)
	}
}

amd64架构下,Linux系统中将arenaL1Bits被定义为0Windows系统上被定义为6, 第二级arenaL2Bits的位数等于虚拟地址有效位数48减去单个arena大小对应的位数第一级(arenaL1Bits)的位数, amd64架构下,例如:

  • Linux系统,arena大小为64M(2的26次方),arenaL1Bits0,则 arenaL2Bits = 48 - 26 - 0 = 22
  • Windows系统,arena大小为4M(2的22次方),arenaL1Bits6,则 arenaL2Bits = 48 - 22 - 6 = 20

Linux系统上,第一维数组的大小为1, 相当于没有用到,只用到了第二维这个大小为4M 的数组,arenaIdx全部的22位都用作第二维下标来寻址,如图:

image-20231208144919197

Windows系统上,第一维数组的大小为64,第二维大小为1M,因为两级都存储了指针,利用稀疏数组按需分配的特性,可以大幅节省内存。arenaIdx被分成两段,高6位用作第一维下标,低20位用作第二维下标,如图:

image-20231208144851439

spanOf

在前面我们知道了如何用地址p定位到arena结构体heapArena所在mheap.arenas的位置,那我们怎么去找到p它所在的mspan呢?

答案就是:再用parena的大小取模,得到parena中的偏移量, 然后除以页面(page)大小, 就可以得到对应页面的序号, 将该序号用作heapArena.spans数组的下标,就可以得到mspan的地址了。

runtime中提供了一些函数,专门用来根据给定的地址查找对应的mspan,其中最常用的就是spanOf函数。

该函数在进行映射的同时,还会校验给定的地址是不是一个有效的堆地址,如果有效就会返回对应的mspan指针,如果无效则返回nil,函数的代码如下:

//go 1.20.3 path: /src/runtime/mheap.go

// spanOf 函数根据给定的地址 p,返回包含该地址的内存页的 mspan 结构体指针。
func spanOf(p uintptr) *mspan {
    // 计算地址 p 对应的 arena 索引
    ri := arenaIndex(p)

    // 检查 arenaL1Bits 是否为 0,如果为 0,则表示没有第二级索引
    if arenaL1Bits == 0 {
        // 检查 l2 的值是否超出了第二级 arena 数组的长度
        if ri.l2() >= uint(len(mheap_.arenas[0])) {
            return nil
        }
    } else {
        // 检查 l1 的值是否超出了第一级 arena 数组的长度
        if ri.l1() >= uint(len(mheap_.arenas)) {
            return nil
        }
    }

    // 获取第二级 arena 数组中的 l2 对应的指针
    l2 := mheap_.arenas[ri.l1()]

    // 如果 arenaL1Bits 不为 0 并且 l2 对应的指针为 nil,则返回 nil
    if arenaL1Bits != 0 && l2 == nil {
        return nil
    }

    // 获取 l2 对应的 heapArena 指针
    ha := l2[ri.l2()]

    // 如果 heapArena 指针为 nil,则返回 nil
    if ha == nil {
        return nil
    }

    // 根据地址 p 计算在 heapArena 中 spans 数组的索引,并返回相应的 mspan 结构体指针
    return ha.spans[(p / pageSize) % pagesPerArena]
}

runtime中还有一个spanOfUnchecked函数,与spanOf函数功能类似,只不过移除了与安全校验相关的代码,需要调用者来保证提供的是一个有效的堆地址,函数的代码如下:

func spanOfUnchecked(p uintptr) *mspan {
	ai := arenaIndex(p)
	return mheap_.arenas[ai.l1()][ai.l2()].spans[(p/pageSize)%pagesPerArena]
}

关于arena相关的分析就到这里,期间我们多次提到了mspan,下面我们就来讲讲它。

内存管理单元 — mspan

前面说了,heapArena 的内存大小是64M,直接管理这么粗粒度的内存明显不符合实践。

golang使用span机制来减少碎片, 每个span至少分配1page(8KB), 其大小是page(Go 中的 page 大小为 8KB)的倍数,是Go中内存管理的基本单位。每个span 管理着 arena 中的一块内存。其结构体为runtime.mspan,后续文中,我们所说的spanmspan其实是一个概念,不用过多纠结。

runtime.mspan相同大小等级的 span 的双向链表的一个节点,每个节点还记录了自己的起始地址、指向的 span 中页的数量, 一句话概括: mspan是一个包含起始地址、 mspan规格、页的数量等内容的双端链表。其结构如下:

//go 1.20.3 path: /src/runtime/mheap.go

type mspan struct {
    _                     sys.NotInHeap // 这个字段是一个占位符,表示这个字段不需要在堆内存中分配空间
    next                  *mspan        // 指向下一个 mspan 结构体的指针
    prev                  *mspan        // 指向前一个 mspan 结构体的指针
    list                  *mSpanList    // 指向包含此 mspan 的信息的指针
    startAddr             uintptr       // 这是内存块的起始地址
    npages                uintptr       // 这个字段表示内存块包含的页面数
    manualFreeList        gclinkptr     // 这个字段可能是用于手动管理内存的链表,其中的元素由 gclinkptr 类型表示。
    freeindex             uintptr       // 当前内存块中的空闲索引
    nelems                uintptr       // 当前内存块中的元素数量
    allocCache            uint64        // 分配缓存
    allocBits             *gcBits       // 分配位图
    gcmarkBits            *gcBits       // GC 标记位图
    sweepgen              uint32        // 垃圾回收的分代状态
    divMul                uint32        // 用于内存管理的乘数系数
    allocCount            uint16        // 表示已经分配的内存块的数量
    spanclass             spanClass     // span 类别
    state                 mSpanStateBox // mspan 状态
    needzero              uint8         // 需要清零标志
    isUserArenaChunk      bool          // 是否是用户 arena chunk
    allocCountBeforeCache uint16        // 缓存前的分配计数
    elemsize              uintptr       // 表示每个元素的大小
    limit                 uintptr       // 内存块的界限
    speciallock           mutex         // 一个互斥锁,可能用于保护对特定资源的访问
    specials              *special      // 指向 special 类型的指针,可能用于存储与内存块相关的特殊信息
    userArenaChunkFree    addrRange     // 用户 arena chunk 空闲范围
    freeIndexForScan      uintptr       // 用于追踪内存块的空闲索引以进行扫描操作
}

下面对重点字段进行分析:

  • next,prev,list

    next :指向后一个 mspan 结构体的指针;

    prev :指向前一个 mspan 结构体的指针;

    list :存储着包含该mspan的双向链表的头结点和尾节点结构体的指针。

    nextprev串联起当前规格大小(spanclass) 的mspan节点会构成如下双向链表, 运行时会使用list存储双向链表的头结点和尾节点并在线程缓存以及中心缓存中使用。list字段的类型为runtime.mSpanList,定义如下:

    //go 1.20.3 path: /src/runtime/mheap.go
    type mSpanList struct {
    	_     sys.NotInHeap
    	first *mspan    //头结点
    	last  *mspan		//尾节点
    }
    

    nextprev 双向链表以及list示意图如下:

    image-20231211112336051

  • **spanclass,nelems, elemsize **

    nelems: 某mspan规格类型中含有对象的数量;

    elemsize:某mspan规格类型中单个对象的大小;

    spanclassmspan的规格类型, 其数据类型为spanClass,在了解这个结构前,我们先了解mspan的规格跨度内容。

    前面我们说过go使用span机制来减少碎片, 在Go的内存管理模块中,将内存分配67 种跨度类,就是会有67种不同大小的mspan,每一个跨度类都会存储特定大小的对象并且包含特定数量的页数以及对象。所有的数据都会被预选计算好并存储在 runtime.class_to_sizeruntime.class_to_allocnpages等变量中。来看下源码中的定义:

    //go 1.20.3 path: /src/runtime/sizeclasses.go
    
    // class  bytes/obj  bytes/span  objects  tail waste  max waste  min align
    //     1          8        8192     1024           0     87.50%          8
    //     2         16        8192      512           0     43.75%         16
    //     3         24        8192      341           8     29.24%          8
    //     4         32        8192      256           0     21.88%         32
    //     5         48        8192      170          32     31.52%         16
    //     6         64        8192      128           0     23.44%         64
    //     7         80        8192      102          32     19.07%         16
    //     8         96        8192       85          32     15.95%         32
    //     9        112        8192       73          16     13.56%         16
    //    10        128        8192       64           0     11.72%        128
    //    11        144        8192       56         128     11.82%         16
    //    12        160        8192       51          32      9.73%         32
    //    13        176        8192       46          96      9.59%         16
    //    14        192        8192       42         128      9.25%         64
    //    15        208        8192       39          80      8.12%         16
    //    16        224        8192       36         128      8.15%         32
    //    17        240        8192       34          32      6.62%         16
    //    18        256        8192       32           0      5.86%        256
    //    19        288        8192       28         128     12.16%         32
    //    20        320        8192       25         192     11.80%         64
    //    21        352        8192       23          96      9.88%         32
    //    22        384        8192       21         128      9.51%        128
    //    23        416        8192       19         288     10.71%         32
    //    24        448        8192       18         128      8.37%         64
    //    25        480        8192       17          32      6.82%         32
    //    26        512        8192       16           0      6.05%        512
    //    27        576        8192       14         128     12.33%         64
    //    28        640        8192       12         512     15.48%        128
    //    29        704        8192       11         448     13.93%         64
    //    30        768        8192       10         512     13.94%        256
    //    31        896        8192        9         128     15.52%        128
    //    32       1024        8192        8           0     12.40%       1024
    //    33       1152        8192        7         128     12.41%        128
    //    34       1280        8192        6         512     15.55%        256
    //    35       1408       16384       11         896     14.00%        128
    //    36       1536        8192        5         512     14.00%        512
    //    37       1792       16384        9         256     15.57%        256
    //    38       2048        8192        4           0     12.45%       2048
    //    39       2304       16384        7         256     12.46%        256
    //    40       2688        8192        3         128     15.59%        128
    //    41       3072       24576        8           0     12.47%       1024
    //    42       3200       16384        5         384      6.22%        128
    //    43       3456       24576        7         384      8.83%        128
    //    44       4096        8192        2           0     15.60%       4096
    //    45       4864       24576        5         256     16.65%        256
    //    46       5376       16384        3         256     10.92%        256
    //    47       6144       24576        4           0     12.48%       2048
    //    48       6528       32768        5         128      6.23%        128
    //    49       6784       40960        6         256      4.36%        128
    //    50       6912       49152        7         768      3.37%        256
    //    51       8192        8192        1           0     15.61%       8192
    //    52       9472       57344        6         512     14.28%        256
    //    53       9728       49152        5         512      3.64%        512
    //    54      10240       40960        4           0      4.99%       2048
    //    55      10880       32768        3         128      6.24%        128
    //    56      12288       24576        2           0     11.45%       4096
    //    57      13568       40960        3         256      9.99%        256
    //    58      14336       57344        4           0      5.35%       2048
    //    59      16384       16384        1           0     12.49%       8192
    //    60      18432       73728        4           0     11.11%       2048
    //    61      19072       57344        3         128      3.57%        128
    //    62      20480       40960        2           0      6.87%       4096
    //    63      21760       65536        3         256      6.25%        256
    //    64      24576       24576        1           0     11.45%       8192
    //    65      27264       81920        3         128     10.00%        128
    //    66      28672       57344        2           0      4.91%       4096
    //    67      32768       32768        1           0     12.50%       8192
    
    const (
    	_MaxSmallSize   = 32768
    	smallSizeDiv    = 8
    	smallSizeMax    = 1024
    	largeSizeDiv    = 128
    	_NumSizeClasses = 68
    	_PageShift      = 13
    )
    
    var class_to_size = [_NumSizeClasses]uint16{0, 8, 16, 24, 32, 48, 64, 80, 96, 112, 128, 144, 160, 176, 192, 208, 224, 240, 256, 288, 320, 352, 384, 416, 448, 480, 512, 576, 640, 704, 768, 896, 1024, 1152, 1280, 1408, 1536, 1792, 2048, 2304, 2688, 3072, 3200, 3456, 4096, 4864, 5376, 6144, 6528, 6784, 6912, 8192, 9472, 9728, 10240, 10880, 12288, 13568, 14336, 16384, 18432, 19072, 20480, 21760, 24576, 27264, 28672, 32768}
    var class_to_allocnpages = [_NumSizeClasses]uint8{0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 2, 1, 2, 1, 3, 2, 3, 1, 3, 2, 3, 4, 5, 6, 1, 7, 6, 5, 4, 3, 5, 7, 2, 9, 7, 5, 8, 3, 10, 7, 4}
    

    从上面的规格来看,没有规格0,实际上规格0用来指示所有大对象(大于32768字节)span,这种规格的span直接从堆中分配(其他小的span会从这67种跨度的通过一系列方式获取)。这边以规格 4 解释每一个指标的由来:

     class  bytes/obj  bytes/span  objects  tail waste  max waste  min align
       4         32        8192      256           0     21.88%         32
    
    • class :规格编号,此处为规格4
    • bytes/obj :规格4中,每个object32字节(byte),即mspan中的elemsize属性值;
    • bytes/span :规格4中,每个span的内存大小为为8192字节(8K),即一个page大小,因为一个内存页足够分配32字节的内存空间;
    • objects :规格4中,一个spanobjects个数为256 = 8192/32,即mspan中的nelems属性值;
    • tail wastespan连续分配object后,不足分配1object的剩余空间大小。因为8192字节的内存能完成分配25632字节的object,不存在剩余空间,所以tail waste0
    • max waste : 申请规格4的对象最小内存长度为25字节(小于25字节则申请规格3及以下规格mspan),如果每个object都被25字节的对象申请,此时内存浪费最大,对应浪费率为(32-25)/32=21.88%

    class_to_size变量存储的则是提前计算好的每个规格span中单个object的大小,即存储的是每种mspan中的elemsize属性值;class_to_allocnpages变量存储的是提前计算好的每个规格span需要分配的page8K)数量。

    mspan跨度示意图如下:

    image-20231211144337066

    了解完mspan基本跨度类型后,在回过头来看看 其数据类型为spanClass以及相关方法函数,定义如下:

    //go 1.20.3 path: /src/runtime/mheap.go
    type spanClass uint8
    
    // makeSpanClass 创建 spanClass 类别。
    func makeSpanClass(sizeclass uint8, noscan bool) spanClass {
        // 将 sizeclass 左移 1 位,然后使用按位或操作将 noscan 的布尔值编码到最低位。
        return spanClass(sizeclass<<1) | spanClass(bool2int(noscan))
    }
    
    // sizeclass 返回 spanClass 对应的 sizeclass。
    func (sc spanClass) sizeclass() int8 {
        // 右移 1 位得到原始 sizeclass。
        return int8(sc >> 1)
    }
    
    // noscan 返回 spanClass 是否为 noscan。
    func (sc spanClass) noscan() bool {
        // 通过与 1 进行按位与操作,判断最低位是否为 1。
        return sc&1 != 0
    }
    
    

    go中,会将 spanclass + nocan 两部分信息组装成一个 uint8,形成完整的 spanClass 标识。 8bit 中,高 7 位表示了上表的 span 等级(总共 67 + 1 个等级ID8bit 足够用了),最低位表示 nocan 信息,该标记位表示对象是否包含指针,垃圾回收会对包含指针的 runtime.mspan结构体进行扫描。

  • startAddr,npages

    startAddr: 该mspan起始地址;

    npages: 该mspan含有多少个page,注意的这里的page不是linuxpage概念,是go里面用的8k大小的page

    示意如图:

    image-20231211151834503

  • freeindex,allocCache,allocCount, allocBits

    freeindex:当前mspan中,下一个空对象的索引,即0 ~ nelemes-1,表示分配到第几个块;也就是说freeindex之前的元素(存储对象的空间)均是已经被使用的,freeindex之后的元素可能被使用也可能没被使用;

    allocBits:当前mspan中内存的使用情况的位图标记,1表示该对象已经被申请,0则表示该对象空闲, 注意的是它标记的对象是elem, 并非page 。与heapArena.bitmap不同,mspan这里的位图标记,面向的是划分好的内存块单元,allocBits位图用于标记哪些内存块已经被分配了;

    allocCacheallocBits 的补码,缓存allocBits中一段未使用的位区(64elem)。主要是为了提升性能,如果不做缓存,就需要遍历allocBits中所有的位区,才能找到未使用的位区。通过freeindex allocCache的联合,可以不用遍历,就能找到未使用的位区。allocCache是一连串的bit位,1代表未使用,0代表已使用;

    allocCount:当前mspan中已分配的内存对象数量。

    我们用一张图形象的表示下这几个字段的关系:

    image-20231211173758141

  • gcmarkBits

    gcmarkBits是当前span的标记位图,在GC标记阶段会对这个位图进行标记,一个二进制位对应span中的一个内存块。

    GC清扫阶段会释放掉旧的allocBits,然后把标记好的gcmarkBits用作allocBits,这样未被GC标记的内存块就能回收利用了。当然会重新分配一段清零的内存给gcmarkBits位图。

  • sweepgen

    sweepgen: 垃圾回收的分代状态,主要拥有同mheap中的当前mSpansweepgen进行比较,每次GCh->sweepgen都会 +2 ; 比较分以下几种情况:

    • sweepgen == h->sweepgen - 2, 这个span需要清除;
    • sweepgen == h->sweepgen - 1, 这个span正在被清除;
    • if sweepgen == h->sweepgen, 这个span已经被清除过并且就绪可用;
    • if sweepgen == h->sweepgen + 1, 这个span在清除之前就被缓存且仍在缓存中,需要被清除(很明显这里mSpansweepgen>h.sweepgen,证明已经活过了上一次清除,即被缓存下来);
    • if sweepgen == h->sweepgen + 3, 这个span被清除后缓存,且在缓存中。

    这字段跟GC相关,后续GC章节内容细说,这边不多展开了。

  • state

    state:该字段是来描绘当前mspan的运行状态的。状态值有 mSpanDeadmSpanInUsemSpanManualmSpanFree 四种情况。当 runtime.mspan在空闲堆中,它会处于 mSpanFree 状态;当 runtime.mspan已经被分配时,它会处于 mSpanInUsemSpanManual 状态,运行时会遵循下面的规则转换该状态:

    • 在垃圾回收的任意阶段,可能从 mSpanFree 转换到 mSpanInUsemSpanManual
    • 在垃圾回收的清除阶段,可能从 mSpanInUsemSpanManual 转换到 mSpanFree
    • 在垃圾回收的标记阶段,不能从 mSpanInUsemSpanManual 转换到 mSpanFree

    设置 runtime.mspan状态的操作必须是原子性的以避免垃圾回收造成的线程竞争问题。

线程缓存 — mcache

runtime.mcacheGo 语言中的线程缓存,它会与线程上的处理器(P)一一绑定,主要用来缓存用户程序申请的微小对象(0-31Kb),它是一个包含不同大小等级的 mspan 链表的数组,它将每种 spanClass 等级的 mspan 各缓存了一个,mspan 缓存总数为 2nocan 维度) * 68(大小维度)= 136

runtime.mcache是分配给M运行中的goroutine,是协程级所以无需加锁。因为在M上运行的goroutine只有一个,不会存在抢占资源的情况,所以是无需加锁的。

runtime.mcache源码定义如下:

//go 1.20.3 path: /src/runtime/runtime2.go

type p struct {
  ......
  mcache      *mcache
  ......
}

//go 1.20.3 path: /src/runtime/mcache.go

const (
	numSpanClasses = _NumSizeClasses << 1
	tinySpanClass  = spanClass(tinySizeClass<<1 | 1)
)

type mcache struct {
	_           sys.NotInHeap  // 不在堆上分配的标记
	nextSample  uintptr         // 下一个采样点的地址
	scanAlloc   uintptr         // 扫描时分配的内存大小
	tiny        uintptr         // 用于小对象的内存分配
	tinyoffset  uintptr         // tiny 内存的偏移量
	tinyAllocs  uintptr         // tiny 内存的分配次数
	alloc       [numSpanClasses]*mspan  // 用于不同大小类别的 mspan 链表,存储分配的内存块
	stackcache  [_NumStackOrders]stackfreelist  // 多个大小类别的栈缓存,用于快速分配
	flushGen    atomic.Uint32   // 刷新代数,用于刷新栈缓存
}

下面对主要字段进行解析:

  • alloc

    alloc:该字段主要用于缓存存储不同类别的mspan指针信息,其类型为:[numSpanClasses]*mspan,是一个存储*mspan且大小为numSpanClasses的数组。

    Mcache将同一级别的span是分成2类,一类是可以被GC扫描的span,里面是包含指针的对象;另一类似不可以被GC扫描的span,里面不包含指针的对象。所以alloc数组大小为span规格数量(_NumSizeClasses)的两倍(numSpanClasses),即_NumSizeClasses << 1 = 68 << 1 = 136

    runtime.mcache.alloc示意图如下:

    image-20231212101446401

  • tiny , tinyoffset, tinyAllocs

    mcache 中还有一个为对象分配器 tiny allocator,用于处理小于 16B 对象的内存分配,tiny , tinyoffset, tinyAllocs字段与该对象分配器有关,后续我们会详细分析,此处知道即可。

当小对象申请内存在mache不够时,会继续向mcentral进行申请,下面我们了解下mcentral

中心缓存 — mcentral

mcentral是所有线程共享的的缓存,需要加锁访问;它的主要作用是为mcache提供切分好的mspan资源。我们先来看看其结构体源码:

//go 1.20.3 path: /src/runtime/mecetral.go

type spanClass uint8

type mcentral struct {
	_         sys.NotInHeap
	spanclass spanClass			// mcentral对应的spanClass
	partial [2]spanSet			// 储存空闲的Span的列表 
	full    [2]spanSet			// 储存非空闲Span的列表
}

每个mcentral 管理一种spanClassmspan, 分别存储包含空闲对象(mcentral.partial)和不包含空闲对象(mcentral.full)的内存管理单元:

  • mcentral.partial 用于存储那些部分分配了内存的 span。所谓“部分分配”,意味着这些 span 中的一些内存块已经被分配给对象,但还有其他内存块是空闲的。
  • mcentral.full 用于存储那些完全分配了内存的 span。在这些 span 中,所有内存块都已经被分配,没有剩余的空闲空间。

每种内存管理单元同时持有两个 runtime.spanSet,表示 *mspans 集, 一个用在扫描 spans,另一个用在未扫描spans。在每轮GC期间都扮演着不同的角色。这也是mheap_.sweepgen 在每轮GC期间都会递增2的原因。我们可以根据当前的垃圾回收周期(sweep generation)选择相应mcentral.partialmcentral.full持有的不同扫描状态的 spanSet

//go 1.20.3 path: /src/runtime/mcentral.go

func (c *mcentral) partialUnswept(sweepgen uint32) *spanSet {
    // 获取当前垃圾回收周期未清扫的部分分配的 spanSet。
    return &c.partial[1-sweepgen/2%2]
}

func (c *mcentral) partialSwept(sweepgen uint32) *spanSet {
    // 获取当前垃圾回收周期已清扫的部分分配的 spanSet。
    return &c.partial[sweepgen/2%2]
}

func (c *mcentral) fullUnswept(sweepgen uint32) *spanSet {
    // 获取当前垃圾回收周期未清扫的完全分配的 spanSet。
    return &c.full[1-sweepgen/2%2]
}

func (c *mcentral) fullSwept(sweepgen uint32) *spanSet {
    // 获取当前垃圾回收周期已清扫的完全分配的 spanSet。
    return &c.full[sweepgen/2%2]
}

来看看runtime.spanSet定义:

//go 1.20.3 path: /src/runtime/mspanset.go

type spanSet struct {
	spineLock mutex                     // spine 的互斥锁
	spine     atomicSpanSetSpinePointer // 指向 spanSetSpine 结构的原子指针
	spineLen  atomic.Uintptr            // spine 的长度
	spineCap  uintptr                   // spine 的容量
	index     atomicHeadTailIndex       // 原子的头尾指针,前32位是头指针,后32位是尾指针
}

type atomicHeadTailIndex struct {
	u atomic.Uint64
}

spanSet这个数据结构里面有一个由index组成的头尾指针,pop数据的时候会从head获取,push数据的时候从tail放入,spine相当于数据块的指针,通过headtail的位置可以算出每个数据块的具体位置,数据块由spanSetBlock表示:

//go 1.20.3 path: /src/runtime/mspanset.go
const (
	spanSetBlockEntries = 512
)

type spanSetBlock struct {
	lfnode                   												// lock-free 节点
	popped atomic.Uint32     												// 弹出的标记位
	spans  [spanSetBlockEntries]atomicMSpanPointer  // 存储 mspan 的数组
}

spanSetBlock是一个存放mspan的数据块,里面会包含一个存放512mspan的数据指针。所以mcentral的总体数据结构如下:

image-20231212144129791

值得注意的是mcentral链表都在mheap.central中进行维护, mcentral存储67级别大小span,其中class=0是不使用的, 每一级别的span分为2种,分别为有空间 mspan 链表 partial 和满空间 mspan 链表 full,代码如下:

//go 1.20.3 path: /src/runtime/mheap.go
type mheap struct {
	 central [numSpanClasses]struct {
		  mcentral mcentral
		  pad      [(cpu.CacheLinePadSize - unsafe.Sizeof(mcentral{})%cpu.CacheLinePadSize) % cpu.CacheLinePadSize]byte
	}
}

示意图如下:

image-20231212151245495

mcentral 列表中也没有可分配的 span 时,则会向 mheap 提出请求,从而获得 新的 span,并进而交给 mcache。下面来讲解页堆。

页堆 — mheap

mheap代表Go中所持有的堆空间,mcentral管理的span也是从这里拿到的。当mcentral没有空闲span时,会向mheap申请,如果mheap中也没有资源了,会向操作系统来申请内存。向操作系统申请是按照页为单位来的(4kb),然后把申请来的内存页按照page8kb)、spanpage的倍数)、chunk512kb)、heapArena64m)这种级别来组织起来。

mheap的结构体是runtime.mheap,是内存分配的核心结构体,Go 语言程序会将其作为全局变量存储,而堆上初始化的所有对象都由该结构体统一管理,runtime.mheap定义如下:

//go 1.20.3 path: /src/runtime/mheap.go

type mheap struct {
	_                  sys.NotInHeap
	lock               mutex                      // 锁,用于保护堆操作
	pages              pageAlloc                  // 用于分配和释放操作系统内存页
	sweepgen           uint32                     // 垃圾回收的代数
	allspans           []*mspan                   // 所有的 mspan 列表
	pagesInUse         atomic.Uintptr             // 正在使用的页数
	pagesSwept         atomic.Uint64              // 已经清扫的页数
	pagesSweptBasis    atomic.Uint64              // 基础的已清扫页数
	sweepHeapLiveBasis uint64                     // 基础的堆存活字节数
	sweepPagesPerByte  float64                    // 每字节的清扫页数

	reclaimIndex   atomic.Uint64                   // 待回收的页的索引
	reclaimCredit  atomic.Uintptr                  // 回收信用

	arenas            [1 << arenaL1Bits]*[1 << arenaL2Bits]*heapArena // 堆的 arena 结构
	heapArenaAlloc    linearAlloc                   // arena 的线性分配器
	arenaHints        *arenaHint                    // arena 提示
	arena             linearAlloc                   // arena 的线性分配器
	allArenas         []arenaIdx                    // 所有 arena 的索引
	sweepArenas       []arenaIdx                    // 清扫的 arena 列表
	markArenas        []arenaIdx                    // 标记的 arena 列表
	curArena          struct {                      // 当前 arena 的信息
		base, end uintptr
	}

	central           [numSpanClasses]struct {      // 各个 spanClass 的中心缓存
		mcentral mcentral
		pad      [cpu.CacheLinePadSize - unsafe.Sizeof(mcentral{})%cpu.CacheLinePadSize]byte
	}

	spanalloc             fixalloc                    // mspan 结构的分配器
	cachealloc            fixalloc                    // mcache 结构的分配器
	specialfinalizeralloc fixalloc                    // 特殊 finalizer 结构的分配器
	specialprofilealloc   fixalloc                    // 特殊 profile 结构的分配器
	specialReachableAlloc fixalloc                    // 特殊可达结构的分配器
	speciallock           mutex                       // 特殊分配器的锁
	arenaHintAlloc        fixalloc                    // arena 提示的分配器

	userArena struct {                                // 用户 arena 的信息
		arenaHints     *arenaHint                    // 用户 arena 提示
		quarantineList mSpanList                     // 用户 arena 的 quarantine 列表
		readyList      mSpanList                     // 用户 arena 的 ready 列表
	}
  
	unused *specialfinalizer                          // 未使用的特殊 finalizer
}

该结构体中包含两组非常重要的字段,其中一个是全局的中心缓存列表 central,另一个是管理堆区内存区域的 arenas 以及相关字段,这两个字段以及相关信息都在各自内容部分已经做了分析,这里就不再重复,下面着重看看runtime.mheap一些其他重要概念和字段。

pageAlloc分配器

结构 — pageAlloc

runtime.mheap.pages字段是个类型为pageAlloc的分配器,该分配器是堆分配器的一部分,用于管理和分配内存页面。Go 使用page作为内存管理的基本单位,每页通常为 8KB 大小。pageAlloc 分配器负责在堆中直接高效地分配、释放和合并这些页面,其设计旨在处理大块内存的分配和回收,与处理小块内存分配的分配器(如 fixallocmcache)形成互补。

pageAlloc结构体定义为:

//go 1.20.3 path: /src/runtime/mpagealloc.go

type pageAlloc struct {
    // summary 是一个多级数组,用于快速总结内存页的分配状态。
    // 它是一个层级化的数据结构,每一级都以压缩的方式提供了对其所覆盖的内存页的分配情况的概述。
    summary [summaryLevels][]pallocSum

    // chunks 是一个两级的直接映射表,用于表示内存块(chunk)的分配数据。
    // 每个 chunk 表示一组页面,chunks 使得可以快速访问和管理这些页面。
    chunks [1 << pallocChunksL1Bits]*[1 << pallocChunksL2Bits]pallocData

    // searchAddr 代表下一次内存分配搜索的起始地址。
    // 它帮助 pageAlloc 记住上一次搜索结束的位置,以实现高效的下一次搜索。
    searchAddr offAddr

    // start 和 end 表示目前堆内存中 chunk 的起始索引和结束索引。
    // 它们定义了当前堆空间的边界。
    start, end chunkIdx

    // inUse 表示当前正在使用的内存地址范围。
    // 这是一个地址范围的集合,标记了哪些内存正在被使用。
    inUse addrRanges

    // scav 结构体包含关于已释放内存页(被清扫的页)的信息。
    scav struct {
        // index 是用于记录和查询被清扫页的索引。
        index scavengeIndex
        // released 表示已经被释放回操作系统的内存总量。
        released uintptr
    }

    // mheapLock 是用来同步访问堆内存的互斥锁。
    mheapLock *mutex

    // sysStat 是用于记录内存统计信息的指针。
    sysStat *sysMemStat

    // summaryMappedReady 是一个标记,表示 summary 数组是否已经准备好被使用。
    // 如果为 0,则表示 summary 还没有完全映射到内存中。
    summaryMappedReady uintptr

    // test 是一个用于测试的布尔标志。
    test bool
}

这个结构体定义了 Go 运行时内存分配器如何跟踪内存页的分配状态,以及如何高效地进行内存分配和回收。不同字段和子结构体共同协作,以优化内存操作和垃圾收集的性能。下面对重点字段进行分析:

  • chunks

    chunk 是堆内存中的一块连续区域,通常包含多个内存页,被用来跟踪和管理它包含的内存页的分配状态。这包括哪些页被分配,哪些是空闲的,以及它们是否可以被回收。

    chunks数组表示的是表示了地址空间内所有chunkpage分配和scavenge情况,是个2级的直接映射表,其第一维的大小为: 1 << pallocChunksL1Bits = 1 << 13 = 8192, 其二维的大小为:1 << pallocChunksL2Bits = 1 << 13 = 8192, 所以chunks数组最终就是一个[8192]*[8192]pallocData数组。

    我们知道 L4 层最大有 2^26^4M的块,如果将 chunks定义成一维数组来存储是一个相当庞大的数据,性能会大大降低。因此将其设计为二维数组[8192]*[8192]这个维度,能容纳元素数量正好是 2^26^,完美的将数组维度降低了,方便了存储。

    我们再来仔细看看chunks [1 << pallocChunksL1Bits]*[1 << pallocChunksL2Bits]pallocData,这个数组结构是不是有点眼熟,能不能想起它来:

    arenas [1 << arenaL1Bits]*[1 << arenaL2Bits]*heapArena,对,就是它arenaarenachunks在存储设计上如出一辙,arena可以通过arenaIdx来互相转换成第一维度L1和第二维度L2的值,而chunks也可以通过chunkIdx完成同样的转换,来看下chunkIdx的定义和转换方法:

    //go 1.20.3 path: /src/runtime/mpagealloc.go
    type chunkIdx uint
    
    //计算给定地址相对于块索引(chunk index)
    func chunkIndex(p uintptr) chunkIdx {
    	return chunkIdx((p - arenaBaseOffset) / pallocChunkBytes)
    }
    
    //计算给定chunk index相对于地址
    func chunkBase(ci chunkIdx) uintptr {
    	return uintptr(ci)*pallocChunkBytes + arenaBaseOffset
    }
    
    //通过chunkIdx计算该索引在pageAlloc.chunks数组中的一维index
    func (i chunkIdx) l1() uint {
    	if pallocChunksL1Bits == 0 {
    		return 0
    	} else {
    		return uint(i) >> pallocChunksL1Shift
    	}
    }
    
    //通过chunkIdx计算该索引在pageAlloc.chunks数组中的二维index
    func (i chunkIdx) l2() uint {
    	if pallocChunksL1Bits == 0 {
    		return uint(i)
    	} else {
    		return uint(i) & (1<<pallocChunksL2Bits - 1)
    	}
    }
    

    当然pageAlloc分配器还提供了更加方便的函数runtime.pageAlloc.chunkOf,通过chunkIdx直接获取数组存储的对象指针:

    //go 1.20.3 path: /src/runtime/mpagealloc.go
    func (p *pageAlloc) chunkOf(ci chunkIdx) *pallocData {
    	return &p.chunks[ci.l1()][ci.l2()]
    }
    

    了解完存储和查找的知识点,再来看看其存储的pallocData是个何方神圣,其定义如下:

    //go 1.20.3 path: /src/runtime/mpallocbits.go
    
    type pallocBits pageBits
    type pageBits [pallocChunkPages / 64]uint64   //pallocChunkPages值为512,所以pageBits可以看做[8]uint64
    
    type pallocData struct {
    	pallocBits
    	scavenged pageBits													//GC相关
    }
    

    每一个chunk都有一个pallocData结构,其中pallocBits管理其分配的位图。pallocBits是一个uint64的大小为8的数组。由于每一位bit对应着一个page,因此pallocBits总共对应着64*8 = 512page,恰好是一个chunk块的大小。位图的对应方式和之前是一样的。结构关系图如下:

    image-20220921151819465

    关于 pallocBits 相关位图操作,源码中提供了下了方法以方便我们操作:

    //go 1.20.3 path: /src/runtime/mpallocbits.go
    
    //返回位图中第 i 个位的值
    func (b *pageBits) get(i uint) uint {
    	return uint((b[i/64] >> (i % 64)) & 1)
    }
    
    // 返回包含第 i 个位的 64 位对齐的位块
    func (b *pageBits) block64(i uint) uint64 {
    	return b[i/64]
    }
    
  • searchAddr

    searchAddr表示搜索起始地址,存储了一个地址值,这个值指示了 pageAlloc 在搜索可用内存页面时的起始位置。这是一个优化,帮助快速定位到可能的空闲内存区域。

  • start, end

    start:表示 pageAlloc 管理的内存区域的起始地址,它标记了可用于分配的内存页面区域的开始。

    end: 表示 pageAlloc 管理的内存区域的结束地址,这个字段定义了可用于页面分配的堆内存区域的上界,其值通常是动态变化的,它会随着堆的增长而增长。

  • summay

    summary 是一个多级数据结构,该结构提供了一种层级化的方式来索引和查找 chunks。它类似于一本书的目录,其中高层级的 summary 快速指导到包含空闲页的 chunks。也就是说summary 分为多层,每层存储着不同数量的 chunk。通过 summarypageAlloc 可以迅速定位到有空闲页的 chunks,而无需逐个检查每个 chunk。这大大提升了内存分配的效率。

    summary 维度以及层级包含chunk 数量由 summaryLevelslevelBitslevelLogPages等和其他相关常量定义,我们给出其在64位系统下相关常量代码定义:

    //go 1.20.3 path: /src/runtime/mpagealloc.go
    
    const (
        // summaryLevels 定义了概要信息的层数,是层级化概要的深度。
        summaryLevels = 5
    
        // pageAlloc32Bit 和 pageAlloc64Bit 是用于标识系统是32位还是64位的常量,
        // 这对于地址空间的大小和页面大小有影响。
        pageAlloc32Bit = 0
        pageAlloc64Bit = 1
    
        // pallocChunksL1Bits 定义了第一级 chunks 数组的大小。
        // 这个值影响了 chunks 数组的分段,这是内存分配表的一部分。
        pallocChunksL1Bits = 13
      
        // summaryLevelBits 定义了除了顶层外,每个概要层级的位数。
        // 在层级化的概要信息中,每一层(除了顶层)使用这个值作为位数来表示更大区域的页面分配状态。
        summaryLevelBits = 3
    
        // logPallocChunkPages 是 pallocChunkPages 的对数表示,
        // 其中 pallocChunkPages 表示每个 chunk 包含的页数。
        // 这个值用于在位图和其他数据结构中定位和管理页面。
        logPallocChunkPages = 9
    
        // _PageShift 是系统页面大小的对数表示,通常是系统分配内存的最小单位(通常为 4KiB)。
        // 这个值用于将页面大小从字节转换为页数。
        _PageShift = 13
    
        // pageShift 是_PageShift的别名,用于表示一个页面的大小的位移量。
        pageShift = _PageShift
    
        // logPallocChunkBytes 是每个 chunk 的字节大小的对数表示。
        // 它是由每个 chunk 的页面数的对数(logPallocChunkPages)加上每页的字节大小的对数(pageShift)计算得出的。
        logPallocChunkBytes = logPallocChunkPages + pageShift
    
        // summaryL0Bits 是顶层概要层级的位数。
        // 它根据堆地址空间的位数(heapAddrBits)减去一个 chunk 的字节大小的对数(logPallocChunkBytes)
        // 和其他概要层级占用的位数来计算。
        // 这是最顶层概要的位数,它覆盖了堆中最大的区域。
        summaryL0Bits = heapAddrBits - logPallocChunkBytes - (summaryLevels-1)*summaryLevelBits
    
    )
    
    // levelBits 是一个数组,定义了每个层级逐层的chunk的数量对象,即radix tree的逐层基数位
    // 这些位代表了内存中的页面,用于快速检查页面的分配状态。
    var levelBits = [summaryLevels]uint{
        summaryL0Bits, 		// 第 0 层的位数,值为14,可以计算获取chunk数量为:2的14方
        summaryLevelBits, // 第 1 层的位数,值为14+3=17,可以计算获取chunk数量为:2的17方
        summaryLevelBits, // 第 2 层的位数,值为17+3=20,可以计算获取chunk数量为:2的20方
        summaryLevelBits, // 第 3 层的位数,值为20+3=23,可以计算获取chunk数量为:2的23方
        summaryLevelBits, // 第 4 层的位数,值为23+3=26,可以计算获取chunk数量为:2的26方
    }
    
    // levelShift 是一个数组,定义了每个层级chunk的容量的对数值。
    // 这是计算某个内存地址在概要信息中位置时使用的位移量。
    var levelShift = [summaryLevels]uint{
        heapAddrBits - summaryL0Bits,             				// 第 0 层的位移量,即48-14=34,容量大小为:2的34方=16G
        heapAddrBits - summaryL0Bits - 1*summaryLevelBits, // 第 1 层的位移量,即34-3=31,容量大小为:2的31方=2G
        heapAddrBits - summaryL0Bits - 2*summaryLevelBits, // 第 2 层的位移量,即31-3=28,容量大小为:2的28方=256M
        heapAddrBits - summaryL0Bits - 3*summaryLevelBits, // 第 3 层的位移量,即28-3=25,容量大小为:2的25方=32M
        heapAddrBits - summaryL0Bits - 4*summaryLevelBits, // 第 4 层的位移量,即25-3=22,容量大小为:2的22方=4M
    }
    
    // levelLogPages 是一个数组,定义了每个层级对应的页面数量的对数值。
    // 这有助于确定一个层级概要所代表的页面数量。
    var levelLogPages = [summaryLevels]uint{
        logPallocChunkPages + 4*summaryLevelBits, // 第 0 层的页面对数,即9+12=21,page数量为:2的21方=2097152
        logPallocChunkPages + 3*summaryLevelBits, // 第 1 层的页面对数,即9+9=18,page数量为:2的18方=262144
        logPallocChunkPages + 2*summaryLevelBits, // 第 2 层的页面对数,即9+6=15,page数量为:2的15方=32768
        logPallocChunkPages + 1*summaryLevelBits, // 第 3 层的页面对数,即9+3=12,page数量为:2的15方=4096
        logPallocChunkPages,                      // 第 4 层的页面对数,即9,page数量为:2的9方=512
    }
    
    

    根据上述常量定义,我们将summary 捋一捋:

    • summary数组第一维度层级由summaryLevels常量决定,该值为5,则说明summary数组第一维度分为5层,我们分别称这五层为L0-L4
    • summary数组每层包含的chunk数量、chunk大小容量、chunk包含page数量分别由常量levelBitslevelShiftlevelLogPages定义,这些常量定义的为对数值,求其对数平方就可以求的值,这些在代码注释上解释的非常清楚了。

    根据上面值定义,我们可以得出pageAlloc.summary 最终示意图如下:

    image-20231214103231933

    说完summary数组的划分,我们再来看看summary数组存储的数据类型:pallocSum,其定义如下:

    //go 1.20.3 path: /src/runtime/mpagealloc.go
    type pallocSum uint64
    

    pallocSum其数据类型就是一个uint64数字,其占了8字节内存,含有64bit,将pallocSum划分成3部分:startmaxend,如下图:

    image-20231214105724851

    startmaxend每一个都是位图的摘要,每部分能分到21bit,我们知道L0层每个chunk16G,每个page8KB, 所以每个chunk最多支持2^34/2^13=2^21page,而21bit能表示的数最大恰好为2^21-1,因此是满足使用条件的,在极限条件下,可以通过设置第 64位来表示。

    相关代码定义如下:

    //go 1.20.3 path: /src/runtime/mpagealloc.go
    
    //从pallocSum中提取起始值。
    //实现方式:首先检查pallocSum是否包含特定的位标记(即最高位是否为1,表示为 1<<63)。如果是,返回一个最大值(maxPackedValue)。否则,返回pallocSum的低位部分,这部分代表起始值。
    func (p pallocSum) start() uint {
    	if uint64(p)&uint64(1<<63) != 0 {
    		return maxPackedValue
    	}
    	return uint(uint64(p) & (maxPackedValue - 1))
    }
    
    //从pallocSum中提取最大值
    //实现方式:类似于 start(),先检查是否有特定位标记。如果有,同样返回最大值。如果没有,它会将pallocSum向右移动一定位数(logMaxPackedValue),然后与 maxPackedValue - 1 进行按位与操作,以提取中间部分的最大值。
    func (p pallocSum) max() uint {
    	if uint64(p)&uint64(1<<63) != 0 {
    		return maxPackedValue
    	}
    	return uint((uint64(p) >> logMaxPackedValue) & (maxPackedValue - 1))
    }
    
    //从pallocSum中提取结束值
    //这个函数也首先检查是否有特定的位标记。如果有,返回最大值。否则,它将pallocSum向右移动更多的位数(两倍的 logMaxPackedValue),然后执行按位与操作,以提取打包总和中的结束值部分
    func (p pallocSum) end() uint {
    	if uint64(p)&uint64(1<<63) != 0 {
    		return maxPackedValue
    	}
    	return uint((uint64(p) >> (2 * logMaxPackedValue)) & (maxPackedValue - 1))
    }
    

    讲到这里,那么pallocSumstartmaxend的含义是啥呢?其实这些都是表示的是块内的page的分配情况。

    我们用L4层举个例子,每个块chunk大小为4M,也就是有512page,编号(index)从0511。那么:

    • start :表示这512page中从第几个page是空闲的,小于start编号的page是都已分配出去了;
    • end : 表示512page中分配出去page的结束索引编号;
    • max :表示这512page中连续为0(即未分配出去) 一片区域最大有多少个page

    image-20231214114632556

    上图中最大连续的空闲块数即max的值为3,这时候假设要分配一个npages的值为3的空间,因为max>=npages,所以肯定可以从当前的chuck中分配到内存。假如npages的值为4,这时候max<npages,显然是从这个chuck中找不到一个块有npages页面的区域的,所以不用一个一个遍历当前chuck中的每个pagepageAlloc.chuck中的pallocData.pallocBits)查找是否满足分配要求了。

    下面来分析解决两个问题:

    1. 对于给定的地址addr,怎么知道这个addr在哪个chunk中?

    因为对整个虚拟内存空间采用的是平坦划分,所以对于任意给定的地址addr除以单个chunk的大小,就可以定位到这个地址属于哪个块(chunkIdx),对于L4来说,每个chunk4M,直接看源码:

    type chunkIdx uint
    pallocChunkBytes    = pallocChunkPages * pageSize // 就是L4的块大小4M
    func chunkIndex(p uintptr) chunkIdx {
    	return chunkIdx((p - arenaBaseOffset) / pallocChunkBytes)
    }
    

    转为公式就是:
    c h u n k I d x = ( a d d r − a r e n a B a s e O f f s e t ) / p a l l o c C h u n k B y t e s chunkIdx = (addr - arenaBaseOffset) / pallocChunkBytes chunkIdx=(addrarenaBaseOffset)/pallocChunkBytes
    除了定位到chunkIdx,也可以根据地址直接定位到page

    func chunkPageIndex(p uintptr) uint {
    	return uint(p % pallocChunkBytes / pageSize)
    }
    

    转为公式就是:
    c h u n k p a g e I n d e x = a d d r % p a l l o c C h u n k B y t e s / p a g e S i z e chunkpageIndex = addr\%pallocChunkBytes / pageSize chunkpageIndex=addr%pallocChunkBytes/pageSize

    1. 从某个块i中找到了空闲的page,该page索引为n,怎么得到空闲page对应的内存地址addr?

      我们可以先计算出 i 块的base地址,base地址计算如下:

      func chunkBase(ci chunkIdx) uintptr {
      	return uintptr(ci)*pallocChunkBytes + arenaBaseOffset
      }
      

      计算出块的base地址后,再加上npage 的 地址大小即可,转为公式即:
      a d d r = ( i ∗ p a l l o c C h u n k B y t e s + a r e n a B a s e O f f s e t ) + n ∗ p a g e S i z e addr = (i * pallocChunkBytes + arenaBaseOffset) + n * pageSize addr=(ipallocChunkBytes+arenaBaseOffset)+npageSize

初始化 — pageAlloc.init

pageAlloc分配器初始化是在mehp初始化中被调用的,主要负责初始化 pageAlloc 结构体,这是内存管理系统的一部分。其代码如下:

//go 1.20.3 path: /src/runtime/mpagealloc.go

//初始化 pageAlloc 结构体。该方法负责设置初始状态和参数。
func (p *pageAlloc) init(mheapLock *mutex, sysStat *sysMemStat) {
    
    // 检查是否满足一些基本条件,保证内存分配的逻辑和限制正确。
    // levelLogPages[0] 表示的是最顶层页表的日志大小。
    // 如果这个值大于 logMaxPackedValue,说明分页大小超出了预期,这是一个异常情况。
    if levelLogPages[0] > logMaxPackedValue {
        print("runtime: root level max pages = ", 1<<levelLogPages[0], "\n")
        print("runtime: summary max pages = ", maxPackedValue, "\n")
        
        // 如果条件不满足,则抛出异常。
        throw("root level max pages doesn't fit in summary")
    }

    // 设置内存统计结构体。
    p.sysStat = sysStat

    // 初始化 inUse 字段,它追踪哪些页面正在使用中。
    p.inUse.init(sysStat)

    // 执行系统级别的初始化操作,涉及到操作系统层面的内存分配和管理。
    p.sysInit()

    // 设置搜索地址,这是内存分配的起始地址。
    p.searchAddr = maxSearchAddr()

    // 设置堆内存的互斥锁。
    p.mheapLock = mheapLock
}

根据代码,可以看出该函数主要工作是:

  1. 参数检查:验证内存页面的大小设置是否符合预期,确保不超过设定的最大值;
  2. 内存统计设置:关联一个内存统计对象(sysStat)以跟踪内存使用情况;
  3. 初始化内存使用追踪:设置一个用于追踪正在使用中的内存页面的数据结构;
  4. 执行系统级初始化:进行与操作系统相关的内存管理初始化工作;
  5. 设置内存分配起始地址:确定内存分配搜索的起始点,优化内存分配性能;
  6. 设置内存堆互斥锁:引入互斥锁以保障多线程环境下内存分配操作的线程安全。

流程很简单,注释很清楚了,就不再解释了,主要我们看下sysInit()函数,看看它的做了什么,其源码如下:

//go 1.20.3 path: /src/runtime/mpagealloc_bit64.go

func (p *pageAlloc) sysInit() {
    // 遍历 levelShift,这个数组定义了不同级别的chunk的容量对数
    for l, shift := range levelShift {
        // 根据位移计算每个级别的条目数。
        entries := 1 << (heapAddrBits - shift)
        
        // 计算需要预留的内存大小,并确保它按物理页面大小对齐。
        b := alignUp(uintptr(entries)*pallocSumBytes, physPageSize)
        
        // 为页面摘要信息预留内存。如果预留失败,则抛出异常。
        r := sysReserve(nil, b)
        if r == nil {
            throw("failed to reserve page summary memory")
        }
        
        // 将预留的内存转换为不在堆上的切片。
        sl := notInHeapSlice{(*notInHeap)(r), 0, entries}
        
        // 将转换后的切片赋值给 p.summary[l],用于存储页面摘要。
        p.summary[l] = *(*[]pallocSum)(unsafe.Pointer(&sl))
    }

    // 计算用于跟踪空闲和占用内存块的位图所需的字节数。
    nbytes := uintptr(1<<heapAddrBits) / pallocChunkBytes / 8

    // 为位图预留内存。如果预留失败,则抛出异常。
    r := sysReserve(nil, nbytes)

    // 将预留的内存转换为不在堆上的切片,用于存储位图。
    sl := notInHeapSlice{(*notInHeap)(r), int(nbytes), int(nbytes)}

    // 将转换后的切片赋值给 p.scav.index.chunks,用于跟踪内存的回收状态。
    p.scav.index.chunks = *(*[]atomic.Uint8)(unsafe.Pointer(&sl))
}

在这个方法中,pageAlloc 结构体通过预留并设置内存来初始化它的内部数据结构,这些数据结构用于跟踪和管理内存分配。主要包括为页面摘要信息和内存块的位图预留内存,以及将这些预留的内存区域转换为特定的切片类型以便后续使用。这是 Go 语言内存管理系统的一部分,确保内存分配的高效和有序。

分配 — pageAlloc.alloc

pageAlloc.alloc 函数的主要职责是在 Go 运行时环境中分配一定数量的内存页。

这个过程不仅需要确保内存的有效利用,还需要考虑线程安全和性能优化。函数通过一系列复杂的步骤来搜索、验证和分配内存,这些步骤包括验证锁的状态、搜索足够的连续内存空间以及处理可能出现的异常情况。

代码定义如下:

//go 1.20.3 path: /src/runtime/mpagealloc.go

func (p *pageAlloc) alloc(npages uintptr) (addr uintptr, scav uintptr) {
    //断言确保已经持有了 mheapLock。这是一个同步机制,用于确保在多线程环境中对堆内存的操作是安全的。
    assertLockHeld(p.mheapLock)

    // 检查 pageAlloc 的搜索起始地址是否已经超出了其维护的内存区域的末端。如果是,就直接返回 0,表示没有找到足够的空间。
    if chunkIndex(p.searchAddr.addr()) >= p.end {
        return 0, 0
    }

    // 设置搜索地址的初始值为最小的偏移地址。
    searchAddr := minOffAddr

    // 检查当前搜索地址的块内是否有足够的空间来分配请求的页数。
    if pallocChunkPages-chunkPageIndex(p.searchAddr.addr()) >= uint(npages) {
        // 计算当前搜索地址所在的块索引。
        i := chunkIndex(p.searchAddr.addr())

        // 获取当前块的最大可用连续页数,并检查是否满足请求的页数。
        if max := p.summary[len(p.summary)-1][i].max(); max >= uint(npages) {
            // 在当前块中查找足够大的连续空间来分配请求的页数。同时获取更新后的搜索地址。
            j, searchIdx := p.chunkOf(i).find(npages, chunkPageIndex(p.searchAddr.addr()))

            // 如果没有找到足够的空间,打印错误信息并抛出异常。
            if j == ^uint(0) {
                print("runtime: max = ", max, ", npages = ", npages, "\n")
                print("runtime: searchIdx = ", chunkPageIndex(p.searchAddr.addr()), ", p.searchAddr = ", hex(p.searchAddr.addr()), "\n")
                throw("bad summary data")
            }

            // 计算找到的空间的起始地址,并更新搜索地址。
            addr = chunkBase(i) + uintptr(j)*pageSize
            searchAddr = offAddr{chunkBase(i) + uintptr(searchIdx)*pageSize}
            goto Found
        }
    }

    // 如果之前的步骤没有找到足够的空间,调用 find 方法在整个 pageAlloc 中搜索。
    addr, searchAddr = p.find(npages)

    // 如果仍然没有找到足够的空间,对于请求的页数为 1 的情况,设置搜索地址为最大值,并返回 0。
    if addr == 0 {
        if npages == 1 {
            p.searchAddr = maxSearchAddr()
        }
        return 0, 0
    }

Found:
    // 在找到的地址范围内进行实际的内存分配,并返回分配的内存地址和清理的页数。
    scav = p.allocRange(addr, npages)

    // 更新 pageAlloc 的搜索地址为新的搜索起点。
    if p.searchAddr.lessThan(searchAddr) {
        p.searchAddr = searchAddr
    }

    // 返回分配的内存地址和清理的页数。
    return addr, scav
}

总结该函数主要流程:

  1. 确认已经持有对应的 mheapLock,检查 searchAddr 是否超出了内存分配区的末端,如果是,返回 (0, 0) 表示没有足够的空间进行分配;
  2. 初始化 searchAddr 为最小偏移地址 (minOffAddr),判断当前 searchAddr 对应的 chunk 中是否有足够的空间分配 npages 个页面;
  3. 如果有足够的空间,查找具体可用的连续页面的开始地址。如果找到了连续页面,计算页面的地址,更新 searchAddr,并跳转到标签 Found
  4. 如果在当前 chunk 中没有找到足够的页面,则调用 find 方法搜索整个堆以找到足够的空间;
    • 如果 find 方法找到了足够的空间,那么 addr 将被设置为分配到的内存地址;
    • 如果 find 方法没有找到足够的空间,且请求的页面数为 1,那么设置 searchAddr 为最大搜索地址,然后返回 (0, 0)
  5. Found 标签处,调用 allocRange 方法实际分配内存页,更新 searchAddr 为较大的值,并返回内存地址和清理内存页的数量。
查找 — pageAlloc.find

pageAlloc.find主要任务是遍历 pageAlloc 的多级摘要结构,寻找足够大的连续空闲内存区域以满足指定大小(npages)的内存分配请求。它使用了一系列复杂的逻辑来高效地搜索和计算空闲内存区域的地址,确保找到的内存区域不仅足够大,而且满足一定的地址约束。如果找不到合适的空间,它会打印错误信息并抛出异常。其代码定义如下:

//go 1.20.3 path: /src/runtime/mpagealloc.go

func (p *pageAlloc) find(npages uintptr) (uintptr, offAddr) {
    // 确保拥有内存堆锁,这是为了线程安全。
    assertLockHeld(p.mheapLock)

    // i 用于遍历 pageAlloc 的摘要层级。
    i := 0

    // firstFree 用于记录找到的第一个合适的空闲内存区域。
    firstFree := struct {
        base, bound offAddr
    }{
        base:  minOffAddr,
        bound: maxOffAddr,
    }

    // foundFree 是一个闭包函数,用于更新 firstFree 的值。
    foundFree := func(addr offAddr, size uintptr) {
        // 判断找到的空闲区域是否在 firstFree 的范围内。
        if firstFree.base.lessEqual(addr) && addr.add(size-1).lessEqual(firstFree.bound) {
            firstFree.base = addr
            firstFree.bound = addr.add(size - 1)
        } else if !(addr.add(size-1).lessThan(firstFree.base) || firstFree.bound.lessThan(addr)) {
            // 如果空闲区域部分重叠,则打印错误信息并抛出异常。
            print("runtime: addr = ", hex(addr.addr()), ", size = ", size, "\n")
            print("runtime: base = ", hex(firstFree.base.addr()), ", bound = ", hex(firstFree.bound.addr()), "\n")
            throw("range partially overlaps")
        }
    }

    // lastSum 和 lastSumIdx 用于跟踪最后一个处理过的摘要和其索引。
    lastSum := packPallocSum(0, 0, 0)
    lastSumIdx := -1

    // 遍历 pageAlloc 的摘要层级。
nextLevel:
    for l := 0; l < len(p.summary); l++ {
        // 计算每个块内的条目数。
        entriesPerBlock := 1 << levelBits[l]
        logMaxPages := levelLogPages[l]

        // 更新索引 i。
        i <<= levelBits[l]
        entries := p.summary[l][i : i+entriesPerBlock]

        // 计算搜索的起始索引。
        j0 := 0
        if searchIdx := offAddrToLevelIndex(l, p.searchAddr); searchIdx&^(entriesPerBlock-1) == i {
            j0 = searchIdx & (entriesPerBlock - 1)
        }

        // base 和 size 用于跟踪当前找到的空闲区域的基址和大小。
        var base, size uint
        for j := j0; j < len(entries); j++ {
            sum := entries[j]
            // 如果当前摘要项为零,则重置大小并继续。
            if sum == 0 {
                size = 0
                continue
            }

            // 更新 firstFree 的值。
            foundFree(levelIndexToOffAddr(l, i+j), (uintptr(1)<<logMaxPages)*pageSize)

            // 根据摘要项来更新 base 和 size。
            s := sum.start()
            if size+s >= uint(npages) {
                if size == 0 {
                    base = uint(j) << logMaxPages
                }
                size += s
                break
            }
            if sum.max() >= uint(npages) {
                i += j
                lastSumIdx = i
                lastSum = sum
                continue nextLevel
            }
            if size == 0 || s < 1<<logMaxPages {
                size = sum.end()
                base = uint(j+1)<<logMaxPages - size
                continue
            }
            size += 1 << logMaxPages
        }
        // 如果找到了足够大的空闲区域,则返回其地址。
        if size >= uint(npages) {
            addr := levelIndexToOffAddr(l, i).add(uintptr(base) * pageSize).addr()
            return addr, p.findMappedAddr(firstFree.base)
        }
        if l == 0 {
            return 0, maxSearchAddr()
        }

        // 如果没有找到足够大的空闲区域,则打印错误信息并抛出异常。
        print("runtime: summary[", l-1, "][", lastSumIdx, "] = ", lastSum.start(), ", ", lastSum.max(), ", ", lastSum.end(), "\n")
        print("runtime: level = ", l, ", npages = ", npages, ", j0 = ", j0, "\n")
        print("runtime: p.searchAddr = ", hex(p.searchAddr.addr()), ", i = ", i, "\n")
        print("runtime: levelShift[level] = ", levelShift[l], ", levelBits[level] = ", levelBits[l], "\n")
        for j := 0; j < len(entries); j++ {
            sum := entries[j]
            print("runtime: summary[", l, "][", i+j, "] = (", sum.start(), ", ", sum.max(), ", ", sum.end(), ")\n")
        }
        throw("bad summary data")
    }

    // 在最后一层进行查找。
    ci := chunkIdx(i)
    j, searchIdx := p.chunkOf(ci).find(npages, 0)
    if j == ^uint(0) {
        sum := p.summary[len(p.summary)-1][i]
        print("runtime: summary[", len(p.summary)-1, "][", i, "] = (", sum.start(), ", ", sum.max(), ", ", sum.end(), ")\n")
        print("runtime: npages = ", npages, "\n")
        throw("bad summary data")
    }

    // 计算并返回找到的空闲内存区域的地址。
    addr := chunkBase(ci) + uintptr(j)*pageSize
    searchAddr := chunkBase(ci) + uintptr(searchIdx)*pageSize
    foundFree(offAddr{searchAddr}, chunkBase(ci+1)-searchAddr)
    return addr, p.findMappedAddr(firstFree.base)
}

总结该函数的主要流程:

  1. 锁检查:确保在调用函数时持有内存堆锁,以保证线程安全。初始化第一个空闲区域:定义一个结构体 firstFree,初始设定为最小和最大的偏移地址;
  2. 定义找到空闲区域的处理函数:创建一个闭包函数 foundFree,用于在找到合适的空闲区域时更新 firstFree 的值;初始化摘要索引和状态:设置 lastSumlastSumIdx 为初始状态,以跟踪最后一个处理过的摘要和其索引;
  3. 遍历summary层级:对 pageAllocsummary层级进行遍历,每个层级都对应不同大小的内存块。计算索引和条目:对每个层级,计算相关索引和摘要条目。搜索空闲内存块:遍历当前层级的摘要条目,寻找足够大的空闲内存块。这涉及到对每个条目的开始、最大和结束值的计算。更新空闲区域信息:根据找到的空闲块信息,更新 firstFree 结构体;
  4. 检查是否满足需求:检查当前找到的空闲内存是否满足所需的页面数(npages)。处理不同情况:根据不同情况进行处理,如找到足够大的内存块或需要进入下一个摘要层级;
  5. 如果在所有层级中都未找到足够的内存,打印错误信息并抛出异常;如果找到合适的内存块,计算并返回其地址。

对于pageAllocsummary的设计,其实是一颗基数树(radix tree),基数树中,每个节点称之为 PallocSum,是一个 uint64 类型,体现了索引的聚合信息,其特点如下:

  • 每个父 pallocSum8 个子 pallocSum
  • pallocSum 总览全局,映射的 bitMap 范围为全局的 16 GB 空间(其 max 最大值为 2^21,因此总空间大小为 2^21*8KB=16GB);
  • 从首层向下是一个依次八等分的过程,每一个 pallocSum 映射其父节点 bitMap 范围的八分之一,因此第二层 pallocSumbitMap 范围为 16GB/8 = 2GB,以此类推,第五层节点的范围为 16GB / (8^4) = 4 MB
  • 聚合信息时,自底向上. 每个父 pallocSum 聚合 8 个子 pallocSumstart、max、end 信息,形成自己的信息,直到根 pallocSum,坐拥全局 16 GBstart、max、end 信息;
  • mheap 寻页时,自顶向下. 对于遍历到的每个 pallocSum,先看起 start 是否符合,是则寻页成功;再看 max 是否符合,是则进入其下层孩子 pallocSum 中进一步寻访;最后看 end 和下一个同辈 pallocSumstart 聚合后是否满足,是则寻页成功。

image-20231219120010142

扩容 — pageAlloc.grow

runtime.pageAlloc.grow 方法的主要职责是扩展 pageAlloc 管理的内存范围,以便提供更多的内存页供分配。这个过程涉及与操作系统的交互、内存地址的计算和对齐,以及更新内存管理结构的状态。

该函数一般是在堆增长操作 runtime.mheap.grow操作后执行,下面我们将深入探讨这个函数的源码,以理解其工作原理和执行流程。

//go 1.20.3 path: /src/runtime/mpagealloc.go

// grow 方法用于扩展 pageAlloc 的内存范围。
func (p *pageAlloc) grow(base, size uintptr) {
    // 确保持有 mheap 锁。
    assertLockHeld(p.mheapLock)

    // 将范围的结束地址对齐到 pallocChunkBytes。
    limit := alignUp(base+size, pallocChunkBytes)
    // 将范围的开始地址向下对齐到 pallocChunkBytes。
    base = alignDown(base, pallocChunkBytes)

    // 从操作系统中增长内存。
    p.sysGrow(base, limit)

    // 检查是否是首次增长或者新的开始地址是否小于当前开始地址。
    firstGrowth := p.start == 0
    start, end := chunkIndex(base), chunkIndex(limit)
    if firstGrowth || start < p.start {
        p.start = start
    }
    // 如果新的结束地址大于当前结束地址,更新它。
    if end > p.end {
        p.end = end
    }

    // 将新的地址范围标记为使用中。
    p.inUse.add(makeAddrRange(base, limit))

    // 如果新的基地址小于当前的搜索地址,更新搜索地址。
    if b := (offAddr{base}); b.lessThan(p.searchAddr) {
        p.searchAddr = b
    }

    // 遍历新范围内的每个 chunk。
    for c := chunkIndex(base); c < chunkIndex(limit); c++ {
        // 如果 chunk 尚未分配,从操作系统中分配内存。
        if p.chunks[c.l1()] == nil {
            r := sysAlloc(unsafe.Sizeof(*p.chunks[0]), p.sysStat)
            if r == nil {
                throw("pageAlloc: out of memory")
            }
            *(*uintptr)(unsafe.Pointer(&p.chunks[c.l1()])) = uintptr(r)
        }
        // 将新分配的 chunk 标记为已清理。
        p.chunkOf(c).scavenged.setRange(0, pallocChunkPages)
    }

    // 更新 pageAlloc 的内部状态。
    p.update(base, size/pageSize, true, false)
}

整个 grow 方法的目的是在 pageAlloc 结构体需要更多内存时,动态地从操作系统中分配并准备内存区域,确保内存分配器可以继续满足内存申请的需求。其主要流程如下:

  1. 确保在执行 grow 方法时持有 mheap 的锁,以保证线程安全,将请求的内存范围的开始和结束地址按 pallocChunkBytes 对齐;
  2. 通过 sysGrow 方法从操作系统申请新的内存区域,
  3. 更新开始和结束地址:
    • 如果是首次增长或新的开始地址小于当前的开始地址,则更新 pageAlloc 的开始地址;
    • 如果新的结束地址大于当前的结束地址,则更新 pageAlloc 的结束地址;
  4. 使用 inUse.add 方法将新分配的地址范围标记为正在使用;如果新的基地址小于当前的搜索地址,则更新 pageAlloc 的搜索地址;
  5. 分配和初始化 chunk
    • 遍历新分配的内存范围内的每个 chunk
    • 对于尚未初始化的 chunk,使用 sysAlloc 从操作系统中分配内存,并将其初始化;
    • 将新分配的 chunk 标记为已清理。
  6. 调用 update 方法更新 pageAlloc 的内部状态,以反映新的内存布局。

fixalloc分配器

heap中,有着许多字段是fixalloc分配器类型,例如字段:spanalloccacheallocspecialfinalizerallocspecialprofileallocarenaHintAlloc等,如下:

//go 1.20.3 path: /src/runtime/mheap.go
type mheap struct {
    .......
    spanalloc             fixalloc // span*的内存分配器,只是分配空结构
    cachealloc            fixalloc // mcache*的内存分配器
    specialfinalizeralloc fixalloc // specialfinalizer*的内存分配器
    specialprofilealloc   fixalloc // specialprofile*的内存分配器  
    arenaHintAlloc        fixalloc // arenaHintAlloc 的内存分配器
    ......
}

Go 语言的运行时内存管理中,fixalloc 是一个专门用于固定大小对象的简单分配器。其核心原理是将若干未分配的内存块连接起来, 将未分配的区域的第一个字为指向下一个未分配区域的指针使用。

Go 的主分配堆中 mallocspancachetreapfinalizerprofilearena hint 等)均 围绕它为实体进行固定分配和回收。

来看看其定义的源码结构体:

//go 1.20.3 path: /src/runtime/mfixalloc.go
type fixalloc struct {
    size   uintptr        // 每个分配对象的大小。
    first  func(arg, p unsafe.Pointer) // 当分配新块时调用的构造函数。
    arg    unsafe.Pointer // 传递给构造函数 first 的参数。
    list   *mlink         // 空闲对象列表,用于跟踪可用的对象。
    chunk  uintptr        // 当前内存块的起始地址。
    nchunk uint32         // 当前内存块的大小。
    nalloc uint32         // 内存块中已分配对象的数量。
    inuse  uintptr        // 当前已使用的内存总量。
    stat   *sysMemStat    // 用于内存统计的结构体指针。
    zero   bool           // 如果为 true,则在分配时清零内存。
}

根据结构体,我们可以得出一个大致的fixalloc结构图:

image-20231219154101649

fixalloc 分配器,非常简洁,只包含三个基本操作:初始化、分配、回收。

初始化 — fixalloc.init

fixalloc 作为内存分配器内部组件的来源于操作系统的内存,自然需要自行初始化,因此,fixalloc 的初始化也就不可避免的需要将自身的各个字段归零:

//go 1.20.3 path: /src/runtime/mfixalloc.go

_FixAllocChunk = 16 << 10

func (f *fixalloc) init(size uintptr, first func(arg, p unsafe.Pointer), arg unsafe.Pointer, stat *sysMemStat) {
    // 检查 size 是否大于 _FixAllocChunk(一个预定义的最大分配大小)
    // 如果是,则抛出异常,因为 fixalloc 不支持过大的对象分配。
    if size > _FixAllocChunk {
        throw("runtime: fixalloc size too large")
    }

    // 确保 size 至少为 mlink 结构体的大小。
    // mlink 用于在内部自由列表中链接空闲对象。
    if min := unsafe.Sizeof(mlink{}); size < min {
        size = min
    }

    // 设置每个分配对象的大小。
    f.size = size

    // 设置用于初始化每个新内存块的构造函数。
    f.first = first

    // 设置传递给构造函数的参数。
    f.arg = arg

    // 初始化自由列表为 nil,表示当前没有空闲对象。
    f.list = nil

    // 初始化当前内存块的起始地址和大小为 0。
    f.chunk = 0
    f.nchunk = 0

    // 初始化 nalloc 为可在 _FixAllocChunk 中分配的最大对象数。
    // 这保证了每个对象都能在内存块中完整地分配。
    f.nalloc = uint32(_FixAllocChunk / size * size) 

    // 初始化已使用的内存量为 0。
    f.inuse = 0

    // 设置内存统计结构体。
    f.stat = stat

    // 设置在分配时清零内存。
    f.zero = true
}

这个 init 函数负责设置 fixalloc 结构体的各种参数和状态,为之后的固定大小对象分配做好准备。这包括设置对象大小、构造函数、内存统计以及一些内部的状态和计数器。初始化后,fixalloc 可以高效地分配和回收固定大小的内存块。

分配 — fixalloc.alloc

fixalloc 分配器分配源码如下:

//go 1.20.3 path: /src/runtime/mfixalloc.go

func (f *fixalloc) alloc() unsafe.Pointer {
    // 检查是否在初始化 fixalloc 之前就调用了 alloc 方法。
    // 如果是,打印错误信息并抛出异常。
    if f.size == 0 {
        print("runtime: use of FixAlloc_Alloc before FixAlloc_Init\n")
        throw("runtime: internal error")
    }

    // 如果自由列表(list)不为空,从列表中分配一个对象。
    if f.list != nil {
        v := unsafe.Pointer(f.list) // 获取列表中的第一个对象。
        f.list = f.list.next        // 更新列表头指向下一个对象。
        f.inuse += f.size           // 更新已使用的内存量。

        // 如果设置了 zero 标志,清零分配的内存。
        if f.zero {
            memclrNoHeapPointers(v, f.size)
        }
        return v // 返回分配的对象。
    }

    // 如果当前块的剩余空间不足以容纳一个新对象,
    // 则分配一个新的内存块。
    if uintptr(f.nchunk) < f.size {
        f.chunk = uintptr(persistentalloc(uintptr(f.nalloc), 0, f.stat)) // 分配新块。
        f.nchunk = f.nalloc // 重置 nchunk 为新块的大小。
    }

    // 从当前块中分配内存。
    v := unsafe.Pointer(f.chunk) // 指向要分配的内存位置。
    if f.first != nil {
        f.first(f.arg, v) // 如果有构造函数,调用它初始化内存块。
    }
    f.chunk = f.chunk + f.size   // 更新 chunk 为下一个分配位置。
    f.nchunk -= uint32(f.size)   // 更新剩余空间大小。
    f.inuse += f.size            // 更新已使用的内存量。

    return v // 返回分配的内存。
}

fixalloc 基于自由表分为两种情况执行:

  • 存在被释放、可复用的内存;
  • 不存在可复用的内存

对于第一种情况,也就是在运行时内存被释放,但这部分内存并不会被立即回收给操作系统,我们直接从自由表中获得即可,但需要注意按需将这部分内存进行清零操作。

对于第二种情况,我们直接向操作系统申请固定大小的内存,然后扣除分配的大小即可。

回收 — fixalloc.free

回收的代码相当简单,不用过多解释,直接上代码:

//go 1.20.3 path: /src/runtime/mfixalloc.go

func (f *fixalloc) free(p unsafe.Pointer) {
    // 减少已使用的内存量,每次释放固定大小的内存块。
    f.inuse -= f.size

    // 将传入的指针转换为 *mlink 类型,以便将其加入到自由列表中。
    // mlink 是内部用于链接空闲内存块的结构。
    v := (*mlink)(p)

    // 将当前释放的内存块加入到自由列表的头部。
    // 这是一个典型的链表头插法操作。
    v.next = f.list
    f.list = v
}

free 函数是 fixalloc 分配器的一部分,它处理固定大小内存块的释放。当一个内存块不再需要时,该函数将其加回 fixalloc 的自由列表中,以便将来重新使用。这种机制有助于减少内存分配和回收的开销,提高内存利用率。通过维护一个自由列表,fixalloc 能够高效地管理固定大小的内存块的生命周期。

linearAlloc分配器

linearAlloc 是一个内部使用的内存分配器,它以线性的方式管理内存,为 Go 运行时的某些组件提供所需的内存。由于其不支持内存回收和复杂的内存管理操作,因此它适用于生命周期长、大小固定的内存分配需求。

但由于它只作为 mheap_.heapArenaAllocmheap_.arena32 位系统上使用,这里不做详细分析。

我们直接贴出源码以及注释:

//go 1.20.3 path: /src/runtime/malloc.go

type linearAlloc struct {
    next   uintptr   // 下一个可用于分配的内存地址。
    mapped uintptr   // 已经映射到分配器的内存总量。
    end    uintptr   // 管理内存区域的结束地址。
    mapMemory bool   // 标记是否需要映射更多内存。
}

结构图如下:

image-20231219165049654

linearAlloc 的初始化以及分配函数代码如下:

//go 1.20.3 path: /src/runtime/malloc.go
func (l *linearAlloc) init(base, size uintptr, mapMemory bool) {
    // 检查 base + size 是否会造成整数溢出。
    // 如果会溢出,则减小 size 以避免溢出。
    if base + size < base {
        size -= 1
    }
    // 初始化 next 和 mapped 字段为 base,表示分配的起始地址。
    l.next, l.mapped = base, base

    // 设置 end 字段为 base + size,表示分配区域的结束地址。
    l.end = base + size

    // 设置 mapMemory 字段,决定是否在内存用尽时自动映射更多内存。
    l.mapMemory = mapMemory
}

func (l *linearAlloc) alloc(size, align uintptr, sysStat *sysMemStat) unsafe.Pointer {
    // 将 next 地址按照 align 对齐。
    p := alignUp(l.next, align)
  
    // 检查分配后的地址是否超出了分配区域的结束地址(end)。
    // 如果超出,返回 nil,表示分配失败。
    if p + size > l.end {
        return nil
    }

    // 更新 next 地址为新分配的内存之后的地址。
    l.next = p + size

    // 对 next 地址进行向上对齐,并检查是否超出了已映射的内存(mapped)。
    if pEnd := alignUp(l.next-1, physPageSize); pEnd > l.mapped {
        // 如果 mapMemory 为 true,并且有更多内存需要映射,则映射更多内存。
        if l.mapMemory {
            n := pEnd - l.mapped
            sysMap(unsafe.Pointer(l.mapped), n, sysStat) // 映射新的内存区域。
            sysUsed(unsafe.Pointer(l.mapped), n, n) // 更新内存使用统计。
        }
        // 更新 mapped 字段为新映射的内存的结束地址。
        l.mapped = pEnd
    }

    // 返回分配的内存地址。
    return unsafe.Pointer(p)
}

小结

本文将内存管理的组件heapArenamheapmspanmcentralmcache都做了基本的解析,能够大致知道这些组件的基本构成,但我们还没涉及到这些组件之间的关系以及组件的初始化和堆内存的分配以及分配的流程相关内容,下篇文章将重点介绍这些。

参考资料:

小徐先生 https://zhuanlan.zhihu.com/p/603335718

冰心丹 https://zhuanlan.zhihu.com/p/410317967

Go语言原本 https://golang.design/under-the-hood/zh-cn/

chatgpt https://chat.openai.com/

  • 16
    点赞
  • 25
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值