Go中的内存管理?
- go中的内存管理
- Go的内存分配机制是什么?
- 设计思想 内存分配算法采用Google的TCMalloc算法,每个线程都会自行维护一个独立的内存池,进行内存分配时优先从本线程的内存池中分配,当内存池不足时才会向加锁向全局内存池申请,减少系统调用并且避免不同线程对全局内存池的锁竞争 把内存切分的非常的细小,分为多级管理,以降低锁的粒度 回收对象内存时,并没有将其真正释放掉,只是放回内存池中,以便复用。只有内存闲置过多的时候,才会尝试归还部分内存给操作系统,降低整体开销
 - 分配组件 Go的内存管理组件主要有:mspan、mcache、mcentral和mheap
- 内存管理单元:mspan mspan是 内存管理的基本单元,该结构体中包含 next 和 prev 两个字段,它们分别指向了前一个和后一个mspan,每个mspan 都管理 npages 个大小为 8KB 的页,一个span 是由多个page组成的,这里的页不是操作系统中的内存页,它们是操作系统内存页的整数倍。 page是内存存储的基本单元,Go中的“对象”就是放到page中的
- Go有68种不同大小的spanClass,用于小对象的分配 如果按照序号为1的spanClass(对象规格为8B)分配,每个span占用堆的字节数:8k,mspan可以保存1024个对象 如果按照序号为2的spanClass(对象规格为16B)分配,每个span占用堆的字节数:8k,mspan可以保存512个对象 … 如果按照序号为67的spanClass(对象规格为32K)分配,每个span占用堆的字节数:32k,mspan可以保存1个对象
 
 - 线程缓存:mcache mcentral 的前面又增加了一层 mcache。 每一个 mcache 和每一个处理器(P) 是一一对应的,也就是说每一个 P 都有一个 mcache 成员。 Goroutine 申请内存时,首先从其所在的 P 的 mcache 中分配,如果 mcache 没有可用 span ,再从 mcentral 中获取,并填充到 mcache 中。 从 mcache 上分配内存空间是不需要加锁的,因为在同一时间里,一个 P 只有一个线程在其上面运行,不可能出现竞争。没有了锁的限制,大大加速了内存分配。 mcache在初始化的时候是没有任何mspan资源的,在使用过程中会动态地从mcentral申请,之后会缓存下来。当对象小于等于32KB大小时,使用mcache的相应规格的mspan进行分配。
 - 中心缓存:mcentral mcentral管理全局的mspan,mheap中包含着所有的mcentral 每个mcentral管理一种mspan,并将有空闲空间和没有空闲空间的mspan分开存储。可以通过pop、push方法来获得mspans 具体的结构如下: type mcentral struct {    spanclass spanClass // 指当前存储的span类型是什么     partial [2]spanSet // 有空闲object的mspan列表    full    [2]spanSet // 没有空闲object的mspan列表 }
- mcache从mcentral获取和归还mspan的流程: 获取mspan:加锁,从partial链表找到一个可用的mspan;并将其从partial链表删除;将取出的mspan加入到full链表;将mspan返回给工作线程,解锁。 归还mspan:加锁,将mspan从full链表删除;将mspan加入到partial链表,解锁。
 
 - 页堆:mheap mheap管理Go的所有动态分配内存,可以认为是Go程序持有的整个堆空间,全局唯一 所有mcentral的集合则是存放于mheap中的。mheap里的arena 区域是堆内存的抽象,运行时会将 8KB 看做一页,这些内存页中存储了所有在堆上初始化的对象。运行时使用二维的 runtime.heapArena 数组管理所有的内存,每个 runtime.heapArena 都会管理 64MB 的内存。 当申请内存时,依次经过 mcache 和 mcentral 都没有可用合适规格的大小内存,这时候会向 mheap 申请一块内存。然后按指定规格划分为一些列表,并将其添加到相同规格大小的 mcentral 的 非空闲列表 后面
 
 - 内存管理单元:mspan mspan是 内存管理的基本单元,该结构体中包含 next 和 prev 两个字段,它们分别指向了前一个和后一个mspan,每个mspan 都管理 npages 个大小为 8KB 的页,一个span 是由多个page组成的,这里的页不是操作系统中的内存页,它们是操作系统内存页的整数倍。 page是内存存储的基本单元,Go中的“对象”就是放到page中的
 - 分配对象
- 微对象 (0, 16B):先尝试线程缓存上的微型分配器,再依次尝试线程缓存、中心缓存、堆分配内存; 小对象 [16B, 32KB]:依次尝试线程缓存、中心缓存、堆分配内存; 大对象 (32KB, +∞):直接尝试堆分配内存;
 
 - 分配流程
- 首先通过计算判断需要使用mspan的大小规格 然后使用mcache中对应大小规格的块分配。 如果mcentral中没有可用的块,则向mheap申请,并根据算法找到最合适的mspan。 如果申请到的mspan 超出申请大小,将会根据需求进行切分,以返回用户所需的页数。剩余的页构成一个新的 mspan 放回 mheap 的空闲列表。 如果 mheap 中没有可用 span,则向操作系统申请一系列新的页(最小 1MB)
 
 
 - 为什么三色标记清除法基本不存在内存碎片问题?
- Go 运行时的分配算法基于 tcmalloc,基本上没有碎片问题。
 
 - 为什么go不采用分代gc的策略?
- 分代 GC 依赖分代假设,即 GC 将主要的回收目标放在新创建的对象上(存活时间短,更倾向于被回收),而非频繁检查所有对象。但 Go 的编译器会通过逃逸分析将大部分新生对象存储在栈上(栈直接被回收),只有那些需要长期存在的对象才会被分配到需要进行垃圾回收的堆中。也就是说,分代 GC 回收的那些存活时间短的对象在 Go 中是直接被分配到栈上,当 goroutine 死亡后栈也会被直接回收,不需要 GC 的参与,进而分代假设并没有带来直接优势。并且 Go 的垃圾回收器与用户代码并发执行,使得 STW 的时间与对象的代际、对象的 size 没有关系。Go 团队更关注于如何更好地让 GC 与用户代码并发执行(使用适当的 CPU 来执行垃圾回收),而非减少停顿时间这一单一目标上。
 
 - 内存逃逸机制是什么?
- C++存在有一个很大的坑,下面来说一下,对比一下Go中的内存逃逸机制的好处: int* foo () { int t = 3; return &t; } 在函数内部定义了一个局部变量,然后返回这个局部变量的地址(指针)。这些局部变量是在栈上分配的(静态内存分配),一旦函数执行完毕,变量占据的内存会被销毁,任何对这个返回值作的动作(如解引用),都将扰乱程序的运行,甚至导致程序直接崩溃。 这个坑可以通过一些方式进行解决,使用new函数创建变量t(动态内存分配),然后返回此变量的地址。因为变量是在堆上创建的,所以函数退出时不会被销毁。但是,这样就行了吗?还存在第二个问题:new出来的对象该在何时何地delete呢?调用者可能会忘记delete或者直接拿返回值传给其他函数,之后就再也不能delete它了,也就是发生了内存泄露。
 - 上面C++存在的坑,在Go里面完全不存在,因为有内存逃逸机制存在,而且go里面存在GC回收机制,不用担心第二个问题的解决。真是C/C++之砒霜Go之蜜糖!
 - 每一个函数创建的时候都会创建对应的栈帧,由于函数结束后栈帧会被释放,但是函数里面的变量可能我们当函数结束后,还想使用它,这时候就发生了内存逃逸现象,函数里的变量分配在堆上还是栈上是由Go系统来通过内存逃逸算法进行决定的。
 - 内存逃逸算法: 如果函数外部没有引用,则优先放到栈中; 如果函数外部存在引用,则必定放到堆中; 如果栈上放不下,则必定放到堆上;
 - 发生内存逃逸的场景
- 指针逃逸
- func escape1() *int { var a int = 1 return &a } func main() { escape1() } 函数返回值为局部变量的指针,函数虽然退出了,但是因为指针的存在,指向的内存不能随着函数结束而回收,因此只能分配在堆上。
 
 - 栈空间不足
- func escape2() { s := make([]int, 0, 10000) for index, _ := range s { s[index] = index } } func main() { escape2() } 当栈空间足够时,不会发生逃逸,但是当变量过大时,已经完全超过栈空间的大小时,将会发生逃逸到堆上分配内存。局部变量s占用内存过大,编译器会将其分配到堆上
 
 - 变量大小不确定
- func escape3() { number := 10 s := make([]int, number) // 编译期间无法确定slice的长度 for i := 0; i < len(s); i++ { s[i] = i } } func main() { escape3() } 编译期间无法确定slice的长度,这种情况为了保证内存的安全,编译器也会触发逃逸,在堆上进行分配内存。直接s := make([]int, 10)不会发生逃逸
 
 - 闭包引用对象
- func escape4() func() int { var i int = 1 return func() int { i++ return i } } func main() { escape4() } 闭包函数中局部变量i在后续函数是继续使用的,编译器将其分配到堆上
 
 
 - 指针逃逸
 
 - 内存对齐了解吗?
- CPU 访问内存时,并不是逐个字节访问,而是以字长(word size)为单位访问。比如 32 位的 CPU ,字长为 4 字节,那么 CPU 访问内存的单位也是 4 字节。 CPU 始终以字长访问内存,如果不进行内存对齐,很可能增加 CPU 访问内存的次数, 简言之:合理的内存对齐可以提高内存读写的性能。
 - 为了能让CPU可以更快的存取到各个字段,Go编译器会帮我们把struct结构体做数据的对齐。 对齐原则: 1.结构体变量中成员的偏移量必须是成员大小的整数倍 2.整个结构体的地址必须是最大字节的整数倍(结构体的内存占用是1/4/8/16byte…)
type T1 struct { bool bool // 1 byte i16 int16 // 2 byte } type T2 struct { i8 int8 // 1 byte i64 int64 // 8 byte i32 int32 // 4 byte } type T3 struct { i8 int8 // 1 byte i32 int32 // 4 byte i64 int64 // 8 byte } func main() { t1 := T1{} fmt.Println(unsafe.Sizeof(t1)) // 4 bytes t2 := T2{} fmt.Println(unsafe.Sizeof(t2)) // 24 bytes t3 := T3{} fmt.Println(unsafe.Sizeof(t3)) // 16 bytes }
-
 
 - go中的不同版本的GC原理是什么?
- Go V1.3之前
- 采用标记清除算法: 1.进行STW,然后用可达性分析算法分析出可以到达的对象和不可到达的对象 2.清除所有不可到达的对象 3.停止STW,让程序继续运行 GoV1.3做了一些简单的优化: 把停止STW提前到清除操作,但是这就优化了一点点,这个算法本身就会出现较长的STW。
- 最主要的缺点就是长时间的STW
 
 
 - 采用标记清除算法: 1.进行STW,然后用可达性分析算法分析出可以到达的对象和不可到达的对象 2.清除所有不可到达的对象 3.停止STW,让程序继续运行 GoV1.3做了一些简单的优化: 把停止STW提前到清除操作,但是这就优化了一点点,这个算法本身就会出现较长的STW。
 - Go V1.5的三色并发标记法
- 使用了三色标记法和写屏障技术,提高了效率。 其执行过程可以分成标记(Mark)和清除(Sweep)两个阶段: 标记阶段 — 从根对象出发查找并标记堆中所有存活的对象; 清除阶段 — 遍历堆中的全部对象,回收未被标记的垃圾对象并将回收的内存加入空闲链表。 标记清除算法的一大问题是在标记期间,需要暂停程序(Stop the world,STW),标记结束之后,用户程序才可以继续执行。为了能够异步执行,减少 STW 的时间,Go 语言采用了三色标记法。
 - 三色标记算法将程序中的对象分成白色、黑色和灰色三类。 白色:不确定对象。 灰色:存活对象,子对象待处理。 黑色:存活对象。
 - 具体原理: 标记开始时,所有对象加入白色集合(这一步需 STW )。首先将根对象标记为灰色,加入灰色集合,垃圾搜集器取出一个灰色对象,将其标记为黑色,并将其指向的对象标记为灰色,加入灰色集合。重复这个过程,直到灰色集合为空为止,标记阶段结束。那么白色对象即可需要清理的对象,而黑色对象均为根可达的对象,不能被清理。
 - 三色标记法并发执行仍存在一个问题,即在 GC 过程中,对象指针发生了改变。比如下面的例子: A (黑) -> B (灰) -> C (白) -> D (白) 正常情况下,D 对象最终会被标记为黑色,不应被回收。但在标记过程和用户程序执行过程中(这两个是并发执行的,所以可能会出现并发问题),用户程序删除了 C 对 D 的引用,而 A 获得了 D 的引用。标记继续进行,D 就没有机会被标记为黑色了(A 已经处理过,这一轮不会再被处理)。 A (黑) -> B (灰) -> C (白) ↓ D (白)
 - 可以看出,有两种情况,在三色标记法中,是不希望被发生的。 条件1: 一个白色对象被黑色对象引用(白色被挂在黑色下) 条件2: 灰色对象与它之间的可达关系的白色对象遭到破坏(灰色同时丢了该白色) 如果当以上两个条件同时满足时,就会出现对象丢失现象! 为了解决这个问题,只需要满足强三色不变式或者弱三色不变式中的一个就可以了,强三色不变式和弱三色不变式的具体实现就是内存屏障技术,这个技术会在进程读取对象、创建新对象以及更新对象指针时执行一段代码。 注意,这个时候只需要用插入屏障/删除屏障中的一个就行了
- 强三色不变式: 强三色不变色实际上是强制性的不允许黑色对象引用白色对象,这样就不会出现有白色对象被误删的情况。
- 插入屏障(对象被引用的时候触发的机制): 在A对象引用B对象的时候,B对象被标记为灰色。
- 插入屏障只能用在堆上的对象:栈空间的特点是容量小,但是要求响应速度快,因为函数调用弹出频繁使用, 所以“插入屏障”机制,在栈空间的对象操作中不使用. 而仅仅使用在堆空间对象的操作中. 那么你可能会问,那如果这个版本里我们选择使用了使用插入屏障,那栈里面的对象不是管不了了?出现对象丢失的情况怎么办呢?Go里面是这样解决的:在准备回收白色对象之前,对栈重新进行三色标记,这段标记时间是STW的,称为reScan
 
 
 - 插入屏障(对象被引用的时候触发的机制): 在A对象引用B对象的时候,B对象被标记为灰色。
 - 弱三色不变式: 黑色对象可以引用白色对象,但是这个白色对象必须存在其他灰色对象对它的引用,或者可达它的链路上游存在灰色对象。 
- 删除屏障(对象被删除的时候触发的机制): 被删除的对象,如果自身为灰色或者白色,那么被标记为灰色。
- 这种方式倒是堆和栈都可以用,但是这种方法回收精度低,一个对象即使被删除了最后一个指向它的指针也依旧可以活过这一轮,在下一轮GC中被清理掉。
 
 
 - 删除屏障(对象被删除的时候触发的机制): 被删除的对象,如果自身为灰色或者白色,那么被标记为灰色。
 
 - 强三色不变式: 强三色不变色实际上是强制性的不允许黑色对象引用白色对象,这样就不会出现有白色对象被误删的情况。
 
 - Go V1.8的混合写屏障机制
- 插入写屏障和删除写屏障的短板: 插入写屏障:结束时需要STW来重新扫描栈  删除写屏障:回收精度低 Go V1.8版本引入了混合写屏障机制(hybrid write barrier),避免了对栈re-scan的过程,极大的减少了STW的时间。结合了两者的优点。 注意,混合写屏障只在堆上启动,栈上不启动,栈上启动的是删除屏障来解决rescan问题
- 混合写屏障的具体操作: 1、GC开始首先将栈上可达的对象全部扫描并标记为黑色(之后不再进行第二次重复扫描,无需STW) 2、GC期间,任何在栈上创建的新对象,均为黑色。 3、被删除的对象标记为灰色。(删除屏障) 4、被添加的对象标记为灰色。(插入屏障)
- 情景1:对象被一个堆对象删除引用,成为栈对象的下游
- 1
 - 2
 
 - 场景2: 对象被一个栈对象删除引用,成为另一个栈对象的下游
- 1
 - 2
 - 3
 
 - 场景3:对象被一个堆对象删除引用,成为另一个堆对象的下游
- 1(黑色节点为什么特殊呢?存疑)
 - 2
 - 3
 
 - 场景四:对象从一个栈对象删除引用,替代之前的下游成为另一个堆对象的下游
- 1
 - 2
 - 3
 
 - 按照之前的规律,你可能会感觉会有:对象从一个堆中删除,替代之前的下游成为另一个栈的下游,但是其实是不存在这种可能的,因为go会进行内存逃逸分析,然后进行分配内存的。
 
 - 情景1:对象被一个堆对象删除引用,成为栈对象的下游
 
 - 混合写屏障的具体操作: 1、GC开始首先将栈上可达的对象全部扫描并标记为黑色(之后不再进行第二次重复扫描,无需STW) 2、GC期间,任何在栈上创建的新对象,均为黑色。 3、被删除的对象标记为灰色。(删除屏障) 4、被添加的对象标记为灰色。(插入屏障)
 
 - 插入写屏障和删除写屏障的短板: 插入写屏障:结束时需要STW来重新扫描栈  删除写屏障:回收精度低 Go V1.8版本引入了混合写屏障机制(hybrid write barrier),避免了对栈re-scan的过程,极大的减少了STW的时间。结合了两者的优点。 注意,混合写屏障只在堆上启动,栈上不启动,栈上启动的是删除屏障来解决rescan问题
 
 - Go V1.3之前
 - GO中GC算法的演进了解吗?
- Go 1:mark and sweep操作都需要STW Go 1.3:分离了mark和sweep操作,mark过程需要 STW,mark完成后让sweep任务和普通协程任务一样并行,停顿时间在约几百ms Go 1.5:引入三色并发标记法、插入写屏障,不需要每次都扫描整个内存空间,可以减少stop the world的时间,停顿时间在100ms以内 Go 1.6:使用 bitmap 来记录回收内存的位置,大幅优化垃圾回收器自身消耗的内存,停顿时间在10ms以内 Go 1.7:停顿时间控制在2ms以内 Go 1.8:混合写屏障(插入写屏障和删除写屏障),停顿时间在0.5ms左右 Go 1.9:彻底移除了栈的重扫描过程 Go 1.12:整合了两个阶段的 Mark Termination Go 1.13:着手解决向操作系统归还内存的,提出了新的 Scavenger Go 1.14:替代了仅存活了一个版本的 scavenger,全新的页分配器,优化分配内存过程的速率与现有的扩展性问题,并引入了异步抢占,解决了由于密集循环导致的 STW 时间过长的问题
 
 - Go的GC如何进行调优呢?
- Go 的 GC 被设计为极致简洁,与较为成熟的 Java GC 的数十个可控参数相比,严格意义上来讲,Go 可供用户调整的参数只有 GOGC 环境变量。当我们谈论 GC 调优时,通常是指减少用户代码对 GC 产生的压力 GC 的调优是在特定场景下产生的,并非所有程序都需要针对 GC 进行调优。只有那些对执行延迟非常敏感、 当 GC 的开销成为程序性能瓶颈的程序,才需要针对 GC 进行性能调优,几乎不存在于实际开发中 99% 的情况。 所以基本上我们都是通过程序员编码的能力来进行GC调优的 当然。我们还应该谨记 过早优化是万恶之源这一警语,在没有遇到应用的真正瓶颈时,将宝贵的时间分配在开发中其他优先级更高的任务上。
 
 - 在编码中如何尽量的减少GC的可能呢?
- 少量使用+连接string(使用+操作符进行拼接时,会对字符串进行遍历,计算并开辟一个新的空间来存储原来的两个字符串),最好用切片的append函数来连接
 - slice提前分配足够的内存来降低扩容带来的拷贝
 - 避免map 的key对象过多(多key),导致扫描时间增加
 - 变量复用,减少对象分配,例如使用池类技术来复用需要频繁创建临时对象或者使用全局变量来复用
 
 - GC的触发条件是什么?
- 分为主动触发(手动触发)和被动触发: 主动触发:通过调用 runtime.GC 来触发GC,此调用阻塞式地等待当前GC运行完毕 被动触发:通过两种来进行触发: 1.根据阈值触发:使用步调(Pacing)算法,其核心思想是控制内存增长的比例,每次内存分配时检查当前内存分配量是否已达到阈值,比如当前堆内存占用到达上次GC结束后占用内存的2倍时,就会触发GC。 2.定时触发:如果两分钟之内没有任何GC,就会强制触发GC。
 
 - 如果内存分配速度超过了标记清除的速度怎么办?
- 目前的 Go 实现中,当 GC 触发后,会进入标记前的准备阶段。这个阶段会设置一个标志(标志代表着目前内存分配速度是否过快),并在堆内存分配函数调用时进行检查。 当新的内存分配时,会暂停那些分配内存过快的 goroutine,并将其转去执行一些辅助标记的工作,从而达到放缓继续分配、辅助 GC 的标记工作的目的。
 
 - 为什么小对象多了会造成GC压力?
- 因为创建小对象过多,会导致GC三色标记法消耗过多的CPU。所以要减少GC的压力,最好减少对象的分配
 
 
 - Go的内存分配机制是什么?
 
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 是小白菜哦!

