# 高并发下的内存模型与垃圾回收

  • 栈内存(协程栈,调用栈)
  • 堆内存
  • 垃圾回收

# 为什么说Go的栈在堆上?

GO协程栈的作用

  • 协程的执行路径
  • 局部变量
  • 函数传参
  • 函数返回值

执行完 sum 函数后,sum函数的栈帧就会回收

Go 协程栈的位置

  • Go 协程栈位于 Go 堆内存上
    • c语言,栈与堆分开,栈由程序管理,堆由用户自己管理
    • go 自己的栈内存也是在堆上的,栈的内存也是由GC释放的
  • Go 堆内存位于操作系统虚拟内存(由操作系统分配)上

参数传递

  • Go 使用参数拷贝传递(值传递)
  • 传递结构体时:会拷贝结构体中的全部内容
  • 传递结构体指针是,会拷贝结构体指针

总结

  • 协程栈记录了协程的执行现场
  • 协程栈还负责记录局部变量,传递参数和返回值
  • Go 使用参数拷贝传递

思考

协程栈在空间上连续方,遇到不够大的情况怎么办?

  • 本地变量太大(初始2K到4K)
  • 栈帧太多

# 协程栈不够用了怎么办?

局部变量太大

  • 不是所有变量都能放在协程栈上
  • 栈帧回收后,需要继续使用的变量
  • 太大的变量

局部变量太大的情况可以通过逃逸分析解决。

逃逸分析

  • 指针逃逸
  • 空接口逃逸
  • 大变量逃逸

逃逸分析 -> 指针逃逸

  • 函数返回了对象的指针

逃逸分析 -> 空接逃逸

  • 如果函数的参数为interfacel{}
  • 函数的实参很可能会逃逸
  • 因为 interface{} 类型的函数往往会使用反射

逃逸分析 -> 大变量逃逸

  • 过大的变量会导致栈空间不足
  • 64 位机器中,一般超过 64 KB 的变量会逃逸

栈帧太多

解决方式为:栈扩容

栈扩容

  • Go 栈的初始空间为 2KB
  • 在函数调用前判断栈空间(morestack)
  • 必要时对栈进行扩容
  • 早期使用分段栈,后期使用连续栈

分段栈

  • 1.13 之前使用
  • 优点:没有空间浪费
  • 缺点:栈指针会在不连续的空间跳转

连续栈

找一个放的下两倍栈空间的空闲空间进行扩容

  • 优点:空间一直连续
  • 缺点:伸缩时的开销大
  • 当空间不足时扩容,变为原来的2倍
  • 当空间使用率不足1/4时缩容,变为原来的1/2

总结

  • 三种特殊情况下,变量可能分配到堆上
  • 1.13之前,Go使用可伸缩的分段栈
  • 1.14以后,Go使用连续栈,伸缩时直接使用新栈

# Go的堆内存结构是怎样的?

操作系统虚拟内存

  • 不是Win的”虚拟内存“(类似linux的swap空间)
  • 操作系统给应用提供的虚拟内存空间
  • 背后是物理内存,也由可能由磁盘
  • Linux 获取虚拟内存:mmap、madvice

heapArena

arena 竞技场;斗兽场

  • Go每次申请的虚拟内存单元为64MB
  • 最多由 4 194 304 个虚拟内存单元(2^20)
  • 内存单元也叫 heapArena
  • 所有的heapArena组成了mheap(Go 堆内存)

一个heapArena暂用虚拟内存64MB,heapArena在虚拟内存的空间不一定连续,虚拟内存连续的空间对应的物理内存空间也不一定连续。

Go/src/runtime/mheap.go

type mheap struct {
    ...
    allspans []*mspan // all spans out there
	arenas [1 << arenaL1Bits]*[1 << arenaL2Bits]*heapArena
    ...
}

type heapArena struct {
	// bitmap stores the pointer/scalar bitmap for the words in
	// this arena. See mbitmap.go for a description. Use the
	// heapBits type to access this.
	bitmap [heapArenaBitmapBytes]byte
}
1
2
3
4
5
6
7
8
9
10
11
12
13

如何使用heapArena(线性分配)

采用线性分配,每次需要的空间都往后放,有些用不到的空间被释放时,还是继续往后放,直到放完,再看看之前又没有空间被释放,有的话继续分配。

链表分配

将释放的的空间采用链表连接起来,用于下次分配,但是遇到装不下的大空间,需要采用线性分配,往后放。容易产生碎片化的空间。

  • 线性分配或者链表分配容易出现空间碎片

