这是我参与「第五届青训营 」伴学笔记创作活动的第 4 天
性能优化及自动内存管理
自动内存管理
自动内存管理主要是管理动态内存
动态内存: 程序在运行时根据需求动态分配的内存 : C
自动内存管理(垃圾回收): 由程序语言的运行时系统管理管理动态内存
- 避免手动内存管理,专注于实现业务逻辑
- 保证内存使用的 正确性 和 安全性 :
double-free problem,use-after-free problemdouble-free: 连续释放了两次同一块内存use-after-free: 释放内存后再次使用这块内存
三个任务
- 为新对象分配空间
- 找到存货对象
- 回收死亡对象的内存空间
相关概念
Mutator : 指业务线程,主要任务为分配新对象,然后修改对象指向关系
Collector : 指 GC 线程,主要工作为找到存活对象,回收死亡对象的内存空间
GC 算法
Serial GC : 只有一个 collector
Parallel GC : 支持多个 collector 同时回收的 GC 算法
Concurrent GC : mutator(s) 和 collector(s) 可以 同时执行
- Collectors 必须感知对象指向关系的改变!
-
GC 算法示意图
评价 GC 算法的指标
- 安全性 : 不能回收存活对象 基本要求
- 吞吐率 : 花在 GC 上的时间
- 暂停时间 : stop the world (STW) 越短越好 业务是否感知
- 内存开销 : GC 元数据开销
追踪垃圾回收 (Tracing garbage collection)
对象被回收的条件 : 指针指向关系不可达的对象
回收的步骤:
- 标记根对象 :
- 将静态变量,全局变量,常量,线程栈等的指针指向指向的对象标记为存活的
- 因为这些地方的指针,程序以后还可能会用到,不能立刻回收
- 标记 : 找到可达对象
- 从根对象出发,找到所有的可达对象,即为求指针指向关系的传递闭包
- 清理 : 所有不可达对象
- Copying GC : 将存活的对象复制到另外的内存空间,这样原来的区域就被清空了,可以重新分配
- Mark - sweep GC : 将死亡对象的内存标记为 “ 可分配对象 ”,使用
free list管理死亡对象,下次做分配的时候,直接在free list上分配 - Mark - compact GC : 移动并整理存活对象,将存活对象拷贝压缩到内存空间的最开头,然后从后面开始分配。原地整理 GC
根据对象的生命周期,使用不同的标记和清理策略。如何选择?
分代 GC (Generational GC)
分代 GC 是一种常见的内存管理方式,基于分代假说。
分代假说 (Generational hypothesis) : most objects die young, 大多数对象很快就会死亡,很多对象在分配出来后很快就不再使用了。
每个对象的 年龄 为该对象经历过 GC 的次数,比如 : 某个对象经历过两次 GC 后 仍然活着,则认为该对象的年龄为 2 。
目的: 将所有存活的对象分成 Yong Generation 和 Old Generation 两个部分,不同年龄的对象放在 heap 的不同区域,对不同的对象制定不同的** GC 策略,降低整体内存管理的开销。
不同代的对象特点以及建议 GC 策略
- 年轻代 Young Generation
- 常规的对象分配
- 由于存活对象少,可以采用 copying collection
- GC 吞吐率很高
- 老年代 Old Generation
- 对象趋于一直活着,反复复制开销很大
- 可以采用 mark-sweep collection
引用计数 (Reference counting)
引用计数也是一种常见的管理对象的方式,在 引用计数 中,每个对象都有一个与之关联的引用数目。此时,对象的存活条件为 : 当且仅当引用数大于 0
优点 :
- 内存管理的操作被平摊到了程序执行过程中:程序的执行过程中,可以同时维护引用计数,当引用计数为0的时候,就可以在程序执行完后直接清空对象。
- 引用计数在内存管理的时候不需要了解 runtime 的实现细节 : C ++ 智能指针 smart pointer
缺点 :
- 维护开销比较大 : 因为通过 原子操作 才能保证对引用计数操作的 原子性 和 可见性, 但这样同时使得开销较大
- 无法回收环形数据结构。当内存中出现环形不可达的数据结构时,由于它是环形的,导致环内每个对象的引用次数都为 ,导致本该回收的环不能回收。需要使用到 weak reference 之类的方法去解决
- 内存开销比较大:每个对象都要引入格外的内存空间存储引用次数
- 回收内存的时候依然存在触发暂停的可能性(大的数据结构)
Go 内存分配
分块
目标: 为对象在 heap 上分配内存
提前将内存分块
- 调用系统调用
mmp()向 OS 申请一大块内存,例如:4KB - 现将内存划分为大块,例如 8KB, 称作
mspan - 再将大块内存继续划分成为 特定大小 的小块,用于对象分配
noscan mspan: 分配不包含指针的对象 —— GC 不需要扫描scan mspan: 分配包含指针的对象 —— GC 需要扫描
对象分配:根据对象的大小,选择最适合的块返回
缓存
TCMalloc : thead caching
- 每个
p包含一个mcache用于快速分配,用于为绑定于p上的g分配对象 mcache管理一组mspan, 其中每个mspan大小不一样。在mspan中找到最适合的块分配出去,就完成一次对象分配- 当
mcache中的mspan分配完毕或者不够分配时,向mcentral申请带有未分配块的mspan,填入到mcache中,然后再将适合的mspan返回出去,完成一次对象分配。 - 当
mspan中没有分配的对象,即所有对象都被清理干净后,mspan会被缓存在mcentral中,而不是立刻释放并归还给 OS,这样一旦有需求分配的时候,可以立刻从缓存中取出。
Go内存管理优化
-
对象分配是非常高频的操作 : 每秒会分配出 GB 级别的内存
-
分配的时候小对象占比会比较高
-
Go 内存分配比较耗时
- 分配路径很长 :
g ——> m --> p --> mcache --> mspan --> memory block --> return pointer - pprof : 对象分配的函数是最频繁调用的函数之一
- 分配路径很长 :
Balanced GC
字节跳动的优化方案:BALANCED GC
- 每个
g都绑定一大块内存(1 KB),称作goroutine allocation buffer (GAB) - GAB 用于
noscan类型的小对象分配: < 128 B - 使用三个指针维护 GAB :
base,end,top Bump poiner (指针碰撞)风格对象分配- 无需和其他分配请求互斥
- 分配动作简单高效
if top + size <= end { // 如果内存足够分配
addr := top // 记一下当前 top 指针的位置
top += size // 分配内存
return addr // 将开始地址返回出去
}
然而 GAB 对于 GO 内存管理来说是一个 大对象,其本质是 将多个小对象的分配合并成一个大对象的分配。这里存在的问题是 : GAB 的对象分配方式会导致内存被延迟释放。当一个 GAB 中只存在一个很小的对象存活,比如:8b 。此时,GO 会将这 1KB 的 GAB 都标记成为存活的对象。导致 GAB 被延迟释放。
针对这种情况,使用 移动 GAB 中存活的对象 这种方式来管理:
- 当 GAB 总大小超过一定阈值的时候,将 GAB 中存活的对象复制到另外分配的 GAB 中
- 原先的 GAB 可以被释放,避免内存泄露
- 本质 : 采用 copying GC 的算法管理小对象