课程介绍
本节围绕 Go 内存分配和编译器相关知识展开,探讨目前 Go 内存管理过程中问题,提出解决方案,同时将通过对编译器基本算法讲解,引出编译器优化路径。
基本概念
在了解Go的内存管理之前,我们需要了解一些基本概念。如果之前了解过JVM,这部分就相当于复习了
- Mutator:业务线程
- Collector:GC线程
- Serial GC:只有一个collector
- Parallel GC:支持多个collectors同时回收
- Concurrent GC:业务线程和GC线程可以同时执行
如何评价GC算法
- 安全性(基本要求):不能回收存活的对象
- 吞吐量:1- GC时间/程序执行总时间
- 暂停时间(STW)
- 内存开销
如何判断对象是否是垃圾
引用计数法
给每个对象创建一个计数器,每当有地方引用它时,计数器加一,如果引用失效,计数器减一,当计数器为0时,认为是垃圾。
这种方法最大的缺点就是就是无法解决循环引用,比如A引用了B,B又引用了A,虽然没有其他地方引用这两个对象,但它们却无法被垃圾回收
追踪垃圾回收(可达性分析)
通过标记根对象,来判断哪些节点不可达,从而进行清除。这也是JVM采用的方法
GC算法
标记清除算法
先标记出所有需要回收的对象,之后再进行统一的回收
缺点:
- 如果有大量对象需要回收,那么就需要大量的标记和清除操作,时间效率低
- 容易产生内存碎片
标记复制算法
准备两个半区,首先只分配其中一个半区的空间,当需要垃圾回收时,将已使用半区的存活对象复制到未使用半区的空间,清除使用过的半区的空间
优点:
- 不会产生内存碎片
- 当剩余存活对象较少时,效率很高
缺点:
- 当剩余存活对象较多时,效率低
标记整理算法
为了解决标记清除算法的内存碎片问题,标记整理算法在标记之后,选择先让所有用户线程停止,然后将所有的存活对象向一端进行对齐,之后清除边界外的内存空间。
缺点:
- 虽然标记清除算法也需要Stop the world操作来进行标记,但标记整理算法的停顿时间较长
分代假说
因为有些对象被创建出来后很快就不被使用了,如果我们还是按照统一的频率和策略进行GC,那么性能就不会有很好的表现。因此,我们按照每个对象的年龄(经过GC的次数)进行划分,分为老年代和新生代。新生代可以采取标记-复制法,而老年代可以采取标记-清除法。
Go内存分配
-
提前将内存分块
- 通过mmap向OS申请一大块内存,例如4MB
- 先将内存划分成大块,称作mspan
- 再将大块划分成特定大小的小块,用于对象分配
- noscan mspan:分配不包含指针的对象——GC不需要扫描
- scan mspan:分配包含指针的对象——GC需要扫描
-
根据对象的大小,选择最合适的块返回
除此之外,为了goroutine快速分配对象,每个逻辑处理器都有自己的mcache,mcache管理着一组mspan,当mcache中的mspan分配完毕,会向mcentral申请带有未分配块的mspan,当mspan中没有分配的对象,mspan会被缓存在mcentral中,而不是立刻归还给OS
再次优化
目前字节内部对Go的内存分配也进行优化,其中一点为Balanced GC,即将多次小对象的分配合成为一次大对象的分配
对于每个goroutine,都绑定一块内存,称为goroutine allocate buffer(GAB),用于noscan类型的小对象分配,我们只需三个指针,就能维护这片内存的使用情况,即base,top,end