go语言内存管理

参考连接:

https://www.cnblogs.com/xumaojun/p/8547439.html

https://studygolang.com/articles/11904

https://making.pusher.com/golangs-real-time-gc-in-theory-and-practice/

如何测量GC

GODEBUG=gctrace=1这个环境变量可以开启gc调试信息的打印

GODEBUG=gctrace=1 ./myserver

GOMAXPROCS

Go在运行时可能会创建很多线程,但任何时候仅有限的几个线程参与并发任务执行。该量默认与处理器核数相等,可用runtime.GOMAXPROCS函数或者环境变量修改;

channel和锁

通道并不是用来取代锁的,它们有各自不同的使用场景。通道倾向于解决逻辑层次的并发处理架构,而锁则用来保护局部范围的数据安全

常见的垃圾回收方法

引用计数(reference counting)

每个对象维护一个引用计数器,记录指向这个对象的引用数量。每次有一个新的引用指向这个对象,计数器加一;反之每次有一个指向这个对象引用被置空或者指向其他对象,计数器减一。当计数器变为 0 的时候,自动删除这个对象。c语言大多使用此方法,还有内存泄漏扫描工具,但也难免会有疏漏

引用计数的优点是:

* 算法易于实现,相对简单

* 内存的回收及时,相比其他回收算法,堆耗尽或者达到某个阈值才会进行垃圾回收,不会给正常程序的执行带来额外中断。

缺点:

频繁更新引用计数降低了性能 

原始的引用计数不能处理循环引用问题

标记清除(mark and sweep)

从根变量来迭代遍历所有被引用对象,标记之后进行清除操作,对未标记对象进行回收,这种方法解决了引用计数的不足,但是也有比较明显的问题:每次垃圾回收的时候都会暂停所有的正常运行的代码,系统的响应能力会大大降低。当然后续也出现了很多mark&sweep算法的变种(如三色标记法)优化了这个问题。

标记清扫法在标记和清理时需要停止所有的goroutine,来保证已经被标记的区域不会被用户修改引用关系,造成清理错误

分代搜集(Generational Garbage Collection)

在面向对象编程语言中,绝大多数对象的生命周期都非常短。分代收集的基本思想是,将堆划分为两个或多个称为代(generation)的空间。新创建的对象存放在称为新生代(young generation)中(一般来说,新生代的大小会比 老年代小很多),随着垃圾回收的重复执行,生命周期较长的对象会被提升(promotion)到老年代中(这里用到了一个分类的思路,这个是也是科学思考的一个基本思路)。新生代垃圾回收的速度非常快,比老年代快几个数量级,即使新生代垃圾回收的频率更高,执行效率也仍然比老年代垃圾回收强,这是因为大多数对象的生命周期都很短,根本无需提升到老年代。

这样的做法,相对于全区域扫描,分代提升了扫描的效率。另外,由于减少了需要扫描的区域大小,卡顿时间也会相对缩短。

缺点:实现复杂

三色标记-清扫

         白色:待回收对象,对象在这次GC中未标记

         灰色:处理中对象,对象在这次GC中已标记, 但这个对象包含的子对象未标记

         黑色:活跃的对象,对象在这次GC中已标记, 且这个对象包含的子对象也已标记

在go内部对象并没有保存颜色的属性, 三色只是对它们的状态的描述,

白色的对象在它所在的span的gcmarkBits中对应的bit为0,

灰色的对象在它所在的span的gcmarkBits中对应的bit为1, 并且对象在标记队列中,

黑色的对象在它所在的span的gcmarkBits中对应的bit为1, 并且对象已经从标记队列中取出并处理.

gc完成后, gcmarkBits会移动到allocBits然后重新分配一个全部为0的bitmap, 这样黑色的对象就变为了白色.


