go内存管理

176 阅读29分钟

堆 与 栈

  • 在CPU中存在核心模块ALU;主要是用于进行 数学计算、逻辑计算
// 对于ALU来说进行简单运算是可以快速得出答案、而复杂的运算也会如人一样需要先进行记录再运算
// 当然实际上在程序中存在大量的,复杂运算或者处理的时候只利用寄存器是不够的;因此这个需要就会利用到内存

image.png

  • 栈的理解
// 1.栈的特点就是:先进后出
// 2.栈对于元素的写入称之为叫 入栈 / 压栈 (push);栈顶会随元素的写入个数而定,默认是与栈底在同一个位置
// 3.对于元素从栈中取出数据的时候、需要先把数据复制到CPU中的寄存器中、在栈中元素读取完之后栈顶的位置下移、并且读取的元素仍然还在栈中

  • 栈的函数调用
// 在程序中因为函数的调用是基本且套的方式调用、因此函数的存储就比较适合用栈来存储先进后出
  • 堆的理解
// 关于堆、其本身一般是由开发人员进行分配释放,如果程序员不释放,程序结束的时候可能就需要通过os回收(不过这里不同的编程语言的设计上会存在不同的情况)
  • 堆 与 栈 对比
// 1.内存分配:
// 栈:内存分配主要由编译器负责分配和释放、栈上存储的是局部变量、函数返回地址等
// 堆:堆的内存操作一般主要是程序员(编程语言)实现、系统提供堆内存的接口,编程语言提供相应的方法(需要注意垃圾回收不然会存在内存泄露)
// 2.效率区别:栈的效率要大于堆的效率(栈是进程初始化就分配好、而堆是动态分配)
// 3.空间管理:
// 栈:由编译器自动管理,弹栈和压栈,不会产生内存碎片
// 堆:手动申请和释放,会产生大量的内存碎片
// 4.大小:
// 栈内存,大小限制一般为1~2MB
// 堆内存,不同程序没有限制

计算机存储基础

  • 计算机存储基础理论
// 1.对于CPU来说运行速率很快,如果CPU直接操作硬盘则会拉低cpu的速度,降低机器性能,因此在两者之间运用内存来提高性能
// 2.CPU的运行速率随技术的发展越来越快内存也跟不上,后续就提出L1/L2/L3缓存
// 3.程序对内存的访问,实际访问的是虚拟内存,虚拟内存通过页表查看,当前要访问的虚拟内存地址,是否已经加载到了物理内存,如果已经在物理内存,则取物理内存数据,如果没有对应的物理内存,则从磁盘加载数据到物理内存,并把物理内存地址和虚拟内存地址更新到页表

image_1.png

// 对虚拟内存进一步了解:栈和堆(内存管理),代码中使用的内存地址都是虚拟内存地址,而不是实际的物理内存地址,栈和堆只是虚拟内存上的2块不同的内存区域

image_2.png

// • 栈的内存管理简单,分配堆快
// • 栈的内存不需要回收,而堆需要,无论主动还是被动的垃圾回收,都需要额外的CPU
// • 栈的内存有更好的局部性,堆上的内存访问不一定很好、两块数据可能在不同页上,会比较消耗时间
  • 内存的分配设计-分配方法

内存管理一般包含三个不同的组件,分别是用户程序、分配器和收集器,当用户程序申请内存的时候,会通过内存分配器申请新的内存,而分配器会负责从堆中初始化相应的内存区域.

image_3.png

  • 内存的分配设计-分配方法: 线性分配
// 线性分配:是一种高效的内存分配方法,但是有较大的局限性。当我们使用线性分配器时,只需要在内存中维护一个指向内存特定位置的指针,如果用户程序向分配器申请内存,分配器只需要检查剩余的空闲内存、返回分配的内存区域并修改指针在内存中的位置
// 线性分配的性能不仅好,速度快;但是线性分配器存在的问题主要是内存被释放的时候无法重用内存所以针对这个问题,会结合对应的垃圾回收机制(算法)使用;利用拷贝的方式整理存活对象的碎片,将空闲的内存定期合并,这样就能利用线性分配器的效率提升内存分配器的性能
  • 内存的分配设计-分配方法:空闲链表分配
