Golang知识点一、 内存管理

Golang 内存管理

  本篇文章记录Golang内存管理相关的内容,Golang内存管理相关的知识点可以分为两部分进行记录,分别是手动内存管理和自动内存管理。

1. 手动内存管理

  面向对象的概念中,随着程序的运行,对象被加载到内存,从某个时刻开始,对象不会再被使用,将无用的对象移除的机制称为内存管理。

  在C语言中,程序员可以调用malloccalloc函数来为对象分配内存,函数返回一个地址,指向对象在堆内存中的位置。当这个对象不被使用时,再调用free函数释放这块内存,这种内存管理的方式称为显式释放。

  这种内存管理的方式,将内存分配和内存释放的权限授权给程序员,如果程序员操作不当,会产生一定问题。(1)悬空指针:如果free调用过早,会导致该指针成为悬空指针,悬空指针是指不再指向内存中有效对象的指针。(2)内存泄露:程序员忘记释放一个对象,将会造成该对象一直占用内存的现象,成为内存泄露,如果存在大量内存泄露会导致程序变慢或直接崩溃。

  针对手动内存管理会造成的问题,在很多后续高级语言中,将内存管理机制设计成语言层面,程序员不用关心内存管理,而是由系统自动进行。Python、Ruby、Java和Go等语言使用自动的内存管理机制,称为垃圾回收机制。

2. Go内存布局和分配

2.1. 内存分配

  Golang在处理高并发方面具有独特的优势,一方面得益于GMP模型,另一方面go的内存布局和分配机制也有起到很大作用。

2.1.1. 三大组件

Golang在内存分配的过程中,主要由三大组件所管理:mheapmcentralmcache

  • mheap: Go 在程序启动时,首先会向操作系统申请一大块内存,并交由mheap结构全局管理。mheap 会将这一大块内存,切分成不同规格的小内存块,为 mspan,根据规格大小不同,mspan 大概有 70类左右,足以满足各种对象内存的分配。管理这么多大大小小规格的mspan,便有了mcentral。

  • mcentral: 启动一个 Go 程序,会初始化很多的 mcentral ,每个 mcentral 只负责管理一种特定规格的 mspan。但是 mcentral 在 Go 程序中是全局可见的,因此如果每次协程来 mcentral 申请内存的时候,都需要加锁。可以预想,如果每个协程都来 mcentral 申请内存,那频繁的加锁释放锁开销是非常大的。于是,便有了mcache。

  • mcache: 在一个 Go 程序里,每个线程M会绑定给一个处理器P,在单一粒度的时间里只能做多处理运行一个goroutine,每个P都会绑定一个叫 mcache 的本地缓存。当需要进行内存分配时,当前运行的goroutine会从mcache中查找可用的mspan。从本地mcache里分配内存时不需要加锁,这种分配策略效率更高。

   mcache 的 mspan 数量并不总是充足的,当供不应求的时候,mcache 会从 mcentral 再次申请更多的 mspan,同样的,如果 mcentral 的 mspan 数量也不够的话,mcentral 也会向它的上级 mheap 申请 mspan。再极端一点,如果 mheap 里的 mspan 也无法满足程序的内存申请,mheap 只能厚着脸皮跟操作系统申请。

2.1.2. 堆内存和栈内存

  根据内存管理(分配和回收)方式的不同,可以将内存分为 堆内存 和 栈内存。堆内存:由内存分配器和垃圾收集器负责回收,栈内存:由编译器自动进行分配和释放。

   一个程序运行过程中,也许会有多个栈内存,但肯定只会有一个堆内存。每个栈内存都是由线程或者协程独立占有,因此从栈中分配内存不需要加锁,并且栈内存在函数结束后会自动回收,性能相对堆内存好要高。而堆内存呢?由于多个线程或者协程都有可能同时从堆中申请内存,因此在堆中申请内存需要加锁,避免造成冲突,并且堆内存在函数结束后,需要 GC (垃圾回收)的介入参与,如果有大量的 GC 操作,将会使程序性能下降得历害。

  为了提高程序的性能,应当尽量减少内存在堆上分配,这样就能减少 GC 的压力。在判断一个变量是在堆上分配内存还是在栈上分配内存,虽然已经有前人已经总结了一些规律,但依靠程序员能够在编码的时候时刻去注意这个问题,对程序员的要求相当之高。好在 Go 的编译器,也开放了逃逸分析的功能,使用逃逸分析,可以直接检测出你程序员所有分配在堆上的变量(这种现象,即是逃逸)

2.2 函数调用栈

  按照编程语言语法编写的函数,会被编译器编译成一堆机器指令,写入可执行文件。程序执行时,可执行文件被加载到内存,这些机器指令对应到虚拟地址空间中,位于代码段。

  如果在一个函数中调用另一个函数,编译器会对应生成一条call指令(可执行文件),程序执行到call时就会跳转到被调用函数入口处开始执行;每个函数最后,都有一条ret指令,负责在函数结束后,跳回到调用处,继续执行。

