Cloud Native Foundation - Go Memory Management
堆内存管理
Linux 进程
管理过程
- Allocator 的职责:通过系统调用向 OS 申请内存(MB/GB);响应 Mutator(即应用) 的内存申请(KB)
- Collector:交还给 Allocator,再由 Allocator 决定是否释放并归还给 OS
- 初始化连续内存块为堆
- 在内存申请的时候,Allocator 从堆内存的未分配区域分割小内存块
- 用链表将已分配内存连接起来
- 需要信息描述每个内存块的元数据(对象头):大小、是否使用、下一个内存块的地址
Example: Ruby allocates memory from the memory allocator, which in turn allocates from the kernel
面临挑战
- 内存分配需要系统调用,在频繁分配内存的时候,系统性能较低
- C 的每次
malloc
是需要系统调用的 - Java 的 Allocator 可以直接一次性申请大内存,然后在用户态再慢慢分配
- C 的每次
- 多线程共享相同的内存空间,同时申请内存,需要加锁,或者采用类似 JVM 中的 TLAB 来缓解竞争
- 经过不断地内存分配和回收,内存碎片会比较严重,内存使用率低
TCMalloc
- Thread Cache ≈ JVM TLAB
- Go 预先申请的大内存,会先放在 PageHeap
- 下一级向上一级别申请内存的时候,上一级都是按一定规格分配,减少加锁的频率
- Page:内存页,8K;Go 与 OS 之间的内存申请和释放,都是以 Page 为单位
- Span:连续内存块,Span N = Page × N
- Size Class
- 应用申请内存的大小规格,单位 Byte
- 每个 Span 都有属性 Size Class,Span 内部的的 Page 会按照 Size Class 被预先格式化
- 举例
- 如针对 Size Class = 8,对应的 Page 会被切分成1024份
- 如果此时应用申请 ≤ 8(最佳适配原则)的内存空间,会分配上面被切分的1024份之一
- 应用申请内存的大小规格,单位 Byte
- Object:对象,用来存储一个变量数据的内存空间
- 分配流程
- 小对象:0 ~ 256KB
- ThreadCache -> CentralCache -> HeapPage,大部分情况下 ThreadCache 是足够的,不需要加锁
- 中对象:258KB ~ 1MB
- 直接在 HeapPage 中选择适当大小即可
- 大对象:> 1MB
- 从 Large Span Set 中选择合适数量的 Page 来组成 Span
- 小对象:0 ~ 256KB
- 分配流程
Go
与 Java 不同的是,Go 在大部分情况,不需要 GC 调优,相对于 Java,在 GC 方面比较省心
内存分配
- Go 的内存分配机制是从 TCMalloc 衍生,两者都是来自于 Google
- 增强 1(加快 GC 效率,利用栈上分配):Span Class 为 134 个,Size Class 为 67 个
- 一个 Size Class 对应两个 Span Class,一个存指针,一个存直接引用
- 存直接引用的 Span 无需 GC 扫描 – 栈上分配
- 指针:指针本质上是存放变量地址的一个变量,逻辑上是独立的,可以被改变,分配在堆上,GC 是针对堆的
- 直接引用:是一个别名,逻辑上是不独立的,具有依附性,分配在栈上
- 一个 Size Class 对应两个 Span Class,一个存指针,一个存直接引用
- 增强 2(加快分配效率):Span Class 也维护了两个 Span 列表,Empty 和 Non Empty
- Empty 语义:是否有可分配空间,true = 没有
- Empty:Span 双向链表,包括没有空闲对象的 Span 和 mcache 中的 Span
- 当 Empty 中的 Span 被释放时,Span 将被移动到 Non Empty
- Non Empty:有空闲对象的 Span 双向链表
- 从 mcentral 请求新 Span,先尝试从 Non Empty 获取 Span 并移动到 Empty
- 如果 mcentral 没有可用的 Span,则 mcentral 向 mheap 申请新页
- 增强 3:mheap 中的 Span 是按树来组织的(TCMalloc#PageHeap是按链表来组织的)
- free:空闲 + 不是来自于 GC
- scav:来自于 GC
内存回收
Go 使用的 GC 算法是 Mark-Sweep,会存在 STW
mspan
- bitmap
- allocBits:记录每块内存的分配情况
- gcmarkBits:记录每块内存的引用情况
- 标记结束后进行回收,回收时,将 allocBits 指向 gcmarkBits
- 标记阶段:活跃标记为1,不活跃标记为0
- 标记过则继续存在,未进行标记则进行回收
- 回收对象:Span 中的元素
GC 过程
类似于 JVM 的 CMS
三色标记
白色:垃圾
黑色:存活 + 所有孩子已经扫描
灰色:存活 + 还有孩子未扫描
- 图遍历
- GC 开始时,认为所有 Object 都是白色(即垃圾)
- 从 Root 区开始遍历,被触达的 Object 置为灰色
- 遍历所有灰色 Object, 将他们内部引用的变量置为灰色(加入灰色队列),然后将自己置为黑色(已经完成扫描工作)
- 最终只会剩下两种颜色,黑色(存活)或白色(垃圾)
- 并发标记
- 对于黑色 Object,如果在标记期间发生了写操作,写屏障会在真正赋值前将新对象标记为灰色(重新入队,再次扫描确认)
- 新分配的 Object,会先标记为黑色再返回
详细过程
Go GC 的大部分处理是和用户代码是并发的
- Mark
- Mark Prepare
- STW:初始化 GC 任务,包括开启写屏障、统计 Root 对象的数量
- GC Drains
- 并发:扫描所有 Root 对象(全局指针和 G 栈上的指针),将其加入到灰色队列,并循环处理灰色队列的对象,直到灰色队列为空 – 图遍历
- Mark Prepare
- Mark Termination
- STW:完成标记工作,重新扫描全局指针和 G 栈
- Sweep
- 并发:按照标记结果回收所有白色 Object
- Sweep Termination
- 对未清扫的 Span 进行清扫,只有完成上一轮 GC 的清理工作后才可以开始新一轮的 GC
简单类比
Go | Java CMS | |
---|---|---|
STW | Mark Prepare | Initial Mark |
GC Drains | Concurrent Mark | |
STW | Mark Termination | Final Remark |
Sweep | Concurrent Sweep | |
Sweep Termination | Concurrent Reset |
触发机制
- 内存分配量达到阈值
- 每次内存分配都会检查当前内存分配量是否已经达到阈值,达到则启动 GC
- 阈值 = 上次 GC 内存分配量 * 内存增长率(默认是100%,由
GOGC
控制) – 类似于 Ruby GC
- 定期触发:默认2分钟
- 手动触发:
runtime.GC()
– 类似于 JavaSystem.gc()
All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.