死磕GC:Java GC 和 GO GC 大对比,看完秒成高高手

说在前面

现在拿到offer超级难,甚至连面试电话,一个都搞不到。

尼恩的技术社群中(50+),很多小伙伴凭借 “左手云原生+右手大数据”的绝活,拿到了offer,并且是非常优质的offer,据说年终奖都足足18个月

第二个案例就是:前段时间,一个2年小伙伴希望涨薪到18K, 尼恩把GO语言的项目架构,给他写入了简历,导致他的简历金光闪闪,脱胎换骨,完全可以去拿头条、腾讯等30K的offer, 年薪可以直接多 20W

第二个案例就是:一个6年小伙伴凭借Java+go双语言云原生架构,年薪60W

从Java高薪岗位和就业岗位来看,云原生、K8S、GO 现在对于 高级工程师/架构师 来说,越来越重要。所以,尼恩给大家写了一个 从Java 快速 转型 Java+ GO 双栖 高手的 PDF 《Go学习圣经 - 技术自由圈 版》。

在GO的学习过程中, GC是绝对的核心重点和难点。并且,在大家面试过程中,遇到的大量GC相关难题,比如:

  • 聊聊:常见的垃圾回收算法
  • 聊聊:三色标记法
  • 聊聊:GO的 STW(Stop The World)
  • 聊聊:如何观察 Go GC?
  • 聊聊:有了 GC,为什么还会发生内存泄露?

本文从原理出发,介绍 Java 和Golang垃圾回收算法,并从原理上对他们做一个对比。

通过此答案,使得大家可以充分展示一下大家雄厚的 “技术肌肉”,让面试官爱到 “不能自已、口水直流”。也一并把题目以及参考答案,收入咱们的《Go学习圣经 - 技术自由圈 版》 V2,供后面的小伙伴参考,提升大家的 3高 架构、设计、开发水平。

注:本文以 PDF 持续更新,《尼恩架构笔记》《尼恩高并发三部曲》《尼恩Java面试宝典》的PDF,请到文末公号【技术自由圈】取

作为 Java+ GO 双栖 高手,咱们首先从 Java 的GC开始。

文章目录

Java垃圾回收篇

现代高级编程语言管理内存的方式分自动和手动两种。手动管理内存的典型代表是C和C++,编写代码过程中需要主动申请或者释放内存;而PHP、Java 和Go等语言使用自动的内存管理系统,由内存分配器和垃圾收集器来代为分配和回收内存,其中垃圾收集器就是我们常说的GC。

一、java 垃圾回收区域及划分

在介绍 Java 垃圾回收之前,我们需要了解 Java 的垃圾主要存在于哪个区域。

JVM内存运行时区域划分如下图所示:

JVM内存运行时区域划分

程序计数器:是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器,各条线程之间计数器互不影响,独立存储。

虚拟机栈:它描述的是 Java 方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame,是方法运行时的基础数据结构)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

本地方法栈:它与虚拟机栈所发挥的作用是非常相似的,它们之间的区别不过是虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务。

Java堆:它是 Java 虚拟机所管理的内存中最大的一块。Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。

方法区:它与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

Java 内存运行时区域的各个部分,其中程序计数器、虚拟机栈、本地方法栈3个区域随着线程而生,随着线程而灭;栈中的栈帧随着方法的进入退出而进栈出栈,在类结构确定下来时就已知每个栈帧中的分配内存。而 Java 堆和方法区则不同,一个接口中的多个实现类需要的内存可能不同,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间时才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,而在java8中,方法区存放于元空间中,元空间与堆共享物理内存,因此,Java 堆和方法区是垃圾收集器管理的主要区域

从垃圾回收的角度,由于JVM垃圾收集器基本都采用分代垃圾收集理论,所以 Java 堆还可以细分为如下几个区域(以HotSpot虚拟机默认情况为例):

其中,Eden区、From Survivor0(“From”)区、To Survivor1(“To”)区都属于新生代,Old Memory区属于老年代。

大部分情况,对象都会首先在Eden区域分配;在一次新生代垃圾回收后,如果对象还存活,则会进入To区,并且对象的年龄还会加1(Eden 区->Survivor区后对象的初始年龄变为1),当它的年龄增加到一定程度(超过了survivor区的一半时,取这个值和MaxTenuringThreshold中更小的一个值,作为新的晋升年龄阈值),就会晋升到老年代中。经过这次GC后,Eden区和From区已经被清空。这个时候,From和To会交换他们的角色,保证名为To的Survivor区域是空的。Minor GC会一直重复这样的过程。在这个过程中,有可能当次Minor GC后,Survivor 的"From"区域空间不够用,有一些还达不到进入老年代条件的实例放不下,则放不下的部分会提前进入老年代。

针对HotSpot VM的实现,它里面的GC其实准确分类只有两大种:

1.部分收集(Partial GC)

  • 新生代收集(Minor GC/Young GC):只对新生代进行垃圾收集;
  • 老年代收集(Major GC/Old GC):只对老年代进行垃圾收集。需要注意的是Major GC在有的语境中也用于指代整堆收集;
  • 混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集。

2.整堆收集 (Full GC):收集整个Java堆和方法区

Java 堆内存常见分配策略

1.对象优先在eden区分配。大部分对象朝生夕灭。

2.大对象直接进入老年代。大对象就是需要大量连续内存空间的对象(比如:字符串、数组),容易导致内存还有不少空间就提前触发垃圾收集获取足够的连续空间来安置它们。为了避免为大对象分配内存时,由于分配担保机制带来的复制而降低效率,建议大对象直接进入空间较大的老年代。

3.长期存活的对象将进入老年代,动态对象年龄判定:在一次新生代垃圾回收后,如果对象还存活,则会进入s0或者s1,并且对象的年龄还会加1(Eden 区->Survivor区后对象的初始年龄变为1),当它的年龄增加到一定程度(超过了survivor区的一半时,取这个值和MaxTenuring Threshold中更小的一个值,作为新的晋升年龄阈值),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数-XX:MaxTenuring Threshold来设置。

4.空间分配担保。在发生Minor GC之前,虚拟机会先检查老年代最大可用连续内存空间是否大于新生代所有对象总空间。如果这个条件成立,那么Minor GC可以确保是安全的。如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许【担保失败】:

  • 如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小。
  • 如果大于,将尝试着进行一次Minor GC,尽管这次Minor GC是有风险的。
  • 如果小于,或者HandlePromotionFailure设置不允许冒险,那这时也要改为进行一次Full GC。

二、 判断对象死亡

堆中几乎放着所有的对象实例,对堆垃圾回收前的第一步就是要判断哪些对象已经死亡(即不能再被任何途径使用的对象)。

判断一个对象是否存活有引用计数、可达性分析这两种算法,两种算法各有优缺点。

Java 和Go都使用可达性分析算法,一些动态脚本语言(如:ActionScript)一般使用引用计数算法。

(1)引用计数法

引用计数法给每个对象的对象头添加一个引用计数器,每当其他地方引用一次该对象,计数器就加1;

当引用失效,计数器就减1;任何时候计数器为0的对象就是不可能再被使用的。

这个方法实现简单,效率高,但是主流的Java虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间相互循环引用的问题。

即如下代码所示:除了对象objA和objB相互引用着对方之外,这两个对象之间再无任何引用。

但是他们因为互相引用对方,导致它们的引用计数器都不为0,于是引用计数算法无法通知GC回收器回收他们。

public class ReferenceCountingGc {
    
    
    Object instance = null;
    public static void main(String[] args) {
    
    
        ReferenceCountingGc objA = new ReferenceCountingGc();
        ReferenceCountingGc objB = new ReferenceCountingGc();
        objA.instance = objB;
        objB.instance = objA;
        objA = null;
        objB = null;
    }
}

(2)可达性分析算法

这个算法的基本思想就是通过一系列的称为“GC Roots”的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连的话,则证明此对象是不可用的。

算法优点是能准确标识所有的无用对象,包括相互循环引用的对象;

缺点是算法的实现相比引用计数法复杂。比如如下图所示Root1和Root2都为“GC Roots”,白色节点为应被垃圾回收的。

关于Java查看可达性分析、内存泄露的工具,强烈推荐“Memory Analyzer Tool”,可以查看内存分布、对象间依赖、对象状态。

在Java中,可以作为“GC Roots”的对象有很多,比如:

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
  • 在方法区中类静态属性引用的对象,譬如Java类的应用类型静态变量。
  • 在方法区中常量应用的对象,譬如字符串池中的引用。
  • 在本地方法栈中JNI引用的对象。
  • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻异常对象(如NPE),还有系统类加载器。
  • 所有被同步锁(synchronized)持有的对象。
  • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

不可达的对象并非“非死不可”

即使在可达性分析法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑阶段”,要真正宣告一个对象死亡,至少要经历两次标记过程;

可达性分析法中不可达的对象被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize方法。

当对象没有覆盖finalize方法,或 finalize 方法已经被虚拟机调用过时,虚拟机将这两种情况视为没有必要执行。

被判定为需要执行的对象将会被放在一个队列中进行第二次标记,除非这个对象与引用链上的任何一个对象建立关联,否则就会被真的回收。

判断一个运行时常量池中的常量是废弃常量

1.JDK1.7 之前运行时常量池逻辑包含字符串常量池存放在方法区, 此时 hotspot 虚拟机对方法区的实现为永久代。

2.JDK1.7 字符串常量池被从方法区拿到了堆中, 这里没有提到运行时常量池,也就是说字符串常量池被单独拿到堆,运行时常量池剩下的东西还在方法区, 也就是 hotspot 中的永久代。

3.JDK1.8 hotspot 移除了永久代用元空间(Metaspace)取而代之, 这时候字符串常量池还在堆, 运行时常量池还在方法区, 只不过方法区的实现从永久代变成了元空间(Metaspace)。

假如在字符串常量池中存在字符串"abc",如果当前没有任何String对象引用该字符串常量的话,就说明常量"abc"就是废弃常量,如果这时发生内存回收的话而且有必要的话,"abc"就会被系统清理出常量池了。

如何判断一个方法区的类是无用的类

类需要同时满足下面3个条件才能算是“无用的类”,虚拟机可以对无用类进行回收。

1.该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。

2.加载该类的ClassLoader已经被回收。

3.该类对应的 java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

三、垃圾收集算法

当确定了哪些对象可以回收后,就要需要考虑如何对这些对象进行回收,目前垃圾回收算法主要有以下几种。

  • 标记-清除算法:标记无用对象,然后进行清除回收。缺点:效率不高,无法清除垃圾碎片。
  • 复制算法:按照容量划分二个大小相等的内存区域,当一块用完的时候将活着的对象复制到另一块上,然后再把已使用的内存空间一次清理掉。缺点:内存使用率不高,只有原来的一半。
  • 标记-整理算法:标记无用对象,让所有存活的对象都向一端移动,然后直接清除掉端边界以外的内存。
  • 分代算法:根据对象存活周期的不同将内存划分为几块,一般是新生代和老年代,新生代基本采用复制算法,老年代采用标记整理算法。

(1)标记-清除算法

该算法分为“标记”和“清除”阶段:首先标记出所有不需要回收的对象,在标记完成后统一回收掉所有没有被标记的对象。适用场合:存活对象较多的情况、适用于年老代。

标记-清除算法(Mark-Sweep)是一种常见的基础垃圾收集算法,它将垃圾收集分为两个阶段:

  • 标记阶段:标记出可以回收的对象。
  • 清除阶段:回收被标记的对象所占用的空间。

标记-清除算法的执行的过程如下图所示

优点:实现简单,不需要对象进行移动。

缺点

1.空间问题,易产生内存碎片,当为一个大对象分配空间时可能会提前触发垃圾回收(例如,对象的大小大于空闲表中的每一块儿大小但是小于其中两块儿的和)。

2.效率问题,扫描了整个空间两次(第一次:标记存活对象;第二次:清除没有标记的对象)。标记、清除过程效率低,产生大量不连续的内存碎片,提高了垃圾回收的频率。

总之:

标记-清除算法之所以是基础的,是因为后面讲到的垃圾收集算法都是在此算法的基础上进行改进的。

(2)标记-复制算法

为了解决标记-清除算法的效率不高的问题,产生了标记-复制算法。

它把内存空间划为两个相等的区域,每次只使用其中一个区域。

垃圾收集时,遍历当前使用的区域,把存活对象复制到另外一个区域中,最后将当前使用的区域的可回收的对象进行回收。

优点:按顺序分配内存即可,实现简单、运行高效,不用考虑内存碎片。

缺点:可用的内存大小缩小为原来的一半,对象存活率高时会频繁进行复制。

复制算法的执行过程如下图所示

(3)标记-整理算法

在新生代中可以使用复制算法,但是在老年代就不能选择复制算法了,因为老年代的对象存活率会较高,这样会有较多的复制操作,导致效率变低。

标记-清除算法可以应用在老年代中,但是它效率不高,在内存回收后容易产生大量内存碎片。

因此就出现了一种标记-整理算法(Mark-Compact)算法,与标记-整理算法不同的是,在标记可回收的对象后将所有存活的对象压缩到内存的一端,使他们紧凑的排列在一起,然后对端边界以外的内存进行回收。

回收后,已用和未用的内存都各自一边。

优点:解决了标记-清理算法存在的内存碎片问题。

缺点:仍需要进行局部对象移动,一定程度上降低了效率。

标记-整理算法的执行过程如下图所示

标记-整理算法适用场合:存活对象较少的情况下比较高效、用于年轻代(即新生代)。

(4)分代收集算法

分代收集法是目前大部分 JVM 所采用的方法,其核心思想是根据对象存活的不同生命周期将内存划分为不同的域,一般情况下将 GC 堆划分为老生代(Tenured/Old Generation)和新生代(YoungGeneration)。

老生代的特点是每次垃圾回收时只有少量对象需要被回收,新生代的特点是每次垃圾回收时都有大量垃圾需要被回收,因此可以根据不同区域选择不同的算法。

当前商业虚拟机都采用分代收集的垃圾收集算法。分代收集算法,顾名思义是根据对象的存活周期将内存划分为几块。一般包括年轻代老年代永久代,如图所示:

当前主流 VM 垃圾收集都采用”分代收集” (Generational Collection)算法, 这种算法会根据对象存活周期的不同将内存划分为几块, 如 JVM 中的 新生代、老年代、永久代, 这样就可以根据各年代特点分别采用最适当的 GC 算法

新生代与标记-复制算法

每次垃圾收集都能发现大批对象已死, 只有少量存活. 因此选用复制算法,

只需要付出少量存活对象的复制成本就可以完成收集

目前大部分 JVM 的 GC 对于新生代都采取 Copying 算法,因为新生代中每次垃圾回收都要回收大部分对象,即要复制的操作比较少,但通常并不是按照 1: 1 来划分新生代。

一般将新生代划分为一块较大的 Eden 空间和两个较小的 Survivor 空间(From Space, To Space),每次使用Eden 空间和其中的一块 Survivor 空间,当进行回收时,将该两块空间中还存活的对象复制到另一块 Survivor 空间中。

老年代与标记清理算法

因为老年代对象存活率高、没有额外空间对它进行分配担保, 就必须采用“标记—清理”或“标记—整理” 算法来进行回收, 不必进行内存复制, 且直接腾出空闲内存。因而采用 Mark-Compact 算法。

  1. JAVA 虚拟机提到过的处于方法区的永生代(Permanet Generation), 它用来存储 class 类,常量,方法描述等。对永生代的回收主要包括废弃常量和无用的类。
  2. 对象的内存分配主要在新生代的 Eden Space 和 Survivor Space 的 From Space(Survivor 目前存放对象的那一块),少数情况会直接分配到老生代。
  3. 当新生代的 Eden Space 和 From Space 空间不足时就会发生一次 GC,进行 GC 后, EdenSpace 和 From Space 区的存活对象会被挪到 To Space,然后将 Eden Space 和 FromSpace 进行清理。
  4. 如果 To Space 无法足够存储某个对象,则将这个对象存储到老生代。
  5. 在进行 GC 后,使用的便是 Eden Space 和 To Space 了,如此反复循环。
  6. 当对象在 Survivor 区躲过一次 GC 后,其年龄就会+1。 默认情况下年龄到达 15 的对象会被移到老生代中。

四、java中 GC 垃圾收集器

Java 堆内存被划分为新生代和年老代两部分,新生代主要使用复制和标记-清除垃圾回收算法;年老代主要使用标记-整理垃圾回收算法,因此 java 虚拟中针对新生代和年老代分别提供了多种不同的垃圾收集器, JDK1.6 中 Sun HotSpot 虚拟机的垃圾收集器如下:

垃圾收集器 特点 算法 适用场景 优点 缺点
Serial 最基本、历史最悠久的单线程垃圾收集器。 新生代采用标记-复制算法,老年代采用标记-整理算法。 运行在 Client 模式下的虚拟机 简单、高效 垃圾回收时必须暂停其他所有的工作线程
ParNew Serial 收集器的多线程版本 新生代采用标记-复制算法,老年代采用标记-整理算法 运行在Server 模式下的虚拟机 并行,效率高
Parallel Scavenge 使用标记-复制算法的多线程收集器,关注吞吐量 新生代采用标记-复制算法,老年代采用标记-整理算法. JDK1.8 默认收集器在注重吞吐量及CPU资源的场合 吞吐量高
SerialOld Serial 收集器的老年代版本 标记-整理算法 在JDK<1.5与 Parallel Scavenge收集器搭配使用作为CMS收集器的后备方案 简单、高效 垃圾回收时必须暂停其他所有的工作线程
Parallel Old Parallel Scavenge收集器的老年代 标记-整理算法 在注重吞吐量及CPU资源的场合 吞吐量高
CMS 多线程的垃圾收集器(用户线程和垃圾回收线程可以同时进行 标记-清除算法 希望系统停顿时间最短,注重服务的响应速度的场景 并发收集、低停顿 对CPU资源敏感,无法处理浮动垃圾,产生垃圾碎片
G1 一款面向服务器的垃圾收集器,并行并发,空间整合,可预测的停顿时间 标记-复制算法 服务端应用、针对具有大内存多处理器的机器 停顿时间可控、基本无空间碎片 可能存在空间浪费、程序运行时的额外执行负载高

虽然我们对各个收集器进行比较,但并非要挑选出一个最好的收集器。

因为直到现在为止还没有最好的垃圾收集器出现,更加没有万能的垃圾收集器,我们能做的就是根据具体应用场景选择适合自己的垃圾收集器。

以上内容太过复杂,如果看不懂,请参见 《Go学习圣经-技术自由圈版》 配套视频

著名的高性能内存分配器

作为 Java+ GO 双栖 高手,咱们首先从java 的GC开始,然后到了 GO的 GC。

要介绍go中的GC,必须介绍一下 高性能内存分配器。

业界有一些著名的高性能内存分配器实现,比如 ptmalloc 、 tcmalloc、jemalloc 。

简单对比如下:

  • ptmalloc(per-thread malloc) 基于 glibc 实现的内存分配器,由于是标准实现,兼容性较好。缺点是多线程之间内存无法实现共享,内存开销很大。
  • tcmalloc(thread-caching malloc) 是由 Google 开源,最大特点是带有线程缓存,目前在 Chrome、Safari 等产品中有所应用。tcmalloc 为每个线程分配一个局部缓存,可以从线程局部缓冲分配小内存对象,而对于大内存分配则使用自旋锁减少内存竞争,提高内存效率。
  • jemalloc 借鉴 tcmalloc 优秀的设计思路,所以在架构设计方面两者有很多相似之处,同样都包含线程缓存特性。但是 jemalloc 在设计上比 tcmalloc 要复杂。它将内存分配粒度划分为Small、Large、Huge,并记录了很多元数据,所以元数据占用空间高于 tcmalloc

高性能内存分配器的核心目标,无外乎有两点:

  • 高效的内存分配和回收,提升单线程或多线程场景下的性能。
  • 减少内存碎片,包括内存碎片和外部碎片。提高内存的有效利用率

内部碎片和外部碎片

在 Linux 世界,物理内存会被划分成若干个 4KB 大小的内存页(page),这是分配内存大小的最小粒度。分配和回收都是基于 page 完成的。

page 内产生的碎片称为 内部碎片,page 外产生的碎片称为 外部碎片。

内存碎片产生的原因:

1、内存被分割成很小的块,虽然这些块是空闲且地址连续的,但却小到无法使用。

2、随着内存的分配和释放次数的增加,内存将变得越来越不连续。

3、最后,整个内存将只剩下碎片,即便有足够的空闲页框可以满足请求,但要分配一个大块的连续页框就无法满足,

外部碎片产生的原因:

1、外部碎片指的是还没有被分配出去(不属于任何进程),但由于太小了无法分配的内存空闲区域

2、外部碎片是处于任何已分配区域或页面外部的空闲存储块

3、这些存储块的总和可以满足当前申请的长度要求,但是由于它们的地址不连续或其他原因,使得系统无法满足当前申请。

因此减少内存浪费的核心就是尽量避免产生内存碎片。

伙伴算法

如何避免外部碎片的方法有两种:

(1)是利用分页单元把一组非连续的空闲页框映射到连续的线性地址区间;

(2)伙伴系统:是记录现存空闲连续页框块情况,以尽量避免为了满足对小块的请求而分割大的连续空闲块。

伙伴内存分配技术是一种内存分配算法,它将内存划分为分区,以最合适的大小满足内存请求。

Buddy memory allocation 于 1963 年 Harry Markowitz 发明。

伙伴算法的原理

伙伴(buddy)算法按照不同的规格,以块为单位进行分配。各个内存块可分可合,但不是任意的分与合。

每一个块都有个朋友,或叫“伙伴”,既可与之分开,又可与之结合。

所以,只有伙伴关系的内存块,才能分开和合并。

系统中的空闲内存总是按照相邻关系,两两分组,每组中的两个内存块称作伙伴。
伙伴的分配可以是彼此独立的。

但如果两个小伙伴都是空闲的,内核将其合并为一个更大的内存块,作为下一层次上某个内存块的伙伴。

具体先看下一个例子:

首先,伙伴算法把所有的空闲页面分为10个块组,

每组中块的大小是2的幂次方个页面,例如

  • 第0组中块的大小都为1个页面,
  • 第1组中块的大小为都为2个页面,
  • 第9组中块的大小都为512个页面。

也就是说,每一组中块的大小是相同的,且这同样大小的块形成一个链表(可以解释为hashmap中hash值相同的key的桶 bucket)。

剩下的未分配的内存,我们将其添加到第10个块组中。

并且,除了第0个块组有2个块以外,其余都只有一个块,但是第10个块组可以有多个块。

什么的伙伴块?如何获取?

伙伴块指的是连续的两个块,这两个块大小相等,并且两个块合并后的块可以一直迭代合并为1024页的大块。

这一点可能不太好理解,画了个图:

假设我们寻找1号块的伙伴块,如果是2号块的话,当1,2号块合并后,是无法继续与0号块 合并的,此时0号块就变成了不可合并状态,所以1号块的伙伴块应该是0号而不是2号。

寻找到伙伴块的方法是这样的:

当块大小为n时,寻找到的伙伴块必须满足,合并后的大块的左边(低内存)区域的大小应该是合并大块的k倍,即2nk的大小(k为非负整数)。

怎么进行快分配?

当需要分配一个内存大小为n时,需要分配一个内存块,块的大小为m,且m满足: m/2 < n and m>=n.

通过这个限制条件,我们可以获得要分配的块的大小,并且到对应的块组寻找有没有空闲块,如果有的话就把这个块分配出来,如果没有的话就把继续寻找到上一级块组,如果上一级有的话,就将这个空闲块拆分为两个并且分配一个块出来,另一个块归入下一级块组中。

如果上一级也没有空闲块的话,就继续向上一级寻找,递归寻找到合适的块。

怎么进行块释放?

类似于块分配的逆向操作,回收一个块时会首先检测其伙伴块是否空闲,如果空闲的话,回收块会与伙伴块合并为更大的块,并且在上一级块组中寻找伙伴块合并,递归进行此操作,直到无法再次合并为止。

伙伴算法的一个简单例子

假设,一个最初由256KB的物理内存。假设申请21KB的内存,内核需分片过程如下:

内核将256KB的内存进行分割,变成两个128KB的内存块,AL和AR,这两个内存块称为伙伴。

随后他发现128KB也远大于21KB,于是他继续分割为两个64KB的内存块,发现64KB 也不是满足需求的最小的内存块,于是他继续分割为两个32KB的。

32KB再往下就是16KB,就不满足需求了,所以32KB是它满足需求的最下的内存块了,所以他就分割出来的CL 或者CR 分配给需求方。

当需求方用完了,需要进行归还:

然后他把32KB的内存还回来,它的另一个伙伴如果没被占用,那么他们地址连续,就合并成一个64KB的内存块,以此类推,进行合并。

注意:

这里的所有的分割都是进行二分来分割,所有内存块的大小都是2的幂次方。

伙伴算法的秘诀:

把内存块存放在比链接表更先进的数据结构中。这些结构常常是桶型、树型和堆型的组合或变种。一般来说,由于所选数据结构的不同,而各伙伴分配程序的工作方式是相差很大。

由于有各种各样的具有已知特性的数据结构可供使用,所以伙伴分配程序得到广泛应用。

伙伴分配程序编写起来常常很复杂,其性能可能各不相同。

linux内核中伙伴算法

Linux内核内存管理的一项重要工作就是如何在频繁申请释放内存的情况下,避免碎片的产生。

Linux采用伙伴系统解决外部碎片的问题,采用slab解决内部碎片的问题。

Linux2.6为每一个管理区使用不一样的伙伴系统,内核空间分为三种区,DMA,NORMAL,HIGHMEM,对于每一种区,都有对应的伙伴算法。

linux内核中,伙伴算法把所有的空闲页框分组成 11 个块链表,每一个块链表分别包含大小为1、2、4、8、16、32、64、128、256、512 和 1024 个连续的页框。

最大内存请求大小为 4MB,该内存是连续的。伙伴算法即大小相同、地址连续。

11个块链表中:

  • 第0个块链表包含大小为2^0个连续的页框,
  • 第1个块链表中,每一个链表元素包含2个页框大小的连续地址空间
  • ….
  • 第10个块链表中,每一个链表元素表明4M的连续地址空间。

每一个链表中元素的个数在系统初始化时决定,在执行过程当中,动态变化。

#ifndef CONFIG_FORCE_MAX_ZONEORDER
#define MAX_ORDER 11
#else
#define MAX_ORDER CONFIG_FORCE_MAX_ZONEORDER
#endif
#define MAX_ORDER_NR_PAGES (1 << (MAX_ORDER - 1))

  struct free_area {
    
    
    struct list_head    free_list[MIGRATE_TYPES];//空闲块双向链表
    unsigned long       nr_free;//空闲块的数目
  };
  
  struct zone{
    
    
       ....
       struct free_area    free_area[MAX_ORDER];  
       ....
  };

zone从上到下的每一个元素的类型为free_area,free_area内部都是都保存一个free_list链表,

struct free_area free_area[MAX_ORDER] #MAX_ORDER 默认值为11,分别存放着11个组

free_area数组中第K个free_area元素,它标识所有大小为2^k的空闲块,所有空闲快由free_list指向的双向循环链表组织起来。

伙伴算法每次只能分配2的幂次个页框的空间,每页大小通常为4K

例如:一次分配1页,2页,4页,8页,…,1024页(2^10)等等,所以,伙伴算法最多一次可以分配4M(1024*4K)的内存空间

MAX_ORDER默认值为11,分别存放着11个组,free_area结构体里面又标注了该组别空闲内存块的情况

zone的成员:nr_free和zone_mem_map数组

伙伴位图mem_map

伙伴关系: 两个内存块,大小相同,地址连续,同属于一个大块区域。(第0块和第1块是伙伴,第2块和第3块是伙伴,但第1块和第2块不是伙伴)

伙伴位码:用一位描述伙伴块的状态位码,称之为伙伴位码。

比如,bit0为第0块和第1块的伙伴位码,若是bit0为1,表示这两块至少有一块已经分配出去,若是bit0为0,说明两块都空闲或者两块都被使用。

如果bit0为1,表示这两块至少有一块已经分配出去,如果bit0为0,说明两块都空闲,还没分配。

整个过程当中,位图扮演了重要的角色

Linux内核伙伴算法中每一个order 的位图都表示全部的空闲块,位图的某位对应于两个伙伴块,

为1就表示其中一块忙,为0表示两块都闲或都在使用。

系统每次分配和回收伙伴块时都要对它们的伙伴位跟1进行异或运算。

所谓异或是指刚开始时,两个伙伴块都空闲,它们的伙伴位为0:

  • 若是其中一块被使用,异或后得1;
  • 若是另外一块也被使用,异或后得0;
  • 若是前面一块回收了异或后得1;
  • 若是另外一块也回收了异或后得0。

如图所示,位图的某一位对应两个互为伙伴的块,为1表示其中一块已经分配出去了,为0表示两块都空闲或都已被使用。

伙伴中不管是分配/还是释放,都只是相对的位图进行异或操做。

分配内存时对位图的是为释放过程服务,释放过程根据位图判断伙伴是否存在:

  • 若是对相应位的异或操做,得1,说明之前为0,两块都是busy,没有伙伴能够合并,
  • 若是异或操做得0,说明之前是1,说明只有一块是busy,一块是 idle(/free),busy的是自己,就进行合并。并且,继续按这种方式合并伙伴,直到不能合并为止。

位图的主要用途是在回收算法中指示是否能够和伙伴块合并,分配时只要搜索空闲链表就足够了。但是,分配的同时还要对相应位异或一下,这是为回收算法服务。

伙伴算法问题:

伙伴算法管理的是原始内存,比如最原始的物理内存(Java中的堆外),或者说一大块连续的 堆内存。

申请时,伙伴算法会给程序分配一个较大的内存空间,即保证所有大块内存都能得到满足。

很明显分配比需求还大的内存空间,会产生内部碎片。

所以伙伴算法虽然能够完全避免外部碎片的产生,但这恰恰是以产生内部碎片为代价的。

缺点:

虽然伙伴算法有效减少了外部碎片,但最小粒度还是 page(4K),因此有可能造成非常严重的内部碎片,最严重带来 50% 的内存碎片。

slab算法

伙伴 算法 在小内存场景下并不适用,因为每次都会分配一个 page,导致非常严重的内部碎片。

而 Slab 算法 则是在 伙伴算法 的基础上对小内存分配场景做了专门的优化:

提供调整缓存机制 存储内核对象,当内核需要再次分配内存时,基本上可以通过缓存中获取。

Linux 底层采用 Slab 算法 进行小内存分配。

Linux采用伙伴系统解决外部碎片的问题,采用slab解决内部碎片的问题。

slab 分配器的基本原理:
按照预定固定的大小,将分配的内存分割成特定长度的块,以完全解决内存碎片问题。

具体来说:

slab 分配器将分配的内存分割成各种尺寸的块,并把相同尺寸的块分成组。

另外分配到的内存用完之后,不会释放,而是返回到对应的组,重复利用。

jemalloc算法实现

jemalloc 是基于 buddy+Slab 而来,比buddy+ Slab 更加复杂。

Slab 提升小内存分配场景下的速度和效率,jemalloc 通过 Arena 和 Thread Cache 在多线程场景下也有出色的内存分配效率。

Arena 是分而治之思想的体现,与其让一个人管理全部内存,到不如将任务派发给多个人,每个人独立管理,互不干涉(线程竞争)。

Thread Cache 是 tcmalloc 的核心思想,jemalloc 也把它借鉴过来。

通过Thread Cache机制, 每个线程有自己的内存管理器,分配在这个线程内完成,就不需要和其他线程竞争。

以上内容太过复杂,如果看不懂,请参见 《Go学习圣经-技术自由圈版》 配套视频

注意,Netty的内存池,就是参考了jemalloc算法。具体请参见尼恩的博客:

Netty内存池 (5w长文+史上最全)

TCMalloc线程缓冲内存分配器

TCMalloc简介

为啥要介绍 TCMalloc

因为golang的内存分配算法绝大部分都是来自TCMalloc,golang只改动了其中的一小部分。

所以要理解golang内存分配算法,就要先了解下TCMalloc,为后面分析golang内存做一做功课。

tcmalloc 是google开发的内存分配算法库,最开始它是作为google的一个性能工具库 perftools 的一部分。

TCMalloc是用来替代传统的malloc内存分配函数。

TCMalloc有减少内存碎片,适用于多核,更好的并行性支持等特性。TCMalloc 前面TC就是Thread Cache两英文的简写。

TCMalloc提供了很多优化,如:

  • TCMalloc用固定大小的page(页)来执行内存获取、分配等操作。这个特性跟Linux物理内存页的划分是不是有同样的道理。
  • TCMalloc用固定大小的对象,比如8KB,16KB 等用于特定大小对象的内存分配,这对于内存获取或释放等操作都带来了简化的作用。
  • TCMalloc还利用缓存常用对象来提高获取内存的速度。
  • TCMalloc还可以基于每个线程或者每个CPU来设置缓存大小,这是默认设置。
  • TCMalloc基于每个线程独立设置缓存分配策略,减少了多线程之间锁的竞争。

TCMalloc架构简图

来自:google tcmalloc design

  • Front-end:
    它是一个内存缓存,提供了快速分配和重分配内存给应用的功能。它主要有2部分组成:Per-thread cache 和 Per-CPU cache。
  • Middle-end:
    职责是给Front-end提供缓存。也就是说当Front-end缓存内存不够用时,从Middle-end申请内存。
    它主要是 Central free list 这部分内容。
  • Back-end:
    这一块是负责从操作系统获取内存,并给Middle-end提供缓存使用。它主要涉及 Page Heap 内容。

TCMalloc将整个虚拟内存空间划分为n个同等大小的Page。将n个连续的page连接在一起组成一个Span。

PageHeap向OS申请内存,申请的span可能只有一个page,也可能有n个page。

ThreadCache内存不够用会向CentralCache申请,CentralCache内存不够用时会向PageHeap申请,PageHeap不够用就会向OS操作系统申请。

TCMalloc中的概念

Page

操作系统对内存管理的单位,TCMalloc也是以页为单位管理内存,但是TCMalloc中Page大小是操作系统中页的倍数关系。2,4,8 …

Span

Span 是PageHeap中管理内存页的单位,它是由一组连续的Page组成,比如2个Page组成的span,多个这样的span就用链表来管理。

当然,还可以有4个Page组成的span等等。

ThreadCache

ThreadCache是每个线程各自独立拥有的cache,一个cache包含多个空闲内存链表(size classes),每一个链表(size-class)都有自己的object,每个object都是大小相同的。

CentralCache

CentralCache是当ThreadCache内存不足时,提供内存供其使用。它保持的是空闲块链表,链表数量和ThreadCache数量相同。ThreadCache中内存过多时,可以放回CentralCache中。

PageHeap

PageHeap保存的也是若干链表,不过链表保存的是Span(多个相同的page组成一个Span)。CentralCache内存不足时,可以从PageHeap获取Span,然后把Span切割成object。

小对象内存分配 ThreadCache

TCMalloc 定义了很多个size class,每个size class都维护了一个可分配的的空闲列表,空闲列表中的每一项称为一个object(如下图),同一个size-class的空闲列表中每个object大小相同。

在申请小内存时(小于256K),TCMalloc会根据申请内存大小映射到某个size-class中。

比如,

  • 申请0到8个字节的大小时,会被映射到size-class1中,分配8个字节大小;
  • 申请9到16字节大小时,会被映射到size-class2中,分配16个字节大小….

以此类推。

上面每一个object都是 N bytes。用于Thread Cache小内存分配。
这个就组成了每一个ThreadCache的free list,thread可以从各自的free list获取对象,不需要加锁,所以速度很快。

如果ThreadCache的free list为空呢?那就从CentralCache中的CentralFreeList中获取若干个object到ThreadCache对应的size class列表中,然后在取出其中一个object返回。
如果CentralFreeList中的object不够用了呢?那CentralFreeList就会向PageHeap申请一连串由Span组成页面,并将申请的页面切割成一系列的object之后,再将部分object转移给ThreadCache。
如果PageHeap也不够用了呢?那就向OS操作系统申请内存。
从上面论述可以看出,这也是一个多级缓存思想的应用。

当申请的内存大于256K时,不在通过ThreadCache分配,而是通过PageHeap直接分配大内存。

大对象内存分配 PageHeap

PageHeap负责向操作系统申请内存。

tcmalloc也是基于页的分配方式,即每次申请至少一页(page)的内存大小。

tcmalloc中一页大小为8KB(默认,可设置),多数linux中一页为4KB,tcmallo的一页是linux一页大小的2倍。

PageHeap申请内存时按照页申请,但它管理分配好的page内存时的基本单位是Span,Span对象代表了连续的页。如下图所示:

PageHeap中是如何组织Span,如下图

Middle end-Central Free List

CentralFreeList是CentralCahe中,它的作用就是从PageHeap中取出部分Span,然后按照预定大小将其拆分成固定大小的object,提供给ThreadCache使用。

以上内容太过复杂,如果看不懂,请参见 《Go学习圣经-技术自由圈版》 配套视频

Golang垃圾回收篇

从Go v1.12版本开始,Go使用了非分代的、并发的、基于三色标记清除的垃圾回收器

相关标记清除算法可以参考C/C++,而Go是一种静态类型的编译型语言。

因此,Go不需要VM,Go应用程序二进制文件中嵌入了一个小型运行时(Go runtime),可以处理诸如垃圾收集(GC)、调度和并发之类的语言功能。

首先让我们看一下Go内部的内存管理是什么样子的。

一、 Golang内存管理

这里先简单介绍一下 Golang 运行调度。

在 Golang 里面有三个基本的概念:G, M, P。

  • G: Goroutine 执行的上下文环境。
  • M: 操作系统线程。
  • P: Processer。进程调度的关键,调度器,也可以认为约等于CPU。

一个 Goroutine 的运行需要G+P+M三部分结合起来。

Go内存管理

图源:《Golang—内存管理(内存分配)》

(http://t.zoukankan.com/zpcoding-p-13259943.html)

(1)TCMalloc

Go将内存划分和分组为页(Page),这和Java的内存结构完全不同,

Go没有分代内存,这样的原因是Go的内存分配器采用了TCMalloc的设计思想:

1.Page

与TCMalloc中的Page相同,x64下1个Page的大小是8KB。

上图的最下方,1个浅蓝色的长方形代表1个Page。

2.Span

与TCMalloc中的Span相同,Span是内存管理的基本单位,代码中为mspan,一组连续的Page组成1个Span,所以上图一组连续的浅蓝色长方形代表的是一组Page组成的1个Span,另外,1个淡紫色长方形为1个Span。

3.mcache

mcache是提供给P(逻辑处理器)的高速缓存,用于存储小对象(对象大小<= 32Kb)。

尽管这类似于线程堆栈,但它是堆的一部分,用于动态数据。

所有类大小的mcache包含scan和noscan类型mspan。Goroutine可以从mcache没有任何锁的情况下获取内存,因为一次P只能有一个锁G。

因此,这更有效。mcache从mcentral需要时请求新的span。

4.mcentral

mcentral与TCMalloc中的CentralCache类似,

是所有线程共享的缓存,需要加锁访问,它按Span class对Span分类,串联成链表,当mcache的某个级别Span的内存被分配光时,它会向mcentral申请1个当前级别的Span。

每个mcentral包含两个mspanList:

  • empty:双向span链表,包括没有空闲对象的span或缓存mcache中的span。当此处的span被释放时,它将被移至non-empty span链表。
  • non-empty:有空闲对象的span双向链表。当从mcentral请求新的span,mcentral将从该链表中获取span并将其移入empty span链表。

5.mheap

mheap与TCMalloc中的PageHeap类似,它是堆内存的抽象,也是垃圾回收的重点区域,把从OS申请出的内存页组织成Span,并保存起来。

当mcentral的Span不够用时会向mheap申请,mheap的Span不够用时会向OS申请,向OS的内存申请是按页来的,然后把申请来的内存页生成Span组织起来,同样也是需要加锁访问的。

6.栈

这是栈存储区,每个Goroutine(G)有一个栈。

在这里存储了静态数据,包括函数栈帧,静态结构,原生类型值和指向动态结构的指针。

这与分配给每个P的mcache不是一回事。

(2)内存分配

Go 中的内存分类并不像TCMalloc那样分成小、中、大对象,但是它的小对象里又细分了一个Tiny对象,Tiny对象指大小在1Byte到16Byte之间并且不包含指针的对象。

小对象和大对象只用大小划定,无其他区分。

核心思想:把内存分为多级管理,降低锁的粒度(只是去mcentral和mheap会申请锁), 以及多种对象大小类型,减少分配产生的内存碎片。

  • 微小对象(Tiny)(size<16B)

使用mcache的微小分配器分配小于16个字节的对象,并且在单个16字节块上可完成多个微小分配。

  • 小对象(尺寸16B〜32KB)

大小在16个字节和32k字节之间的对象被分配在G运行所在的P的mcache的对应的mspan size class上。

  • 大对象(大小>32KB)

大于32 KB的对象直接分配在mheap的相应大小类上(size class)。

  • 如果mheap为空或没有足够大的页面满足分配请求,则它将从操作系统中分配一组新的页(至少1MB)。
  • 如果对应的大小规格在mcache中没有可用的块,则向mcentral申请。
  • 如果mcentral中没有可用的块,则向mheap申请,并根据BestFit 算法找到最合适的mspan。如果申请到的mspan超出申请大小,将会根据需求进行切分,以返回用户所需的页数。剩余的页构成一个新的mspan放回mheap的空闲列表。
  • 如果mheap中没有可用span,则向操作系统申请一系列新的页(最小 1MB)。Go 会在操作系统分配超大的页(称作arena)。分配一大批页会减少和操作系统通信的成本。

(3)内存回收

go内存会分成堆区(Heap)和栈区(Stack)两个部分,程序在运行期间可以主动从堆区申请内存空间,这些内存由内存分配器分配并由垃圾收集器负责回收。

栈区的内存由编译器自动进行分配和释放,栈区中存储着函数的参数以及局部变量,它们会随着函数的创建而创建,函数的返回而销毁。如果只申请和分配内存,内存终将枯竭。

Go使用垃圾回收收集不再使用的span,把span释放交给mheap,mheap对span进行span的合并,把合并后的span加入scav树中,等待再分配内存时,由mheap进行内存再分配。

因此,Go堆是Go垃圾收集器管理的主要区域

二、 标记清除算法

当成功区分出 Go 垃圾收集器管理区域的存活对象和死亡对象后,Go 垃圾收集器接下来的任务就是执行GC,释放无用对象占用的内存空间,以便有足够的可用内存空间为新对象分配内存。

目前常见的垃圾回收算法在前文中的“垃圾收集算法”部分已有介绍,而Go使用的是标记清除算法,这是一种非常基础和常见的垃圾收集算法,于1960年被J.McCarthy等人提出。

当堆空间被耗尽的时,就会STW(也被称为stop the world),其执行过程可以分成标记和清除两个阶段。Go 垃圾收集器从根结点开始遍历,执行可达性分析算法,递归标记所有被引用的对象为存活状态;标记阶段结束后,垃圾收集器会依次遍历堆中的对象并清除其中的未被标记为存活的对象。

由于用户程序在垃圾收集的过程中也不能执行(STW)。在可达性分析算法中,Go 的GC Roots一般为全局变量和G Stack中的引用指针,和整堆的对象相比只是极少数,因此它带来的停顿是非常短暂且相对固定的,不随堆容量增长。

在从GC Roots往下遍历对象的过程,堆越大,存储对象越多,递归遍历越复杂,要标记更多对象而产生的停顿时间自然就更长。因此我们需要用到更复杂的机制来解决STW的问题。

三、三色可达性分析

为了解决标记清除算法带来的STW问题,Go和Java都会实现三色可达性分析标记算法的变种以缩短STW的时间。三色可达性分析标记算法按“是否被访问过”将程序中的对象分成白色、黑色和灰色:

  • 白色对象 — 对象尚未被垃圾收集器访问过,在可达性分析刚开始的阶段,所有的对象都是白色的,若在分析结束阶段,仍然是白色的对象,即代表不可达。
  • 黑色对象 — 表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经被扫描过,黑色的对象代表已经被扫描过而且是安全存活的,如果有其他对象只想黑色对象无需再扫描一遍,黑色对象不可能直接(不经过灰色对象)指向某个白色对象。
  • 灰色对象 — 表示对象已经被垃圾收集器访问过,但是这个对象上至少存在一个引用还没有被扫描过,因为存在指向白色对象的外部指针,垃圾收集器会扫描这些对象的子对象。

三色可达性分析算法大致的流程是(初始状态所有对象都是白色):

1.从GC Roots开始枚举,它们所有的直接引用变为灰色(移入灰色集合),GC Roots变为黑色。

2.从灰色集合中取出一个灰色对象进行分析:

  • 将这个对象所有的直接引用变为灰色,放入灰色集合中;
  • 将这个对象变为黑色。

3.重复步骤2,一直重复直到灰色集合为空。

4.分析完成,仍然是白色的对象就是GC Roots不可达的对象,可以作为垃圾被清理。

具体例子如下图所示,经过三色可达性分析,最后白色H为不可达的对象,是需要垃圾回收的对象。

三色标记清除算法本身是不可以并发或者增量执行的,它需要STW

而如果并发执行,用户程序可能在标记执行的过程中修改对象的指针。

这种情况一般会有2种:

1.一种是把原本应该垃圾回收的死亡对象错误的标记为存活。

虽然这不好,但是不会导致严重后果,只不过产生了一点逃过本次回收的浮动垃圾而已,下次清理就可以,比如上图所示的三色标记过程中,用户程序取消了从B对象到E对象的引用,但是因为B到E已经被标记完成不会继续执行步骤2,所以E对象最终会被错误的标记成黑色,不会被回收,这个D就是浮动垃圾,会在下次垃圾收集中清理。

2.一种是把原本存活的对象错误的标记为已死亡,导致“对象消失”,这在内存管理中是非常严重的错误。

比如上图所示的三色标记过程中,用户程序建立了从B对象到H对象的引用(例如B.next =H),接着执行D.next=nil,但是因为B到H中不存在灰色对象,因此在这之间不会继续执行三色并发标记中的步骤2,D到H之间的链接被断开,所以H对象最终会被标记成白色,会被垃圾收集器错误地回收。我们将这种错误称为悬挂指针,即指针没有指向特定类型的合法对象,影响了内存的安全性。

四、屏障技术

为了解决上述的“对象消失”的现象,Wilson于1994年在理论上证明了,当且仅当以下两个条件同时满足时,会产生“对象消失”的问题,即原本应该是黑色的对象被误标为白色

  • 赋值器插入了一条或多条从黑色对象到白色对象的新引用;
  • 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。

因此为了我们要解决并发扫描时的对象消失问题,保证垃圾收集算法的正确性,只需破坏这两个条件的任意一个即可,屏障技术就是在并发或者增量标记过程中保证三色不变性的重要技术。

注意,垃圾收集中的屏障技术和 操作系统的内存屏障技术,并不是一个维度的概念

内存屏障技术是一种屏障指令,它可以让CPU或者编译器在执行内存相关操作时遵循特定的约束,目前多数的现代处理器都会乱序执行指令以最大化性能,但是该技术能够保证内存操作的顺序性,在内存屏障前执行的操作一定会先于内存屏障后执行的操作。

垃圾收集中的屏障技术更像是一个钩子方法,它是在用户程序读取对象、创建新对象以及更新对象指针时执行的一段代码,根据操作类型的不同,我们可以将它们分成读屏障(Read barrier)和写屏障(Write barrier)两种,因为读屏障需要在读操作中加入代码片段,对用户程序的性能影响很大,所以编程语言往往都会采用写屏障保证三色不变性。

(1)插入写屏障

Dijkstra在1978年提出了插入写屏障,也被叫做增量更新

通过如下所示的写屏障,破坏上述第一个条件(赋值器插入了一条或多条从黑色对象到白色对象的新引用):

func DijkstraWritePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) 
     shade(ptr)  //先将新下游对象 ptr 标记为灰色
     *slot = ptr
}