分级分配

不是go中具体实现。体现思想

  • 如果最小的对象大小小于最小单元,直接分配最小单元。

内存管理单元 mspan

  • 根据隔离适应策略,使用内存时的最小单位为mspan
  • 每个mspan为N个相同大小的”格子“
  • Go 中一共有67中mspan

1级的 mspan 最小分配空间时 8 个字节(bytes),每个格子最小可以存一个字节的东西,那么最大浪费可以达到百分之87.5%

分级内存示意:

heapArena 并不是一开始就有所有的span,而是按需开辟

源码解析

Go/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
1
2
3
4

内存管理单元 mspan

  • 每个heapArena中的mspan都不确定
  • 如何快速找到所需的mspan级别?

中心索引 mcentral

  • 136 个 mcentral 结构体,其中
  • 68 个组需要GC扫描的 mspan
  • 68 个组不需要GC扫描的 mspan

源码:Go/src/runtime/mcentral.go

type mcentral struct {
	spanclass spanClass
	partial [2]spanSet // 部分满的span
	full    [2]spanSet // 全满的span   
}
1
2
3
4
5

mcentral 的性能问题

  • mcentral 实际是中心索引,引用互斥锁保护
  • 再高并发场景下,锁冲突问题严总
  • 参考协程GMP模型,增加线程本地缓存

线程缓存mcache

  • 每个P拥有一个mcache
  • 一个mcache拥有136个mspan,其中
  • 68个需要GC扫描的mspan
  • 68个不需要GC扫描的mspan

由于中央索引有并发问题,引入mcache,给每个P分配一个mcache,里头有136的mcetral里的各一个span,当需要存数据,直接再P的mcache里拿,满了 P 需要到mcentral里换。

源码:C:/Program Files/Go/src/runtime/mcache.go

type mcache struct {
    	alloc [numSpanClasses]*mspan // numSpanClasses = 128
}
1
2
3

源码:C:/Program Files/Go/src/runtime/runtime2.go

type p struct {
	mcache      *mcache
}
1
2
3

总结

  • Go 模仿了TCmalloc(c++用的,也是由谷歌开发的),建立了自己的堆内存架构
  • 使用 heapArena 向操作系统申请内存
  • 使用 heapArena时,以mspan为单位,防止碎片化
  • mcentral 是 mspan 们的中心索引
  • mcache 记录了分配给各个P的本地 mspan

# Go 是如何分配堆内存的?

对象分级

  • Tiny 微对象(0, 16B) 无指针
  • Small 小对象 [16B, 32KB]
  • Large 大对象 (32KB, +∞)

分配

  • 微小对象分配至普通 mspan
  • 大对象 量身定做 mspan (0 级 span)

微对象分配

  • 从 mcache 拿到2级 mspan
  • 将多个为对象合并成一个 16 Byte 存入

源码:C:/Program Files/Go/src/runtime/malloc.go

func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
	...
    if size <= maxSmallSize { // 是不是小于small对象的级别 
        if noscan && size < maxTinySize { // 是不是微对象
			// 注释中有说明将多个为对象合并成一个 16 Byte 存入
            span = c.alloc[tinySpanClass] // 那一个二级span
            // 找到空闲的二级span,返回地址 v 
            v, span, shouldhelpgc = c.nextFree(tinySpanClass) 
        } else { // 小对象
            // 查表该对象应该用几级的span
			if size <= smallSizeMax-8 {
				sizeclass = size_to_class8[divRoundUp(size, smallSizeDiv)]
			} else {
				sizeclass = size_to_class128[divRoundUp(size-smallSizeMax, largeSizeDiv)]
			}
            spc := makeSpanClass(sizeclass, noscan) // 找到对应等级的spanClass
            span = c.alloc[spc]	// c 是 mcache,找该级别span
		   v := nextFreeFast(span) // 找到span的下一个空闲的位置,并返回地址
        }
    } else {
        // 大对象 逻辑, 开启 0 级 的span
        span = c.allocLarge(size, noscan)
    }
}

func (c *mcache) allocLarge(size uintptr, noscan bool) *mspan {
    ...
    spc := makeSpanClass(0, noscan) // 开辟0级 span
    ...
}

func (c *mcache) nextFree(spc spanClass) (v gclinkptr, s *mspan, shouldhelpgc bool) {
    ...
    c.refill(spc) // span满了,需要到mcentral里找到空闲的span进行交换 
    ...
}

