这是我参与「第三届青训营 -后端场」笔记创作活动的的第 4 篇笔记
一、本堂课重点内容
-
自动内存管理
-
go语言内存管理及优化
-
编译器和静态分析
-
go编译器优化
二、详细知识点介绍:
1. 自动内存管理
基本概念
-
动态内存:程序在运行时按照需求需要动态分配的内存,例如某个切片的内存,可能根据客户端传参或者数据库的数据不同有不同的动态内存大小
-
自动内存分配(垃圾回收):由程序语言运行时系统管理动态内存;避免手动内存管理,可以更专注于业务逻辑,还可以保证内存管理的正确性和安全性,可避免double-free-problem(重复释放内存)和use-after-free-problem(使用已经释放的内存,即回收了存活的对象)这样的编码问题
-
自动内存管理的任务:为对象分配新的空间、找到存活对象、回收死亡对象的内存空间
-
Mutator:业务线程,分配新对象,修改对象指向关系
-
Collector:GC线程,找到存活对象,回收死亡对象的内存空间
-
Serial GC:只有一个Collector的GC算法
-
Parallel GC:支持多个Collector的GC算法
-
Concurrent GC:表示Mutator和Collector可以同时进行的GC算法,因为在修改指向的同时回收空间,所以必须感知对象指向关系的改变
GC算法的评价
- 安全性(Safty):不能回收存活的对象,基本要求
- 吞吐率(Throughput):1-GC时间/程序运行总时间,即程序业务过程时间占比
- 暂停时间(Pause Time):暂停业务进行GC是否被业务感知
- 内存开销(Space Overhead):GC元数据的内存开销
追踪垃圾回收
-
对象被回收的条件:指针指向关系不可达的对象
-
垃圾回收过程:
1)标记根对象:将静态变量、全局变量、常量、线程栈等这样的以后还会用到的对象标记为存活 2)找到可达对象:求指针指向关系的转递闭包,从根对象出发,找到所有可达对象,标记为存活 3)清理所有不可达对象:将存活对象赋值到另一个内存空间(Copying GC) 将死亡对象的内存标记为可分配(Mark-sweep GC) 移动并整理存活对象(Mark-compact GC) 会根据对象的生命周期选择不同的标记和清理的策略 -
分代GC(Generational GC)
1.分代假说:most objects die young,它的依据是很多对象都是临时变量,创建出来没 多久就会死去,比如函数中的局部变量,函数执行完就没用了 2.对象的年龄:对象经历过GC的次数 3.分代GC的目的:对年轻和老年的对象,制定不同的GC策略,将低整体的内存管理的开销 4.不同年龄的对象分为新生代和老年代处于heap的不同区域 5.年轻代(Young generation):常规的对象分配,由于存活对象很少,可以采用copying collection,不会占用很多内存,并且这样GC的吞吐率很高 6.老年代(Old generation):对象趋向于一直活着,比如全局变量,使用copying collection反复复制开销很大,可以采用mark-sweep collection只用标记少量死亡对象
引用计数
- 基本思想:每个对象都有一个与之关联的引用数目,根据这个引用数目判断是否需要回收
- 对象存活条件:当且仅当引用数大于0
- 优点:内存管理的操作被平摊到程序执行过程中,不需要了解runtime的实现细节
- 缺点:维护引用计数的开销比较大(通过原子操作保证引用计数操作的原子性和可见性);无法回收环形数据结构(数据循环引用成为了一个环形,这样每个对象引用计数都不为0且对外界不可达);内存开销(每个对象都引入的额外内存空间存储引用数目);回收内存是依然可能引发暂停(在回收大型的结构时引起暂停)
2. Go内存管理和优化
Go的内存分配——分块
-
目标:为对象在heap上分配内存
-
原理:提前将内存分块
调用系统调用mmap()向OS申请一大块内存,例如4MB 先将内存划分程大块,例如8KB,称作mspan 再将大块继续均分分成特定大小的小块,用于对象分配 noscan mspan:分配不包含指针的对象——GC不需要扫描 scan mspan:分配一个包含指针的对象——GC需要扫描,找到指针的指向传递闭包内所有对象 对象分配:在mspan里面,根据对象的大小找一个最合适的块返回
Go内存分配——缓存
-
借鉴了TcMalloc:thread caching malloc
-
每个p包含一个mcache用于快速分配,用于为绑定于p上的g分配对象
-
mchache管理一组mspan
-
当mchache中的mspan分配完毕,向mcentral申请带有未分配块的mspan
-
当mspan中没有分配的对象,mspan会被缓存在mcentral中,而不是立刻释放并归还给OS
GO内存分配问题
- 对象分配时非常高频的操作,每秒分配GB级别的内存
- 小对象占比较高
- GO内存分配比较耗时,分配经过路径:g->m->p->mchache->mspan->memory block->return pointer(对象分配的函数是go调用最多的函数)
Balance GC(针对大量的小对象分配的优化)
-
每个G都绑定一大块内存(1KB),称作goroutine alloction buffer(GAB)
-
GAB用于noscan类型的小对象分配,小于128B
-
使用三个指针维护GAB:base,end,top
-
Bump pointer指针碰撞风格对象分配:根据top指针到end指针表示当前GAB剩余空间,如果空间足够将top后移然后返回地址即可
-
GAB对于Go的内存管理来说就是一个大对象,Balance GC本质上就是将多次向GO的小分配合并成一次大分配,接下来从GAB上获取即可
-
GAB对象分配方式存在的问题:导致内存被延迟释放,比如一个GAB上只有一个小部分存活,会导致整个GAB都存活
-
针对问题的处理:移动GAB中存活的对象,当GAB总大小超过一定阈值时将GAB中存活的对象复制到另外分配的GAB中,原先的GAB释放,本质上时用copying GC的算法管理了小对象
3.编译器和静态分析
编译器的结构
- 重要的系统软件:识别符合语法和非法的程序,生成正确且高效的代码
- 分析部分:词法分析,生成词素;语法分析,生成语法树;语义分析,收集类型信息,进行予以检查;中间代码生成
- 综合部分:代码优化,机器无关优化;代码生成,生成目标代码
静态分析
- 静态分析:不执行程序代码,推导程序的行为,分析程序的性质,比如一段代码不论什么情况下结果都是一样的,就不用多次执行这段代码,直接得到结果即可
过程内和过程间分析
- 过程内分析:仅在函数体内进行分析
- 过程间分析:考虑过程调用时的参数传递和返回值的数据流和控制流
- 过城间分析需要通过数据流分析得到对象的具体类型才知道调用的是哪一个方法,根据整个类型残生新的控制流,因为需要同时分析控制流和数据流,联合求解,比较复杂
4. Go编译器优化
概述
- 目的:用户无感知,重新编译即可获得性能收益;通用性优化
- 现状:采用的优化较少,编译时间较短,没有复杂的代码分析和优化
- 编译优化的思路:面向后端长期执行任务,用编译时间换取更高效的机器码
函数内联
- 定义:将被调用函数的函数体(callee)的副本替换到调用位置(caller)上,同时重写代码以反映参数的绑定
- 优点:消除函数调用开销,例如传递参数,保存寄存器等;将过程间分析转换为过程内分析,帮助其他优化,例如逃逸分析
- 缺点:函数体变大,instruction cache(icache)不友好,编译生成的Go镜像变大
- 函数内联在大多数情况下是正向优化
- Beast Mode:调整函数内联的策略,使更多函数被内联,降低了函数调用的开销,增加了其他优化的机会:逃逸分析
逃逸分析
-
定义:分析代码中指针的动态作用域,指针在何处可以被访问
-
思路:
从对象分配处触发,沿着控制流,观察对象的数据流 发现指针p在当前作用域s:作为参数传递给其他函数、传递给全局变量、传递给其他的goroutine、传递给已逃逸的指针指向的对象 则指针p指向的对象逃逸处s,反之没有逃逸出s -
Beast Mode:函数内联拓展了函数边界,更多对象不逃逸;未逃逸的对象可以在栈上分配,独享在站上分配和回收很快:移动sp;减少heap上的分配,降低GC负担
总结
服务的性能是多方面的,诸如编译器、垃圾回收,而不是仅仅局限于业务代码的性能,一个大型的系统需要这样多方的性能支持,才能够在三高场景下顺畅运行