性能优化和go内存管理 | 青训营笔记

153 阅读6分钟

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

本课的重点如下:

  • 了解什么是性能优化,为什么要进行优化,学习常见的优化方法
  • 学习go的自动内存管理,了解字节对于GC的优化方案 Balanced GC

性能优化

  • 业务层优化

    • 针对特定场景,具体问题,具体分析
    • 容易获得较大性能收益
  • 语言运行时优化

    • 解决更通用的性能问题
    • 考虑更多场景
    • Tradeoffs
  • 数据驱动型优化

    • 自动化性能分析工具 —— pprof
    • 依靠数据而非猜测
    • 首先优化最大瓶颈

在性能优化的执行中,我们需要编写清晰的文档告诉用户这一项优化主要是做了什么没做什么能达到怎样的效果

常见的性能优化要点

1、Slice

预分配内存场景

slice和map都一样,都可以通过预分配内存来提高性能

image-20230120203818854

这应该是一个比较常见的优化方式

当一个连续的内存不够用的时候,需要再次扩容,这时候就需要申请一片更大的连续内存,然后再将原来的内容拷贝过去,这里就会造成一个耗时较大的拷贝消耗

所以就需要按照场景需要,预先分配足够大的内存,防止频繁扩容从而造成的性能下降

内存泄漏场景

在go中对于切片的拷贝会是一种浅拷贝,所以不会创建新的底层数组,而是引用该底层数组

go的垃圾回收机制(不清楚),应该和JVM相似,会清除掉没有被引用的内存

而在以下这种场景中:

原切片是1MB的大小,而我们只取其中最后两个int元素,也就是说只使用8B的大小

而如果采用切片浅拷贝的方式,会引用底层的1MB数组,从而导致该1MB数组无法被GC释放内存,造成内存泄漏

如果采用切片深拷贝的方式,就只会创建一个8B大小的数组,并且将最后两个元素拷贝进去,由于原数组没有被引用,所以原1MB的数组可以被GC释放内存

image-20230120205648496

所以在这种只使用大切片中一部分数据的场景,就需要多加考虑内存泄漏的问题

2、string(和java中相似)

image-20230120211920600

由于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(原子性包)

image-20230120214514335

在高并发场景下,要保证变量的原子性,有以下两种方式

1、atomic包(性能更高,底层使用硬件实现)

2、使用sync.Mutex互斥锁(锁通过OS的系统调用实现,性能较差)

Go自动内存管理

常见垃圾回收方法

  • Tracing garbage collection: 追踪垃圾回收(从GC ROOTS开始标记可达的对象,回收不可达的对象)

    • 标记-删除 Mark-sweep GC: 将死亡对象所在内存块标记为可分配(碎片多)
    • 标记-复制 Copying GC: 将存活对象从一块内存空间复制到另外一块内存空间(用于复制的区域占用一定内存,导致内存利用率不高)
    • 标记-整理 Mark-compact GC: 将存活对象复制到同一块内存区域的开头(防止内存空间碎片)
  • 引用计数法(每个对象有一个数字用于记录被引用的次数,当引用次数为0后被回收)

    • 优点:算法简单
    • 缺点:开销大,需要维护引用次数的原子性 ; 无法回收环形数据结构

Go的自动内存管理

  • 在Go中,会先使用mmap系统调用向操作系统申请一片大内存 (一种类似于java堆的概念,或者说是内存池)
  • 然后将这一大片内存进行分成同样大小的块,这样的块叫做mspan
  • 然后再将mspan划分为大小不同的小块,用于分配给对象

img

  • 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进行释放

img