func (c *mcache) refill(spc spanClass) {
    // 从中央索引找新的span
    s = mheap_.central[spc].mcentral.cacheSpan() 
}

func (c *mcentral) cacheSpan() *mspan {
    
    // We failed to get a span from the mcentral so get one from mheap.
    // 中央的 mcentral 也没有 mspan 了
	s = c.grow()
	if s == nil {
		return nil
	}
}

func (c *mcentral) grow() *mspan {
    s := mheap_.alloc(npages, c.spanclass)
}

func (h *mheap) alloc(npages uintptr, spanclass spanClass) *mspan {
	s = h.allocSpan(npages, spanAllocHeap, spanclass)
	return s
}

func (h *mheap) allocSpan(npages uintptr, typ spanAllocType, spanclass spanClass) (s *mspan) {
   ...
   growth, ok = h.grow(npages + extraPages)
   ...
}

func (h *mheap) grow(npage uintptr) (uintptr, bool) {
    // 增加 heapArena 一个 heapArena 大小为64M
}
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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70

一级的span当前版本是用不到的

mecache 的替换

  • mcache 中,每个级别的mspan中只有一个
  • 当mspan满了之后,会从 mcentral 中换一个新的

mcentral 的扩容

  • mcentral中,只有有限数量的mspan
  • 当mspan缺少时,会从heapArena开辟新的mspan

大对象的分配

  • 直接从 heapArena 开辟 0 级的 mspan
  • 0 级的 mspan 为大对象定制

heapArena 的扩充

  • 当 heapArena 空间不足时
  • 向操作系统申请新的heapArena

总结

  • Go 将对象按照大小分为3种
  • 微小对象使用mcache
  • mcache中的mspan填满后,与mcentral交换新的
  • mcentral 不足时,再heapArena开辟新的mspan
  • 大对象直接再heapArena开辟新的mspan

# 什么对象需要垃圾回收?

垃圾回收(Garbage Collecting)思路

  • 标记-清除
  • 标记-整理
  • 复制

标记-清除

标记没有引用的数据,进行清除

缺点:如果是其他语言可能会产生碎片的问题

标记-整理

标记清除后,再对空间再次进行整理

缺点:开销大(java老年代使用的标记整理,因为GC的次数少)

复制

标记删除的数据,将需要的数据复制到新空间,并进行了整理(JAVA新生代在用,但是并不是成倍拷贝)

缺点:空间浪费

Go 中的垃圾回收机制

  • Go因为堆内存结构的独特优势,选择最简单的标记-清除
  • 找到有引用的对象。剩下的就是没有引用的。

从哪开始找

  • 被栈上的指针引用
  • 被全局变量指针引用
  • 被寄存器中的指针引用
  • 上述变量被称为 Root Set (GC Root)

怎么找

  • Root 节点进行广度优先搜索 BFS
  • 这种方法也叫可达性分析标记法

串行GC 步骤

  • Stop The World, 暂停所有其他协程
  • 通过可达性分析,找到无用的堆内存
  • 释放堆内存
  • 恢复所有其他协程

问题:

  • STW 对性影响大(Go 每隔几十毫秒或者几百毫秒就要停下来进行GC的话)

总结

  • 从GC Root 出发,寻找被引用对象
  • 没有被引用的就是无用对象
  • 串行GC需要STW,对性能影响大

# 如何减小GC对性能的影响?

并发垃圾回收

  • 并发的难点在于标记阶段

三色标记法

  • 黑色:有用,已经分析扫描
  • 灰色:有用,还未分析扫描
  • 展示无用

起初所有的对象都是白色的

从根对象出发扫描可达对象,标记为灰色

扫描灰色对象,将其引用的对象标记为灰色,自身标记为黑色

继续扫描

清除白色对象

再次标记时,,所有对象恢复为白色

并发标记问题(删除)

并发标记进行中

业务:B指向C的指针释放

业务:E的一个指针成员指向了C

继续扫描

由于E已经扫描过了,C还是白色,C被错误删除

Yuase 删除屏障

  • 并发标记时
  • 对指针释放的白色对象置灰

c被删除时,删除屏障会把C标记为灰

并发标记问题(插入)

在烧苗完E对象后,有业务对E新增了指向C的指针

此事 C 也会因此被误删除

