本文从Go源码角度探索内存分配原理。Go的内存分配是一个演进的过程,最初借鉴于tcmalloc,这里我们直接看go 1.15.6的源码,不再探究历史。

注意:以下代码片段为了方便说明都经过不同程度的简化。

基本概念

fixalloc

一种放置大小一样对象的free-list内存分配器,下面提到的mheap、mcache等堆区管理结构都是用这个分配器分配的。

Go内存页

Go堆内存管理中以8KB作为内存页。

mheap

runtime.mheap是内存分配器的核心组件,每个go进程只维护一个runtime.mheap结构体。管理整个malloc堆区。

runtime/mheap.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
type mheap struct {
    // arenaL1Bits在linux下为0, arenaL2Bits的计算公式为:
    // arenaL2Bits = heapAddrBits - logHeapArenaBytes - arenaL1Bits
    // heapAddrBits就是地址空间所占字节数,在amd64上一般是48,logHeapArenaBytes是每个
    // heapArena管理的内存所占字节数,64M是2^26,所以logHeapArenaBytes为26
    //
    // 最终对于Linux系统来说这里等于定义了arenas [1]*[1 << 22]*heapArena,完美包含48位的地址空间
    arenas [1 << arenaL1Bits]*[1 << arenaL2Bits]*heapArena

    // 中心缓存,管理mspan双向列表
    central [numSpanClasses]struct {
        mcentral mcentral
        pad      [cpu.CacheLinePadSize - unsafe.Sizeof(mcentral{})%cpu.CacheLinePadSize]byte
    }
}

heapArena

runtime.heapArena是堆内存块,每个内存块管理64M内存(2^26 Bytes)。arena的作用主要是一个快速索引,能根据内存地址迅速找到所在内存页是否被用,被用的话能快速找到关联的mspan。

runtime/mheap.go

1
2
3
4
5
6
7
8
type heapArena struct {
    // 表示管理的2^26(64M)内存的占用情况
    // heapArenaBitmapBytes = heapArenaBytes / (sys.PtrSize * 4)
    // 对于64位的linux,bitmap[i]管理32字节的占用情况
    bitmap [heapArenaBitmapBytes]byte
    // 表示这个arena中每个Go内存页对应的管理单元
    spans [pagesPerArena]*mspan
}

mcentral

中心缓存,管理进程中所有相同spanClass的mspan。

runtime/mcentral.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
type mcentral struct {
	lock      mutex
    spanclass spanClass

    // 没有空闲对象的mspan双向链表
    // 给mcache分配mspan的时候,先从这个链表扫描mspan,
    // 并进行清扫标记,促进未来得及GC的mspan重复利用
    nonempty mSpanList
    // 有空闲对象的mspan双向链表
	empty    mSpanList
}

mspan

内存管理基本单元,每个mspan管理npages个内存页,这个内存是连续的。

runtime/mheap.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
type mspan struct {
    // 管理的起始地址
    startAddr uintptr
    // 本mspan管理多少个Go内存页
    npages    uintptr

    // 跨度类,下面有介绍,表示对象大小等级和是否有指针
    spanclass   spanClass
    // 对象大小等级对应的大小。由于对象大小各异,不利于快速内存分配,
    // 这是一种以空间换时间的方法,做了一个权衡,下面spanclass会详细介绍
    // 具体空间损耗等问题。
    elemsize    uintptr
    // 根据对象大小等级算出的本mspan管理的对象个数
    // nelems = nbytes / elemsize
    nelems uintptr

    // 主要用于runtime.mSpanList,在runtime.mcentral中使用来管理内存
    // 前一个span
    next *mspan
    // 后一个span
    prev *mspan
}

spanClass

runtime/mheap.go

1
2
3
4
5
// 包含了size class,和noscan标识
// size class是指go将对象大小分成67个类别
// noscan是一个标识,代表对象中是否含有指针,含有指针的需要被扫描
// spanClass = sizeclass<<1 | noscan
type spanClass uint8

其中size class就是对象大小等级,从0到66,0是特殊值,表示大对象(大于32KB),大对象独立存在一个mspan中,不分级。

class elemSize npages nelems tail waste max waste
1 8 1 1024 0 87.5%
2 16 1 512 0 43.75%
3 32 1 256 0 46.88%
4 48 1 170 0.39% 31.52%
5 64 1 128 0 23.44%
65 28672 7 2 0 4.91%
66 32768 4 1 0 12.50%

我们可以看到mspan的elemSize、npages、nelems都由spanClass决定(除了大对象),其中损耗率计算公式如下:

1
2
# pageSize为8KB
waste=1-(actualElemSize*nelems)/(npages*pageSize)

细心的读者会看到class为4的时候,最小的损耗不是0,因为8192不能被48整除,有一部分空间被浪费了。

mcache

runtime.mcache是一个和Go中P-M-G调度模型中P绑定的缓存,管理着有空闲空间的mspan,供绑定到这个P中的线程使用。

runtime/mheap.go

1
2
3
4
5
6
type mcache struct {
    // numSpanClasses = _NumSizeClasses << 1
    // 也就是线程缓存中,包含67*2个mspan
    // 之所以要乘以2,是因为对象大小等级有67种,是否包含指针(noscan)有两种,所以是67*2
    alloc [numSpanClasses]*mspan
}

堆分配内存过程

Go语言的内存分配器会根据申请分配的内存大小选择不同的处理逻辑,运行时根据对象的大小将对象分成微对象、小对象和大对象三种。

类别 大小
tiny (0,16B)
small [16B, 32KB]
large (32KB, +∞)

这三种对象的分配策略各不相同。

微对象

对于小于16字节的微对象,Go的优化分配策略是改成分配一块比较大的内存块(maxTinySize,目前是16字节),从这个内存中分配较小的字符串以及逃逸的临时变量。需要注意的是,只有当内存块中的所有对象都需要被回收时,整片内存才可能被回收。微对象不能含有指针。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43

// 相关字段展示
type mcache struct {
    // 微内存分配器的内存块起始地址
    tiny             uintptr
    // 微内存分配器用到哪里了
    tinyoffset       uintptr
    // 微分配器分配了多少个还没统计到memstats.tinyallocs的对象
	local_tinyallocs uintptr
}

func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
    ...
    if noscan && size < maxTinySize {
        off := c.tinyoffset
        if off+size <= maxTinySize && c.tiny != 0 {
            // The object fits into existing tiny block.
            x = unsafe.Pointer(c.tiny + off)
            c.tinyoffset = off + size
            c.local_tinyallocs++
            mp.mallocing = 0
            releasem(mp)
            return x
        }
        // Allocate a new maxTinySize block.
        span = c.alloc[tinySpanClass]
        v := nextFreeFast(span)
        if v == 0 {
            v, span, shouldhelpgc = c.nextFree(tinySpanClass)
        }
        x = unsafe.Pointer(v)
        (*[2]uint64)(x)[0] = 0
        (*[2]uint64)(x)[1] = 0
        // See if we need to replace the existing tiny block with the new one
        // based on amount of remaining free space.
        if size < c.tinyoffset || c.tiny == 0 {
            c.tiny = uintptr(x)
            c.tinyoffset = size
        }
        size = maxTinySize
    }
    ...
}

相关链接

daveness.me