//说明:
添加下游对象(当前下游对象slot, 新下游对象ptr) {
    
     
 //step 1
 标记灰色(新下游对象ptr) 
 
 //step 2
 当前下游对象slot = 新下游对象ptr 
}

//场景:
A.添加下游对象(nil, B) //A 之前没有下游, 新添加一个下游对象B, B被标记为灰色
A.添加下游对象(C, B) //A 将下游对象C 更换为B, B被标记为灰色

上述伪代码非常好理解,当黑色对象(slot)插入新的指向白色对象(ptr)的引用关系时,就尝试使用shade函数将这个新插入的引用(ptr)标记为灰色。

假设我们上图的例子并发可达性分析中使用插入写屏障:

  1. GC 将根对象Root2指向的B对象标记成黑色并将B对象指向的对象D标记成灰色;
  2. 用户程序修改指针,B.next=H这时触发写屏障将H对象标记成灰色;
  3. 用户程序修改指针D.next=null
  4. GC依次遍历程序中的H和D将它们分别标记成黑色。

由于栈上的对象在垃圾回收中被认为是根对象,并没有写屏障,那么导致黑色的栈可能指向白色的堆对象,例如上图1中Root2指向H,且删除了由D指向H的引用,由于没有写屏障,那么H将会被删除。为了保障内存安全,Dijkstra必须为栈上的对象增加写屏障或者在标记阶段完成重新对栈上的对象进行扫描,这两种方法各有各的缺点,前者会大幅度增加写入指针的额外开销,后者重新扫描栈对象时需要暂停程序,垃圾收集算法的设计者需要在这两者之前做出权衡。

(2)删除写屏障