Dijkstra 插入屏障

  • 并发标记时
  • 对指针新指向的白色对象置灰
  • 插入屏障可以杜绝在GC标记中被插入的指针,被清理

混合屏障

  • 被删除的对对象标记为灰色
  • 被添加的堆对象标记为灰色

总结

  • 并发垃圾会搜的关键在于标记安全
  • 混合屏障机制兼顾了安全与效率

# 实战:如何优化GC效率?

GC触发的时机

  • 系统定时触发
  • 用户显示触发
  • 申请内存时触发

系统定时触发

  • sysmon定时检查(runtime背后的循环,g0协程执行)
  • 如果两分钟内没有过GC,触发
  • 谨慎调整

源码:C:/Program Files/Go/src/runtime/proc.go

// This is a variable for testing purposes. It normally doesn't change.
var forcegcperiod int64 = 2 * 60 * 1e9 // 两分钟
1
2

用户显示触发

  • 用户调用runtime.GC方法
  • 并不推荐调用

申请内存时触发

源码:C:/Program Files/Go/src/runtime/malloc.go

分配内存用的函数后缀还跟了gc,是因为分配内存时如果空间不够了,那么会申请内存并进行一次gc

func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {}
1

GC 优化的原则

尽量少在对上生产垃圾

  • 内存池化
  • 减少逃逸
  • 使用空结构体

内存池化

  • 缓存性质的对象(chanl的缓存区,环形缓存)
  • 频繁创建和删除
  • 使用内存池,不GC

减少逃逸

  • 逃逸会使原来在栈上的ui想进入堆中
  • fmt包(需要慎用,尽量用log组件)
  • 返回了指针而不是拷贝

使用空结构体

  • 空结构体指向一个固定地址
  • 不占用堆空间
  • 比如channel传递空结构体

GC 分析工具

  • go tool pprof
  • go tool trace
  • go build -gcflags="-m"
  • GODEBUG="gctrace=1" --- 简单粗暴好用

实列

代码:

func main() {
	wg := sync.WaitGroup{}
	wg.Add(10)
	for i:=0; i<10; i++ {
		go func(wg *sync.WaitGroup) {
			var count int
			for i:=0; i<1e10; i++ {
				count++
			}
			wg.Done()
		}(&wg)
	}
}
1
2
3
4
5
6
7
8
9
10
11
12
13

设置环境:

set GODEBUG=gctrace=1
go run mian.go
1
2

查看信息

gc 2 @0.020s 1%: 0+2.5+0 ms clock, 0+0/0/2.5+0 ms cpu, 14->14->14 MB, 7 MB goal, 0 MB stacks, 0 MB globals, 12 P
 
0.020s   // 启动到现在的时间
14->14->14 //检测前-检测中-完成gc 内存占用的空间
12 P       // 当前有几个线程
1%:        // GC 占用的时间
1
2
3
4
5
6

总结

  • GC主要由系统定时触发或者申请内存触发
  • GC优化的原则是减少在对上产生垃圾
  • 使用GC分析工具可以帮助分析GC问题

# 本章小结

协程栈

  • 协程栈记录协程的执行现场
  • Go 协程栈位于 Go 堆内存上
  • Go 使用参数拷贝传递
  • 3中特殊情况下,变量可能会逃逸到堆上
  • 1.14 以后, Go 使用连续栈,伸缩时直接使用新栈

堆内存结构

  • Go 模仿了TCmalloc,建立了自己的堆内存架构
  • 使用 heapArena 向操作系统申请内存
  • 使用 heapArena 时,以mspan 为单位,防止碎片化
  • mcentral 时 mspan 们的中心索引
  • mcache 记录了分配给各个 P 的本地 mspan

堆内存分配

  • Go 将对象按照大小分为3种
  • 微小对象使用 mcache
  • mcache 种的 mspan 填满后,与mcentral交换新的
  • mcentral 不足时,在heapArena 开辟新的 mspan
  • 大对象直接在 heapArena 开辟新的 mspan

堆内存回收(GC)

  • 标记-清除法
    • 标记有用对象,清除无用对象
  • 可达性分析标记法
    • 从 GC Root 出发,寻找被引用对象

并发GC

  • 并发垃圾回收的关键在于标记安全
  • 回合兵长机制兼顾了安全与效率

GC 优化

  • GC 主要由系统定时触发或者申请内存触发
  • GC 优化的原则时减少在堆上产生的垃圾
  • 使用 GC 分析工具可以帮助分析GC问题
上次更新: 3/12/2023,