// 空闲链表分配:以重用已经被释放的内存,它在内部会维护一个类似链表的数据结构。当用户程序申请内存时,空闲链表分配器会依次遍历空闲的内存块,找到足够大的内存,然后申请新的资源并修改链表
// 因为不同的内存块通过指针构成了链表,所以使用这种方式的分配器可以重新利用回收的资源,但是因为分配内存时需要遍历链表,所以它的时间复杂度是 O(n);空闲链表分配器可以选择不同的策略在链表中的内存块中进行选择,常见有如下四种:
// (1)首次适应法(First Fit):选择第一个满足要求的空闲块
// (2)最佳适应法(Best Fit):选择满足要求的,且大小最小的空闲块
// (3)循环首次适应法(Next Fit):从上次分配位置开始找到第一个满足要求的空闲块
// (4)隔离适应(Segregated-Fit)— 将内存分割成多个链表,每个链表中的内存块大小相同,申请内存时先找到满足条件的链表,再从链表中选择合适的内存块
  • Go的分配方式类似于:隔离适应
// 在go中的策略会将内存分割为4,8,16,32字节的内存块组成的链, 当我们向内存中申请 n 字节内存的时候比如(16字节)那么会就会从链表中寻找满足条件的内存块回复,而隔离适应的分配策略是减少需要遍历的内存块数据量,提高内存分配效率

image_4.png

Go与TCMalloc

Go中对内存管理的实现上是参考与TCMalloc(线程缓存分配)实现的、当然随着go的发展其内存管理也发生了改变,与TCMalloc也就有一些差别,但其主要思想、原理和概念还是与TCMalloc一致

它的核心理念是使用多级缓存将对象根据大小分类,并按照类型实行不同的分配策略

  • 对象大小
// TCMalloc对对象大小分为
// * 小对象(0~256KB)、
// * 中对象(257KB~1MB)、
// * 大对象(>1MB)

// Go对对象大小分为
// * 微对象(0~16B)、
// * 小对象(16B~32KB)、
// * 大对象(>32B)

// 因为程序中的绝大多数对象的大小都在 32KB 以下,而申请的内存大小影响 Go 语言运行时分配内存的过程和开销,所以分别处理大对象和小对象有利于提高内存分配器的性能

  • 多级缓存
// 内存分配器会区分不同的对象,Go与TCMalloc运行的时候分配器会引入线程缓存、中心缓存、页堆
// • 线程缓存:主要存放微,小对象 / 线程独有 / 不会有竞争
// • 中心缓存:主要存放小对象 / 线程共享 / 访问要加锁 (当线程缓存满了才存入中心缓存)
// • 页堆:存在若干链表 / 线程共享 / 存大对象
// 这种多层级的内存分配设计与计算机存储是类似的,因程序中多少位小对象,通过线程缓存与中心缓存即可提供足够的内存空,如果不足则从上一级组件中获取更多的内存资源

image_5.png

image_6.png

Go内存结构

  • 结构类型:Go语言程序在不同的版本中对堆区内存地址空间的设计会有所不同;1.11之前堆区的内存空间是连续的;而1.11之后为稀疏型;当然无论哪种方式在其大体的方向上都是相似的
// 注意:关于虚拟内存的科普,虚拟内存并非指的是我们的物理内存(如16G内存),它是计算机实现的一种技术,一般程序在运行的时候会申请比较大的虚拟内存;
// 比如go在初始化的时候就会申请超过512GB的虚拟内存(具体会随系统而调整)但并不会影响到系统的正常物理内存的占用
  • Go在1.10版本中:
// 启动程序会先申请一片比较大的虚拟内存区域(为后续程序运行做准备);如下为go线性内存的布局,共分为三段spans/bitmap/arena分别预留512MB、16GB、512GB的内存空间;注意:这不是真正存在的物理内存,是虚拟内存

image_7.png

  • Arena
// Arena的区域就是所谈及的heap、go从heap分配的内存都在这个区域中,在heap中以page为单位做内存管理,而在go中基于连续的page组成的span为单位进行内存管理

image_8.png

  • Bitmap
// bitmap区域用于表示arena区域中哪些地址保存了对象, 并且对象中哪些地址包含了指针
// 在此bitmap的做作用是标记标记arena(即heap)中的对象。一是的标记对应地址中是否存在对象,另外是标记此对象是否被gc标记过。一个功能一个bit位,所以,heapbitmaps用两个bit位

image_9.png

  • Spans