Yuasa在1990年的论文Real-time garbage collection on general-purpose machines 中提出了删除写屏障,因为一旦该写屏障开始工作,它会保证开启写屏障时堆上所有对象的可达。

起始时STW扫描所有的goroutine栈,保证所有堆上在用的对象都处于灰色保护下,所以也被称作快照垃圾收集(Snapshot GC),这是破坏了“对象消失”的第二个条件(赋值器删除了全部从灰色对象到该白色对象的直接或间接引用)。

// 黑色赋值器 Yuasa 屏障
func YuasaWritePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) {
    
    
    shade(*slot) 先将*slot标记为灰色
    *slot = ptr
}

//说明:
添加下游对象(当前下游对象slot, 新下游对象ptr) {
    
    
  //step 1
  if (当前下游对象slot是灰色 || 当前下游对象slot是白色) {
    
    
          标记灰色(当前下游对象slot)     //slot为被删除对象, 标记为灰色
  }  
  //step 2
  当前下游对象slot = 新下游对象ptr
}

//场景
A.添加下游对象(B, nil)   //A对象,删除B对象的引用。B被A删除,被标记为灰(如果B之前为白)
A.添加下游对象(B, C)     //A对象,更换下游B变成C。B被A删除,被标记为灰(如果B之前为白)

上述代码会在老对象的引用被删除时,将白色的老对象涂成灰色,这样删除写屏障就可以保证弱三色不变性,老对象引用的下游对象一定可以被灰色对象引用。

但是这样也会导致一个问题,由于会将有存活可能的对象都标记成灰色,因此最后可能会导致应该回收的对象未被回收,这个对象只有在下一个循环才会被回收,比如下图的D对象。

由于原始快照的原因,起始也是执行STW,删除写屏障不适用于栈特别大的场景,栈越大,STW扫描时间越长。

(3)混合写屏障

在 Go 语言 v1.7版本之前,运行时会使用Dijkstra插入写屏障保证强三色不变性,但是运行时并没有在所有的垃圾收集根对象上开启插入写屏障。

因为应用程序可能包含成百上千的Goroutine,而垃圾收集的根对象一般包括全局变量和栈对象,如果运行时需要在几百个Goroutine的栈上都开启写屏障,会带来巨大的额外开销,所以 Go 团队在v1.8结合上述2种写屏障构成了混合写屏障,实现上选择了在标记阶段完成时暂停程序、将所有栈对象标记为灰色并重新扫描。

Go 语言在v1.8组合Dijkstra插入写屏障和Yuasa删除写屏障构成了如下所示的混合写屏障,该写屏障会将被覆盖的对象标记成灰色并在当前栈没有扫描时将新对象也标记成灰色:

writePointer(slot, ptr):
    shade(*slot)
    if current stack is gray:
        shade(ptr)
    *slot = ptr

为了移除栈的重扫描过程,除了引入混合写屏障之外,在垃圾收集的标记阶段,我们还需要将创建的所有新对象都标记成黑色,防止新分配的栈内存和堆内存中的对象被错误地回收,因为栈内存在标记阶段最终都会变为黑色,所以不再需要重新扫描栈空间。总结来说主要有这几点:

  • GC开始将栈上的对象全部扫描并标记为黑色;
  • GC期间,任何在栈上创建的新对象,均为黑色;
  • 被删除的堆对象标记为灰色;
  • 被添加的堆对象标记为灰色。

五、GC演进过程

v1.0 — 完全串行的标记和清除过程,需要暂停整个程序;

v1.1 — 在多核主机并行执行垃圾收集的标记和清除阶段;

v1.3 — 运行时基于只有指针类型的值包含指针的假设增加了对栈内存的精确扫描支持,实现了真正精确的垃圾收集;将unsafe.Pointer类型转换成整数类型的值认定为不合法的,可能会造成悬挂指针等严重问题;

v1.5 — 实现了基于三色标记清扫的并发垃圾收集器:

  • 大幅度降低垃圾收集的延迟从几百 ms 降低至 10ms 以下;
  • 计算垃圾收集启动的合适时间并通过并发加速垃圾收集的过程;

v1.6 — 实现了去中心化的垃圾收集协调器:

  • 基于显式的状态机使得任意Goroutine都能触发垃圾收集的状态迁移;
  • 使用密集的位图替代空闲链表表示的堆内存,降低清除阶段的CPU占用;

v1.7 — 通过并行栈收缩将垃圾收集的时间缩短至2ms以内;

v1.8 — 使用混合写屏障将垃圾收集的时间缩短至0.5ms以内;

v1.9 — 彻底移除暂停程序的重新扫描栈的过程;

v1.10 — 更新了垃圾收集调频器(Pacer)的实现,分离软硬堆大小的目标;

v1.12 — 使用新的标记终止算法简化垃圾收集器的几个阶段;

v1.13 — 通过新的 Scavenger 解决瞬时内存占用过高的应用程序向操作系统归还内存的问题;

v1.14 — 使用全新的页分配器优化内存分配的速度

v1.15 — 改进编译器和运行时内部的CL 226367,它使编译器可以将更多的x86寄存器用于垃圾收集器的写屏障调用;

v1.16 — Go runtime默认使用MADV_DONTNEED更积极的将不用的内存释放给OS。

Go V1.3 之前的标记 - 清除 (mark and sweep) 算法

来看一下在 Golang1.3 之前的时候主要用的普通的标记 - 清除算法,此算法主要有两个主要的步骤:

  • 标记 (Mark phase)
  • 清除 (Sweep phase)
(1)标记清除算法的具体步骤

第一步,暂停程序业务逻辑,分类出可达和不可达的对象,然后做上标记。

(1)图中表示是程序与对象的可达关系,目前程序的可达对象有对象 1-2-3,对象 4-7 等五个对象

(1)图中表示是程序与对象的可达关系,目前程序的可达对象有对象 1-2-3,对象 4-7 等五个对象

第二步 , 开始标记,程序找出它所有可达的对象,并做上标记。如下图所示:

(2)对象 1-2-3、对象 4-7 等五个对象可达,被做上标记。

(2)对象 1-2-3、对象 4-7 等五个对象可达,被做上标记。

第三步 , 标记完了之后,然后开始清除未标记的对象。结果如下。

(3)对象5、6不可达,被GC所清除

(3)对象5、6不可达,被GC所清除

操作非常简单,但是有一点需要额外注意:

mark and sweep 算法在执行的时候,需要程序暂停!即 STW(stop the world),STW 的过程中,CPU 不执行用户代码,全部用于垃圾回收,这个过程的影响很大,所以 STW 也是一些回收机制最大的难题和希望优化的点。所以在执行第三步的这段时间,程序会暂定停止任何工作,卡在那等待回收执行完毕。

第四步 , 停止暂停,让程序继续跑。然后循环重复这个过程,直到 process 程序生命周期结束。

以上便是标记 - 清除(mark and sweep)回收的算法。

(2)标记 - 清除 (mark and sweep) 的缺点

标记清除算法明了,过程鲜明干脆,但是也有非常严重的问题。

  • STW,stop the world;让程序暂停,程序出现卡顿(重要问题);
  • 标记需要扫描整个 heap;
  • 清除数据会产生 heap 碎片。

Go V1.3 版本之前就是以上来实施的,在执行 GC 的基本流程就是首先启动 STW 暂停,然后执行标记,再执行数据回收,最后停止 STW,如图所示。

从上图来看,全部的 GC 时间都是包裹在 STW 范围之内的,这样貌似程序暂停的时间过长,影响程序的运行性能。所以 Go V1.3 做了简单的优化,将 STW 的步骤提前,减少 STW 暂停的时间范围。如下所示

上图主要是将 STW 的步骤提前了异步,因为在 Sweep 清除的时候,可以不需要 STW 停止,因为这些对象已经是不可达对象了,不会出现回收写冲突等问题。

但是无论怎么优化,Go V1.3 都面临这个一个重要问题,就是 mark-and-sweep 算法会暂停整个程序

Go 是如何面对并这个问题的呢? G V1.5 版本 就用三色并发标记法来优化这个问题.

Go V1.5 的三色并发标记法

Golang 中的垃圾回收主要应用三色标记法,GC 过程和其他用户 goroutine 可并发运行,但需要一定时间的 STW(stop the world),所谓三色标记法实际上就是通过三个阶段的标记来确定清楚的对象都有哪些?

我们来看一下具体的过程。

第一步 , 每次新创建的对象,默认的颜色都是标记为 “白色”,如图所示。

(1)程序起初创建,全部标记为白色,将所有对象放入白色集合中

(1)程序起初创建,全部标记为白色,将所有对象放入白色集合中

上图所示,应用程序Root Set可抵达的内存对象关系如左图所示,右边的标记表,是用来记录目前每个对象的标记颜色分类。

(2)将程序的根节点集合展开的形式

(2)将程序的根节点集合展开的形式

第二步 , 每次 GC 回收开始,会从根节点开始遍历所有对象,把遍历到的对象从白色集合放入 “灰色” 集合如图所示。

遍历Root Set(非递归形式,只遍历一次)得到灰色节点

这里 要注意的是,本次遍历是一次遍历,非递归形式,是从程序抽次可抵达的对象遍历一层,如上图所示,

当前可抵达的对象是对象 1 和对象 4,那么自然本轮遍历结束,对象 1 和对象 4 就会被标记为灰色,灰色标记表就会多出这两个对象。

第三步 , 遍历灰色集合,将灰色对象引用的对象从白色集合放入灰色集合,之后将此灰色对象放入黑色集合,如图所示。

遍历 Gray灰色标记表,将可达的对象,从白色标记为 灰色,遍历之后的灰色,标记为 黑色

遍历 Gray灰色标记表,将可达的对象,从白色标记为 灰色,遍历之后的灰色,标记为 黑色

这一次遍历是只扫描灰色对象,将灰色对象的第一层遍历可抵达的对象由白色变为灰色,如:对象 2、对象 7. 而之前的灰色对象 1 和对象 4 则会被标记为黑色,同时由灰色标记表移动到黑色标记表中。

第四步 , 重复第三步 , 直到灰色中无任何对象,如图所示。

重复上一步,直到 灰色标记表 中无任何对象

重复上一步,直到 灰色标记表 中无任何对象

重复上一步,直到 灰色标记表 中无任何对象

重复上一步,直到 灰色标记表 中无任何对象

当我们全部的可达对象都遍历完后,灰色标记表将不再存在灰色对象,目前全部内存的数据只有两种颜色,黑色和白色。那么黑色对象就是我们程序逻辑可达(需要的)对象,这些数据是目前支撑程序正常业务运行的,是合法的有用数据,不可删除,白色的对象是全部不可达对象,目前程序逻辑并不依赖他们,那么白色对象就是内存中目前的垃圾数据,需要被清除。

第五步: 回收所有的白色标记表的对象。也就是回收垃圾,如图所示。

以上我们将全部的白色对象进行删除回收

收集所有白色对象(垃圾)

收集所有白色对象(垃圾)

剩下的就是全部依赖的黑色对象。

以上便是三色并发标记法,不难看出,我们上面已经清楚的体现三色的特性。

但是这里面可能会有很多并发流程均会被扫描,执行并发流程的内存可能相互依赖,为了在 GC 过程中保证数据的安全,我们在开始三色标记之前就会加上 STW,在扫描确定黑白对象之后再放开 STW。

但是很明显这样的 GC 扫描的性能实在是太低了。

那么 Go 是如何解决标记 - 清除 (mark and sweep) 算法中的卡顿 (stw,stop the world) 问题的呢?

没有 STW 的三色标记法

先抛砖引玉,我们加入如果没有 STW,那么也就不会再存在性能上的问题,那么接下来我们假设如果三色标记法不加入 STW 会发生什么事情?
我们还是基于上述的三色并发标记法来说,他是一定要依赖 STW 的。因为如果不暂停程序,程序的逻辑改变对象引用关系,这种动作如果在标记阶段做了修改,会影响标记结果的正确性,我们来看看一个场景,如果三色标记法,标记过程不使用 STW 将会发生什么事情?

我们把初始状态设置为已经经历了第一轮扫描,目前黑色的有对象 1 和对象 4, 灰色的有对象 2 和对象 7,其他的为白色对象,且对象 2 是通过指针 p 指向对象 3 的,如图所示。

(1)已经标记为灰色的对象2,有指针p指向白色的对象3

(1)已经标记为灰色的对象2,有指针p指向白色的对象3

现在如何三色标记过程不启动 STW,那么在 GC 扫描过程中,任意的对象均可能发生读写操作,如图所示,在还没有扫描到对象 2 的时候,已经标记为黑色的对象 4,此时创建指针 q,并且指向白色的对象 3。

(2)在还没有扫描到对象2,已经标记为黑色的对象4,创建指针q,指向对象3

(2)在还没有扫描到对象2,已经标记为黑色的对象4,创建指针q,指向对象3

与此同时灰色的对象 2 将指针 p 移除,那么白色的对象 3 实则就是被挂在了已经扫描完成的黑色的对象 4 下,如图所示。

(3)与此同时对象2将指针p移除,对象3就被挂在了已经扫描完成的黑色的对象4下

(3)与此同时对象2将指针p移除,对象3就被挂在了已经扫描完成的黑色的对象4下

然后我们正常指向三色标记的算法逻辑,将所有灰色的对象标记为黑色,那么对象 2 和对象 7 就被标记成了黑色,如图所示。

(4)正常执行算法逻辑,对象2、3,标记为黑色,而对象3,因为对象4已经不会再扫描,而等待被回收清除

(4)正常执行算法逻辑,对象2、3,标记为黑色,而对象3,因为对象4已经不会再扫描,而等待被回收清除

那么就执行了三色标记的最后一步,将所有白色对象当做垃圾进行回收,如图所示。

(5)对象3,一个被正常引用的对象,被无辜的清除掉了

(5)对象3,一个被正常引用的对象,被无辜的清除掉了

但是最后我们才发现,本来是对象 4 合法引用的对象 3,却被 GC 给 “误杀” 回收掉了。

可以看出,有两种情况,在三色标记法中,是不希望被发生的。

  • 条件 1:一个白色对象被黑色对象引用 (白色被挂在黑色下)
  • 条件 2:灰色对象与它之间的可达关系的白色对象遭到破坏 (灰色同时丢了该白色)
    如果当以上两个条件同时满足时,就会出现对象丢失现象!

并且,如图所示的场景中,如果示例中的白色对象 3 还有很多下游对象的话,也会一并都清理掉。

为了防止这种现象的发生,最简单的方式就是 STW,直接禁止掉其他用户程序对对象引用关系的干扰,但是 STW 的过程有明显的资源浪费,对所有的用户程序都有很大影响。那么是否可以在保证对象不丢失的情况下合理的尽可能的提高 GC 效率,减少 STW 时间呢?答案是可以的,我们只要使用一种机制,尝试去破坏上面的两个必要条件就可以了。

屏障机制

我们让 GC 回收器,满足下面两种情况之一时,即可保对象不丢失。

这两种方式就是 “强三色不变式” 和 “弱三色不变式”。

(1) “强 - 弱” 三色不变式
  • 强三色不变式

不存在黑色对象引用到白色对象的指针。

强制性的不允许黑色对象引用白色对象

强制性的不允许黑色对象引用白色对象

强三色不变色实际上是强制性的不允许黑色对象引用白色对象,这样就不会出现有白色对象被误删的情况。

  • 弱三色不变式

所有被黑色对象引用的白色对象都处于灰色保护状态。

黑色对象可以引用白色对象,白色对象存在其他灰色对象对它的引用,或者可达它的链路上游存在灰色对象

黑色对象可以引用白色对象,白色对象存在其他灰色对象对它的引用,或者可达它的链路上游存在灰色对象

弱三色不变式强调,黑色对象可以引用白色对象,但是这个白色对象必须存在其他灰色对象对它的引用,或者可达它的链路上游存在灰色对象。

这样实则是黑色对象引用白色对象,白色对象处于一个危险被删除的状态,但是上游灰色对象的引用,可以保护该白色对象,使其安全。

为了遵循上述的两个方式,GC 算法演进到两种屏障方式,他们 “插入屏障”, “删除屏障”。

(2) 插入屏障

具体操作: 在 A 对象引用 B 对象的时候,B 对象被标记为灰色。(将 B 挂在 A 下游,B 必须被标记为灰色)

满足: 强三色不变式. (不存在黑色对象引用白色对象的情况了, 因为白色会强制变成灰色)

伪码如下:

func DijkstraWritePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) 
     shade(ptr)  //先将新下游对象 ptr 标记为灰色
     *slot = ptr
}

//说明:
添加下游对象(当前下游对象slot, 新下游对象ptr) {
    
     
 //step 1
 标记灰色(新下游对象ptr) 
 
 //step 2
 当前下游对象slot = 新下游对象ptr 
}

