Go内存管理与编译器 | 青训营笔记

94 阅读6分钟

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

1 自动内存管理

🤔自动内存管理是什么?为什么需要自动内存管理?

📎自动内存管理: 在Go程序运行时系统会管理动态内存,而不需要程序员手动清理内存;事实上自动内存管理并不是独属于Go的秘笈,Java的自动内存管理模块集成于JVM中,相信熟悉Java的小伙伴非常清楚当初打着C++--旗号的Java就是靠着减掉指针减掉手动内存管理迅速“上位”,力压C++,C等一众语言。

随着编程语言的推陈出新,新语言大多都支持自动内存管理,这是为什么呢❓

📎首先可以从实用性角度出发:自动内存管理避免了手动内存管理,专注于业务逻辑,保证了内存使用的正确性和安全性;其次从语言开发初衷出发:为什么C、C++这些语言不提供自动内存管理?最重要的原因就是它们被开发时初衷是更加高效的使用计算机的硬件资源,就以C++来说,它的定位就是一个较为底层的语言,其设计之初是为了贴近C语言的性能同时提供面向对象的抽象能力;最后从发展的角度出发:计算机的性能发展基本符合“摩尔定律”,内存资源的稀缺大不如前,与其死守手动内存管理不如将内存管理交由“系统(runtime JVM)”来完成,让程序员专心实现业务。

2 Go内存管理及优化

🤔Go语言是如何进行内存管理的?在生产实践中这种内存管理方式是否存在什么问题?字节又是如何优化的呢?

内存管理主要分两部分:分配内存、释放内存

🤔Go如何进行内存分配的呢?

Go的内存分配器在分配对象时,根据对象的大小分为3类:Tiny对象(0,16B]、中等对象(16B,32B]、大对象(32B,+∞)

大体上的分配流程:

大对象,直接从mheap上分配;

Tiny对象,使用mcache的tiny分配器分配;

中等对象:

  • 首先计算对象的规格大小,然后使用mcache中相应规格大小的mspan分配;
  • 如果mcache没有相应规格大小的mspan,则向mcentral申请;
  • 如果mcentral没有相应规格大小的mspan,则向mheap申请;
  • 如果mheap中也没有合适大小的mspan,则向操作系统申请。

Go内存分配概览图:

事实上,在生产环境中小对象(一般小于80B)的创建与销毁频繁,会导致频繁地GC从而STW(stop the world)阻塞生产服务。

字节跳动优化方案——Balanced GC

字节跳动优化方案

  • Balanced GC
  • 核心:将 noscan 对象在 per-g allocation buffer (GAB) 上分配,并使用移动对象 GC 管理这部分内存,提高对象分配和回收效率

img

  • 每个 g 会附加一个较大的 allocation buffer (例如 1 KB) 用来分配小于 128 B 的 noscan 小对象
  • bump pointer 风格的对象分配。示意如下。
if g.ab.end - g.ab.top < size {
    // Allocate a new allocation buffer
}
addr := g.ab.top
g.ab.top += size
return addr
复制代码
复制代码
  • 分配对象时,根据对象大小移动 top 指针并返回,快速完成一次对象分配
  • 同原先调用 mallocgc() 进行对象分配的方式相比,balanced GC 缩短了对象分配的路径,减少了对象分配执行的指令数目,降低 CPU 使用

从 Go runtime 内存管理模块的角度看,一个 allocation buffer 其实是一个大对象。本质上 balanced GC 是将多次小对象的分配合并成一次大对象的分配。因此,当 GAB 中哪怕只有一个小对象存活时,Go runtime 也会认为整个大对象(即 GAB)存活。为此,balanced GC 会根据 GC 策略,将 GAB 中存活的对象移动到另外的 GAB 中,从而压缩并清理 GAB 的内存空间,原先的 GAB 空间由于不再有存活对象,可以全部释放,如下图所示。

img

上图上方是两个 GAB,其中虚线表示 GAB 中对象的分界线。黑色表示 GAB 中存活的对象,白色表示死掉的对象。由于 GAB 中有存活对象,整个 GAB 无法被回收。

Balanced GC 会将 GAB 中存活的对象移动到下面的 GAB 中,这样原先的两个 GABs 就可以被释放,压缩并清理 GAB 的内存空间。

Balanced GC 只负责 noscan 对象的分配和移动,对象的标记和回收依然依赖 Go GC 本身,并和 Go GC 保持兼容。

