高性能GO 【内存管理 / 编译器优化】课堂笔记| 青训营笔记

308 阅读7分钟

这是我参与「第三届青训营 -后端场」笔记创作活动的的第4篇笔记


针对老师课后的review要求,结合上课的内容和自己的拙见,做个简单的记录

1. 从业务层和语言运行时层进行优化分别有什么特点?

  • 业务层:
    • 业务相关性强
    • 针对性比较强
    • 容易见到成效
  • 语言运行时层:
    • 对业务透明,符合更多的场景
    • 对单个服务带来的改变不大,但是可用的范围广,数量级达到一定级别,效益很高
    • 后来的服务也可以受益

2. 从软件工程的角度出发,为了保证语言 SDK 的可维护性和可拓展性,在进行运行时优化时需要注意什么?

应该在不改变调用接口的前提下,实现优化

也就是说,优化对于使用者来说应该是透明的,无感知的

站在用户的视角,几乎只是重新编译运行了一下,就能见到性能的优化

3. 自动内存管理技术从大类上分为哪两种,每一种技术的特点以及优缺点有哪些?

引用计数法

  • 特点:对每个对象设置计数器,引用一次就将计数器加一,取消引用就对计数器减一
  • 优点:逻辑简单,操作过程可以和运行过程同步执行
  • 缺点:
    1. 环形依赖(A包含B的属性,B包含A的属性)
    2. 引用计数的维护成本很大,需要原子操作
    3. 每个对象都需要设置计数器,增加整个程序内存消耗
    4. 回收内存过大会引发STP(stop the world)

追踪垃圾回收(可达性标记法)

  • 特点:设置合理的根节点(静态变量、全局变量、常量、线程栈等),从根节点开始,标记可以通过根节点访问到的节点,然后对不可达的空间进行释放,包括三种方式:

    1. Copying GC: 将存活对象从一块内存空间复制另外一块内存空间,原先的空间可以直接进行对象分配 image.png
    2. Mark-sweep GC: 将死亡对象所在内存块标记为可分配,使用 free list 管理可分配的空间 image.png
    3. Mark-compact GC: 将存活对象复制同一块内存区域的开头 image.png
  • 优点:解决循环依赖问题

  • 缺点:检查过程中可能需要执行STP,因为要保证对象的一致性,效率也比引用计数法要慢一点

4. 什么是分代假说?分代 GC 的初衷是为了解决什么样的问题?

分代假说

most object die young

翻译过来就是:大部分的对象被创建之后很快就不再使用了

分代GC

分代GC是为了针对不同代的对象,指定不同带GC策略

上面提到 Copying GC 和 Mark-compact GC,都涉及到了对象的复制

而 Mark-sweep GC 则只用 free-list 维护 free 的空间

如果一片空间中,存储的都是经常被使用的对象(也就是所谓的老年代),那么对经常使用的资源反复的复制,就很浪费资源,没有必要,使用 mark-sweep 更有性价比

相反,如果空间中存储的都是生命周期很短的对象,很快被销毁,那么GC时要维护的对象就很少,只需要复制存放好后直接释放那块空间即可

由此可见,针对不同类型的对象,出现了不同的处理方案,分代GC就是基于这种 “对症下药” 的思路的产物。


image.png

tips

从我在网上查找的资料来看,Go的GC是不分代的,但是字节跳动的老师分享的 Balanced-GC 应该引用了这方面的思想:

事先申请 GAB(goroutine allocation buffer),将年轻代的内存较小(< 128k)的对象放置到其中,用 bump-pointer 风格为新对象分配内存

这样优化的本质其实就是把多个 小对象的内存申请过程合并,然后新对象申请的时候直接调申请好的空间,同时采用了bump-pointer,效率更高

image.png

但是,一个GAB对于go内存管理来说是一个对象,所以如果整个GAB中只有很少的对象存活,大块的空间等待释放,但是由于GAB是一个整体,还是无法释放,这就是导致了内存延时释放

因此 Balanced-GC 引入了移动 GAB 存活对象的方式,将剩余的少量对象移动到新的空间,然后释放原来的空间,这样就解决了延时空间释放的问题

这个优化的本质是使用了 copying-GC 去管理小对象,也比较符合上面 分代GC 的说法

image.png

5. Go 是如何管理和组织内存的?

Go对内存主要有两个要求:分配内存和释放内存

分配内存(分块 + 缓存)

老师讲GO的内存分配主要由两个重要部分组成,分块和缓存

分块

  • 目标:为对象在heap上分配内存
  • 提前将内存分块
    • 使用系统调用mmap(),向OS申请一大块内存,例如4MB
    • 先将内存划分为大块的mspan,例如8kb
    • 再将啊快继续划分成特定大小的小块,用于对象分配
    • noscan mspan:分配不含指针的对象 -- GC不需要扫描
    • scan mspan:分配包含指针的对象 -- GC需要扫描(更新可达性)
    • 对象分配:根据对象大小,选择最合适的块返回

image.png

缓存

  • GO 的内存分配其实引用了 TCMalloc(thread caching)
  • 每个 Process 包含一个mcache哟关于快速分配,用于为绑定于 Process 上的 goroutine 分配对象
  • mcache 管理一组mspan
  • 当 mcache 中的 mspan 分配完毕,向 mcntral 申请带有未分配块的 mspan
  • mspan没有分配的对象也不会立即释放,会先缓存,减少频繁申请空间的事情发生

image.png

释放内存

其实就是自动内存管理(GC)的职责,见问题3

这里可以补充一下老师讲的有关 Serial GC、Parallel GC、Concurrent GC的内容:

Serial GC:只有一个GC线程执行

Parallel GC:支持多个GC线程同时执行

Concurrent GC:多个GC线程和多个业务线程同时执行

业务线程:分配内存,修改对象引用的用户线程

6. 为什么采用 bump-pointer 的方式分配内存会很快?

bump-pointer 的核心思想在于只追踪创建的最后一个对象

之后需要创建对象只需要查看申请的空间中是否有足够的剩余空间

没有就重新申请空间,有就跟在最后一个对象的后面,更新索引,比较像栈

可以看到逻辑是线性的,空间的申请是顺序的,不用回过头看之前的空间,所以快

7. 为什么我们需要在编译器优化中进行静态代码分析?

抛开无用的上下文干扰,分析代码的性质和核心逻辑,抓住本质,方便后面的优化

8. 函数内联是什么,这项优化的优缺点是什么?

函数内联是指将被调用函数的函数体副本替换到调用位置上,同时将形式参数替换成实际参数

优点:

  1. 减少调用带来的上下文切换的开销
  2. 多个过程合并为一个过程,将函数间分析改为函数内分析,方便其他分析机制的处理
  3. 各种语言的支持程度不一样 缺点:
  4. 冗余代码增多,函数变大
  5. 生成的代码镜像文件变大

9. 什么是逃逸分析?逃逸分析是如何提升代码性能的?

我理解的逃逸分析是在函数粒度下分析代码片段中指针的作用域(tips:Java也有类似的优化)

如果没有超出该函数,则可以称为没有逃逸,反之可以判断其已经逃逸

常见触发场景:

  1. 作为参数传递给其他函数
  2. 传递给全局变量
  3. 传递给其他的 goroutine
  4. 传递给已逃逸的指针指向的对象

如果指针被判断没有逃逸,那么虚拟机可以将其对应的对象分配到栈上,栈上的空间申请和释放比堆快,同时也降低了GC的压力

小结

这部分内容网上文章众说纷纭,错综复杂,好在老师讲解的非常清晰,深入浅出,通俗易懂

但是我的理解仍有不足,也欢迎大家批评指正。