// spans区域用于表示arena区中的某一页(Page)属于哪个span
// Spans区域、其作用主要是用于管理分配arena的区域,存放了mspan的指针,spans区域用于表示arena中的某一页(page)属于哪个mspan

image_10.png

  • Spans 、bitmap与arena三者的关系
// Arena中包含基本的管理单元和程序运行时候生成的对象,这两部分与bitmap和span非heap区域的内存对应

image_11.png

  • 在go1.11版本
// 提出了稀疏内存布局,这种布局可以移除堆大小的上限,还能够解决C和Go混合使用时的地址空间冲突问题。但因为稀疏内存的内存管理失去了内存的连续性,因此在管理上更加复杂, 里面的概念都是一样的

image_12.png

内存管理组件

  • 在go中的内存管理组件主要有:mspan、mcache、mcentral和mheap
// • mspan为内存管理的基础单元,直接存储数据的地方
// • mcache每个运行期的(GMP中的P) P 都会绑定一个mcache,mcache会分配P运行中所需要的内存空间(mspan)
// • mcentral为所有mcache切分好的mspan
// • mheap代表GO程序持有的所有堆空间,还会管理闲置的span
  • 内存管理组件-mspan
// mspan:Go中内存管理的基本单元,是由一片连续的8KB的页组成的大块内存。注意,这里的页和操作系统本身的页并不是一回事,它一般是操作系统页大小的几倍。一句话概括:mspan是一个包含起始地址、mspan规格、页的数量等内容的双端链表

image_13.png

  • 内存管理组件-mcache
// mcache:为了避免多线程申请内存时不断的加锁,goroutine为每个线程分配了span内存块的缓存,这个缓存即是mcache,每个goroutine都会绑定的一个mcache,各个goroutine申请内存时不存在锁竞争的情况

image_14.png

// mcache:在结构体中存在alloc为mspan的指针数组,数组大小为class总数的2倍。数组中每个元素代表了一种class类型的span列表,每种class类型都有两组span列表,第一组列表中所表示的对象中包含了指针,第二组列表中所表示的对象不含有指针,这么做是为了提高GC扫描性能,对于不包含指针的span列表,没必要去扫描。

image_15.png

  • 内存管理组件-mcentral
// mcentral:为所有mcache提供切分好的mspan资源。每个central保存一种特定大小的全局mspan列表,包括已分配出去的和未分配出去的。 每个mcentral对应一种mspan,而mspan的种类导致它分割的object大小不同。当工作线程的mcache中没有合适(也就是特定大小的)的mspan时就会从mcentral获取

image_16.png

  • 内存管理组件-mheap
// mheap:代表Go程序持有的所有堆空间,Go程序使用一个mheap的全局对象_mheap来管理堆内存

image_17.png

内存分配流程(借用一下别人的图片)

  • 大概的流程图

未命名文件(1).png

  • 内存管理组件的分配图(微对象和小对象都存在mcache中)

未命名文件.png

image_18.png

理解GC基础

  • 理解GC
// GC:全称Garbage Collection;即垃圾回收,是一种自动内存管理的机制 (GC还是比较重要的)
// 程序在执行的过程中必不可少的就会产生内存的消耗、而一些不需要 一直存在的内存如果不及时回收就会造成很大的内存消耗
// 垃圾回收机制:主动将其回收并供其他代码进行内存申请时候复用,或者将其归还给操作系统,这种针对内存级别资源的自动回收过程,即为垃圾回收。而负责垃圾回收的程序组件,即为垃圾回收器
  • 什么是根对象-Root
// 根对象:根在垃圾回收的术语中又叫做根集合,它是垃圾回收器在标记过程时最先检查的对象,包括:
// • 全局变量:程序在编译期就能确定的那些存在于程序整个生命周期的变量。
// • 执行栈:每个 goroutine 都包含自己的执行栈,这些执行栈上包含栈上的变量及指向分配的堆内存区块的指针。
// • 寄存器:寄存器的值可能表示一个指针,参与计算的这些指针可能指向某些赋值器分配的堆内存区块。
  • 常见GC算法
