这是我参与「第五届青训营 」伴学笔记创作活动的第 4 天
1. 内存管理
动态内存
- 程序在运行时根据需求动态分配的内存:malloc()
自动内存管理(垃圾回收):由程序语言的运行时系统回收动态内存
- 避免手动内存管理,专注于实现业务逻辑
- 保证内存使用的正确性和安全性: double-free problem, use-after-free(uaf) problem三个任务
- 多线程的内存分配器
垃圾收集器/内存分配器的任务(如果内存分配器的free操作不由使用者完成,那么就是垃圾收集器)
- 为新对象分配空间
- 找到存活对象
- 回收死亡对象的内存空间
在mgc.go 中,是这么描述go垃圾收集器的
go的垃圾收集器:并行收集器。JVM的垃圾收集器是STW的,也就是SerialGC和ParallelGC共同存在的。
-
“mutator threads” are threads actually trying to perform a heap modification during the marking phase 可以理解为对堆进行操作的用户线程
-
go不采取分带内存管理,基于写屏障的标记-清理算法。不带压缩。
-
go的内存分配是用固定大小分割P内存块,以达到最小化的内存和尽量少的加所操作。
-
安全性问题
由于go的concurrentGC,只有部分线程会被STW(mark过程是绝对要STW的,这里的并发指的是,并不是gc开始时,所有活跃的goroutine都要进行gc,而是挑选一部分goroutine进行gc。那么没有STW的goroutine,会对mark过程影响)
所以mark过程不仅要开启写屏障,还要进行STW,在一切标记完毕后,还要再检查一次。
- 评价gc算法的指标
STW对用户体验影响极大。 如果STW时间过长,用户会感觉很难受。
1.1 追踪垃圾回收
go就采取这种垃圾回收策略。
- 定位GCroots,也就是对象指针引用处。
- 找到GCroots直接可达对象,并找到这些对象的传递闭包。完成mark过程。
最后是清理过程,go采取的是
mark-sweep GC清理过程是为了下一次分配服务的。我们只管分配,gc只管收集,所以gc的收集要让下一次的分配更舒服CopyGC:将存活对象拷贝到新的内存空间:适用于一次GC中,存活对象较少,并且存活的都是小对象。
Mark-sweep:使用空闲列表管理,适用于一次GC存活对象较多(不会频繁产生内存碎片)。并且存活的都是大对象
Mark compact对存活对象进行原地压缩,使得内存碎片被清理干净。适用于指针碰撞的分配内存方式。但是go不采取指针碰撞。
1.2 分代gc
go的runtime不采取分代gc,但是jvm采取
理论就是:年轻的新对象很小,并且很容易被回收。年长的对象很大,很难被回收。因此采取不同的回收策略
1.3 引用计数
python使用的就是引用计数。无法解决循环依赖。但是python也有自己的一套,用于探测循环依赖情况。
2. go的内存分配策略
垃圾回收 是为了 更好的内存分配
讲完垃圾回收,就要看看go的内存分配策略
创建对象时,必须伴随的是go的内存分配。
go提前把内存进行了分块。这是内存分配器的一个实现手段,现代语言的内存分配器往往采取了更丰富的手段,以尽可能保证分配效率。
可以看到,gc为了内存分配服务,内存分配也要给gc服务。
noscan区和scan区的分配,无异减少了gc的负担。
而固定大小块的分配,又是有效控制了内存的对齐,和碎片产生。并且这种查找固定大小块的分配,也使得分配十分迅速。
这个分配方式应该是csapp里指定的一种方式。
go的span缓存
由于go以span为单位管理内存,那么定位一个对象,就是定位一个span。
那么可不可以对span进行缓存呢?
mcache缓存者不同大小的span块,首先就根据span大小,去mcache查找。如果找不到再去mcentral。
所以mcache是mcentral的一个缓存(mcentral就对应OS的一页[mmap])
缓存采取LRU应该,因为新分配的块会优先存在于mcache。并且这个page是延迟释放的。 就算mcentral没有被使用到,也不会立即释放。而是缓存于mcentral,等待分配
go内存分配体系的缺点
通过pprof探测,
runtime.mallocgc这个函数的调用非常频繁,而且耗时。因为对象分配必须是频繁的,所以我们只能降低这个函数的耗时,通过缩短调用链完成耗时的缩短,是我们重要的优化方向
优化:BalancedGC
这个优化很像jvm内存优化之TLAB
即线程私有的一块内存分配区。
在go中就是goroutine私有的一片内存区域,GAB。
- GAB中,对象的分配使用 指针碰撞。
- GAB用于不会被GC的对象,比如基本类型的分配与存放等。这样不需要回收,也不需要清理内存空间,分配直接就增加top指针,直到分配不下去。同时,由于是goroutine私有的内存区域,也不用加锁
- GAB本身作为一个span,接受go的内存回收策略
缺点
- GAB中只要有一个对象仍在使用,那么将导致GAB无法被释放
3. 编译器和静态分析
我们能做的主要是后端编译器部分的优化。
静态分析
这个玩意,就好像读源码...
有的编译器自己就会读源码,在语义分析的时候,直接计算出已知的内容
而我们进行静态分析的目的是,绘制出程序控制流图。
有了控制流图,可以模拟数据在其上的传递。根据数据传递优化代码
控制流图可能是嵌套的,因为方法存在嵌套的调用。因此根据控制流图中是否有方法嵌套,又分为过程内分析和过程间分析
当然,既然进行了静态分析,那就意味着编译期就可以获取到结果。
如果运行结果需要在运行期反射获取,我们就无法进行优化。因为结果是什么都不确定,也就是数据在程序控制流中的传递是不确定的。
4. go编译器优化
边界检查消除,往往会被编译器进行自行优化。也就是编译器可以分得清一个变量是否位于一定的范围内,从而避免反复的检查。(csapp第五章)。
而循环展开也是一个在现代流水线CPU下优化性能的好方式。
函数内联化是现代编译器的一个重要优化指标。jvm也在做函数内联方面的优化。
最大的优点就是,减少了函数调用的开销。函数调用需要的额外开销:传参,依赖于寄存器,甚至需要在栈上分配。把被调用者保存寄存器安排到栈上,结束后还要弹出,加大内存访问。最后还要保存返回值等。这些开销都是除去直接执行汇编代码的额外开销。将函数内联化,可以减少这些开销。
缺点
- 函数体变大,不利于指令缓存。尤其是递归函数,内联展开后,出现大量重复代码,使得整个函数臃肿难读。
- go镜像变大,这是自然,因为提取出函数的目的就是复用,现在你给人家打开了,必然会加大代码量。
- 不能进行打桩操作:打桩是基于函数调用的。如果内联之后,打桩就没用了。
可以适当降低内联优化的标准,使得更多函数都可以被内联优化。以提高效率。
逃逸分析同样也是JVM的优化策略之一。
主要体现在栈上分配,对象栈上分配,不仅malloc容易(类似指针碰撞)。回收也容易,不用任何gc算法。就可以回收。开的快去的快,何乐不为?甚至,进一步的,可以把矢量分解。都在栈上分配了,那么对结构的操作,也是类似直接对局部变量的操作,因此可以把结构体拆成组成其的标量,存储为局部变量
逃逸分析主要耗时在如何分析一个对象是否逃逸。是否逃逸的标准是 对象是否进会被当前函数的栈帧内部访问。而内联化可以减少函数的调用,进而避免某一个对象被其他函数栈帧引用。