Go 语言内存管理详解 | 青训营笔记

79 阅读4分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第 5 天

自动内存管理

管理Go内存分为三个部分:

  • 根据需求分配的内存:malloc
  • 自动内存管理:由runtime回收内存
    • 避免手动管理,专注于业务
    • 保证正确性和安全性
  • 三个任务:
    • 为新对象分配内存
    • 找到存活对象
    • 回收死亡对象的内存空间

相关概念:

  • Mutator:内务线程,分配新对象,修改对象指向关系
  • Collector:GC线程,找到存活对象,回收死亡对象的内存空间
  • Serial GC:只有一个Collector
  • Parallel GC:支持多个Collectors同时回收的GC算法
  • Concurrent GC:mutators和collectors可以同时执行
    • Collectors必须感知对象指向关系的改变
  • 评价GC算法:
    • 安全性:不能回收存活的对象
    • 吞吐率:1GC时间吞吐总时间1-\cfrac{GC时间}{吞吐总时间}
    • 暂停时间:业务是否被感知
    • 内存开销
  • 追踪垃圾回收
  • 引用计数

追踪垃圾回收

需要回收的垃圾是指针指向关系不可达的对象。垃圾回收分为三步:

  • 标记根对象
  • 标记:找到可达对象
  • 清理:所有不可达对象

所谓根对象,指的是在栈内存全局内存中可以直接管理的内存。这些内存可能引用堆内存中的内存,当存在引用时,就相当于一个指向可达对象的指针。

求解可达性一般是dfs。不清楚Go工程上的实现,但是求传递闭包 O(n3)O(n^3),应该不太可能。

清理不可达对象包括三个内容:

  • 将可达对象复制到新内存(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与逃逸分析

该两者提高了内联函数的效率。