// 业界常见GC算法如下:
// • 引用计数:对每个对象维护一个引用计数,当引用该对象的对象被销毁时,引用计数减1,当引用计数器为0时回收该对象。代表语言:Python、PHP
// • 标记清除:从根变量开始遍历所有引用的对象,引用的对象标记为”被引用”,没有被标记的进行回收。代表语言:Golang
// • 分代收集:按照对象生命周期长短划分不同的代空间,生命周期长的放入老年代,而短的放入新生代,不同代有不同的回收算法和回收频率。代表语言:Java
  • 注意:GC不是一开始就有会运行,而是在某一特定的时间节点会被触发

image_19.png

理解标记清除与STW

  • 标记清除
// 标记清除:从根变量开始遍历所有引用的对象,引用的对象标记为”被引用”,没有被标记的进行回收。
// 根据如上可分为两个阶段:标记、清除 两个阶段
// 1. 标记阶段:从跟对象出发查找并标记堆中所有存活的对象(什么是存活对象)
// 2. 清除阶段:遍历堆中的全部对象,回收未被标记的垃圾对象并将回收的内存加入空闲链表中

  • 标记清除的步骤
    • 第一步:开始GC、找出不可达的对象 image_20.png
    • 第二步:开始标记,程序找出所有可达的对象并做上标记 image_21.png
    • 第三步:标记完之后,然后开始清除未标记对象 image_22.png
  • STW
// STW(Stop the World)的简写,意思是指从STW这一动作发送到STW结束的时间段内万物静止;
// 在GC过程中为了更好的确保程序的正确性,防止无止境的内存增长等问题而选择的一种机制 STW ,其表示让程序在这个过程中整个用户代码会被停止,直到GC结束;
//因此STW越长、对用户代码造成的影响就越大,因此GO的版本升级对GC的优化就是在优化STW的性能
  • 没有STW的标记清除步骤

    • 第一步:开始GC、找出不可达的对象
    // 开始找出的时候 AB EF之间还没有连接,可能是在开始之后没有执行到B和F的时候,再步骤2之间,A-》B E-》F
    

    image_23.png

    • 第二步:开始标记,程序找出所有可达的对象并做上标记;第三步准备清空 D 和 G 两个对象 image_24.png
    • 第三步:标记完之后,然后开始清除未标记对象但是因为没有STW标记这个时候用户程序中F对象对G对象进行引用 image_25.png
    • 第四步:标记完之后,然后开始清除未标记对象;连同D 和 G 均被清楚;因此这个时候就会造成不应该回收的对象却被回收了,这在内存管理中是非常严重的错误;因此就需要运用STW来暂停程序确保正确性 image_26.png
    • 以上是没有STW的步骤,如果有STW的话,在标记之后GC回收之前会暂停程序,然后判断是否还有被引用的对象没有被标记,在步骤三之后,就会将G对象也标记上,然后GC结束之后,在执行程序

三色标记

  • 理解三色标记
// 三色标记实际上就是三个阶段的标记来确定清除的对象都有哪些;这种方式会讲程序中的对象分成白色、黑色、灰色三类
// • 白色对象 — 潜在的垃圾,其内存可能会被垃圾收集器回收; 存储的是最后会被回收的对象
// • 黑色对象 — 活跃的对象,包括不存在任何引用外部指针的对象以及从根对象可达的对象; 存储的是不会被回收的对象
// • 灰色对象 — 活跃的对象,因为存在指向白色对象的外部指针,垃圾收集器会扫描这些对象的子对象; 最后不会存在任何对象,因为存在于灰色的对象最终一定会转成黑色的对象
  • 三色标记的执行步骤
    • 第一步:就是只要是新创建的对象,默认的颜色都是标记为“白色” image_27.png
    • 第二步:每次GC回收开始, 然后从根节点开始遍历所有对象,把遍历到的对象从白色集合放入“灰色”集合。 image_28.png
    • 第三步:遍历灰色集合,将灰色对象引用的对象从白色集合放入灰色集合,之后将此灰色对象放入黑色集合 image_29.png
    • 第四步:重复第三步, 直到灰色中无任何对象 image_30.png image_31.png
    • 第五步:回收所有的白色标记表的对象. 也就是回收垃圾 image_32.png
    • 三色标记也是无法避免 不存在STW的情况时出现错误的问题
    • 三色标记的优点:可以渐进执行而不需要每次都去扫描整个空间,减少了stop the world的时间。相比传统的标记清扫算法,三色标记最大的好处是可以异步执行,从而可以以中断时间极少的代价或者完全没有中断来进行整个 GC
  • 没有STW的三色标记
    • 如果没有STW的三色标记过程不启动STW,则出现的可能就是在标记过程中比如E指向C的情况 或 A指向 G的情况 (已经变成黑色的对象再去指向白色的对象时)
    • 比如下面的情况中E指向了 C、这个时候C会挂在E的下面而不在B的下面 image_33.png
    • 三色标记主要是针对扫描灰色以及灰色对象所挂的其他的对象而作为黑色的对象则不会再去扫描因此C就无法改为灰色 image_34.png
    • 最终在垃圾回收的时候就会把DCG这三个对象进行回收;其中C原本是需要应用的但是因为机制的关系不得不被回收 image_35.png
  • 三色标记中存在的问题