go语言内存管理_第1张图片

    判断一个对象是不是垃圾需不需要标记,就看是否能从当前栈或全局数据区直接或间接的引用到这个对象。这个初始的当前goroutine的栈和全局数据区成为GC的root区;通过markroot将所有的root区域的指针标记为可达,然后沿着这些指针扫描,标记遇到的所有可达对象。

         1)、起初所有对象都是白 色(虽然是白色,但是未标记,不能直接回收);

         2)、扫描找出所有可达对象,即全局对象或者栈对象(root集合)或者说全局指针和goroutine栈上的指针,标记为灰色,放入待处理队列(gcWork高性能缓存队列);

         3)、从队列提取灰色对象,将其引用对象标记为灰色放入队列,自身标记为黑色;

         4)、写屏障监控对象内存的修改 ,对白色对象的引用修改被写屏障捕获后,重新标色或放回队列。(re-scan全局指针和栈,因为mark和用户程序是并行的,所以在过程1的时候可能会有新的对象分配,这个时候就需要通过写屏障Write Barrier记录下来。re-scan再完成检查一下)

         5)、当完成全部扫描和标记工作后,剩余的不是白色就是黑色,分别代表待回收和活跃对象,清理操作只需将白色对象内存收回即可;

用户程序和mark并发进行,Stop The World有两个过程:

a.第一次是Mark阶段的开始, 这个时候主要是一些准备工作,比如enable write barrier;第一次STW会准备根对象的扫描, 启动写屏障(Write Barrier)和辅助GC(mutator assist). 

b.第二次是Mark Termination阶段. re-scan过程,如果这个时候没有stw,那么mark将无休止,第二次STW会重新扫描部分根对象, 禁用写屏障(Write Barrier)和辅助GC(mutator assist). 需要注意的是, 不是所有根对象的扫描都需要STW, 例如扫描栈上的对象只需要停止拥有该栈的G. 从go 1.9开始, 写屏障的实现使用了Hybrid Write Barrier, 大幅减少了第二次STW的时间.

这里的写屏障(write barrier)是因为在GC的时候用户代码可以同时运行,这样在扫描的时候,对象的依赖树可能被改变了,为了避免这个问题,Golang在GC中标记阶段会启用写屏障。

Go的垃圾回收:是一个非分代,非压缩的, 写屏障 ,并发的,三色标记清扫垃圾回收;非分代是指没有使用分代垃圾回收算法,非压缩的是指没有做内存的整理和紧缩,这里的"并发"是指在垃圾回收的时候,用户代码可以同时运行。三色标记清扫是一个经典的垃圾回收算法

并发清理: 垃圾回收(清理过程)与用户逻辑并发执行 

三色并发标记 : 标记与用户逻辑并发执行

为什么markTermination需要rescan全局指针和栈。因为mark阶段是跟用户代码并发的,所以有可能栈上都分了新的对象,这些对象通过write barrier记录下来,在rescan的时候再检查一遍。

golang中gc的总时间

Tgc = Tseq + Tmark + Tsweep(T表示time)

Tseq表示是停止用户的 goroutine 和做一些准备活动(通常很小)需要的时间

Tmark 是堆标记时间,标记发生在所有用户 goroutine 停止时,因此可以显著地影响处理的延迟

Tsweep 是堆清除时间,清除通常与正常的程序运行同时发生,所以对延迟来说是不太关键的

Go触发GC机制(进程内存高居不下的问题)

1. gcTriggerHeap 在申请内存的时候,检查当前已分配的内存是否大于上次GC后的内存的两倍,若是则触发;默认情况下是GOGC=100,即新增一倍就会触发,通过设大环境变量GOGC可以减少GC的触发,设置"GOGC=off"可以彻底关掉GC。

2. gcTriggerTime 监控线程发现上次GC的时间已经超过两分钟,触发;将一个G任务放到全局G队列中去。这个值在Golang里面为两分钟var forcegcperiod int64 = 2 * 60 * 1e9。

3、gcTriggerCycle 主动调用GC来回收,有两处可以实现:runtime.GC()

出现内存居高不下的问题

1.gcmark在每次标记结束后重置阈值大小。当前使用了4MB内存,这时设置gc_trigger为2*4MB,也就是当内存分配到8MB时会再次触发GC。回收之后内存为5MB,那下一次要达到10MB才会触发GC。这个比例triggerRatio是由gcpercent/100决定的。

如果系统启动或短时间内大量分配对象,会将垃圾回收的gc_trigger推高。当服务正常后,活跃对象远小于这个阈值,造成垃圾回收无法触发。它每隔2分钟force触发GC一次。