🤔Go如何进行内存释放的呢?

Go的内存释放接近于JVM中GC过程,Go使用Tracing garbage collection(追踪垃圾回收)和引用计数法来释放内存;

追踪垃圾回收具体过程如下:

  • 标记GC roots:静态变量、全局变量、常量、线程栈等;

  • 标记GC roots所有可达对象;

  • 清理回收不可达对象内存空间;回收策略如下:

    • Copying GC:复制清除,将存活对象复制到另一内存区域,当前区域内存置为可分配状态

    img

    • Mark-sweep GC:标记清除,将死亡对象所在空间标记为可分配,使用free List记录可分配空间地址

    img

    • Mark-compact GC:标记压缩,将标记出的存活对象迁移到当前内存区域的头部,记录存活对象的尾部地址
    • img

引用计数:

  • 每个对象都有一个与之关联的引用数目
  • 对象存活的条件:当且仅当引用数大于 0
  • 优点

    • 内存管理的操作被平摊到程序运行中:指针传递的过程中进行引用计数的增减
    • 不需要了解 runtime 的细节:因为不需要标记 GC roots,因此不需要知道哪里是全局变量、线程栈等
  • 缺点

    • 开销大,因为对象可能会被多线程访问,对引用计数的修改需要原子* *** 操作保证原子性和可见性
    • 无法回收环形数据结构
    • 每个对象都引入额外存储空间存储引用计数
    • 虽然引用计数的操作被平摊到程序运行过程中,但是回收大的数据结构依然可能引发暂停

3 编译器和静态分析

❓编译器的结构如何?静态分析又是什么?

编译器结构

img

静态分析

静态分析是一种在不运行代码的情况下检查代码问题的方法,“静态”的意思是编译时而非运行时,“分析”即进行编译器前端部分的分析:词法分析 -> 语法分析 -> 语义分析

4 Go编译器优化

❓在Go语言开发过程中有什么编译器优化启示?函数内联优化的是否存在隐患呢?逃逸分析又是怎么进行优化的呢?

优化启示

  • 面向后端长期执行的任务
  • 适当增强编译时间以换取更高性能的代码

优化方案

  • 函数内联:将被调用函数体的代码替换到调用位置上,同时重写代码以反映参数的绑定
  • 逃逸分析:一种确定指针动态范围的静态分析,它可以分析在程序的哪些地方可以访问到指针

函数内联

优点:

  • 函数内联节省了调用开销;
  • 将过程间分析问题转化为过程内分析,有助于其他分析。

弊端:

  • 函数体变大;
  • 编译生成的Go镜像文件变大。

函数内联在大多数场景下是正向优化;牺牲编译时间以及存储空间,提升一定的程序性能。

🤔内联优化什么场景下不应该使用呢?

  • 递归函数
  • 函数体代码较长,使用内联函数加剧运行时栈扩展开销
  • 函数体内有循环,函数执行时间比函数调用开销大

逃逸分析

逃逸分析大致思路:

  • 从对象分配处出发,沿着控制流,观察数据流。若发现指针 p 在当前作用域 s:

    • 作为参数传递给其他函数;
    • 传递给全局变量;
    • 传递给其他的 goroutine;
    • 传递给已逃逸的指针指向的对象;
  • 则指针 p 逃逸出 s,反之则没有逃逸出 s.

*控制流:程序的执行流程

*数据流:数据在控制流上的传递

👻👻看不懂?看不懂就对了,我也不是很会🐯

逃逸分析是对变量放到堆上还是栈上进行的静态分析。

逃逸————如果一个变量超过了函数调用的生命周期,也意味着这个变量在函数外部存在引用,编译器会把这个变量分配到堆上,这时我们就说这个变量发生逃逸了。

❓变量放在还是让编译器决定不就好了吗?至于多此一举吗?

❗NO,NO,如果放在栈中,变量即用即销(有时需要变量复用);如果放在堆上,堆又不会像栈自动清理内存空间,这会引起频繁的GC,GC又比较占用系统开销。

一般逃逸的场景:

  • interface{}赋值;优化:将interface{}替换为固定类型
  • return指针类型;优化:视情况而定
  • 栈空间不足;优化:设置栈容量,根据场景扩容,⚠️栈空间不宜过大

参考文献:后端专场 学习资料二