//场景:
A.添加下游对象(nil, B) //A 之前没有下游, 新添加一个下游对象B, B被标记为灰色
A.添加下游对象(C, B) //A 将下游对象C 更换为B, B被标记为灰色

场景:

  • 添加下游对象 (nil, B) //A 之前没有下游, 新添加一个下游对象 B, B 被标记为灰色
  • 添加下游对象 (C, B) //A 将下游对象 C 更换为 B, B 被标记为灰色

这段伪码逻辑就是写屏障,

我们知道,黑色对象的内存槽有两种位置,.

栈空间的特点是容量小,但是要求相应速度快,因为函数调用弹出频繁使用,所以 “插入屏障” 机制,在栈空间的对象操作中不使用.

“插入屏障” 仅仅使用在堆空间对象的操作中.

接下来,我们用几张图,来模拟整个一个详细的过程, 希望您能够更可观的看清晰整体流程。

(1)程序起初创建,全部标记为白色,将所有对象放入白色集合中

(1)程序起初创建,全部标记为白色,将所有对象放入白色集合中


(2)遍历Root Set(非递归形式,只遍历一次)得到灰色节点

(2)遍历Root Set(非递归形式,只遍历一次)得到灰色节点


(3)遍历Gray灰色标记表,将可达的对象,从白色标记为灰色,遍历之后的灰色,标记为黑色

(3)遍历Gray灰色标记表,将可达的对象,从白色标记为灰色,遍历之后的灰色,标记为黑色


(4)由于并发特性,此刻外界向对象4添加对象8、对象1添加对象9,对象4在堆内,即将触发插入屏障机制,对象1不触发

(4)由于并发特性,此刻外界向对象4添加对象8、对象1添加对象9,对象4在堆内,即将触发插入屏障机制,对象1不触发


(5)由于插入写屏障(黑色对象添加白色,将白色改为灰色),对象9变成灰色,对象9依然为白色

(5)由于插入写屏障(黑色对象添加白色,将白色改为灰色),对象9变成灰色,对象9依然为白色


(6)继续循环上述流程进行三色标记,直到没有灰色节点

(6)继续循环上述流程进行三色标记,直到没有灰色节点


但是如果栈不添加,当全部三色标记扫描之后,栈上有可能依然存在白色对象被引用的情况 (如上图的对象 9). 所以要对栈重新进行三色标记扫描,但这次为了对象不丢失,要对本次标记扫描启动 STW 暂停。

直到栈空间的三色标记结束.

(7)在准备回收白色前,重新遍历扫描一次栈空间。此时加STW暂停保护栈,防止外界干扰(有新的白色被黑色添加)

(7)在准备回收白色前,重新遍历扫描一次栈空间。此时加STW暂停保护栈,防止外界干扰(有新的白色被黑色添加)


(8)在STW中,将栈中的对象一次三色标记,直到没有灰色节点

(8)在STW中,将栈中的对象一次三色标记,直到没有灰色节点


(9)停止STW

(9)停止STW


最后将栈和堆空间 扫描剩余的全部 白色节点清除。这次 STW 大约的时间在 10~100ms 间.

(10)清除白色

(10)清除白色


(3) 删除屏障

具体操作: 被删除的对象,如果自身为灰色或者白色,那么被标记为灰色。

满足: 弱三色不变式. (保护灰色对象到白色对象的路径不会断)

// 黑色赋值器 Yuasa 屏障
func YuasaWritePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) {
    
    
    shade(*slot) 先将*slot标记为灰色
    *slot = ptr
}

//说明:
添加下游对象(当前下游对象slot, 新下游对象ptr) {
    
    
  //step 1
  if (当前下游对象slot是灰色 || 当前下游对象slot是白色) {
    
    
          标记灰色(当前下游对象slot)     //slot为被删除对象, 标记为灰色
  }  
  //step 2
  当前下游对象slot = 新下游对象ptr
}

//场景
A.添加下游对象(B, nil)   //A对象,删除B对象的引用。B被A删除,被标记为灰(如果B之前为白)
A.添加下游对象(B, C)     //A对象,更换下游B变成C。B被A删除,被标记为灰(如果B之前为白)

场景:

  • 添加下游对象 (B, nil) //A 对象,删除 B 对象的引用。 B 被 A 删除,被标记为灰 (如果 B 之前为白)
  • 添加下游对象 (B, C) //A 对象,更换下游 B 变成 C。 B 被 A 删除,被标记为灰 (如果 B 之前为白)

接下来,我们用几张图,来模拟整个一个详细的过程, 希望您能够更可观的看清晰整体流程。

(1)程序起初创建,全部标记为白色,将所有对象放入白色集合中

(1)程序起初创建,全部标记为白色,将所有对象放入白色集合中


(2)遍历Root Set(非递归形式,只遍历一次)得到灰色节点

(2)遍历Root Set(非递归形式,只遍历一次)得到灰色节点


(3)灰色对象1删除对象5,如果不触发删除写屏障,5-2-3路径与主链路断开,最后均会被清除

(3)灰色对象1删除对象5,如果不触发删除写屏障,5-2-3路径与主链路断开,最后均会被清除


(4)触发删除写屏障,被删除的对象5,自身被标记为灰色

(4)触发删除写屏障,被删除的对象5,自身被标记为灰色


(5)遍历Gray灰色标记表,将可达的对象,从白色标记为灰色,遍历之后的灰色,标记为黑色

(5)遍历Gray灰色标记表,将可达的对象,从白色标记为灰色,遍历之后的灰色,标记为黑色


(6)继续循环上述流程进行三色标记,直到没有灰色节点

(6)继续循环上述流程进行三色标记,直到没有灰色节点


(7)清除白色

(7)清除白色


这种方式的回收精度低,一个对象即使被删除了最后一个指向它的指针也依旧可以活过这一轮,在下一轮 GC 中被清理掉。

Go V1.8 的混合写屏障 (hybrid write barrier) 机制

插入写屏障和删除写屏障的短板:

  • 插入写屏障:结束时需要 STW 来重新扫描栈,标记栈上引用的白色对象的存活;
  • 删除写屏障:回收精度低,GC 开始时 STW 扫描堆栈来记录初始快照,这个过程会保护开始时刻的所有存活对象。

Go V1.8 版本引入了混合写屏障机制(hybrid write barrier),避免了对栈 re-scan 的过程,极大的减少了 STW 的时间。结合了两者的优点。

(1) 混合写屏障规则

具体操作:

1、GC 开始将栈上的对象全部扫描并标记为黑色 (之后不再进行第二次重复扫描,无需 STW),

2、GC 期间,任何在栈上创建的新对象,均为黑色。

3、被删除的对象标记为灰色。

4、被添加的对象标记为灰色。

满足: 变形的弱三色不变式.

伪代码:

添加下游对象 (当前下游对象 slot, 新下游对象 ptr) {
    
    
//1
标记灰色 (当前下游对象 slot) // 只要当前下游对象被移走,就标记灰色

  //2 
  标记灰色(新下游对象ptr)

  //3
  当前下游对象slot = 新下游对象ptr

}
writePointer(slot, ptr):
    shade(*slot)
    if current stack is gray:
        shade(ptr)
    *slot = ptr

这里我们注意, 屏障技术是不在栈上应用的,因为要保证栈的运行效率。

(2) 混合写屏障的具体场景分析

接下来,我们用几张图,来模拟整个一个详细的过程, 希望您能够更可观的看清晰整体流程。

注意混合写屏障是 Gc 的一种屏障机制,所以只是当程序执行 GC 的时候,才会触发这种机制。

GC 开始:扫描栈区,将可达对象全部标记为黑

(1)GC刚刚开始,默认都为白色

(1)GC刚刚开始,默认都为白色


(2)三色标记法,优先扫描全部栈对象,将可达对象均标记为黑

(2)三色标记法,优先扫描全部栈对象,将可达对象均标记为黑


场景一: 对象被一个堆对象删除引用,成为栈对象的下游

伪代码:

// 前提:堆对象 4-> 对象 7 = 对象 7; // 对象 7 被 对象 4 引用
栈对象 1-> 对象 7 = 堆对象 7// 将堆对象 7 挂在 栈对象 1 下游
堆对象 4-> 对象 7 = null; // 对象 4 删除引用 对象 7

(1)将对象7添加到对象1下游,因为栈不启动写屏障,所以直接挂在下面

(1)将对象7添加到对象1下游,因为栈不启动写屏障,所以直接挂在下面


(2)对象4删除对象7的引用关系,因为对象4是堆区,所以触发写屏障(删除即赋新值为null),标记被删除对象7为灰

(2)对象4删除对象7的引用关系,因为对象4是堆区,所以触发写屏障(删除即赋新值为null),标记被删除对象7为灰


场景二: 对象被一个栈对象删除引用,成为另一个栈对象的下游

伪代码:

new 栈对象 9;
对象 8-> 对象 3 = 对象 3// 将栈对象 3 挂在 栈对象 9 下游
对象 2-> 对象 3 = null; // 对象 2 删除引用 对象 3

(1)新创建一个对象9在栈上(混合写屏障模式中,GC过程中任何新创建的对象均标记为黑色)

(1)新创建一个对象9在栈上(混合写屏障模式中,GC过程中任何新创建的对象均标记为黑色)


(2)对象9添加下游引用栈对象3(直接添加,栈不启动屏障,无屏障效果)

(2)对象9添加下游引用栈对象3(直接添加,栈不启动屏障,无屏障效果)


(3)对象2删除对象3的引用关系(直接删除,栈不启动写屏障,无屏障效果)

(3)对象2删除对象3的引用关系(直接删除,栈不启动写屏障,无屏障效果)


场景三:对象被一个堆对象删除引用,成为另一个堆对象的下游

伪代码:

堆对象 10-> 对象 7 = 堆对象 7// 将堆对象 7 挂在 堆对象 10 下游
堆对象 4-> 对象 7 = null; // 对象 4 删除引用 对象 7

(1)堆对象10已经扫描标记为黑(黑色情况较特殊,其他颜色暂不考虑)

(1)堆对象10已经扫描标记为黑(黑色情况较特殊,其他颜色暂不考虑)


(2)堆对象10添加下游引用堆对象7,触发屏障机制,被添加的对象标记为灰色,对象7变成灰色(对象6被保护)

(2)堆对象10添加下游引用堆对象7,触发屏障机制,被添加的对象标记为灰色,对象7变成灰色(对象6被保护)


(3)堆对象4删除下游引用堆对象7,触发屏障机制,被删除的对象标记为灰色,对象7被标记灰色

(3)堆对象4删除下游引用堆对象7,触发屏障机制,被删除的对象标记为灰色,对象7被标记灰色


场景四:对象从一个栈对象删除引用,成为另一个堆对象的下游

伪代码:

堆对象 10-> 对象 7 = 堆对象 7// 将堆对象 7 挂在 堆对象 10 下游
堆对象 4-> 对象 7 = null; // 对象 4 删除引用 对象 7

(1)栈对象1删除对栈对象2的引用(栈空间不触发写屏障)

(1)栈对象1删除对栈对象2的引用(栈空间不触发写屏障)


(2)堆对象4将之前引用对象7的关系,转移至对象2(对象4删除对象7引用关系)

(2)堆对象4将之前引用对象7的关系,转移至对象2(对象4删除对象7引用关系)


(3)对象4在删除的时候,触发写屏障,标记被删除对象7为灰色,保护对象7及下游节点

(3)对象4在删除的时候,触发写屏障,标记被删除对象7为灰色,保护对象7及下游节点


Golang 中的混合写屏障满足弱三色不变式,结合了删除写屏障和插入写屏障的优点,只需要在开始时并发扫描各个 goroutine 的栈,使其变黑并一直保持,这个过程不需要 STW,而标记结束后,因为栈在扫描后始终是黑色的,也无需再进行 re-scan 操作了,减少了 STW 的时间。

GC演进过程总结

以上便是 Golang 的 GC 全部的标记 - 清除逻辑及场景演示全过程。

GoV1.3- 普通标记清除法,整体过程需要启动 STW,效率极低。

GoV1.5- 三色标记法, 堆空间启动写屏障,栈空间不启动,全部扫描之后,需要重新扫描一次栈 (需要 STW),效率普通

GoV1.8 - 三色标记法,混合写屏障机制, 栈空间不启动,堆空间启动。整个过程几乎不需要 STW,效率较高。

六、GC过程源码分析(翻译自Golang v1.16版本源码)

Golang GC 相关的代码在runtime/mgc.go文件下,可以看见GC总共分为4个阶段:

1.sweep termination(清理终止)

  • 暂停程序,触发STW。所有的P(处理器)都会进入safe-point(安全点);
  • 清理未被清理的 span 。如果当前垃圾收集是强制触发的,需要处理还未被清理的内存管理单元;

2.the mark phase(标记阶段)

  • GC状态gcphase从_GCoff改成_GCmark、开启写屏障、启用协助线程(mutator assists)、将根对象入队;
  • 恢复程序执行,标记进程(mark workers)和协助程序会开始并发标记内存中的对象,写屏障会覆盖的重写指针和新指针(标记成灰色),而所有新创建的对象都会被直接标记成黑色;
  • GC执行根节点的标记,这包括扫描所有的栈、全局对象以及不在堆中的运行时数据结构。扫描goroutine栈会导致goroutine停止,并对栈上找到的所有指针加置灰,然后继续执行goroutine;
  • GC遍历灰色对象队列,会将灰色对象变成黑色,并将该指针指向的对象置灰;
  • 由于GC工作分布在本地缓存中,GC会使用分布式终止算法(distributed termination algorithm)来检测何时不再有根标记作业或灰色对象,如果没有了GC会转为mark termination(标记终止)。

3. mark termination(标记终止)

  • STW;
  • 将GC状态gcphase切换至_GCmarktermination,关闭gc工作线程和协助程序;
  • 执行housekeeping,例如刷新mcaches。

4. the sweep phase(清理阶段)

  • 将GC状态gcphase切换至_GCoff来准备清理阶段,初始化清理阶段并关闭写屏障;
  • 恢复用户程序,从现在开始,所有新创建的对象会标记成白色;如果有必要,在使用前分配清理spans;
  • 后台并发清理所有的内存管理类单元。

GC过程代码示例

func gcfinished() *int {
    
    
  p := 1
  runtime.SetFinalizer(&p, func(_ *int) {
    
    
    println("gc finished")
  })
  return &p
}
func allocate() {
    
    
  _ = make([]byte, int((1<<20)*0.25))
}
func main() {
    
    
  f, _ := os.Create("trace.out")
  defer f.Close()
  trace.Start(f)
  defer trace.Stop()
  gcfinished()
  // 当完成 GC 时停止分配
  for n := 1; n < 50; n++ {
    
    
    println("#allocate: ", n)
    allocate()
  }
  println("terminate")
}

运行程序

hewittwang@HEWITTWANG-MB0 rtx % GODEBUG=gctrace=1 go run new1.go  
gc 1 @0.015s 0%: 0.015+0.36+0.043 ms clock, 0.18+0.55/0.64/0.13+0.52 ms cpu, 4->4->0 MB, 5 MB goal, 12 P
gc 2 @0.024s 1%: 0.045+0.19+0.018 ms clock, 0.54+0.37/0.31/0.041+0.22 ms cpu, 4->4->0 MB, 5 MB goal, 12 P
....

栈分析

gc 2      : 第一个GC周期
@0.024s   : 从程序开始运行到第一次GC时间为0.0241%        : 此次GC过程中CPU 占用率

wall clock
0.045+0.19+0.018 ms clock
0.045 ms  : STW,Marking Start, 开启写屏障
0.19 ms   : Marking阶段
0.018 ms  : STW,Marking终止,关闭写屏障

CPU time
0.54+0.37/0.31/0.041+0.22 ms cpu
0.54 ms   : STW,Marking Start
0.37 ms  : 辅助标记时间
0.31 ms  : 并发标记时间
0.041 ms   : GC 空闲时间
0.22 ms   : Mark 终止时间

4->4->0 MB, 5 MB goal
4 MB      :标记开始时,堆大小实际值
4 MB      :标记结束时,堆大小实际值
0 MB      :标记结束时,标记为存活对象大小
5 MB      :标记结束时,堆大小预测值

12 P      :本次GC过程中使用的goroutine 数量

七、GC触发条件

运行时会通过runtime.gcTrigger.test方法决定是否需要触发垃圾收集,当满足触发垃圾收集的基本条件(即满足_GCoff阶段的退出条件)时——允许垃圾收集、程序没有崩溃并且没有处于垃圾收集循环,该方法会根据三种不同方式触发进行不同的检查:

