这是我参与「第五届青训营」伴学笔记创作活动的第4天
本课的重点如下:
- 了解什么是性能优化,为什么要进行优化,学习常见的优化方法
- 学习go的自动内存管理,了解字节对于GC的优化方案 Balanced GC
性能优化
-
业务层优化
- 针对特定场景,具体问题,具体分析
- 容易获得较大性能收益
-
语言运行时优化
- 解决更通用的性能问题
- 考虑更多场景
- Tradeoffs
-
数据驱动型优化
- 自动化性能分析工具 —— pprof
- 依靠数据而非猜测
- 首先优化最大瓶颈
在性能优化的执行中,我们需要编写清晰的文档告诉用户这一项优化主要是做了什么,没做什么,能达到怎样的效果
常见的性能优化要点
1、Slice
预分配内存场景
slice和map都一样,都可以通过预分配内存来提高性能
这应该是一个比较常见的优化方式
当一个连续的内存不够用的时候,需要再次扩容,这时候就需要申请一片更大的连续内存,然后再将原来的内容拷贝过去,这里就会造成一个耗时较大的拷贝消耗
所以就需要按照场景需要,预先分配足够大的内存,防止频繁扩容从而造成的性能下降
内存泄漏场景
在go中对于切片的拷贝会是一种浅拷贝,所以不会创建新的底层数组,而是引用该底层数组
go的垃圾回收机制(不清楚),应该和JVM相似,会清除掉没有被引用的内存
而在以下这种场景中:
原切片是1MB的大小,而我们只取其中最后两个int元素,也就是说只使用8B的大小
而如果采用切片浅拷贝的方式,会引用底层的1MB数组,从而导致该1MB数组无法被GC释放内存,造成内存泄漏
如果采用切片深拷贝的方式,就只会创建一个8B大小的数组,并且将最后两个元素拷贝进去,由于原数组没有被引用,所以原1MB的数组可以被GC释放内存
所以在这种只使用大切片中一部分数据的场景,就需要多加考虑内存泄漏的问题
2、string(和java中相似)
由于string是不可变类型,所以每次对它进行拼接都会重新分配内存创建新的string,这里就会有性能消耗
从string的底层入手,string是由[]byte进行特殊的编码而来,所以就可以直接从[]byte层面进行优化
而string.Builder和bytes.Buffer底层都是从[]byte来优化string
所以初步的优化可以使用string.Builder和bytes.Buffer
再进一步的优化,就可以从[]byte入手,对切片进行内存的预分配即可
3、空结构体(作为占位符)
在go中,空结构体具有较强的语义,并且不占据任何内存空间,所以作为占位符使用是比较常见的
- 使用map来实现set,其中value部分可以采用空结构体,比使用bool还要少一个字节的消耗
- 只判断某元素是否存在的map(和set一样)
4、atomic(原子性包)
在高并发场景下,要保证变量的原子性,有以下两种方式
1、atomic包(性能更高,底层使用硬件实现)
2、使用sync.Mutex互斥锁(锁通过OS的系统调用实现,性能较差)
Go自动内存管理
常见垃圾回收方法
-
Tracing garbage collection: 追踪垃圾回收(从GC ROOTS开始标记可达的对象,回收不可达的对象)
- 标记-删除 Mark-sweep GC: 将死亡对象所在内存块标记为可分配
(碎片多) - 标记-复制 Copying GC: 将存活对象从一块内存空间复制到另外一块内存空间
(用于复制的区域占用一定内存,导致内存利用率不高) - 标记-整理 Mark-compact GC: 将存活对象复制到同一块内存区域的开头
(防止内存空间碎片)
- 标记-删除 Mark-sweep GC: 将死亡对象所在内存块标记为可分配
-
引用计数法(每个对象有一个数字用于记录被引用的次数,当引用次数为0后被回收)
- 优点:算法简单
- 缺点:开销大,需要维护引用次数的原子性 ; 无法回收环形数据结构
Go的自动内存管理
- 在Go中,会先使用mmap系统调用向操作系统申请一片大内存 (一种类似于java堆的概念,或者说是内存池)
- 然后将这一大片
内存进行分成同样大小的块,这样的块叫做mspan - 然后再将mspan
划分为大小不同的小块,用于分配给对象
-
mspan:一个内存大块,会被划分为多个不同大小的小块
- noscan mspan: 分配给不包含指针的对象 —— 不需要GC扫描
- scan mspan: 分配给包含指针的对象 —— 需要GC扫描
-
mcache:用于
缓存一组mspan,当mspan分配完毕后,就向mcentral申请带有未分配块的mspan当mspan中没有分配对象时,就会被缓存在mcache中
-
mcentral:实际指向go所分配到的内存块,其中划分为了多个mspan
字节跳动对于内存管理的优化
在go中,用于分配对象的函数mallocgc() 占用 CPU 较高
对于小对象的分配,和对于大对象的分配,都一样需要调用mallocgc()
但是,在实际场景中,小对象会占对象的大多数,而每分配一个小对象都调用一次mallocgc()就会导致性能降低
所以字节跳动,从小对象分配的方向进行了优化
Balanced GC
- 核心:将 noscan 对象在 per-g allocation buffer (GAB) 上分配,并使用整理对象方式的GC管理这部分内存,提高对象分配和回收效率
- 每个g会附加一个较大的 allocation buffer (例如 1 KB) 用来分配小于 128 B 的 noscan 小对象
本质上:一个 allocation buffer 其实是一个大对象,其中包含了许多个小对象,将多次小对象的分配合并成了一次大对象的分配
但将小对象看作大对象的话,就会有相应的缺陷,比如说,在GAB中只要哪怕一个小对象存活时,这个GAB就被认为还存活,也就是说里面所有的小对象都会被认为是存活的,就不会被GC掉,那么怎么办呢?
balanced GC会扫描GAB,将GAB中存活的对象移动到另外的GAB中(标记-整理),然后将原GAB进行释放