2.go语言在向系统交还内存时只是告诉系统这些内存不需要使用了,可以回收;同时操作系统会采取“拖延症”策略,并不是立即回收,而是等到系统内存紧张时才会开始回收这样该程序又重新申请内存时就可以获得极快的分配速度。

gc时间长的问题

golang gc时过程会stop the world,我们对于应该尽量避免频繁创建临时堆对象(如&abc{}, new, make等)以减少垃圾收集时的扫描时间,对于需要频繁使用的临时对象考虑直接通过数组缓存进行重用

goroutine泄露的问题

在不使用协程后一定要把他依赖的channel close并通过 在协程中判断channel是否关闭以保证其退出。

go语言提供了强大的测试工具,下面举例简单介绍一下

go test 单元测试

go test -bench=. 性能测试

* go tool pprof 性能监控

a.生成web服务器性能监控图,如go程序是用http包启动的web服务器,可以选择引入包_”net/http/pprof” ,go run main.go 后就可以在浏览器中使用http://localhost:8080/debug/pprof/直接看到当前web服务的状态,包括CPU占用情况和内存使用情况等,

b.生成一般应用程序性能监控图

如果只是一个应用程序,你就不能使用net/http/pprof包了,你就需要使用到runtime/pprof。具体做法就是用到pprof.StartCPUProfile和pprof.StopCPUProfile

c.如果重新封装了ServHTTP函数,无法开启go默认web的pprof,则重新改造支持pprof,代码如下所示

switch choice {

    default :pprof.Index(w, r)

    case "" : pprof.Index(w,r)

    case "cmdline": pprof.Cmdline(w, r)

    case "profile":pprof.Profile(w, r)

    case "symbol":  pprof.Symbol(w, r)

    case "trace": pprof.Trace(w,r)

}

d.uber开源的火焰图go-torch,可以直观显示哪个方法调用耗时长了,然后不断的修正代码,重新采样,不断优化。

什么时候从Heap分配对象

当一个对象的内容可能在生成该对象的函数结束后被访问, 那么这个对象就会分配在堆上.

在堆上分配对象的情况包括:

* 返回对象的指针

* 传递了对象的指针到其他函数

* 在闭包中使用了对象并且需要修改对象

* 使用new,make

在C语言中函数返回在栈上的对象的指针是非常危险的事情, 但在go中却是安全的, 因为这个对象会自动在堆上分配.

go决定是否使用堆分配对象的过程也叫"逃逸分析".

内存优化

1. 小对象合并成结构体一次分配,减少内存分配次数,小对象在堆上频繁地申请释放,会造成内存碎片(有的叫空洞),导致分配大的对象时无法申请到连续的内存空间。

2. 缓存区内容一次分配足够大小空间,并适当复用

    在协议编解码时,需要频繁地操作[]byte,可以使用bytes.Buffer或其它byte缓存区对象。

    建议:bytes.Buffert等通过预先分配足够大的内存,避免当Grow时动态申请内存,这样可以减少内存分配次数。同时对于byte缓存区对象考虑适当地复用。

3. slice和map采make创建时,预估大小指定容量

    slice和map与数组不一样,不存在固定空间大小,可以根据增加元素来动态扩容。

    slice初始会指定一个数组,当对slice进行append等操作时,当容量不够时,会自动扩容:重新分配一块"够大"的内存,并把内容从原来的内存块复制到新分配的内存块,这样会产生明显的CPU开销

    map的扩容比较复杂,每次扩容会增加到上次容量的2倍。它的结构体中有一个buckets和oldbuckets,用于实现增量扩容:

    正常情况下,直接使用buckets,oldbuckets为空;

    如果正在扩容,则oldbuckets不为空,buckets是oldbuckets的2倍,

建议:初始化时预估大小指定容量

4. 长调用栈避免申请较多的临时对象

goroutine的调用栈默认大小是4K(1.7修改为2K),它采用连续栈机制,当栈空间不够时,Go runtime会不断扩容:

当栈空间不够时,按2倍增加,原有栈的变量崆直接copy到新的栈空间,变量指针指向新的空间地址;

退栈会释放栈空间的占用,GC时发现栈空间占用不到1/4时,则栈空间减少一半。