//mgc.go 文件 runtime.gcTrigger.test
func (t gcTrigger) test() bool {
    
    
    //测试是否满足触发垃圾手机的基本条件
    if !memstats.enablegc || panicking != 0 || gcphase != _GCoff {
    
    
        return false
    }
    switch t.kind {
    
    
        case gcTriggerHeap:    //堆内存的分配达到达控制器计算的触发堆大小
        // Non-atomic access to gcController.heapLive for performance. If
        // we are going to trigger on this, this thread just
        // atomically wrote gcController.heapLive anyway and we'll see our
        // own write.
        return gcController.heapLive >= gcController.trigger
        case gcTriggerTime:      //如果一定时间内没有触发,就会触发新的循环,该触发条件由 `runtime.forcegcperiod`变量控制,默认为 2 分钟;
        if gcController.gcPercent < 0 {
    
    
            return false
        }
        lastgc := int64(atomic.Load64(&memstats.last_gc_nanotime))
        return lastgc != 0 && t.now-lastgc > forcegcperiod
        case gcTriggerCycle:      //如果当前没有开启垃圾收集,则触发新的循环;
        // t.n > work.cycles, but accounting for wraparound.
        return int32(t.n-work.cycles) > 0
    }
    return true
}

用于开启垃圾回收的方法为runtime.gcStart,因此所有调用该函数的地方都是触发GC的代码:

  • runtime.mallocgc申请内存时根据堆大小触发GC
  • runtime.GC用户程序手动触发GC
  • runtime.forcegchelper后台运行定时检查触发GC

(1)申请内存触发runtime.mallocgc

Go运行时会将堆上的对象按大小分成微对象、小对象和大对象三类,这三类对象的创建都可能会触发新的GC。

1.当前线程的内存管理单元中不存在空闲空间时,创建微对象(noscan &&size<maxTinySize)和小对象需要调用 runtime.mcache.nextFree从中心缓存或者页堆中获取新的管理单元,这时如果span满了就会导致返回的shouldhelpgc=true,就可能触发垃圾收集;

2.当用户程序申请分配32KB以上的大对象时,一定会构建 runtime.gcTrigger结构体尝试触发垃圾收集。

func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
    
    
    省略代码 ...
    shouldhelpgc := false  
  dataSize := size
  c := getMCache()       //尝试获取mCache。如果没启动或者没有P,返回nil;
 
    省略代码 ...
    if size <= maxSmallSize {
    
      
       if noscan && size < maxTinySize {
    
     // 微对象分配
  省略代码 ...
          v := nextFreeFast(span)
          if v == 0 {
    
    
             v, span, shouldhelpgc = c.nextFree(tinySpanClass)
          }
      省略代码 ...
      } else {
    
          //小对象分配
         省略代码 ...
          if v == 0 {
    
    
             v, span, shouldhelpgc = c.nextFree(spc)
          }
        省略代码 ...
      }
    } else {
    
    
       shouldhelpgc = true
       省略代码 ...
    }
  省略代码 ...
    if shouldhelpgc {
    
          //是否应该触发gc
      if t := (gcTrigger{
    
    kind: gcTriggerHeap}); t.test() {
    
       //如果满足gc触发条件就调用gcStart()
          gcStart(t)
      }
    }
  省略代码 ...
    return x
 }

这个时候调用t.test()执行的是gcTriggerHeap情况,只需要判断gcController.heapLive >= gcController.trigger的真假就可以了。 heapLive表示垃圾收集中存活对象字节数,trigger表示触发标记的堆内存大小的;当内存中存活的对象字节数大于触发垃圾收集的堆大小时,新一轮的垃圾收集就会开始。

1.heapLive — 为了减少锁竞争,运行时只会在中心缓存分配或者释放内存管理单元以及在堆上分配大对象时才会更新;

2.trigger — 在标记终止阶段调用runtime.gcSetTriggerRatio更新触发下一次垃圾收集的堆大小,它能够决定触发垃圾收集的时间以及用户程序和后台处理的标记任务的多少,利用反馈控制的算法根据堆的增长情况和垃圾收集CPU利用率确定触发垃圾收集的时机。

(2)手动触发runtime.GC

用户程序会通过runtime.GC函数在程序运行期间主动通知运行时执行,该方法在调用时会阻塞调用方直到当前垃圾收集循环完成,在垃圾收集期间也可能会通过STW暂停整个程序:

func GC() {
    
    
    //在正式开始垃圾收集前,运行时需要通过runtime.gcWaitOnMark等待上一个循环的标记终止、标记和清除终止阶段完成;
    n := atomic.Load(&work.cycles)
    gcWaitOnMark(n)
 
  //调用 `runtime.gcStart` 触发新一轮的垃圾收集
    gcStart(gcTrigger{
    
    kind: gcTriggerCycle, n: n + 1})
 
    //`runtime.gcWaitOnMark` 等待该轮垃圾收集的标记终止阶段正常结束;
    gcWaitOnMark(n + 1)
 
    // 持续调用 `runtime.sweepone` 清理全部待处理的内存管理单元并等待所有的清理工作完成
    for atomic.Load(&work.cycles) == n+1 && sweepone() != ^uintptr(0) {
    
    
        sweep.nbgsweep++
        Gosched()  //等待期间会调用 `runtime.Gosched` 让出处理器
    }
 
    //
    for atomic.Load(&work.cycles) == n+1 && !isSweepDone() {
    
    
        Gosched()
    }
 
    // 完成本轮垃圾收集的清理工作后,通过 `runtime.mProf_PostSweep` 将该阶段的堆内存状态快照发布出来,我们可以获取这时的内存状态
    mp := acquirem()
    cycle := atomic.Load(&work.cycles)
    if cycle == n+1 || (gcphase == _GCmark && cycle == n+2) {
    
       //仅限于没有启动其他标记终止过程
        mProf_PostSweep()
    }
    releasem(mp)
}

(3)后台运行定时检查触发runtime.forcegchelper

运行时会在应用程序启动时在后台开启一个用于强制触发垃圾收集的Goroutine,该Goroutine调用runtime.gcStart尝试启动新一轮的垃圾收集:

// start forcegc helper goroutine
func init() {
    
    
   go forcegchelper()
}
 
func forcegchelper() {
    
    
   forcegc.g = getg()
   lockInit(&forcegc.lock, lockRankForcegc)
   for {
    
    
      lock(&forcegc.lock)
      if forcegc.idle != 0 {
    
    
         throw("forcegc: phase error")
      }
      atomic.Store(&forcegc.idle, 1)
      
     //该 Goroutine 会在循环中调用runtime.goparkunlock主动陷入休眠等待其他 Goroutine 的唤醒
      goparkunlock(&forcegc.lock, waitReasonForceGCIdle, traceEvGoBlock, 1)
       
      if debug.gctrace > 0 {
    
    
         println("GC forced")
      }
      // Time-triggered, fully concurrent.
      gcStart(gcTrigger{
    
    kind: gcTriggerTime, now: nanotime()})
   }
}

以上内容太过复杂,如果看不懂,请参见 《Go学习圣经-技术自由圈版》 配套视频

Java和Golang史上大对比

一、 垃圾回收区域PK

Java内存运行时区域的各个部分,其中程序计数器、虚拟机栈、本地方法栈3个区域随着线程而生,随着线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈的操作,每个栈帧中分配多少内存基本是在类结构确定下来时就已知的。而Java堆和方法区则不同,一个接口中的多个实现类需要的内存可能不同,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间时才能知道会创建哪些对象,这部分内存的分配和回收都是动态的。

因此,Java堆和方法区是Java垃圾收集器管理的主要区域

Go内存会分成堆区(Heap)和栈区(Stack)两个部分,程序在运行期间可以主动从堆区申请内存空间,这些内存由内存分配器分配并由垃圾收集器负责回收。

Go栈区的内存由编译器自动进行分配和释放,栈区中存储着函数的参数以及局部变量,它们会随着函数的创建而创建,函数的返回而销毁。如果只申请和分配内存,内存终将枯竭。Go使用垃圾回收收集不再使用的span,把span释放交给mheap,mheap对span进行span的合并,把合并后的span加入scav树中,等待再分配内存时,由mheap进行内存再分配。

因此,Go堆是Go垃圾收集器管理的主要区域

Go内存管理

Go内存管理

二、 触发垃圾回收的时机PK

Java当应用程序空闲时,即没有应用线程在运行时,GC会被调用。因为GC在优先级最低的线程中进行,所以当应用忙时,GC线程就不会被调用,但以下条件除外。

Java堆内存不足时,GC会被调用。但是这种情况由于java是分代收集算法且垃圾收集器种类十分多,因此其触发各种垃圾收集器的GC时机可能不完全一致,这里我们说的为一般情况。

  1. 当Eden区空间不足时Minor GC;
  2. 对象年龄增加到一定程度时Young GC;
  3. 新生代对象转入老年代及创建为大对象、大数组时会导致老年代空间不足,触发Old GC;
  4. System.gc()调用触发Full GC;
  5. 各种区块占用超过阈值的情况。

Go则会根据以下条件进行触发:

  • runtime.mallocgc申请内存时根据堆大小触发GC;
  • runtime.GC用户程序手动触发GC;
  • runtime.forcegchelper后台运行定时检查触发GC。

三、收集算法PK

当前Java虚拟机的垃圾收集采用分代收集算法,根据对象存活周期的不同将内存分为几块。比如在新生代中,每次收集都会有大量对象死去,所以可以选择“标记-复制”算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集。

当前Go的都是基于标记清除算法进行垃圾回收

四、垃圾碎片处理PK

由于Java的内存管理划分,因此容易产生垃圾对象,JVM这些年不断的改进和更新GC算法,JVM在处理内存碎片问题上更多采用空间压缩和分代收集的思想,例如在新生代使用“标记-复制”算法,G1收集器支持了对象移动以消减长时间运行的内存碎片问题,划分region的设计更容易把空闲内存归还给OS等设计。

由于Go的内存管理的实现,很难实现分代,而移动对象也可能会导致runtime更庞大复杂,因此Go在关于内存碎片的处理方案和Java并不太一样。

1.Go语言span内存池的设计,减轻了很多内存碎片的问题

Go内存释放的过程如下:当mcache中存在较多空闲span时,会归还给 mcentral;而mcentral中存在较多空闲span时,会归还给mheap;mheap再归还给操作系统。这种设计主要有以下几个优势:

  • 内存分配大多时候都是在用户态完成的,不需要频繁进入内核态。
  • 每个 P 都有独立的 span cache,多个 CPU 不会并发读写同一块内存,进而减少 CPU L1 cache 的 cacheline 出现 dirty 情况,增大 cpu cache 命中率。
  • 内存碎片的问题,Go是自己在用户态管理的,在 OS 层面看是没有碎片的,使得操作系统层面对碎片的管理压力也会降低。
  • mcache 的存在使得内存分配不需要加锁。

2.tcmalloc分配机制,Tiny对象和大对象分配优化,在某种程度上也导致基本没有内存碎片会出现

比如常规上sizeclass=1的span,用来给<=8B 的对象使用,所以像 int32, byte, bool以及小字符串等常用的微小对象,都会使用sizeclass=1的span,但分配给他们8B的空间,大部分是用不上的。并且这些类型使用频率非常高,就会导致出现大量的内部碎片。

因此Go尽量不使用sizeclass=1的span,而是将<16B的对象为统一视为tiny对象。分配时,从sizeclass=2的span中获取一个16B的object用以分配。如果存储的对象小于16B,这个空间会被暂时保存起来 (mcache.tiny字段),下次分配时会复用这个空间,直到这个object用完为止。

以上图为例,这样的方式空间利用率是(1+2+8)/16 * 100%= 68.75%,而如果按照原始的管理方式,利用率是(1+2+8)/(8 * 3)=45.83%。源码中注释描述,说是对tiny对象的特殊处理,平均会节省20%左右的内存。如果要存储的数据里有指针,即使<= 8B也不会作为tiny对象对待,而是正常使用sizeclass=1的span。

Go中,最大的sizeclass最大只能存放32K的对象。如果一次性申请超过32K的内存,系统会直接绕过mcache和mcentral,直接从mheap上获取,mheap中有一个freelarge字段管理着超大span。

3.Go的对象(即struct类型)是可以分配在栈上的。

Go会在编译时做静态逃逸分析(Escape Analysis), 如果发现某个对象并没有逃出当前作用域,则会将对象分配在栈上而不是堆上,从而减轻了GC内存碎片回收压力。

比如如下代码:

func F() {
    
    
  temp := make([]int, 0, 20) //只是内函数内部申请的临时变量,并不会作为返回值返回,它就是被编译器申请到栈里面。
  temp = append(temp, 1)
}

func main() {
    
    
  F()
}

运行代码如下,结果显示temp变量被分配在栈上并没有分配在堆上:

hewittwang@HEWITTWANG-MB0 rtx % go build -gcflags=-m
# hello
./new1.go:4:6: can inline F
./new1.go:9:6: can inline main
./new1.go:10:3: inlining call to F
./new1.go:5:14: make([]int, 0, 20) does not escape
./new1.go:10:3: make([]int, 0, 20) does not escapeh

当我们把上述代码更改:

package main
import "fmt"

func F() {
    
    
  temp := make([]int, 0, 20)
  fmt.Print(temp)
}

func main() {
    
    
  F()
}

运行代码如下,结果显示temp变量被分配在堆上,这是由于temp传入了print函数里,编译器会认为变量之后还会被使用。因此就申请到堆上,申请到堆上面的内存才会引起垃圾回收,如果这个过程(特指垃圾回收不断被触发)过于高频就会导致GC压力过大,程序性能出问题。

hewittwang@HEWITTWANG-MB0 rtx % go build -gcflags=-m
# hello
./new1.go:9:11: inlining call to fmt.Print
./new1.go:12:6: can inline main
./new1.go:8:14: make([]int, 0, 20) escapes to heap
./new1.go:9:11: temp escapes to heap
./new1.go:9:11: []interface {
    
    }{
    
    ...} does not escape
<autogenerated>:1: .this does not escape

五、“GC Roots” 的对象选择PK

在Java中由于内存运行时区域的划分,通常会选择以下几种作为“GC Roots” 的对象:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象;
  • 本地方法栈(Native 方法)中引用的对象;
  • 方法区中类静态属性引用的对象;
  • 方法区中常量引用的对象;
  • Java虚拟机内部引用;
  • 所有被同步锁持有的对象。

而在Java中的不可达对象有可能会逃脱。即使在可达性分析法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑阶段”,要真正宣告一个对象死亡,至少要经历两次标记过程;此外Java中由于存在运行时常量池和类,因此也需要对运行时常量池和方法区的类进行清理。

而Go的选择就相对简单一点,即全局变量和G Stack中的引用指针,简单来说就是全局量和go程中的引用指针。因为Go中没有类的封装概念,因而GC Root选择也相对简单一些。

六、写屏障PK

为了解决并发三色可达性分析中的悬挂指针问题,出现了2种解决方案,分别是分别是“Dijkstra插入写屏障”和“Yuasa删除写屏障”。

在java中,对上述2种方法都有应用,比如CMS是基于“Dijkstra插入写屏障”做并发标记的,G1、Shenandoah则是使用“Yuasa删除写屏障”来实现的。

在Go语言v1.7版本之前,运行时会使用Dijkstra插入写屏障保证强三色不变性,Go语言在v1.8组合Dijkstra插入写屏障和Yuasa删除写屏障构成了混合写屏障,混合写屏障结合两者特点,通过以下方式实现并发稳定的GC:

1.将栈上的对象全部扫描并标记为黑色。

2.GC期间,任何在栈上创建的新对象,均为黑色。

3.被删除的对象标记为灰色。

4.被添加的对象标记为灰色。

由于要保证栈的运行效率,混合写屏障是针对于堆区使用的。即栈区不会触发写屏障,只有堆区触发,由于栈区初始标记的可达节点均为黑色节点,因而也不需要第二次STW下的扫描。本质上是融合了插入屏障和删除屏障的特点,解决了插入屏障需要二次扫描的问题。同时针对于堆区和栈区采用不同的策略,保证栈的运行效率不受损。

七、Java和Golang史上大对比总结

对比 Java Go
GC区域 Java堆和方法区 Go堆
出发GC时机 分代收集导致触发时机很多 申请内存、手动触发、定时触发
垃圾收集算法 分代收集。在新生代(“标记-复制”); 老年代(“标记-清除”或“标记-整理”) 标记清除算法
垃圾种类 死亡对象(可能会逃脱)、废弃常量和无用的类 全局变量和G Stack中的引用指针
标记阶段 三色可达性分析算法(插入写屏障,删除写屏障) 三色可达性分析算法(混合写屏障)
空间压缩整理
内存分配 指针碰撞/空闲列表 span内存池
垃圾碎片解决方案 分代GC、对象移动、划分region等设计 Go语言span内存池、tcmalloc分配机制、对象可以分配在栈上、对象池

