这是我参与「第五届青训营 」伴学笔记创作活动的第 5 天
自动内存管理
管理Go内存分为三个部分:
- 根据需求分配的内存:
malloc - 自动内存管理:由runtime回收内存
- 避免手动管理,专注于业务
- 保证正确性和安全性
- 三个任务:
- 为新对象分配内存
- 找到存活对象
- 回收死亡对象的内存空间
相关概念:
- Mutator:内务线程,分配新对象,修改对象指向关系
- Collector:GC线程,找到存活对象,回收死亡对象的内存空间
- Serial GC:只有一个Collector
- Parallel GC:支持多个Collectors同时回收的GC算法
- Concurrent GC:mutators和collectors可以同时执行
- Collectors必须感知对象指向关系的改变
- 评价GC算法:
- 安全性:不能回收存活的对象
- 吞吐率:
- 暂停时间:业务是否被感知
- 内存开销
- 追踪垃圾回收
- 引用计数
追踪垃圾回收
需要回收的垃圾是指针指向关系不可达的对象。垃圾回收分为三步:
- 标记根对象
- 标记:找到可达对象
- 清理:所有不可达对象
所谓根对象,指的是在栈内存或全局内存中可以直接管理的内存。这些内存可能引用堆内存中的内存,当存在引用时,就相当于一个指向可达对象的指针。
求解可达性一般是dfs。不清楚Go工程上的实现,但是求传递闭包 ,应该不太可能。
清理不可达对象包括三个内容:
- 将可达对象复制到新内存(Copying GC)
- 将死亡对象的内存标记为可分配(Mark-swap GC)
- 整理存活对象 (Mark-compact GC)
这是三种各有优劣的清理策略。
分代GC
分代GC假定每个对象都有年龄:经过GC的次数。很多对象分配后很快不再使用,有些内存却被长久使用,因此需要指定不同的GC策略。
对于年轻对象,使用Copying GC,这是因为它们存活很少,针对存活内存的操作开销较小。
而对于存活时间久的老对象,使用针对死亡对象的Mark-swap GC。
引用计数
引用计数方法为每个内存对象记录被引用的次数,当被引用0次时即回收。这使得内存管理的时间被平摊到程序运行中,避免了一次性GC带来可能的卡顿。
缺点是,维护引用计数需要原子操作,开销较大;难以解决环形计数(weak ptr),记录引用带来了额外的内存开销。
Go内存管理及优化
Go首先对内存进行分块操作,起初向系统申请例如4MB的大内存,之后分块为例如8KB的块,称为mspan。之后再将mspan划分为确定大小的块给对象使用。
分配策略:缓存
使用包含多个mspan的mcache缓存堆内存,避免了每次申请内存都进行系统调用。对于死亡的内存对象,也不会直接还给系统,而是存在mcache内。当mcache中的mspan用尽,会向mcentral申请新的内存,释放时内存也将还给mcentral。
使用pprof可以看到内存分配频率很高,大量分配小内存,分配路径很长。字节使用了Balanced GC进行优化。
编译器和静态分析
我们着重考虑编译器的后端优化:代码优化和代码生成。
静态分析
不执行代码而分析程序的行为。分析程序为控制流和数据流。对于冗余控制流,例如
if true {
c = 2
}
return 2 * c
可以直接优化为return 4
过程内分析和过程间分析
前者只分析函数内部的流,而后者需要考虑过程传递,以及调用方法的对象,较为复杂。
Go编译器优化
函数内联
将简单函数的调用直接在调用过程中展开,避免了函数调用的开销。
Beast Mode与逃逸分析
该两者提高了内联函数的效率。