性能优化及自动内存管理 | 青训营笔记

120 阅读7分钟

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

性能优化及自动内存管理

自动内存管理

自动内存管理主要是管理动态内存

动态内存: 程序在运行时根据需求动态分配的内存 : C

自动内存管理(垃圾回收): 由程序语言的运行时系统管理管理动态内存

  • 避免手动内存管理,专注于实现业务逻辑
  • 保证内存使用的 正确性安全性double-free problemuse-after-free problem
    • double-free : 连续释放了两次同一块内存
    • use-after-free : 释放内存后再次使用这块内存

三个任务

  • 为新对象分配空间
  • 找到存货对象
  • 回收死亡对象的内存空间

相关概念

Mutator : 指业务线程,主要任务为分配新对象,然后修改对象指向关系

Collector : 指 GC 线程,主要工作为找到存活对象,回收死亡对象的内存空间

GC 算法

Serial GC : 只有一个 collector

Parallel GC : 支持多个 collector 同时回收的 GC 算法

Concurrent GC : mutator(s)collector(s) 可以 同时执行

  • Collectors 必须感知对象指向关系的改变!
  • img

GC 算法示意图

评价 GC 算法的指标

  1. 安全性 : 不能回收存活对象 基本要求
  2. 吞吐率 : 1GC时间程序执行总时间1 - \frac{GC 时间}{程序执行总时间} 花在 GC 上的时间
  3. 暂停时间 : stop the world (STW) 越短越好 业务是否感知
  4. 内存开销 : GC 元数据开销

追踪垃圾回收 (Tracing garbage collection)

对象被回收的条件 : 指针指向关系不可达的对象

回收的步骤:

  1. 标记根对象 :
    1. 将静态变量,全局变量,常量,线程栈等的指针指向指向的对象标记为存活的
    2. 因为这些地方的指针,程序以后还可能会用到,不能立刻回收
  2. 标记 : 找到可达对象
    1. 从根对象出发,找到所有的可达对象,即为求指针指向关系的传递闭包
  3. 清理 : 所有不可达对象
    1. Copying GC : 将存活的对象复制到另外的内存空间,这样原来的区域就被清空了,可以重新分配
    2. Mark - sweep GC : 将死亡对象的内存标记为 “ 可分配对象 ”,使用 free list 管理死亡对象,下次做分配的时候,直接在 free list 上分配
    3. Mark - compact GC : 移动并整理存活对象,将存活对象拷贝压缩到内存空间的最开头,然后从后面开始分配。原地整理 GC

根据对象的生命周期,使用不同的标记和清理策略。如何选择?

分代 GC (Generational GC)

分代 GC 是一种常见的内存管理方式,基于分代假说。

分代假说 (Generational hypothesis) : most objects die young, 大多数对象很快就会死亡,很多对象在分配出来后很快就不再使用了。

每个对象的 年龄 为该对象经历过 GC 的次数,比如 : 某个对象经历过两次 GC 后 仍然活着,则认为该对象的年龄为 2 。

目的: 将所有存活的对象分成 Yong Generation 和 Old Generation 两个部分,不同年龄的对象放在 heap 的不同区域,对不同的对象制定不同的** GC 策略,降低整体内存管理的开销

不同代的对象特点以及建议 GC 策略

  1. 年轻代 Young Generation
    1. 常规的对象分配
    2. 由于存活对象少,可以采用 copying collection
    3. GC 吞吐率很高
  2. 老年代 Old Generation
    1. 对象趋于一直活着,反复复制开销很大
    2. 可以采用 mark-sweep collection

引用计数 (Reference counting)

引用计数也是一种常见的管理对象的方式,在 引用计数 中,每个对象都有一个与之关联的引用数目。此时,对象的存活条件为 : 当且仅当引用数大于 0

优点 :

  1. 内存管理的操作被平摊到了程序执行过程中:程序的执行过程中,可以同时维护引用计数,当引用计数为0的时候,就可以在程序执行完后直接清空对象。
  2. 引用计数在内存管理的时候不需要了解 runtime 的实现细节 : C ++ 智能指针 smart pointer

缺点 :

  1. 维护开销比较大 : 因为通过 原子操作 才能保证对引用计数操作的 原子性可见性, 但这样同时使得开销较大
  2. 无法回收环形数据结构。当内存中出现环形不可达的数据结构时,由于它是环形的,导致环内每个对象的引用次数都为 11 ,导致本该回收的环不能回收。需要使用到 weak reference 之类的方法去解决
  3. 内存开销比较大:每个对象都要引入格外的内存空间存储引用次数
  4. 回收内存的时候依然存在触发暂停的可能性(大的数据结构)

Go 内存分配

分块

目标: 为对象在 heap 上分配内存

提前将内存分块

  1. 调用系统调用 mmp() 向 OS 申请一大块内存,例如:4KB
  2. 现将内存划分为大块,例如 8KB, 称作 mspan
  3. 再将大块内存继续划分成为 特定大小 的小块,用于对象分配
  4. noscan mspan : 分配不包含指针的对象 —— GC 不需要扫描
  5. scan mspan : 分配包含指针的对象 —— GC 需要扫描

对象分配:根据对象的大小,选择最适合的块返回

缓存

TCMalloc : thead caching

  • 每个 p 包含一个 mcache 用于快速分配,用于为绑定于 p 上的 g 分配对象
  • mcache 管理一组 mspan, 其中每个 mspan 大小不一样。在 mspan 中找到最适合的块分配出去,就完成一次对象分配
  • mcache 中的 mspan 分配完毕或者不够分配时,向 mcentral 申请带有未分配块的 mspan,填入到 mcache 中,然后再将适合的 mspan 返回出去,完成一次对象分配。
  • mspan 中没有分配的对象,即所有对象都被清理干净后, mspan 会被缓存在 mcentral 中,而不是立刻释放并归还给 OS,这样一旦有需求分配的时候,可以立刻从缓存中取出。

Go内存管理优化

  1. 对象分配是非常高频的操作 : 每秒会分配出 GB 级别的内存

  2. 分配的时候小对象占比会比较高

  3. Go 内存分配比较耗时

    1. 分配路径很长 : g ——> m --> p --> mcache --> mspan --> memory block --> return pointer
    2. pprof : 对象分配的函数是最频繁调用的函数之一

Balanced GC

字节跳动的优化方案:BALANCED GC

  • 每个 g 都绑定一大块内存(1 KB),称作 goroutine allocation buffer (GAB)
  • GAB 用于 noscan 类型的小对象分配: < 128 B
  • 使用三个指针维护 GAB : baseendtop
  • Bump poiner (指针碰撞) 风格对象分配
    • 无需和其他分配请求互斥
    • 分配动作简单高效
if top + size <= end { // 如果内存足够分配
    addr := top // 记一下当前 top 指针的位置
    top += size // 分配内存
    return addr // 将开始地址返回出去
}

然而 GAB 对于 GO 内存管理来说是一个 大对象,其本质是 将多个小对象的分配合并成一个大对象的分配。这里存在的问题是 : GAB 的对象分配方式会导致内存被延迟释放。当一个 GAB 中只存在一个很小的对象存活,比如:8b 。此时,GO 会将这 1KB 的 GAB 都标记成为存活的对象。导致 GAB 被延迟释放。

针对这种情况,使用 移动 GAB 中存活的对象 这种方式来管理:

  1. 当 GAB 总大小超过一定阈值的时候,将 GAB 中存活的对象复制到另外分配的 GAB 中
  2. 原先的 GAB 可以被释放,避免内存泄露
  3. 本质 : 采用 copying GC 的算法管理小对象