// 可以看出,有两个问题, 在三色标记法中,是不希望被发生的
// • 条件1: 一个白色对象被黑色对象引用(白色被挂在黑色下)
// • 条件2: 灰色对象与它之间的可达关系的白色对象遭到破坏(灰色同时丢了该白色)
// 当以上两个条件同时满足时, 就会出现对象丢失现象!如果白色对象C, 如果他还有很多下游对象的话, 也会一并都清理掉;
// 为了防止这种现象的发生,最简单的方式就是STW,直接禁止掉其他用户程序对对象引用关系的干扰,但是STW的过程有明显的资源浪费,对所有的用户程序都有很大影响;后续在优化中引用了 屏障机制

屏障技术(写屏障与删除写屏障)

  • 理解屏障技术
// 为了减少STW对程序的性能影响,在后续中go运用了屏障技术;
// 屏障技术:它可以让 CPU 或者编译器在执行内存相关操作时遵循特定的约束,是该技术能够保证内存操作的顺序性,在内存屏障前执行的操作一定会先于内存屏障后执行的操作。
// 同时想要在并发或者增量的标记算法中保证正确性,我们需要达成以下两种三色不变性中的一种
// • 强三色不变性 — 黑色对象不会指向白色对象,只会指向灰色对象或者黑色对象;
// • 弱三色不变性 — 黑色对象指向的白色对象必须包含一条从灰色对象经由多个白色对象的可达路径
  • 强三色不变性—不存在黑色对象引用到白色对象的指针

image_36.png

  • 弱三色不变性—所有被黑色对象引用的白色对象都处于灰色保护状态 image_37.png

  • 插入屏障

// 具体操作: 在A对象引用B对象的时候,B对象被标记为灰色。(将B挂在A下游,B必须被标记为灰色)
// 满足: 强三色不变式. (不存在黑色对象引用白色对象的情况了, 因为白色会强制变成灰色)
// 伪代码:
添加下游对象(当前下游对象slot, 新下游对象ptr) {
  //1
  标记灰色(新下游对象ptr)
  //2
  当前下游对象slot = 新下游对象ptr
}
  • 插入屏障过程

    • 1.程序开始之前会将所有的对象标记为白色 image_38.png
    • 2.遍历Root(一次只遍历一次)得到 灰色节点 image_39.png
    • 3.遍历Root(一次只遍历一次)得到 灰色节点,将上次的灰色节点变成黑色 image_40.png
    • 4.因为并发的情况,在A节点添加G对象 image_41.png
    • 5.因为并发的情况,在A节点添加G对象,由于写屏障的存在,黑色对象添加白色,将 白色改为灰色; image_42.png
    • 6.最后将所有灰色变成黑色对象,将白色对象回收 image_43.png
    • 最后需要注意的事情:
    // 在上述流程中我们主要是讲解go对堆中的内存标记的情况,而上面的流程在栈中是不适用的;----简而言之,栈上不用任何屏障技术;
    // 理由:
    // 栈空间的特点是容量小,但是要求相应速度快,因为函数调用弹出频繁使用, 所以“插入屏障”机制,在栈空间的对象操作中不使用. 而仅仅使用在堆空间对象的操作中
    
    • 下图中:1.主要对栈和堆进行细分,其栈和堆在GC扫描的流程中仍然也是遵循,先全部为白,然后再标记为灰色最后到黑色;(此处省略一些过程) image_44.png image_45.png
    • 2.当扫描器在栈上进行扫描依然存在白色对象被应用的情况,这个时候要对栈重新进行扫描为了防止数据丢失,本次标记扫描启动STW暂定,直到栈空间的三色标记结束 image_46.png
    • 3.最后在STW中,将栈中的元素一次三色标记,直到没有灰色节点才结束 image_47.png
    • 4.最后将栈和堆空间 扫描剩余的全部 白色节点清除. 这次STW大约的时间在10~100ms间. image_48.png
  • 删除屏障