从垃圾回收的角度来说,经过多代发展,Java的垃圾回收机制较为完善,Java划分新生代、老年代来存储对象。对象通常会在新生代分配内存,多次存活的对象会被移到老年代,由于新生代存活率低,产生空间碎片的可能性高,通常选用“标记-复制”作为回收算法,而老年代存活率高,通常选用“标记-清除”或“标记-整理”作为回收算法,压缩整理空间。

Go是非分代的、并发的、基于三色标记和清除的垃圾回收器,它的优势要结合它tcmalloc内存分配策略才能体现出来,因为小微对象的分配均有自己的内存池,所有的碎片都能被完美复用,所以GC不用考虑空间碎片的问题。

以上内容太过复杂,如果看不懂,请参见 《Go学习圣经-技术自由圈版》 配套视频

GC相关面试题

1、聊聊:常见的垃圾回收算法

  • 引用计数:每个对象维护一个引用计数,当被引用对象被创建或被赋值给其他对象时,引用计数自动加 +1;当引用该对象的对象被销毁时,则计数 -1 ,当计数为 0 时,回收该对象。
    • 优点:对象可以很快被回收,不会出现内存耗尽或到达阀值才回收。
    • 缺点:不能很好的处理循环引用
  • 标记-清除:从根变量开始遍历所有引用的对象,引用的对象标记“被引用”,没有被标记的则进行回收。
    • 优点:解决了引用计数的缺点。
    • 缺点:需要 STW(stop the world),暂时停止程序运行。
  • 分代收集:按照对象生命周期长短划分不同的代空间,生命周期长的放入老年代,短的放入新生代,不同代有不同的回收算法和回收频率。
    • 优点:回收性能好
    • 缺点:算法复杂

2、聊聊:三色标记法

  1. 初始状态下所有对象都是白色的。
  2. 从根节点开始遍历所有对象,把遍历到的对象变成灰色对象
  3. 遍历灰色对象,将灰色对象引用的对象也变成灰色对象,然后将遍历过的灰色对象变成黑色对象。
  4. 循环步骤3,直到灰色对象全部变黑色。
  5. 通过写屏障(write-barrier)检测对象有变化,重复以上操作
  6. 回收所有白色对象(垃圾)。

3、聊聊: 根对象是什么?

根对象在垃圾回收的术语中又叫做根集合,它是垃圾回收器在标记过程时最先检查的对象,包括:

  1. 全局变量:程序在编译期就能确定的那些存在于程序整个生命周期的变量。
  2. 执行栈:每个 goroutine 都包含自己的执行栈,这些执行栈上包含栈上的变量及指向分配的堆内存区块的指针。
  3. 寄存器:寄存器的值可能表示一个指针,参与计算的这些指针可能指向某些赋值器分配的堆内存区块。

4、聊聊:GO的 STW(Stop The World)

  • 为了避免在 GC 的过程中,对象之间的引用关系发生新的变更,使得GC的结果发生错误(如GC过程中新增了一个引用,但是由于未扫描到该引用导致将被引用的对象清除了),停止所有正在运行的协程。
  • STW对性能有一些影响,Golang目前已经可以做到1ms以下的STW。

5、聊聊:写屏障(Write Barrier)

  • 为了避免GC的过程中新修改的引用关系到GC的结果发生错误,我们需要进行STW。但是STW会影响程序的性能,所以我们要通过写屏障技术尽可能地缩短STW的时间。
    造成引用对象丢失的条件:
    一个黑色的节点A新增了指向白色节点C的引用,并且白色节点C没有除了A之外的其他灰色节点的引用,或者存在但是在GC过程中被删除了。
    以上两个条件需要同时满足:满足条件1时说明节点A已扫描完毕,A指向C的引用无法再被扫描到;满足条件2时说明白色节点C无其他灰色节点的引用了,即扫描结束后会被忽略 。

写屏障破坏两个条件其一即可

  • 破坏条件1:Dijistra写屏障

满足强三色不变性:黑色节点不允许引用白色节点 当黑色节点新增了白色节点的引用时,将对应的白色节点改为灰色

  • 破坏条件2:Yuasa写屏障

满足弱三色不变性:黑色节点允许引用白色节点,但是该白色节点有其他灰色节点间接的引用(确保不会被遗漏) 当白色节点被删除了一个引用时,悲观地认为它一定会被一个黑色节点新增引用,所以将它置为灰色

GC过程中新分配的内存会立即标记,用的正是写屏障技术,即GC过程中新分配的内存不会在本轮被回收。

6、聊聊:GC 触发时机

内存分配量达到阈值触发GC

每次内存分配时都会检查当前内存分配量是否已达到阈值,如果达到阈值立即启动GC,

阈值 = 上次GC内存分配量 × 内存增长率

内存增长率由环境变量GOGC控制,默认为100,即每当内存扩大一倍时启动GC。

定期触发GC

默认情况下,最长2分钟触发一次GC,这个时间间隔由 runtime.forcegcperiod变量声明

主动触发

程序代码中可以调用 runtime.GC()来触发GC,主要用于GC的性能测试和统计。

7、什么是 GC,有什么作用?

GC,全称 Garbage Collection,即垃圾回收,是一种自动内存管理的机制。

当程序向操作系统申请的内存不再需要时,垃圾回收主动将其回收并供其他代码进行内存申请时候复用,或者将其归还给操作系统,这种针对内存级别资源的自动回收过程,即为垃圾回收。而负责垃圾回收的程序组件,即为垃圾回收器。

垃圾回收其实一个完美的 “Simplicity is Complicated” 的例子。一方面,程序员受益于 GC,无需操心、也不再需要对内存进行手动的申请和释放操作,GC 在程序运行时自动释放残留的内存。另一方面,GC 对程序员几乎不可见,仅在程序需要进行特殊优化时,通过提供可调控的 API,对 GC 的运行时机、运行开销进行把控的时候才得以现身。

通常,垃圾回收器的执行过程被划分为两个半独立的组件:

  • 赋值器(Mutator):这一名称本质上是在指代用户态的代码。因为对垃圾回收器而言,用户态的代码仅仅只是在修改对象之间的引用关系,也就是在对象图(对象之间引用关系的一个有向图)上进行操作。
  • 回收器(Collector):负责执行垃圾回收的代码。

8、常见的 GC 实现方式有哪些?Go 语言的 GC 使用的是什么?

所有的 GC 算法其存在形式可以归结为追踪(Tracing)和引用计数(Reference Counting)这两种形式的混合运用。

  • 追踪式 GC
    从根对象出发,根据对象之间的引用信息,一步步推进直到扫描完毕整个堆并确定需要保留的对象,从而回收所有可回收的对象。Go、 Java、V8 对 JavaScript 的实现等均为追踪式 GC。
  • 引用计数式 GC
    每个对象自身包含一个被引用的计数器,当计数器归零时自动得到回收。因为此方法缺陷较多,在追求高性能时通常不被应用。Python、Objective-C 等均为引用计数式 GC。

目前比较常见的 GC 实现方式包括:

  • 追踪式,分为多种不同类型,例如:
    • 标记清扫:从根对象出发,将确定存活的对象进行标记,并清扫可以回收的对象。
    • 标记整理:为了解决内存碎片问题而提出,在标记过程中,将对象尽可能整理到一块连续的内存上。
    • 增量式:将标记与清扫的过程分批执行,每次执行很小的部分,从而增量的推进垃圾回收,达到近似实时、几乎无停顿的目的。
    • 增量整理:在增量式的基础上,增加对对象的整理过程。
    • 分代式:将对象根据存活时间的长短进行分类,存活时间小于某个值的为年轻代,存活时间大于某个值的为老年代,永远不会参与回收的对象为永久代。并根据分代假设(如果一个对象存活时间不长则倾向于被回收,如果一个对象已经存活很长时间则倾向于存活更长时间)对对象进行回收。
  • 引用计数:根据对象自身的引用计数来回收,当引用计数归零时立即回收。

关于各类方法的详细介绍及其实现不在本文中详细讨论。对于 Go 而言,Go 的 GC 目前使用的是无分代(对象没有代际之分)、不整理(回收过程中不对对象进行移动与整理)、并发(与用户代码并发执行)的三色标记清扫算法。原因[1]在于:

  1. 对象整理的优势是解决内存碎片问题以及“允许”使用顺序内存分配器。但 Go 运行时的分配算法基于 tcmalloc,基本上没有碎片问题。 并且顺序内存分配器在多线程的场景下并不适用。Go 使用的是基于 tcmalloc 的现代内存分配算法,对对象进行整理不会带来实质性的性能提升。
  2. 分代 GC 依赖分代假设,即 GC 将主要的回收目标放在新创建的对象上(存活时间短,更倾向于被回收),而非频繁检查所有对象。但 Go 的编译器会通过逃逸分析将大部分新生对象存储在栈上(栈直接被回收),只有那些需要长期存在的对象才会被分配到需要进行垃圾回收的堆中。也就是说,分代 GC 回收的那些存活时间短的对象在 Go 中是直接被分配到栈上,当 goroutine 死亡后栈也会被直接回收,不需要 GC 的参与,进而分代假设并没有带来直接优势。并且 Go 的垃圾回收器与用户代码并发执行,使得 STW 的时间与对象的代际、对象的 size 没有关系。Go 团队更关注于如何更好地让 GC 与用户代码并发执行(使用适当的 CPU 来执行垃圾回收),而非减少停顿时间这一单一目标上。

9、细致聊聊:三色标记法是什么?

理解三色标记法的关键是理解对象的三色抽象以及波面(wavefront)推进这两个概念。三色抽象只是一种描述追踪式回收器的方法,在实践中并没有实际含义,它的重要作用在于从逻辑上严密推导标记清理这种垃圾回收方法的正确性。也就是说,当我们谈及三色标记法时,通常指标记清扫的垃圾回收。

从垃圾回收器的视角来看,三色抽象规定了三种不同类型的对象,并用不同的颜色相称:

  • 白色对象(可能死亡):未被回收器访问到的对象。在回收开始阶段,所有对象均为白色,当回收结束后,白色对象均不可达。
  • 灰色对象(波面):已被回收器访问到的对象,但回收器需要对其中的一个或多个指针进行扫描,因为他们可能还指向白色对象。
  • 黑色对象(确定存活):已被回收器访问到的对象,其中所有字段都已被扫描,黑色对象中任何一个指针都不可能直接指向白色对象。

这样三种不变性所定义的回收过程其实是一个波面不断前进的过程,这个波面同时也是黑色对象和白色对象的边界,灰色对象就是这个波面。

当垃圾回收开始时,只有白色对象。随着标记过程开始进行时,灰色对象开始出现(着色),这时候波面便开始扩大。当一个对象的所有子节点均完成扫描时,会被着色为黑色。当整个堆遍历完成时,只剩下黑色和白色对象,这时的黑色对象为可达对象,即存活;而白色对象为不可达对象,即死亡。这个过程可以视为以灰色对象为波面,将黑色对象和白色对象分离,使波面不断向前推进,直到所有可达的灰色对象都变为黑色对象为止的过程。如下图所示:

三色标记法全貌

三色标记法全貌

图中展示了根对象、可达对象、不可达对象,黑、灰、白对象以及波面之间的关系。

10、细致聊聊:STW 是什么意思?

STW 可以是 Stop the World 的缩写,也可以是 Start the World 的缩写。通常意义上指指代从 Stop the World 这一动作发生时到 Start the World 这一动作发生时这一段时间间隔,即万物静止。STW 在垃圾回收过程中为了保证实现的正确性、防止无止境的内存增长等问题而不可避免的需要停止赋值器进一步操作对象图的一段过程。

在这个过程中整个用户代码被停止或者放缓执行, STW 越长,对用户代码造成的影响(例如延迟)就越大,早期 Go 对垃圾回收器的实现中 STW 长达几百毫秒,对时间敏感的实时通信等应用程序会造成巨大的影响。我们来看一个例子:

package main

import (
	"runtime"
	"time"
)

func main() {
    
    
	go func() {
    
    
		for {
    
    
		}
	}()

	time.Sleep(time.Millisecond)
	runtime.GC()
	println("OK")
}

上面的这个程序在 Go 1.14 以前永远都不会输出 OK,其罪魁祸首是进入 STW 这一操作的执行无限制的被延长。

尽管 STW 如今已经优化到了半毫秒级别以下,但这个程序被卡死原因是由于需要进入 STW 导致的。原因在于,GC 在需要进入 STW 时,需要通知并让所有的用户态代码停止,但是 for {} 所在的 goroutine 永远都不会被中断,从而始终无法进入 STW 阶段。实际实践中也是如此,当程序的某个 goroutine 长时间得不到停止,强行拖慢进入 STW 的时机,这种情况下造成的影响(卡死)是非常可怕的。好在自 Go 1.14 之后,这类 goroutine 能够被异步地抢占,从而使得进入 STW 的时间不会超过抢占信号触发的周期,程序也不会因为仅仅等待一个 goroutine 的停止而停顿在进入 STW 之前的操作上。

11、聊聊: 如何观察 Go GC?

我们以下面的程序为例,先使用四种不同的方式来介绍如何观察 GC,并在后面的问题中通过几个详细的例子再来讨论如何优化 GC。

package main

func allocate() {
    
    
	_ = make([]byte, 1<<20)
}

func main() {
    
    
	for n := 1; n < 100000; n++ {
    
    
		allocate()
	}
}

方式1:GODEBUG=gctrace=1

我们首先可以通过

$ go build -o main
$ GODEBUG=gctrace=1 ./main

gc 1 @0.000s 2%: 0.009+0.23+0.004 ms clock, 0.11+0.083/0.019/0.14+0.049 ms cpu, 4->6->2 MB, 5 MB goal, 12 P
scvg: 8 KB released
scvg: inuse: 3, idle: 60, sys: 63, released: 57, consumed: 6 (MB)
gc 2 @0.001s 2%: 0.018+1.1+0.029 ms clock, 0.22+0.047/0.074/0.048+0.34 ms cpu, 4->7->3 MB, 5 MB goal, 12 P
scvg: inuse: 3, idle: 60, sys: 63, released: 56, consumed: 7 (MB)
gc 3 @0.003s 2%: 0.018+0.59+0.011 ms clock, 0.22+0.073/0.008/0.042+0.13 ms cpu, 5->6->1 MB, 6 MB goal, 12 P
scvg: 8 KB released
scvg: inuse: 2, idle: 61, sys: 63, released: 56, consumed: 7 (MB)
gc 4 @0.003s 4%: 0.019+0.70+0.054 ms clock, 0.23+0.051/0.047/0.085+0.65 ms cpu, 4->6->2 MB, 5 MB goal, 12 P
scvg: 8 KB released
scvg: inuse: 3, idle: 60, sys: 63, released: 56, consumed: 7 (MB)
scvg: 8 KB released
scvg: inuse: 4, idle: 59, sys: 63, released: 56, consumed: 7 (MB)
gc 5 @0.004s 12%: 0.021+0.26+0.49 ms clock, 0.26+0.046/0.037/0.11+5.8 ms cpu, 4->7->3 MB, 5 MB goal, 12 P
scvg: inuse: 5, idle: 58, sys: 63, released: 56, consumed: 7 (MB)
gc 6 @0.005s 12%: 0.020+0.17+0.004 ms clock, 0.25+0.080/0.070/0.053+0.051 ms cpu, 5->6->1 MB, 6 MB goal, 12 P
scvg: 8 KB released
scvg: inuse: 1, idle: 62, sys: 63, released: 56, consumed: 7 (MB)

在这个日志中可以观察到两类不同的信息:

gc 1 @0.000s 2%: 0.009+0.23+0.004 ms clock, 0.11+0.083/0.019/0.14+0.049 ms cpu, 4->6->2 MB, 5 MB goal, 12 P
gc 2 @0.001s 2%: 0.018+1.1+0.029 ms clock, 0.22+0.047/0.074/0.048+0.34 ms cpu, 4->7->3 MB, 5 MB goal, 12 P
...

以及:

scvg: 8 KB released
scvg: inuse: 3, idle: 60, sys: 63, released: 57, consumed: 6 (MB)
scvg: inuse: 3, idle: 60, sys: 63, released: 56, consumed: 7 (MB)
...

对于用户代码向运行时申请内存产生的垃圾回收:

gc 2 @0.001s 2%: 0.018+1.1+0.029 ms clock, 0.22+0.047/0.074/0.048+0.34 ms cpu, 4->7->3 MB, 5 MB goal, 12 P

含义由下表所示:

字段 含义
gc 2 第二个 GC 周期
0.001 程序开始后的 0.001 秒
2% 该 GC 周期中 CPU 的使用率
0.018 标记开始时, STW 所花费的时间(wall clock)
1.1 标记过程中,并发标记所花费的时间(wall clock)
0.029 标记终止时, STW 所花费的时间(wall clock)
0.22 标记开始时, STW 所花费的时间(cpu time)
0.047 标记过程中,标记辅助所花费的时间(cpu time)
0.074 标记过程中,并发标记所花费的时间(cpu time)
0.048 标记过程中,GC 空闲的时间(cpu time)
0.34 标记终止时, STW 所花费的时间(cpu time)
4 标记开始时,堆的大小的实际值
7 标记结束时,堆的大小的实际值
3 标记结束时,标记为存活的对象大小
5 标记结束时,堆的大小的预测值
12 P 的数量

wall clock 是指开始执行到完成所经历的实际时间,包括其他程序和本程序所消耗的时间; cpu time 是指特定程序使用 CPU 的时间; 他们存在以下关系:

  • wall clock < cpu time: 充分利用多核
  • wall clock ≈ cpu time: 未并行执行
  • wall clock > cpu time: 多核优势不明显

对于运行时向操作系统申请内存产生的垃圾回收(向操作系统归还多余的内存):

scvg: 8 KB released
scvg: inuse: 3, idle: 60, sys: 63, released: 57, consumed: 6 (MB)

含义由下表所示:

字段 含义
8 KB released 向操作系统归还了 8 KB 内存
3 已经分配给用户代码、正在使用的总内存大小 (MB)
60 空闲以及等待归还给操作系统的总内存大小(MB)
63 通知操作系统中保留的内存大小(MB)
57 已经归还给操作系统的(或者说还未正式申请)的内存大小(MB)
6 已经从操作系统中申请的内存大小(MB)

方式2:go tool trace

go tool trace 的主要功能是将统计而来的信息以一种可视化的方式展示给用户。要使用此工具,可以通过调用 trace API:

package main

func main() {
    
    
	f, _ := os.Create("trace.out")
	defer f.Close()
	trace.Start(f)
	defer trace.Stop()
	(...)
}

并通过

$ go tool trace trace.out
2019/12/30 15:50:33 Parsing trace...
2019/12/30 15:50:38 Splitting trace...
2019/12/30 15:50:45 Opening browser. Trace viewer is listening on http://127.0.0.1:51839

命令来启动可视化界面:

选择第一个链接可以获得如下图示:

右上角的问号可以打开帮助菜单,主要使用方式包括:

  • w/s 键可以用于放大或者缩小视图
  • a/d 键可以用于左右移动
  • 按住 Shift 可以选取多个事件

方式3:debug.ReadGCStats

此方式可以通过代码的方式来直接实现对感兴趣指标的监控,例如我们希望每隔一秒钟监控一次 GC 的状态:

func printGCStats() {
    
    
	t := time.NewTicker(time.Second)
	s := debug.GCStats{
    
    }
	for {
    
    
		select {
    
    
		case <-t.C:
			debug.ReadGCStats(&s)
			fmt.Printf("gc %d last@%v, PauseTotal %v\n", s.NumGC, s.LastGC, s.PauseTotal)
		}
	}
}
func main() {
    
    
	go printGCStats()
	(...)
}

我们能够看到如下输出:

$ go run main.go

gc 4954 last@2019-12-30 15:19:37.505575 +0100 CET, PauseTotal 29.901171ms
gc 9195 last@2019-12-30 15:19:38.50565 +0100 CET, PauseTotal 77.579622ms
gc 13502 last@2019-12-30 15:19:39.505714 +0100 CET, PauseTotal 128.022307ms
gc 17555 last@2019-12-30 15:19:40.505579 +0100 CET, PauseTotal 182.816528ms
gc 21838 last@2019-12-30 15:19:41.505595 +0100 CET, PauseTotal 246.618502ms

方式4:runtime.ReadMemStats

除了使用 debug 包提供的方法外,还可以直接通过运行时的内存相关的 API 进行监控:

func printMemStats() {
    
    
	t := time.NewTicker(time.Second)
	s := runtime.MemStats{
    
    }

	for {
    
    
		select {
    
    
		case <-t.C:
			runtime.ReadMemStats(&s)
			fmt.Printf("gc %d last@%v, next_heap_size@%vMB\n", s.NumGC, time.Unix(int64(time.Duration(s.LastGC).Seconds()), 0), s.NextGC/(1<<20))
		}
	}
}
func main() {
    
    
	go printMemStats()
	(...)
}
$ go run main.go

gc 4887 last@2019-12-30 15:44:56 +0100 CET, next_heap_size@4MB
gc 10049 last@2019-12-30 15:44:57 +0100 CET, next_heap_size@4MB
gc 15231 last@2019-12-30 15:44:58 +0100 CET, next_heap_size@4MB
gc 20378 last@2019-12-30 15:44:59 +0100 CET, next_heap_size@6MB

当然,后两种方式能够监控的指标很多,读者可以自行查看 debug.GCStats [2] 和 runtime.MemStats [3] 的字段,这里不再赘述。

12、有了 GC,为什么还会发生内存泄露?

在一个具有 GC 的语言中,我们常说的内存泄漏,用严谨的话来说应该是:预期的能很快被释放的内存由于附着在了长期存活的内存上、或生命期意外地被延长,导致预计能够立即回收的内存而长时间得不到回收。

在 Go 中,由于 goroutine 的存在,所谓的内存泄漏除了附着在长期对象上之外,还存在多种不同的形式。

形式1:预期能被快速释放的内存因被根对象引用而没有得到迅速释放

当有一个全局对象时,可能不经意间将某个变量附着在其上,且忽略的将其进行释放,则该内存永远不会得到释放。例如:

var cache = map[interface{
    
    }]interface{
    
    }{
    
    }

func keepalloc() {
    
    
	for i := 0; i < 10000; i++ {
    
    
		m := make([]byte, 1<<10)
		cache[i] = m
	}
}

形式2:goroutine 泄漏

Goroutine 作为一种逻辑上理解的轻量级线程,需要维护执行用户代码的上下文信息。在运行过程中也需要消耗一定的内存来保存这类信息,而这些内存在目前版本的 Go 中是不会被释放的。因此,如果一个程序持续不断地产生新的 goroutine、且不结束已经创建的 goroutine 并复用这部分内存,就会造成内存泄漏的现象,例如:

func keepalloc2() {
    
    
	for i := 0; i < 100000; i++ {
    
    
		go func() {
    
    
			select {
    
    }
		}()
	}
}

验证

我们可以通过如下形式来调用上述两个函数:

package main

import (
	"os"
	"runtime/trace"
)

func main() {
    
    
	f, _ := os.Create("trace.out")
	defer f.Close()
	trace.Start(f)
	defer trace.Stop()
	keepalloc()
	keepalloc2()
}

运行程序:

go run main.go

会看到程序中生成了 trace.out 文件,我们可以使用 go tool trace trace.out 命令得到下图:

可以看到,图中的 Heap 在持续增长,没有内存被回收,产生了内存泄漏的现象。

值得一提的是,这种形式的 goroutine 泄漏还可能由 channel 泄漏导致。而 channel 的泄漏本质上与 goroutine 泄漏存在直接联系。Channel 作为一种同步原语,会连接两个不同的 goroutine,如果一个 goroutine 尝试向一个没有接收方的无缓冲 channel 发送消息,则该 goroutine 会被永久的休眠,整个 goroutine 及其执行栈都得不到释放,例如:

var ch = make(chan struct{
    
    })

func keepalloc3() {
    
    
	for i := 0; i < 100000; i++ {
    
    
		// 没有接收方,goroutine 会一直阻塞
		go func() {
    
     ch <- struct{
    
    }{
    
    } }()
	}
}

13、并发标记清除法的难点是什么?

在没有用户态代码并发修改三色抽象的情况下,回收可以正常结束。但是并发回收的根本问题在于,用户态代码在回收过程中会并发地更新对象图,从而造成赋值器和回收器可能对对象图的结构产生不同的认知。这时以一个固定的三色波面作为回收过程前进的边界则不再合理。

我们不妨考虑赋值器写操作的例子:

时序 回收器 赋值器 说明
1 shade(A, gray) 回收器:根对象的子节点着色为灰色对象
2 shade(C, black) 回收器:当所有子节点着色为灰色后,将节点着为黑色
3 C.ref3 = C.ref2.ref1 赋值器:并发的修改了 C 的子节点
4 A.ref1 = nil 赋值器:并发的修改了 A 的子节点
5 shade(A.ref1, gray) 回收器:进一步灰色对象的子节点并着色为灰色对象,这时由于 A.ref1nil,什么事情也没有发生
6 shade(A, black) 回收器:由于所有子节点均已标记,回收器也不会重新扫描已经被标记为黑色的对象,此时 A 被着色为黑色,scan(A) 什么也不会发生,进而 B 在此次回收过程中永远不会被标记为黑色,进而错误地被回收。
  • 初始状态:假设某个黑色对象 C 指向某个灰色对象 A ,而 A 指向白色对象 B;
  • C.ref3 = C.ref2.ref1:赋值器并发地将黑色对象 C 指向(ref3)了白色对象 B;
  • A.ref1 = nil:移除灰色对象 A 对白色对象 B 的引用(ref2);
  • 最终状态:在继续扫描的过程中,白色对象 B 永远不会被标记为黑色对象了(回收器不会重新扫描黑色对象),进而对象 B 被错误地回收。

gc-mutator

总而言之,并发标记清除中面临的一个根本问题就是如何保证标记与清除过程的正确性。

14、什么是写屏障、混合写屏障,如何实现?

要讲清楚写屏障,就需要理解三色标记清除算法中的强弱不变性以及赋值器的颜色,理解他们需要一定的抽象思维。写屏障是一个在并发垃圾回收器中才会出现的概念,垃圾回收器的正确性体现在:不应出现对象的丢失,也不应错误的回收还不需要回收的对象。

可以证明,当以下两个条件同时满足时会破坏垃圾回收器的正确性:

  • 条件 1: 赋值器修改对象图,导致某一黑色对象引用白色对象;
  • 条件 2: 从灰色对象出发,到达白色对象的、未经访问过的路径被赋值器破坏。

只要能够避免其中任何一个条件,则不会出现对象丢失的情况,因为:

  • 如果条件 1 被避免,则所有白色对象均被灰色对象引用,没有白色对象会被遗漏;
  • 如果条件 2 被避免,即便白色对象的指针被写入到黑色对象中,但从灰色对象出发,总存在一条没有访问过的路径,从而找到到达白色对象的路径,白色对象最终不会被遗漏。

我们不妨将三色不变性所定义的波面根据这两个条件进行削弱:

  • 当满足原有的三色不变性定义(或上面的两个条件都不满足时)的情况称为强三色不变性(strong tricolor invariant)
  • 当赋值器令黑色对象引用白色对象时(满足条件 1 时)的情况称为弱三色不变性(weak tricolor invariant)

当赋值器进一步破坏灰色对象到达白色对象的路径时(进一步满足条件 2 时),即打破弱三色不变性, 也就破坏了回收器的正确性;或者说,在破坏强弱三色不变性时必须引入额外的辅助操作。 弱三色不变形的好处在于:只要存在未访问的能够到达白色对象的路径,就可以将黑色对象指向白色对象。

如果我们考虑并发的用户态代码,回收器不允许同时停止所有赋值器,就是涉及了存在的多个不同状态的赋值器。为了对概念加以明确,还需要换一个角度,把回收器视为对象,把赋值器视为影响回收器这一对象的实际行为(即影响 GC 周期的长短),从而引入赋值器的颜色:

  • 黑色赋值器:已经由回收器扫描过,不会再次对其进行扫描。
  • 灰色赋值器:尚未被回收器扫描过,或尽管已经扫描过但仍需要重新扫描。

赋值器的颜色对回收周期的结束产生影响:

  • 如果某种并发回收器允许灰色赋值器的存在,则必须在回收结束之前重新扫描对象图。
  • 如果重新扫描过程中发现了新的灰色或白色对象,回收器还需要对新发现的对象进行追踪,但是在新追踪的过程中,赋值器仍然可能在其根中插入新的非黑色的引用,如此往复,直到重新扫描过程中没有发现新的白色或灰色对象。

于是,在允许灰色赋值器存在的算法,最坏的情况下,回收器只能将所有赋值器线程停止才能完成其跟对象的完整扫描,也就是我们所说的 STW。

为了确保强弱三色不变性的并发指针更新操作,需要通过赋值器屏障技术来保证指针的读写操作一致。因此我们所说的 Go 中的写屏障、混合写屏障,其实是指赋值器的写屏障,赋值器的写屏障作为一种同步机制,使赋值器在进行指针写操作时,能够“通知”回收器,进而不会破坏弱三色不变性。

有两种非常经典的写屏障:Dijkstra 插入屏障和 Yuasa 删除屏障。

灰色赋值器的 Dijkstra 插入屏障的基本思想是避免满足条件 1:

// 灰色赋值器 Dijkstra 插入屏障
func DijkstraWritePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) {
    
    
    shade(ptr)
    *slot = ptr
}

为了防止黑色对象指向白色对象,应该假设 *slot 可能会变为黑色,为了确保 ptr 不会在被赋值到 *slot 前变为白色,shade(ptr) 会先将指针 ptr 标记为灰色,进而避免了条件 1。如图所示:

Dijkstra 插入屏障的好处在于可以立刻开始并发标记。但存在两个缺点:

  1. 由于 Dijkstra 插入屏障的“保守”,在一次回收过程中可能会残留一部分对象没有回收成功,只有在下一个回收过程中才会被回收;
  2. 在标记阶段中,每次进行指针赋值操作时,都需要引入写屏障,这无疑会增加大量性能开销;为了避免造成性能问题,Go 团队在最终实现时,没有为所有栈上的指针写操作,启用写屏障,而是当发生栈上的写操作时,将栈标记为灰色,但此举产生了灰色赋值器,将会需要标记终止阶段 STW 时对这些栈进行重新扫描。

另一种比较经典的写屏障是黑色赋值器的 Yuasa 删除屏障。其基本思想是避免满足条件 2:

// 黑色赋值器 Yuasa 屏障
func YuasaWritePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) {
    
    
    shade(*slot)
    *slot = ptr
}

为了防止丢失从灰色对象到白色对象的路径,应该假设 *slot 可能会变为黑色,为了确保 ptr 不会在被赋值到 *slot 前变为白色,shade(*slot) 会先将 *slot 标记为灰色,进而该写操作总是创造了一条灰色到灰色或者灰色到白色对象的路径,进而避免了条件 2。

Yuasa 删除屏障的优势则在于不需要标记结束阶段的重新扫描,结束时候能够准确的回收所有需要回收的白色对象。

Yuasa 删除屏障的缺陷是会拦截写操作,进而导致波面的退后,产生“冗余”的扫描:

Go 在 1.8 的时候为了简化 GC 的流程,同时减少标记终止阶段的重扫成本,将 Dijkstra 插入屏障和 Yuasa 删除屏障进行混合,形成混合写屏障。

该屏障提出时的基本思想是:对正在被覆盖的对象进行着色,且如果当前栈未扫描完成,则同样对指针进行着色。

但在最终实现时原提案[4]中对 ptr 的着色还额外包含对执行栈的着色检查,但由于时间有限,并未完整实现过,所以混合写屏障在目前的实现伪代码是:

// 混合写屏障
func HybridWritePointerSimple(slot *unsafe.Pointer, ptr unsafe.Pointer) {
    
    
	shade(*slot)
	shade(ptr)
	*slot = ptr
}

在这个实现中,如果无条件对引用双方进行着色,自然结合了 Dijkstra 和 Yuasa 写屏障的优势,但缺点也非常明显,因为着色成本是双倍的,而且编译器需要插入的代码也成倍增加,随之带来的结果就是编译后的二进制文件大小也进一步增加。为了针对写屏障的性能进行优化,Go 1.10 前后,Go 团队随后实现了批量写屏障机制。其基本想法是将需要着色的指针统一写入一个缓存,每当缓存满时统一对缓存中的所有 ptr 指针进行着色。

以上内容太过复杂,如果看不懂,请参见 《Go学习圣经-技术自由圈版》 配套视频

说在后面

如果遇到难题,可以来找尼恩求助。

尼恩会针对问题,给大家做起底式、绞杀式、系统化梳理, 帮大家真正让面试官爱到死去活来

另外,如果简历low、项目low,没有面试机会,也可以找尼恩做升级改造,让简历金光闪闪、人见人爱。

推荐相关阅读

Go学习圣经:0基础精通GO开发与高并发架构

Go学习圣经:队列削峰+批量写入 超高并发原理和实操

Go学习圣经:从0开始,精通Go语言Rest微服务架构和开发

Go学习圣经:Go语言实现高并发CRUD业务开发

尼恩 架构笔记、面试题 的PDF文件更新,请到下面《技术自由圈》公号取↓↓↓

猜你喜欢

转载自blog.csdn.net/crazymakercircle/article/details/131673351
GC