比如栈的最终大小2M,则极端情况下,就会有10次的扩栈操作,这会带来性能下降。

建议:

控制调用栈和函数的复杂度,不要在一个goroutine做完所有逻辑;

如查的确需要长调用栈,而考虑goroutine池化,避免频繁创建goroutine带来栈空间的变化。

5. 避免频繁创建临时对象

Go在GC时会引发stop the world,即整个情况暂停。暂停时间还是取决于临时对象的个数,临时对象数量越多,暂停时间可能越长,并消耗CPU。

建议:GC优化方式是尽可能地减少临时对象的个数:

尽量使用局部变量

所多个局部变量合并一个大的结构体或数组,减少扫描对象的次数,一次回尽可能多的内存。

并发优化

1 高并发的任务处理使用goroutine池

goroutine虽轻量,但对于高并发的轻量任务处理,频繁来创建goroutine来执行,执行效率并不会太高效:过多的goroutine创建,会影响go runtime对goroutine调度,以及GC消耗;高并时若出现调用异常阻塞积压,大量的goroutine短时间积压可能导致程序崩溃。

2 高并发时避免共享对象互斥

传统多线程编程时,当并发冲突在4~8线程时,性能可能会出现拐点。Go中的推荐是不要通过共享内存来通讯,Go创建goroutine非常容易,当大量goroutine共享同一互斥对象时,也会在某一数量的goroutine出在拐点。建议:goroutine尽量独立,无冲突地执行;若goroutine间存在冲突,则可以采分区来控制goroutine的并发个数,减少同一互斥对象冲突并发数。

其它优化

1 避免使用CGO或者减少CGO调用次数

2 减少[]byte与string之间转换,尽量采用[]byte来字符串处理

GO里面的string类型是一个不可变类型,而GO中[]byte与string底层两个不同的结构,他们之间的转换存在实实在在的值对象拷贝,所以尽量减少这种不必要的转化建议:存在字符串拼接等处理,尽量采用[]byte

3. 字符串的拼接优先考虑bytes.Buffer

由于string类型是一个不可变类型,但拼接会创建新的string。GO中字符串拼接常见有如下几种方式:

string + 操作 :导致多次对象的分配与值拷贝

fmt.Sprintf :会动态解析参数

strings.Join :内部是[]byte的append

bytes.Buffer :可以预先分配大小,减少对象分配与拷贝

建议:对于高性能要求,优先考虑bytes.Buffer,预先分配大小。非关键路径,视简洁使用。fmt.Sprintf可以简化不同类型转换与拼接。

代码覆盖率可通过例如:

go test -cover -covermode count -coverprofile cover.out 命令来实现,并且可以在浏览器上查看结果;

通过编写测试代码,使用命令:go test -bench . 进行基准测试,可以有针对性地测试出模块某部分的性能瓶颈;

CPU的主频

CPU内核工作的时钟频率(CPU Clock Speed)。CPU的主频的基本单位是赫兹(Hz),但更多的是以兆赫兹(MHz)或吉赫兹(GHz)为单位。时钟频率的倒数即为时钟周期。时钟周期的基本单位为秒(s),但更多的是以毫秒(ms)、微妙(us)或纳秒(ns)为单位。在一个时钟周期内,CPU执行一条运算指令。也就是说,在1000 Hz的CPU主频下,每1毫秒可以执行一条CPU运算指令。在1 MHz的CPU主频下,每1微妙可以执行一条CPU运算指令。而在1 GHz的CPU主频下,每1纳秒可以执行一条CPU运算指令。

在默认情况下,Go语言的运行时系统会以100 Hz的的频率对CPU使用情况进行取样。也就是说每秒取样100次,即每10毫秒会取样一次。为什么使用这个频率呢?因为100 Hz既足够产生有用的数据,又不至于让系统产生停顿。并且100这个数上也很容易做换算,比如把总取样计数换算为每秒的取样数。实际上,这里所说的对CPU使用情况的取样就是对当前的Goroutine的堆栈上的程序计数器的取样。由此,我们就可以从样本记录中分析出哪些代码是计算时间最长或者说最耗CPU资源的部分了。我们可以通过以下代码启动对CPU使用情况的记录。

你可能感兴趣的:(go语言内存管理)