// 具体操作:被删除的对象,如果自身为灰色或者白色,那么被标记为灰色。
// 满足:弱三色不变式. (保护灰色对象到白色对象的路径不会断)
//伪代码:
添加下游对象(当前下游对象slot, 新下游对象ptr) {
  //1
  if (当前下游对象slot是灰色 || 当前下游对象slot是白色) {
    标记灰色(当前下游对象slot) //slot为被删除对象, 标记为灰色
  }
  //2
  当前下游对象slot = 新下游对象ptr
}
  • 删除屏障过程
    • 1.程序开始之前会将所有的对象标记为白色 image_49.png
    • 2.根据三色标记A、E标记为灰色 image_50.png
    • 3.在过程中可能存在A对B对象的应用删除 image_51.png
    • 4.在这个过程中触发删除写屏障、在A与B断开之后,被删除的B会自身标记为灰色 image_52.png
    • 5.在经过三色标记之后,最终就会呈现如下的结果,这种方式回收精度低,一个对象即使被删除了最后一个指向它的指针也依旧可以活过这一轮,在下一轮GC中被清理掉 image_53.png

混合写屏障技术

  • 删除写屏障与插入写屏障 均存在相应的问题
// • 插入写屏障:结束时需要STW来重新扫描栈,标记栈上应用的白色对象的存活
// • 删除写屏障:回收精度低,GC开始的时候STW扫描堆栈来记录初识的快照,这个过程会保护开始节点的所有存活对象
  • 混合写屏障规则
// 1、GC开始将栈上的对象全部扫描并标记为黑色(之后不再进行第二次重复扫描,无需STW),
// 2、GC期间,任何在栈上创建的新对象,均为黑色。
// 3、被删除的对象标记为灰色。
// 4、被添加的对象标记为灰色。
// 这里我们注意, 屏障技术不在栈上应用的,因为要保证栈的运行效率。
// 如下为伪代码:
添加下游对象(当前下游对象slot, 新下游对象ptr) {
  //1
  标记灰色(当前下游对象slot) //只要当前下游对象被移走,就标记灰色
  //2
  标记灰色(新下游对象ptr)
  //3
  当前下游对象slot = 新下游对象ptr
}

  • 混合写屏障过程
    • 1.如下是三色标记的开始状态,我们根据下面内容来分析屏障机制的;注意混合写屏障是GC的一种屏障机制,所以只是当程序执行GC的时候,才会触发这种机制 image_54.png
    • 2.GC三色标记开始的时候,会先扫描栈区间,将可达对象全部标记为黑色 image_55.png
    • 3.1.1情况一:对象被一个堆对象删除应用成为栈对象的下游;栈对象不会触发写屏障会先直接挂在栈上 image_56.png
    • 3.1.2 情况一:而这个时候E会删除F的引用关系,这个时候会触发删除写屏障,标记被删除的对象F为灰色 image_57.png
    • 3.2.1.情况二:一个被引用的栈对象成为另一个栈对象的下游 image_58.png
    • 3.2.2.情况二:对象B删除与C的关系,直接挂在H下(不启动写屏障) image_59.png
    • 3.3.1.情况三:被引用的对象成为另一个堆对象下游 image_60.png
    • 3.3.2.情况三:F挂在H下并且会触发屏障机制,添加的对象标记为灰色,最后再删除E对F的引用 image_61.png
    • 3.4.1.情况四:栈中的对象成为另一个堆对象的引用,而堆对象可能也有元素' image_62.png
    • 3.4.2.情况四:对象E在这个时候引用对象B,删除E对F的引用 image_63.png
    • 3.4.3.情况四:E在删除F的引用的时候会触发写屏障,标记F为灰色包含F下的对象 image_64.png
  • go的版本分别使用的GC的方法