2.2.1 栈帧布局

  内存空间:函数执行时,需要有足够内存空间,供它存放局部变量、返回值、参数等数据,即虚拟地址空间中的栈。运行时,栈上面时高地址,向下增长,分配给函数的栈空间,被称为函数栈帧。栈底通常称为栈基(bp),栈顶称为栈指针(sp)。

  程序执行时,CPU用特定寄存器来存储运行时栈基和栈指针,同时也有指令指针寄存器用于存储下一条要执行的指令地址。CPU读取一条指令(入栈3),会将指令指针移向下一条指令,栈指针向下移动(3存入函数栈帧中);CPU读取下一条指令(入栈4),会将指令指针移向下一条指令,栈指针向下移动(4存入函数栈帧中)。

  Golang中函数栈帧并不是这样逐步扩张的,而是一次性分配,即在分配栈帧时,直接将栈指针移动到所需最大栈空间位置(函数栈帧的大小,可以在编译期间确定),使用sp+偏移这种相对寻址方式使用函数栈帧。

  为什么要一次性分配内存,而不是逐步分配?主要是为了避免栈访问越界。对于栈消耗较大的函数,Golang编译器会在函数头部插入一段检测代码,如需进行栈增长,就会另分配一段足够大的栈空间,并把原来栈上的数据拷过来。原来的这段栈空间就会释放掉。

  Golang中栈帧布局从上到下依此是:调用者栈基,局部变量,被调用者返回值,被调用者参数。
  call指令做两件事:(1)将下一条指令的地址入栈,即返回地址,被调用函数执行完后,回到这里,继续执行后面的指令。(2)跳转到被调用函数入口处执行。

2.2.2 函数跳转与返回

  函数跳转和返回是通过call指令和ret指令实现。例如,一个函数A在a1处调用b1处的函数B,执行过程如下。

  第一,执行到call指令会做两件事:(1)把下一条指令地址,a2入栈保存(2)指令指针,跳转到指令地址b1处。做完这两件事,call指令便结束了。

  第二,函数B开始执行。先把sp向下移动24个字节,为自己分配足够大的栈帧;bp寄存器的值保存到sp+16处;sp+16存入栈基寄存器,之后便可以执行函数剩下的指令了。在ret指令之前,编译器还会插入两条指令:(1)恢复调用者函数A的栈基地址(2)释放自己的栈帧空间,分配时向下移动多少,释放时就向上移动多少。

  第三,做完这两步操作,就到ret指令处了,ret指令也会做两件事:(1)弹出call指令压栈的返回地址。(2)跳转到这个返回地址。

  总结来说,首先,函数通过call指令实现跳转;其次,每个函数开始时会分配栈帧,结束前释放栈帧;最后,ret指令会把栈恢复成call之前的样子。

2.2.3. 传参

  Golang函数调用栈中,从高地址向下依次是:局部变量、返回值和参数。参数按照从右到左的顺序入栈,这样会方便使用sp+偏移的方式操作变量。

  Golang中参数传递都是值传递。如下swap()函数的例子可以很好说明问题。

	// 该函数不可以实现交换功能
	func swap(a, b int) {
		a, b = b, a
	}
	
	func main() {
		a, b := 1, 2
		swap(a, b)
		fmt.Println(a, b)
	}
	// 该函数可以实现交换功能
	func swap(a, b *int) {
		*a, *b := *b, *a
	}

	func main() {
		a, b := 1, 2
		swap(&a, &b)
		fmt.Prinln(a, b)
	}
2.2.4. 返回值

  通常我们认为返回值是通过寄存器传递的,然而Golang中支持多返回值,所以在栈上分配返回值空间更合适。在ret指令前,需要恢复调用者函数的栈基地址,释放自己的栈帧空间。如果被调用者函数中注册了defer函数,被调用者函数会先给返回值赋值,再执行defer函数。

  如果函数A中调用了两个函数B和C,但是B和C两个函数参数和返回值占用的空间并不相同,B和C会以最大的参数和返回值空间为标准来分配,才能满足所有被调用函数的需求。假设B调用完,执行C时,栈帧上面会空出很大一块空间,这样在使用sp+偏移进行寻址会方便很多。

3. Golang中垃圾回收机制

  今天的编程语言通常会使用手动和自动两种方式管理内存,C、C++ 以及 Rust 等编程语言使用手动的方式管理内存,工程师需要主动申请或者释放内存; Python、Ruby、Java 和 Go 等语言使用自动的内存管理系统,一般都是垃圾收集机制,不过 Objective-C 却选择了自动引用计数,虽然引用计数也是自动的内存管理机制,但是我们在这里不会详细介绍它,本节的重点还是垃圾收集。
  相信很多人对垃圾收集器的印象都是暂停程序(Stop the world,STW),随着用户程序申请越来越多的内存,系统中的垃圾也逐渐增多;当程序的内存占用达到一定阈值时,整个应用程序就会全部暂停,垃圾收集器会扫描已经分配的所有对象并回收不再使用的内存空间,当这个过程结束后,用户程序才可以继续执行,Go 语言在早期也使用这种策略实现垃圾收集,但是今天的实现已经复杂了很多。
