这是我参与「第五届青训营 」伴学笔记创作活动的第 4 天
1 性能优化的层面
业务层:
- 针对特定场景,具体问题具体分析
- 容易获得较大的性能收益
runtime (Go SDK) 优化:
- 解决通用的性能问题(如内存分配、编译等)
- 要考虑更多场景,要根据场景做权衡
在优化时,要依靠数据,而不是猜测,首先优化最大的性能瓶颈。
1.1 保证性能优化的质量
在保证接口稳定的前提下改进具体实现。
测试:覆盖尽可能多的场景,驱动开发。
隔离:通过选项控制是否开启优化。
文档:做了什么、没做什么、能达到什么样的效果,让使用者知道自己有没有必要开启这项优化。
2 自动内存管理
由 runtime 管理动态内存,帮我们为新对象分配空间、回收不需要的内存空间。
基本概念
Mutator: 业务线程,制造和修改对象
Collector: GC 线程
Serial GC: 只有一个 collector
Parallel GC: 有多个 collector
Concurrent GC: 业务线程和 GC 线程同时执行
Concurrent GC 的挑战: 必须要感知到一个已标记的对象产生的新对象
清理垃圾这件事其实只有两步:
- 找垃圾
- 丢垃圾
2.1 谁是垃圾?
垃圾的定义就是:没人用的东西。
因此,在发现垃圾这件事上,我们的重点就在于怎样确定哪些对象是没用的。
为此,我们有两种常见的方法:
Tracing GC - 追踪垃圾回收算法
它也被称为可达性收集法。
基本原理就是:
- 标记根对象(如常量、栈空间中的对象等)
- 从根对象出发,找到所有的可达对象
- 那些不可达的,就都是垃圾了
Reference Counting GC - 引用计数算法
每个对象都有一个与之关联的引用数目 refCounter, 多一个指针引用就 +1,少一个指针引用就 -1。
当一个对象 refCounter <= 0 的时候,它就是个没人要的垃圾。
这看起来很不错:
- 内存管理的操作被平摊到了程序执行过程中
- 实现的过程与 runtime 耦合较小,可以作为一个单独的库来实现(如 C++ 中的智能指针)
但问题也来了:
- 维护引用计数的开销较大(
refCounter的操作需要保证原子性和可见性) - 无法不可达的回收环形数据结构
refCounter本身占一定内存
2.2 丢垃圾
通常来说,清理垃圾有三种方法,他们都很好理解。
Mark-sweep GC: 将垃圾对象的内存标记为可分配
这种清理垃圾的方法应该是最快的,因为它其实根本没有清理,只是单纯地声明这个地方可以放新对象了。
但清理的时候轻松了,要用的时候就没那么容易了。
我们都知道,对于新来的对象,如果想要找个安家的位置,那必须得挑选一个合适自己大小的空闲空间。
不同的对象变成垃圾的时间肯定不一样,这一通操作清理完以后,会把可用空间变得东一块西一块,甚至有可能出现一种极端的情况:就算总体的可用空间满足对象的需求,但因为被分散到各个角落,所以无法分配。
这种情况我们叫做内存碎片化。
Compact GC: 移动并整理存活对象
和我们平时打扫卫生一样,我们在丢垃圾的时候,也会顺手把房间整理一下。
要解决碎片化的问题,要实现的就是把存活的对象放到内存中的一端。
这样,另外一端就全部都是垃圾,可以用来分配新的对象。
这种操作让我们的可用内存和空闲内存都是连续的,使得我们在分配新对象时可以进行指针碰撞。
指针碰撞指的是:如果我们把内存当作一个线性表,用一个指针cur指向线性表中第一个可用空间的地址,
那么如果要分配一个新的size的对象,就可以直接得知它的起始位置是 cur,分配完完这个对象以后,下一个可用的位置 cur = cur + size。
但这样做其实还有一个问题:整理的过程需要时间。
其实不难理解,扫描完一次垃圾之后的内存空间肯定是东一块西一块,如果想让所有可用的对象乖乖地贴在一端,那肯定得经过一些交换等操作。
Copying GC: 将存活对象复制到另外的内存空间
和上一种算法相比,它就简单粗暴的多了。
这种算法在清理垃圾的时候,直接找一块干净的内存空间(现在没有存对象的),然后把前面标记的不是垃圾的对象全部丢进去。
这样,原来的那块空间就全部都是垃圾了。
虽然还是需要复制,但至少它整理的过程少了交换这一步骤。
但这种丢法也有它的缺点,比如说:必须找一块干净的内存空间,有点奢侈
既然这些丢垃圾的方法各有不同的缺点,那么我们在实际中肯定要根据对象的特点来选择合适的清理策略。
混合清理策略 —— 分代收集算法
这个算法很多编程语言的 runtime 都在用,特别是 JVM。
分代收集算法认为:大部分对象都属于早年夭折的类型。
这也很好理解,毕竟我们现在的编程语言都喜欢认为万物皆对象,你的一个函数里可能就造出了好几个对象,但最终只返回那么一两个。
所以,如果我们为每个对象设置一个“年龄” —— 这个“年龄”实际上指的是该对象所经历过的 GC 次数。
这样一来,我们就可以区分出对年轻的对象和老的对象,并为他们分别采用不同的 GC 策略。
年轻代
由于存活对象少,可以采用 Copying GC 的策略来清理。
老年代
由于一直活着,反复复制开销较大,可以采用 Mark-sweep 的策略清理。
2.3 小结
这一小节介绍的 GC 策略其实在很多其他编程语言 runtime 中也广泛使用。
以 Java 为例,
Java 使用的垃圾标记方法就是追踪垃圾回收算法,但它把引用分为了四种:强引用、软引用、弱引用、虚引用。
在 Java 中,垃圾回收的方法也是采用分代假说,但 Java 以前还有一个用来存放静态数据的持久代, 1.8 的时候移除了。
其实各种方法都有自身的局限性,因此我们要结合对象的生存周期来选择合适的处理策略。
现如今一些 runtime 采用了比较不同的 GC 策略,有很多也是根据上面所说的这些 GC 策略衍生而来的,说白了就是修复他们 bug 的方案。
如果按照这个思路去理解其他的 GC 策略的话,就可以很快地去理解啦!
3 Go 中的内存管理
Go 在堆上为对象分配内存,它内存管理的手段是提前将内存分块。
具体的步骤如下:
- Go 会通过系统调用
mmap()申请一大块内存,例如 4 MB - 将内存划分成大块,例如 8 KB,称作 mspan。
- 将大块继续划分成特定大小的小块,用于对象分配
mspan 分为两种:
- noscan mspan: 分配不包含指针的对象 —— GC 不需要扫描
- scan mspan: 分配包含指针的对象 —— GC 需要扫描
在对象分配时,根据对象的大小,选择最合适的 mspan 返回。
3.1 内存分配 —— GMP 模型
TCMalloc 内存分配器的方法理念:
从共享内存中获取内存要加锁。如果给线程添加内存缓存,从而避免加锁,就可以减少竞争从而提高性能。
TCMalloc 将内存分为三个部分:
ThreadCache线程缓存CentralFreeList中央缓存PageHeap堆内存
分配内存时,自上而下地寻找可用空间。
Go 的内存分配就是基于 TCMalloc 的理念,分为了下面三个部分:
mcache线程缓存mcentral中央缓存mheap堆内存
唯一不同的是, mcache 的拥有者并不是系统线程,而是逻辑处理器。
下图展示了内存分配的过程,其中 g 表示协程, m 表示系统线程, p 表示逻辑处理器。
3.2 Go 内存管理的优化
线上程序内存分配的特点:
- 对象分配是非常高频的
- 小对象的占比比较高
Go 内存分配的问题:
- 分配链路很长
- 对象分配函数
mallocgc()占用时间较高
Balanced GC
为每个 g 绑定一块大内存(1 KB), 称为 goroutine allocation buffer (GAB) (其实就是一个栈),用于 noscan 类型的小对象分配。
分配方法:
if top + size <= end {
addr := top
top += size
return addr
}
对于 Go 而言,一个 GAB 其实是一个 大对象。
GAB 的目的是将多个小对象的分配合并成一次大对象的分配。
当 GAB 数量超过一定阈值的时候,需要清理一些 GAB 以防止内存泄漏。
GAB 的清理是采用 Copying GC 的算法,将一些 GAB 里残余的小对象合并到一个幸存者 GAB 中。
本质上, Balanced GC 是将多次小对象的分配合并成一次大对象的分配。
Balanced GC 只负责 noscan 对象的分配和移动,对象的标记和回收依然依赖 Go GC 本身,并和 Go GC 保持兼容。
4 编译器和静态分析
4.1 静态分析
不执行程序代码,推导程序行为。
控制流:程序执行的流程 数据流:数据在控制流上的传递
过程内分析:仅在函数内部分析 过程间分析:考虑函数调用时参数传递和返回值的数据流和控制流(有点复杂,需要联合求解,如多态)
4.2 编译器优化
Tradeoff: 用编译时间换取高效的机器码
4.2.1 函数内联
优点:
- 消除函数调用开销
- 将过程间分析转化为过程内分析
缺点:
- 函数体变大, icache 不友好
- 生成的 Go 镜像变大
所以需要一定的内联策略,如函数规模等。
可以使用 micro-benchmark 快速验证性能优化。
4.2.2 逃逸分析
分析代码中指针的作用域,若指针 p 有传递给其他参数,或者传递给全局变量等情况,导致 p 会出现在其他作用域中,则表示 p 逃逸出 s。
未逃逸的对象可以在栈上分配,而不需要丢进堆中,可以降低 GC 的负担。