// GoV1.3- 普通标记清除法,整体过程需要启动STW,效率极低。
// GoV1.5- 三色标记法, 堆空间启动写屏障,栈空间不启动,全部扫描之后,需要重新扫描一次栈(需要STW),效率普通
// GoV1.8-三色标记法,混合写屏障机制, 栈空间不启动,堆空间启动。整个过程几乎不需要STW,效率较高。
// 混合写屏障:结合删除和插入写屏障的有点,只需要在开始并发的扫描栈中的对象,使其变成黑色并一直保持;栈中新的对象也为黑色,这个过程不需要STW,而标记结束之后,因为栈中的对象都是黑色,所以也不行重新的扫描,减少STW的时间

观察Go GC

1. GODEBUG=gctrace=1 go run xxx.go
Wall clock =》 0.13+0.73+0.052 ms clock ;cpu time 0.55+0.11/0.41/0+0.21 ms cpu
标记开始、标记过程、标记终止的时间
Wall clock < cpu time 充分利用多核; = 并未充分利用, > 多核优势不明显
2. go tool trace
3. debug.ReadGCStats
4. runtime.ReadMemStats
// 这个观察的Gc的情况暂时理解的还不是很好,如果以后有用到时,在复习一下视频吧。。。

触发Go GC的时机

// Go语言中对GC的触发时机存在两种形式:
// 1. 主动触发:通过调用runtime.GC触发GC,此方式调用阻塞式地等待当前GC运行完成
// 2. 被动触发:
// • 系统监控堆内存的分配达到控制器计算的触发堆大小
// • 这种类型的触发条件是当超过两分钟没有产生任何GC,强制执行GC
// • 根据指定GC执行轮数,触发条件是指定的轮数n大于已执行过的GC轮数work.cycles;也就是如果已执行GC次数没有到达指定次数,触发GC

GC的流程

  • 阶段的简单说明

image_65.png

image_66.png

  • 流程图梳理

image_67.png

栈内存管理

  • 了解
// Go 语言使用用户态线程 Goroutine 作为执行上下文,它的额外开销和默认栈大小都比线程小很多;
// 关于go的初识栈内存在最初的时候是4kb的大小,在后续的版本迭代升级中进行很多的优化,目前go的goroutine的初识栈大小降低到了2kb;
  • 分段栈(4k到8k)
// GO1.13的版本之前使用的栈的结构是分段栈,在程序中会通过runtime.stackalloc分配一块固定大小的内存空间,运行时会在全局的栈缓存链表中找到空闲的内存块并作为新G的栈空间返回;在其余情况下,栈内存空间会从堆上申请一块合适的内存。

image_68.png

  • 连续栈
// 连续栈可以解决分段栈中存在的两个问题,其核心原理是每当程序的栈空间不足时,初始化一片更大的栈空间并将原栈中的所有值都迁移到新栈中,新的局部变量或者函数调用就有充足的内存空间。使用连续栈机制时,栈空间不足导致的扩容会经历以下几个步骤

image_69.png

// 连续栈的简单步骤:
// 1.在内存空间中分配更大的栈内存空间
// 2.将旧栈中所有的内容复制到新栈中
// 3.将指向旧栈对应的变量的指针重新指向新栈
// 4.销毁并回收旧栈的内容空间

逃逸分析

  • 如下两块代码在运行中a最终分别是分配到栈还是堆
package main
func main() {
  a := make([]int, 10)
  a[1] = 999
  println(a)
}
// 以上代码不会发生逃逸

package main
import "fmt"
func main() {
  a := make([]int, 10)
  a[1] = 999
  fmt.Println(a)
}
// 以上代码会发生逃逸
  • 逃逸分析常用命令
• Go tool compile –S xxx.go ----- go 编译过程
• go build -gcflags ‘-m -l’ xxx.go ---- 对象逃逸分析
  • 理解
// 所谓逃逸分析(Escape analysis)是指由编译器决定内存分配的位置,不需要程序员指定。函数中申请一个新的对象
• 如果分配在栈中,则函数执行结束可自动将内存回收;
• 如果分配在堆中,则函数执行结束可交给GC(垃圾回收)处理;
  • 会逃逸的场景
// 1. 指针逃逸
// 1.1 -- 如果函数外部没有引用(不是拷贝),则优先放到栈中
// 1.2 -- 如果函数外部存在引用(不是拷贝),必然存放在堆中(发生逃逸)
// 2. 栈空间不足逃逸
// 3. 动态类型逃逸 (该场景不一定会发生逃逸)
// 4. 闭包引用对象逃逸

image_70.png