本文从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
     
    
  
    文章作者
    shandowc
  
  
    上次更新
    
        2020-07-15
        (7a1c413)