Go 的内存管理| 青训营笔记

93 阅读5分钟

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

性能优化

自动内存管理

所谓自动内存管理,其实就是指垃圾回收,程序在运行时根据需求动态分配的内存(即动态内存)会被纳入自动内存管理的范畴。

通过自动内存管理,我们可以避免手动释放内存,将注意力专注在业务逻辑,同时还可以避免发生内存安全问题(诸如 内存重复释放问题 double-free problem 或是 内存释放后使用问题 use-after-free problem)。

一个垃圾回收周期大致有三个任务:为新对象分配空间,找到存活对象,回收死亡对象的内存空间。

要想详细了解垃圾回收,就必须先了解其相关概念:

  • Mutator:业务线程,分配新对象,修改对象指向关系;
  • Collector:GC 线程,找到存活对象,回收死亡对象的内存空间;
  • Serial GC(串行 GC):只有一个 collector;
  • Parallel GC(并行 GC):支持多个 collectors 同时回收的 GC 算法;
  • Concurrent GC(并发 GC):mutator(s) 和 collector(s) 可以同时执行。

要想评价一个 GC 算法,大概可以从以下几个方面进行:

  • 安全性(Safety):指垃圾回收器不应回收存活的对象;
  • 吞吐率(Throughput):指垃圾回收器花在 GC 上的时间占程序执行总时间的比率;
  • 暂停时间(Pause time):指垃圾回收导致业务线程挂起(暂停)的时间(GC 导致的暂停被称为 stop the world, STW)
  • 内存开销(Space overhead):指垃圾回收器元数据占用的内存开销;

以下将简单介绍几个垃圾回收算法:

追踪垃圾回收

追踪垃圾回收(Tracing Garge Collection) 是一种最常见的垃圾回收方式,它通过跟踪哪些对象可以通过来自某些“根”对象的引用链访问来确定哪些对象应该被释放(“垃圾回收”),并将其余对象视为“垃圾”并收集它们

追踪垃圾回收也是 Go 目前正在使用的垃圾回收算法。

简单来说,追踪垃圾回收以如下方式工作:

  1. 首先,标记根对象,这些根对象可能包括静态变量,全局变量,常量,线程栈等
  2. 然后,从根对象触发,找到所有引用根对象的可达对象
  3. 最后,清理所有不可达对象,这分为三个步骤:将存活对象复制到另外的内存空间(Copying GC),将死亡对象的内存标记为"可分配"(Mark-sweep GC),移动并整理存活对象(Mark-compact GC)。

根据对象的生命周期,垃圾回收器可能会使用不同的标记和清理策略。

分代 GC

分代 GC 的设计来源于分代假说(Generational hypothesis)

most objects die young

即大多数对象在很短的生命周期内就会死亡,分配出来后很快就不再使用了。通过为年轻和年老(经历过 GC 的次数越多则越老,反之越年轻)的对象指定不同的 GC 策略,降低整体内存管理的开销。

年轻代(Young generation)

  • 常规的对象分配
  • 由于存活对象很少,可以采用 copying collection
  • GC 香吐率很高

老年代(Old generation)

  • 对象趋向于一直活着,反复复制开销较大
  • 可以采用 mark-sweep collection

image-20230121175736081

image-20230121175725373

引用计数

引用计数为每一个对象维护一个关联的引用数目,当且仅当引用数大于 0 时,该对象才会被标记为存活,否则,对象会被回收。

引用计数方案的优点是:

  1. 内存管理的操作被平摊到程序执行过程中
  2. 内存管理不需要了解 runtime 的实现细节: C++ 智能指针(smart pointer)

其缺点也很明显:

  • 维护引用计数的开销较大(因为引用计数操作必须是原子的)
  • 无法回收环形数据结构(因为所有对象的被引用数都大于0但无外部引用指向该环形数据结构)
  • 将引入额外的内存空间以存储引用数目
  • 回收内存时依然可能引发暂停等。

Go 内存管理及优化

内存分配

  • 分块:可以通过系统调用(mmap())提前向操作系统申请一个大的内存块,并连续分配一定大小的小内存块用于对象分配。将内存分配为含有指针的大块(scan mspan)和没有指针的大块(noscan mspan)。

  • 缓存:通过维护 mcache 管理一组 mspan 加快内存分配效率,避免重复向操作系统申请内存。

  • Balanced GC:

    每个g 都绑定一大块内存 (1 KB),称作 goroutine allocation buffer (GAB),GAB 用于 noscan 类型的小对象分配: < 128 B,使用三个指针维护 GAB: base,end,top,然后可以使用Bump pointer (指针碰撞) 风格进行对象分配:无须和其他分配请求互斥,分配动作简单高效,GAB 对于 Go 内存管理来说是一个大对象本质将多个小对象的分配合并成一次大对象的分配

    但使用该方法可能会导致内存被延迟释放:

    • 当 GAB 总大小超过一定闻值时,将 GAB 中存活的对象复制到另外分配的 GAB 中
    • 原先的 GAB 可以释放,避免内存泄漏
    • 本质: 用 copying Gc 的算法管理小对象,根据对象的生命周期,使用不同的标记和清理策略。

编译器优化

  • 函数内联(Inlining)
  • Beast Mode
  • 逃逸分析