https://draveness.me/golang/docs/part3-runtime/ch07-memory/golang-garbage-collector/

  Golang 在V1.3版本之前使用标记清除(mark and sweep)机制;V1.5 版本引入了三色标记法;V1.8版本引入了三色标记法 + 混合写屏障机制

3.1. 标记-清除法(mark and sweep)

  mark and sweep方法是Golang V1.3版本之前使用的垃圾回收机制,也是最原始的垃圾回收机制。这种方法整体过程需要STW,效率极低。

流程:

  1. 暂停程序业务逻辑(stop the world)
    在这里插入图片描述
  2. 将可达对象进行标记
    在这里插入图片描述
  3. 清除垃圾
    在这里插入图片描述
  4. 暂停STW,程序继续执行。

标记-清除法 缺点:

  1. STW使程序暂停,程序出现卡顿。
  2. 标记过程,需要扫描的整个heap。
  3. 清除数据,会产生很多heap碎片。

3.2. 三色标记法

  为了解决传统标记-清除法会导致长时间STW的问题,设计出了三色标记法。

三色标记法流程:

  1. 程序起初创建,全部标记为白色,将所有对象放入白色集合中。
    在这里插入图片描述
  2. 将程序根节点展开
    在这里插入图片描述
  3. 从第一层开始遍历,得到灰色对象集合
    在这里插入图片描述
  4. 遍历灰色标记表,将可达对象变为灰色,结束后,自己变成黑色
    在这里插入图片描述
  5. 循环上一步,直到灰色对象不存在。(最终目的是把灰色,全部变成黑色,灰色对象只是一个中间状态,白色的对象会被回收。)

  如果三色标记不启动STW进行保护,会发生对象丢失现象: (1)一个白色对象被挂在黑色对象下。(2) 灰色对象同时丢了该白色对象。
  以上两个条件同时满足,就会出现对象丢失现象。本来不应该被回收的对象却被回收了,这在内存管理中是非常严重的错误,这种错误被称为悬挂指针。要做到在保证对象不丢失的情况下,尽可能提高GC效率。可以通过破坏上述两个条件来实现,于是,设计了强弱三色不变式

  • 强弱三色不变式
  1. 强三色不变式:强制性不允许黑色对象引用白色对象。(破坏了条件1)
  2. 弱三色不变式:黑色对象可以引用白色对象,但是要保证白色对象存在其他灰色对象对它的引用。(破坏了条件2)

  如果三色标记中满足/中的一项,即可保证对象不丢失。为实现强弱三色不变式,设计了屏障机制

3.3. 屏障机制

  插入屏障: 对象被引用时,触发的机制。
  删除屏障:对象被删除时,触发的机制。

  1. 插入写屏障
      当A对象引用B对象时,B对象被标记为灰色(将B挂在A下游,B必须被标记为灰色)。满足强三色不变式(不会存在黑色对象引用白色对象的形式了,因为白色会被强制变成灰色)。
      为了保证栈的速度,插入对象不在栈上使用。只有在堆上添加下游对象时,才会触发插入屏障。对于栈的情况,还是需要短暂的STW保护(10-100ms),不允许创建和删除新的对象,再扫描一遍所有对象。与纯粹的STW相比,已经很大程度上提高了性能。

  2. 删除写屏障
      被删除的对象,如果自身是灰色或者白色,那么被标记为灰色。满足弱三色不变式(保护灰色对象到白色对象的路径不会变)
    回收精度比较低,一个对象即使被删除了,最后一个指向它的指针也依旧可以活过这一轮,在下一轮GC中被清理掉。

  3. 混合写操作屏障
      插入写屏障的不足:结束时需要STW重新扫描栈,大约需要10-100ms;删除写屏障的不足:回收精度低,一个对象即使被删除了,最后一个指向它的指针也依旧可以活过这一轮,在下一轮GC中被清理掉。

3.4. 三色标记法+混合写屏障机制

  Golang v1.8版本引入了三色标记法 + 混合写屏障机制

操作流程:

  1. GC开始将栈上的对象全部扫描,并标记为黑色(之后不再进行第二次扫描,无需STW)。
  2. GC期间,任何在栈上创建的新对象,均为黑色。
  3. 被删除的对象标记为灰色。
  4. 被添加的对象标记为灰色。
    满足:变形的弱三色不变式(结合了插入、删除写屏障的两者的优点)。

4. 参考文献

[1] https://draveness.me/golang/docs/part3-runtime/ch07-memory/golang-garbage-collector/
[2] https://www.bilibili.com/video/BV1wz4y1y7Kd?from=search&seid=8531887475263906109
[3] https://mp.weixin.qq.com/s/PNRhtdS_gZVTtrkkRmx7yA
[4] https://www.bilibili.com/video/BV1hv411x7we?p=6
[5] https://mp.weixin.qq.com/s/gCDxWzslfPXayJ_RFQVb7g

  • 3
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值