现代高级编程语言管理内存的方式分自动和手动两种。手动管理内存的典型代表是C和C++,编写代码过程中需要主动申请或者释放内存;而 PHP、Java 和 Go等语言使用自动的内存管理系统,由内存分配器和垃圾收集器来代为分配和回收内存,其中垃圾收集器就是我们常说的GC。本文中,笔者将从原理出发,介绍Java和Golang垃圾回收算法,并从原理上对他们做一个对比。
垃圾回收区域及划分
在介绍Java垃圾回收之前,我们需要了解Java的垃圾主要存在于哪个区域。JVM内存运行时区域划分如下图所示:
图源:深入理解Java虚拟机:JVM高级特性与最佳实践(第3版) —机械工业出版社
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 其实准确分类只有两大种:
- 新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集;
- 老年代收集(Major GC / Old GC):只对老年代进行垃圾收集。需要注意的是 MajorGC 在有的语境中也用于指代整堆收集;
- 混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集。
Java堆内存常见分配策略
- 如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小
- 如果大于,将尝试着进行一次Minor GC,尽管这次Minor GC是有风险的
- 如果小于,或者HandlePromotionFailure设置不允许冒险,那这时也要改为进行一次Full GC
判断对象死亡
堆中几乎放着所有的对象实例,对堆垃圾回收前的第一步就是要判断哪些对象已经死亡(即不能再被任何途径使用的对象)。判断一个对象是否存活有引用计数、可达性分析这两种算法,两种算法各有优缺点。 Java和Go都使用可达性分析算法,一些动态脚本语言(如:ActionScript)一般使用引用计数算法。
引用计数法
引用计数法给每个对象的对象头添加一个引用计数器,每当其他地方引用一次该对象,计数器就加1;当引用失效,计数器就减 1;任何时候计数器为 0 的对象就是不可能再被使用的。
这个方法实现简单,效率高,但是主流的Java虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间相互循环引用的问题。即如下代码所示:除了对象 objA 和 objB 相互引用着对方之外,这两个对象之间再无任何引用。但是他们因为互相引用对方,导致它们的引用计数器都不为 0,于是引用计数算法无法通知 GC 回收器回收他们。
目前Python语言使用的是引用计数法,它采用了“标记-清除”算法,解决容器对象可能产生的循环引用问题,关于详细原理可以参考Python垃圾回收机制详解
可达性分析算法
这个算法的基本思想就是通过一系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的。算法优点是能准确标识所有的无用对象,包括相互循环引用的对象;缺点是算法的实现相比引用计数法复杂。比如如下图所示Root1和Root2都为“GC Roots” ,白色节点为应被垃圾回收的
关于Java查看可达性分析、内存泄露的工具,强烈推荐“Memory Analyzer Tool”,可以查看内存分布、对象间依赖、对象状态。
在Java中,可以作为 “GC Roots” 的对象有很多,比如:
不可达的对象并非“非死不可”
即使在可达性分析法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑阶段”,要真正宣告一个对象死亡,至少要经历两次标记过程;可达性分析法中不可达的对象被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize 方法。当对象没有覆盖 finalize 方法,或 finalize 方法已经被虚拟机调用过时,虚拟机将这两种情况视为没有必要执行。被判定为需要执行的对象将会被放在一个队列中进行第二次标记,除非这个对象与引用链上的任何一个对象建立关联,否则就会被真的回收。
判断一个运行时常量池中的常量是废弃常量
1. JDK1.7 之前运行时常量池逻辑包含字符串常量池存放在方法区, 此时 hotspot 虚拟机对方法区的实现为永久代
2. JDK1.7 字符串常量池被从方法区拿到了堆中, 这里没有提到运行时常量池,也就是说字符串常量池被单独拿到堆,运行时常量池剩下的东西还在方法区, 也就是 hotspot 中的永久代 。
3. JDK1.8 hotspot 移除了永久代用元空间(Metaspace)取而代之, 这时候字符串常量池还在堆, 运行时常量池还在方法区, 只不过方法区的实现从永久代变成了元空间(Metaspace)
假如在字符串常量池中存在字符串"abc",如果当前没有任何 String 对象引用该字符串常量的话,就说明常量"abc" 就是废弃常量,如果这时发生内存回收的话而且有必要的话,"abc"就会被系统清理出常量池了。
如何判断一个方法区的类是无用的类
类需要同时满足下面 3 个条件才能算是 “无用的类”,虚拟机可以对无用类进行回收。
当确定了哪些对象可以回收后,就要需要考虑如何对这些对象进行回收,目前垃圾回收算法主要有以下几种。
标记清除算法
该算法分为“标记”和“清除”阶段:首先标记出所有不需要回收的对象,在标记完成后统一回收掉所有没有被标记的对象。
适用场合:存活对象较多的情况、适用于年老代(即旧生代)
缺点:
标记复制算法
为了解决效率问题,出现了“标记-复制”收集算法。它可以将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。使用复制算法,回收过程中就不会出现内存碎片,也提高了内存分配和释放的效率
适用场合:存活对象较少的情况下比较高效、用于年轻代(即新生代)
缺点:需要一块儿空的内存空间,整理阶段,由于移动了可用对象,需要去更新引用。
标记整理算法
对于对象存活率较高的场景,复制算法要进行较多复制操作,使得效率会变低,这种场景更适合标记-整理算法,与标记-清理一样,标记整理算法先标记出对象的存活状态,但在清理时,是先把所有存活对象往一端移动,然后直接清掉边界以外的内存。
适用场合:对象存活率较高(即老年代)
缺点:整理阶段,由于移动了可用对象,需要去更新引用。
分代收集算法
当前Java虚拟机的垃圾收集采用分代收集算法,一般根据对象存活周期的不同将内存分为新生代和老年代。在新生代中,每次收集都会有大量对象死去,可以选择”标记-复制“算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。 而老年代的对象存活几率是比较高,而且没有额外的空间对它进行分配担保,所以我们选择“标记-清除”或“标记-整理”算法进行垃圾收集。
图源:深入理解Java虚拟机:JVM高级特性与最佳实践(第3版) —机械工业出版社
虽然我们对各个收集器进行比较,但并非要挑选出一个最好的收集器。因为直到现在为止还没有最好的垃圾收集器出现,更加没有万能的垃圾收集器,我们能做的就是根据具体应用场景选择适合自己的垃圾收集器。
从Go v1.12版本开始,Go使用了非分代的、并发的、基于三色标记清除的垃圾回收器。相关标记清除算法可以参考和C/C++一样,Go是一种静态类型的编译型语言。因此,Go不需要VM,Go应用程序二进制文件中嵌入了一个小型运行时(Go runtime),可以处理诸如垃圾收集(GC),调度和并发之类的语言功能。首先让我们看一下Go内部的内存管理是什么样子的。
Golang内存管理
这里先简单介绍一下 Golang 运行调度。在 Golang 里面有三个基本的概念:G, M, P。
一个 Goroutine 的运行需要 G + P + M 三部分结合起来。
图源:Golang---内存管理(内存分配)
TCMalloc
Go将内存划分和分组为页(Page),这和Java的内存结构完全不同,没有分代内存,这样的原因是Go的内存分配器采用了TCMalloc的设计思想:
Page
与TCMalloc中的Page相同,x64下1个Page的大小是8KB。上图的最下方,1个浅蓝色的长方形代表1个Page。
Span
与TCMalloc中的Span相同,Span是内存管理的基本单位,代码中为mspan,一组连续的Page组成1个Span,所以上图一组连续的浅蓝色长方形代表的是一组Page组成的1个Span,另外,1个淡紫色长方形为1个Span。
mcache
mcache是提供给P(逻辑处理器)的高速缓存,用于存储小对象(对象大小<= 32Kb)。尽管这类似于线程堆栈,但它是堆的一部分,用于动态数据。所有类大小的mcache包含scan和noscan类型mspan。Goroutine可以从mcache没有任何锁的情况下获取内存,因为一次P只能有一个锁G。因此,这更有效。mcache从mcentral需要时请求新的span。
mcentral
mcentral与TCMalloc中的CentralCache类似,是所有线程共享的缓存,需要加锁访问,它按Span class对Span分类,串联成链表,当mcache的某个级别Span的内存被分配光时,它会向mcentral申请1个当前级别的Span。每个mcentral包含两个mspanList:
mheap
mheap与TCMalloc中的PageHeap类似,它是堆内存的抽象,也是垃圾回收的重点区域,把从OS申请出的内存页组织成Span,并保存起来。当mcentral的Span不够用时会向mheap申请,mheap的Span不够用时会向OS申请,向OS的内存申请是按页来的,然后把申请来的内存页生成Span组织起来,同样也是需要加锁访问的。
栈
这是栈存储区,每个Goroutine(G)有一个栈。在这里存储了静态数据,包括函数栈帧,静态结构,原生类型值和指向动态结构的指针。这与分配给每个P的mcache不是一回事。
内存分配
Go中的内存分类并不像TCMalloc那样分成小、中、大对象,但是它的小对象里又细分了一个Tiny对象,Tiny对象指大小在1Byte到16Byte之间并且不包含指针的对象。小对象和大对象只用大小划定,无其他区分。
核心思想:把内存分为多级管理,降低锁的粒度(只是去mcentral和mheap会申请锁), 以及多种对象大小类型,减少分配产生的内存碎片。
内存回收
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种:
为了解决上述的“对象消失”的现象,Wilson于1994年在理论上证明了,当且仅当以下两个条件同时满足时,会产生“对象消失”的问题,即原本应该是黑色的对象被误标为白色^[7]^:
因此为了我们要解决并发扫描时的对象消失问题,保证垃圾收集算法的正确性,只需破坏这两个条件的任意一个即可,屏障技术就是在并发或者增量标记过程中保证三色不变性的重要技术。
内存屏障技术是一种屏障指令,它可以让 CPU 或者编译器在执行内存相关操作时遵循特定的约束,目前多数的现代处理器都会乱序执行指令以最大化性能,但是该技术能够保证内存操作的顺序性,在内存屏障前执行的操作一定会先于内存屏障后执行的操作。垃圾收集中的屏障技术更像是一个钩子方法,它是在用户程序读取对象、创建新对象以及更新对象指针时执行的一段代码,根据操作类型的不同,我们可以将它们分成读屏障(Read barrier)和写屏障(Write barrier)两种,因为读屏障需要在读操作中加入代码片段,对用户程序的性能影响很大,所以编程语言往往都会采用写屏障保证三色不变性^[1]^。
插入写屏障
Dijkstra 在 1978 年提出了插入写屏障,也被叫做增量更新,通过如下所示的写屏障,破坏上述第一个条件(赋值器插入了一条或多条从黑色对象到白色对象的新引用):
上述伪代码非常好理解,当黑色对象(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 必须为栈上的对象增加写屏障或者在标记阶段完成重新对栈上的对象进行扫描,这两种方法各有各的缺点,前者会大幅度增加写入指针的额外开销,后者重新扫描栈对象时需要暂停程序,垃圾收集算法的设计者需要在这两者之前做出权衡^[1]^。
删除写屏障
Yuasa 在 1990 年的论文 Real-time garbage collection on general-purpose machines 中提出了删除写屏障,因为一旦该写屏障开始工作,它会保证开启写屏障时堆上所有对象的可达。起始时STW 扫描所有的 goroutine 栈,保证所有堆上在用的对象都处于灰色保护下,所以也被称作快照垃圾收集(Snapshot GC),这是破坏了“对象消失”的第二个条件(赋值器删除了全部从灰色对象到该白色对象的直接或间接引用)。
但是这样也会导致一个问题,由于会将有存活可能的对象都标记成灰色,因此最后可能会导致应该回收的对象未被回收,这个对象只有在下一个循环才会被回收,比如下图的D对象。
由于原始快照的原因,起始也是执行 STW,删除写屏障不适用于栈特别大的场景,栈越大,STW 扫描时间越长
混合写屏障
在 Go 语言 v1.7 版本之前,运行时会使用 Dijkstra 插入写屏障保证强三色不变性,但是运行时并没有在所有的垃圾收集根对象上开启插入写屏障。因为应用程序可能包含成百上千的 Goroutine,而垃圾收集的根对象一般包括全局变量和栈对象,如果运行时需要在几百个 Goroutine 的栈上都开启写屏障,会带来巨大的额外开销,所以 Go 团队在v1.8结合上述2种写屏障构成了混合写屏障,实现上选择了在标记阶段完成时暂停程序、将所有栈对象标记为灰色并重新扫描^[1]^。
Go 语言在 v1.8 组合 Dijkstra 插入写屏障和 Yuasa 删除写屏障构成了如下所示的混合写屏障,该写屏障会将被覆盖的对象标记成灰色并在当前栈没有扫描时将新对象也标记成灰色:
为了移除栈的重扫描过程,除了引入混合写屏障之外,在垃圾收集的标记阶段,我们还需要将创建的所有新对象都标记成黑色,防止新分配的栈内存和堆内存中的对象被错误地回收,因为栈内存在标记阶段最终都会变为黑色,所以不再需要重新扫描栈空间。总结来说主要有这几点:
GC 演进过程
o 将 unsafe.Pointer 类型转换成整数类型的值认定为不合法的,可能会造成悬挂指针等严重问题;
o 大幅度降低垃圾收集的延迟从几百 ms 降低至 10ms 以下;
o 计算垃圾收集启动的合适时间并通过并发加速垃圾收集的过程;
o 基于显式的状态机使得任意 Goroutine 都能触发垃圾收集的状态迁移;
o 使用密集的位图替代空闲链表表示的堆内存,降低清除阶段的 CPU 占用;
GC 过程
Golang GC 相关的代码在 runtime/mgc.go 文件下,可以看见gc总共分为4个阶段(翻译自golang v1.16版本源码):
1. sweep termination(清理终止)
a. 暂停程序,触发STW。所有的 P(处理器)都会进入 safe-point(安全点);
b. 清理未被清理的 span 。如果当前垃圾收集是强制触发的,需要处理还未被清理的内存管理单元;
2. the mark phase(标记阶段)
a. 将GC状态 gcphase 从 _GCoff 改成 _GCmark 、开启写屏障、启用协助线程(mutatorassists)、将根对象入队
b. 恢复程序执行,标记进程(mark workers)和协助程序会开始并发标记内存中的对象,写屏障会覆盖的重写指针和新指针(标记成灰色),而所有新创建的对象都会被直接标记成黑色;
c. GC执行根节点的标记,这包括扫描所有的栈、全局对象以及不在堆中的运行时数据结构。扫描goroutine 栈会导致 goroutine 停止,并对栈上找到的所有指针加置灰,然后继续执行 goroutine。
d. GC遍历灰色对象队列,会将灰色对象变成黑色,并将该指针指向的对象置灰。
e. 由于GC工作分布在本地缓存中,GC 会使用分布式终止算法(distributedtermination algorithm)来检测何时不再有根标记作业或灰色对象,如果没有了 GC 会转为mark termination(标记终止)
3. mark termination(标记终止)
a. STW
b. 将GC状态 gcphase 切换至 _GCmarktermination ,关闭gc工作线程和协助程序
c. 执行housekeeping,例如刷新mcaches
4. the sweep phase(清理阶段)
a. 将GC状态 gcphase 切换至 _GCoff 来准备清理阶段,初始化清理阶段并关闭写屏障
b. 恢复用户程序,从现在开始,所有新创建的对象会标记成白色;如果有必要,在使用前分配清理spans
c. 后台并发清理所有的内存管理类单元
GC过程代码示例
运行程序
栈分析
GC 触发条件
运行时会通过 runtime.gcTrigger.test 方法决定是否需要触发垃圾收集,当满足触发垃圾收集的基本条件(即满足 _GCoff 阶段的退出条件)时 — 允许垃圾收集、程序没有崩溃并且没有处于垃圾收集循环,该方法会根据三种不同方式触发进行不同的检查:
用于开启垃圾回收的方法为 runtime.gcStart ,因此所有调用该函数的地方都是触发GC的代码
申请内存触发 runtime.mallocgc
Go运行时会将堆上的对象按大小分成微对象、小对象和大对象三类,这三类对象的创建都可能会触发新的GC
1. 当前线程的内存管理单元中不存在空闲空间时,创建微对象 (noscan && size < maxTinySize)
和小对象需要调用 runtime.mcache.nextFree 从中心缓存或者页堆中获取新的管理单元,这时如果span满了就会导致返回的 shouldhelpgc=true,就可能触发垃圾收集;
2. 当用户程序申请分配 32KB 以上的大对象时,一定会构建 runtime.gcTrigger 结构体尝试触发垃圾收集;
这个时候调用 t.test() 执行的是 gcTriggerHeap 情况,只需要判断 gcController.heapLive>= gcController.trigger 的真假就可以了。 heapLive 表示垃圾收集中存活对象字节数, trigger 表示触发标记的堆内存大小的;当内存中存活的对象字节数大于触发垃圾收集的堆大小时,新一轮的垃圾收集就会开始。
1. heapLive — 为了减少锁竞争,运行时只会在中心缓存分配或者释放内存管理单元以及在堆上分配大对象时才会更新;
2. trigger — 在标记终止阶段调用`runtime.gcSetTriggerRatio` 更新触发下一次垃圾收集的堆大小,它能够决定触发垃圾收集的时间以及用户程序和后台处理的标记任务的多少,利用反馈控制的算法根据堆的增长情况和垃圾收集 CPU 利用率确定触发垃圾收集的时机。
手动触发runtime.GC
用户程序会通过 runtime.GC 函数在程序运行期间主动通知运行时执行,该方法在调用时会阻塞调用方直到当前垃圾收集循环完成,在垃圾收集期间也可能会通过 STW 暂停整个程序:
后台运行定时检查触发runtime.forcegchelper
运行时会在应用程序启动时在后台开启一个用于强制触发垃圾收集的 Goroutine,该 Goroutine调用 runtime.gcStart 尝试启动新一轮的垃圾收集:
垃圾回收区域
Java内存运行时区域的各个部分,其中程序计数器、虚拟机栈、本地方法栈3个区域随着线程而生,随着线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈的操作,每个栈帧中分配多少内存基本是在类结构确定下来时就已知的。而Java堆和方法区则不同,一个接口中的多个实现类需要的内存可能不同,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间时才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,因此,Java堆和方法区是Java垃圾收集器管理的主要区域。
go内存会分成堆区(Heap)和栈区(Stack)两个部分,程序在运行期间可以主动从堆区申请内存空间,这些内存由内存分配器分配并由垃圾收集器负责回收。栈区的内存由编译器自动进行分配和释放,栈区中存储着函数的参数以及局部变量,它们会随着函数的创建而创建,函数的返回而销毁。如果只申请和分配内存,内存终将枯竭。Go使用垃圾回收收集不再使用的span,把span释放交给mheap,mheap对span进行span的合并,把合并后的span加入scav树中,等待再分配内存时,由mheap进行内存再分配。因此,Go堆是Go垃圾收集器管理的主要区域。
触发垃圾回收的时机
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则会根据以下条件进行触发:
收集算法
当前Java虚拟机的垃圾收集采用分代收集算法,根据对象存活周期的不同将内存分为几块。比如在新生代中,每次收集都会有大量对象死去,所以可以选择“标记-复制”算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。 而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集。
当前Go的都是基于标记清除算法进行垃圾回收。
垃圾碎片的处理
由于Java的内存管理划分,因此容易产生垃圾对象,JVM这些年不断的改进和更新GC算法,JVM在处理内存碎片问题上更多采用空间压缩和分代收集的思想,例如在新生代使用“标记-复制”算法,G1收集器支持了对象移动以消减长时间运行的内存碎片问题,划分region的设计更容易把空闲内存归还给OS等设计。
由于Go的内存管理的实现,很难实现分代,而移动对象也可能会导致runtime更庞大复杂,因此Go在关于内存碎片的处理方案和Java并不太一样。
1. Go语言span内存池的设计,减轻了很多内存碎片的问题。
Go内存释放的过程如下:当 mcache 中存在较多空闲 span 时,会归还给 mcentral;而 mcentral 中存在较多空闲 span 时,会归还给 mheap;mheap 再归还给操作系统。这种设计主要有以下几个优势:
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内存碎片回收压力。
比如如下代码
运行代码如下,结果显示temp变量被分配在栈上并没有分配在堆上
当我们把上述代码更改
运行代码如下,结果显示temp变量被分配在堆上,这是由于temp传入了print函数里,编译器会认为变量之后还会被使用。因此就申请到堆上,申请到堆上面的内存才会引起垃圾回收,如果这个过程(特指垃圾回收不断被触发)过于高频就会导致 gc 压力过大,程序性能出问题。
“GC Roots” 的对象的选择
在Java中由于内存运行时区域的划分,通常会选择以下几种作为“GC Roots” 的对象:
而在Java中的不可达对象有可能会逃脱。即使在可达性分析法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑阶段”,要真正宣告一个对象死亡,至少要经历两次标记过程;此外Java中由于存在运行时常量池和类,因此也需要对运行时常量池和方法区的类进行清理。
而Go的选择就相对简单一点,即全局变量和G Stack中的引用指针,简单来说就是全局量和go程中的引用指针。因为Go中没有类的封装概念,因而Gc Root选择也相对简单一些。
写屏障
为了解决并发三色可达性分析中的悬挂指针问题,出现了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的垃圾回收机制较为完善,Java划分新生代、老年代来存储对象。对象通常会在新生代分配内存,多次存活的对象会被移到老年代,由于新生代存活率低,产生空间碎片的可能性高,通常选用“标记-复制”作为回收算法,而老年代存活率高,通常选用“标记-清除”或“标记-整理”作为回收算法,压缩整理空间。
Go是非分代的、并发的、基于三色标记和清除的垃圾回收器,它的优势要结合它tcmalloc内存分配策略才能体现出来,因为小微对象的分配均有自己的内存池,所有的碎片都能被完美复用,所以GC不用考虑空间碎片的问题。
1. Go语言设计与实现
2. 一个专家眼中的Go与Java垃圾回收算法大对比
3. Go语言问题集
4. CMS垃圾收集器
5. Golangv 1.16版本源码
6. Golang---内存管理(内存分配)
7.《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)》—机械工业出版社
现代高级编程语言管理内存的方式分自动和手动两种。手动管理内存的典型代表是C和C++,编写代码过程中需要主动申请或者释放内存;而 PHP、Java 和 Go等语言使用自动的内存管理系统,由内存分配器和垃圾收集器来代为分配和回收内存,其中垃圾收集器就是我们常说的GC。本文中,笔者将从原理出发,介绍Java和Golang垃圾回收算法,并从原理上对他们做一个对比。
垃圾回收区域及划分
在介绍Java垃圾回收之前,我们需要了解Java的垃圾主要存在于哪个区域。JVM内存运行时区域划分如下图所示:
图源:深入理解Java虚拟机:JVM高级特性与最佳实践(第3版) —机械工业出版社
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 其实准确分类只有两大种:
- 新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集;
- 老年代收集(Major GC / Old GC):只对老年代进行垃圾收集。需要注意的是 MajorGC 在有的语境中也用于指代整堆收集;
- 混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集。
Java堆内存常见分配策略
- 如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小
- 如果大于,将尝试着进行一次Minor GC,尽管这次Minor GC是有风险的
- 如果小于,或者HandlePromotionFailure设置不允许冒险,那这时也要改为进行一次Full GC
判断对象死亡
堆中几乎放着所有的对象实例,对堆垃圾回收前的第一步就是要判断哪些对象已经死亡(即不能再被任何途径使用的对象)。判断一个对象是否存活有引用计数、可达性分析这两种算法,两种算法各有优缺点。 Java和Go都使用可达性分析算法,一些动态脚本语言(如:ActionScript)一般使用引用计数算法。
引用计数法
引用计数法给每个对象的对象头添加一个引用计数器,每当其他地方引用一次该对象,计数器就加1;当引用失效,计数器就减 1;任何时候计数器为 0 的对象就是不可能再被使用的。
这个方法实现简单,效率高,但是主流的Java虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间相互循环引用的问题。即如下代码所示:除了对象 objA 和 objB 相互引用着对方之外,这两个对象之间再无任何引用。但是他们因为互相引用对方,导致它们的引用计数器都不为 0,于是引用计数算法无法通知 GC 回收器回收他们。
目前Python语言使用的是引用计数法,它采用了“标记-清除”算法,解决容器对象可能产生的循环引用问题,关于详细原理可以参考Python垃圾回收机制详解
可达性分析算法
这个算法的基本思想就是通过一系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的。算法优点是能准确标识所有的无用对象,包括相互循环引用的对象;缺点是算法的实现相比引用计数法复杂。比如如下图所示Root1和Root2都为“GC Roots” ,白色节点为应被垃圾回收的
关于Java查看可达性分析、内存泄露的工具,强烈推荐“Memory Analyzer Tool”,可以查看内存分布、对象间依赖、对象状态。
在Java中,可以作为 “GC Roots” 的对象有很多,比如:
不可达的对象并非“非死不可”
即使在可达性分析法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑阶段”,要真正宣告一个对象死亡,至少要经历两次标记过程;可达性分析法中不可达的对象被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize 方法。当对象没有覆盖 finalize 方法,或 finalize 方法已经被虚拟机调用过时,虚拟机将这两种情况视为没有必要执行。被判定为需要执行的对象将会被放在一个队列中进行第二次标记,除非这个对象与引用链上的任何一个对象建立关联,否则就会被真的回收。
判断一个运行时常量池中的常量是废弃常量
1. JDK1.7 之前运行时常量池逻辑包含字符串常量池存放在方法区, 此时 hotspot 虚拟机对方法区的实现为永久代
2. JDK1.7 字符串常量池被从方法区拿到了堆中, 这里没有提到运行时常量池,也就是说字符串常量池被单独拿到堆,运行时常量池剩下的东西还在方法区, 也就是 hotspot 中的永久代 。
3. JDK1.8 hotspot 移除了永久代用元空间(Metaspace)取而代之, 这时候字符串常量池还在堆, 运行时常量池还在方法区, 只不过方法区的实现从永久代变成了元空间(Metaspace)
假如在字符串常量池中存在字符串"abc",如果当前没有任何 String 对象引用该字符串常量的话,就说明常量"abc" 就是废弃常量,如果这时发生内存回收的话而且有必要的话,"abc"就会被系统清理出常量池了。
如何判断一个方法区的类是无用的类
类需要同时满足下面 3 个条件才能算是 “无用的类”,虚拟机可以对无用类进行回收。
当确定了哪些对象可以回收后,就要需要考虑如何对这些对象进行回收,目前垃圾回收算法主要有以下几种。
标记清除算法
该算法分为“标记”和“清除”阶段:首先标记出所有不需要回收的对象,在标记完成后统一回收掉所有没有被标记的对象。
适用场合:存活对象较多的情况、适用于年老代(即旧生代)
缺点:
标记复制算法
为了解决效率问题,出现了“标记-复制”收集算法。它可以将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。使用复制算法,回收过程中就不会出现内存碎片,也提高了内存分配和释放的效率
适用场合:存活对象较少的情况下比较高效、用于年轻代(即新生代)
缺点:需要一块儿空的内存空间,整理阶段,由于移动了可用对象,需要去更新引用。
标记整理算法
对于对象存活率较高的场景,复制算法要进行较多复制操作,使得效率会变低,这种场景更适合标记-整理算法,与标记-清理一样,标记整理算法先标记出对象的存活状态,但在清理时,是先把所有存活对象往一端移动,然后直接清掉边界以外的内存。
适用场合:对象存活率较高(即老年代)
缺点:整理阶段,由于移动了可用对象,需要去更新引用。
分代收集算法
当前Java虚拟机的垃圾收集采用分代收集算法,一般根据对象存活周期的不同将内存分为新生代和老年代。在新生代中,每次收集都会有大量对象死去,可以选择”标记-复制“算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。 而老年代的对象存活几率是比较高,而且没有额外的空间对它进行分配担保,所以我们选择“标记-清除”或“标记-整理”算法进行垃圾收集。
图源:深入理解Java虚拟机:JVM高级特性与最佳实践(第3版) —机械工业出版社
虽然我们对各个收集器进行比较,但并非要挑选出一个最好的收集器。因为直到现在为止还没有最好的垃圾收集器出现,更加没有万能的垃圾收集器,我们能做的就是根据具体应用场景选择适合自己的垃圾收集器。
从Go v1.12版本开始,Go使用了非分代的、并发的、基于三色标记清除的垃圾回收器。相关标记清除算法可以参考和C/C++一样,Go是一种静态类型的编译型语言。因此,Go不需要VM,Go应用程序二进制文件中嵌入了一个小型运行时(Go runtime),可以处理诸如垃圾收集(GC),调度和并发之类的语言功能。首先让我们看一下Go内部的内存管理是什么样子的。
Golang内存管理
这里先简单介绍一下 Golang 运行调度。在 Golang 里面有三个基本的概念:G, M, P。
一个 Goroutine 的运行需要 G + P + M 三部分结合起来。
图源:Golang---内存管理(内存分配)
TCMalloc
Go将内存划分和分组为页(Page),这和Java的内存结构完全不同,没有分代内存,这样的原因是Go的内存分配器采用了TCMalloc的设计思想:
Page
与TCMalloc中的Page相同,x64下1个Page的大小是8KB。上图的最下方,1个浅蓝色的长方形代表1个Page。
Span
与TCMalloc中的Span相同,Span是内存管理的基本单位,代码中为mspan,一组连续的Page组成1个Span,所以上图一组连续的浅蓝色长方形代表的是一组Page组成的1个Span,另外,1个淡紫色长方形为1个Span。
mcache
mcache是提供给P(逻辑处理器)的高速缓存,用于存储小对象(对象大小<= 32Kb)。尽管这类似于线程堆栈,但它是堆的一部分,用于动态数据。所有类大小的mcache包含scan和noscan类型mspan。Goroutine可以从mcache没有任何锁的情况下获取内存,因为一次P只能有一个锁G。因此,这更有效。mcache从mcentral需要时请求新的span。
mcentral
mcentral与TCMalloc中的CentralCache类似,是所有线程共享的缓存,需要加锁访问,它按Span class对Span分类,串联成链表,当mcache的某个级别Span的内存被分配光时,它会向mcentral申请1个当前级别的Span。每个mcentral包含两个mspanList:
mheap
mheap与TCMalloc中的PageHeap类似,它是堆内存的抽象,也是垃圾回收的重点区域,把从OS申请出的内存页组织成Span,并保存起来。当mcentral的Span不够用时会向mheap申请,mheap的Span不够用时会向OS申请,向OS的内存申请是按页来的,然后把申请来的内存页生成Span组织起来,同样也是需要加锁访问的。
栈
这是栈存储区,每个Goroutine(G)有一个栈。在这里存储了静态数据,包括函数栈帧,静态结构,原生类型值和指向动态结构的指针。这与分配给每个P的mcache不是一回事。
内存分配
Go中的内存分类并不像TCMalloc那样分成小、中、大对象,但是它的小对象里又细分了一个Tiny对象,Tiny对象指大小在1Byte到16Byte之间并且不包含指针的对象。小对象和大对象只用大小划定,无其他区分。
核心思想:把内存分为多级管理,降低锁的粒度(只是去mcentral和mheap会申请锁), 以及多种对象大小类型,减少分配产生的内存碎片。
内存回收
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种:
为了解决上述的“对象消失”的现象,Wilson于1994年在理论上证明了,当且仅当以下两个条件同时满足时,会产生“对象消失”的问题,即原本应该是黑色的对象被误标为白色^[7]^:
因此为了我们要解决并发扫描时的对象消失问题,保证垃圾收集算法的正确性,只需破坏这两个条件的任意一个即可,屏障技术就是在并发或者增量标记过程中保证三色不变性的重要技术。
内存屏障技术是一种屏障指令,它可以让 CPU 或者编译器在执行内存相关操作时遵循特定的约束,目前多数的现代处理器都会乱序执行指令以最大化性能,但是该技术能够保证内存操作的顺序性,在内存屏障前执行的操作一定会先于内存屏障后执行的操作。垃圾收集中的屏障技术更像是一个钩子方法,它是在用户程序读取对象、创建新对象以及更新对象指针时执行的一段代码,根据操作类型的不同,我们可以将它们分成读屏障(Read barrier)和写屏障(Write barrier)两种,因为读屏障需要在读操作中加入代码片段,对用户程序的性能影响很大,所以编程语言往往都会采用写屏障保证三色不变性^[1]^。
插入写屏障
Dijkstra 在 1978 年提出了插入写屏障,也被叫做增量更新,通过如下所示的写屏障,破坏上述第一个条件(赋值器插入了一条或多条从黑色对象到白色对象的新引用):
上述伪代码非常好理解,当黑色对象(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 必须为栈上的对象增加写屏障或者在标记阶段完成重新对栈上的对象进行扫描,这两种方法各有各的缺点,前者会大幅度增加写入指针的额外开销,后者重新扫描栈对象时需要暂停程序,垃圾收集算法的设计者需要在这两者之前做出权衡^[1]^。
删除写屏障
Yuasa 在 1990 年的论文 Real-time garbage collection on general-purpose machines 中提出了删除写屏障,因为一旦该写屏障开始工作,它会保证开启写屏障时堆上所有对象的可达。起始时STW 扫描所有的 goroutine 栈,保证所有堆上在用的对象都处于灰色保护下,所以也被称作快照垃圾收集(Snapshot GC),这是破坏了“对象消失”的第二个条件(赋值器删除了全部从灰色对象到该白色对象的直接或间接引用)。
但是这样也会导致一个问题,由于会将有存活可能的对象都标记成灰色,因此最后可能会导致应该回收的对象未被回收,这个对象只有在下一个循环才会被回收,比如下图的D对象。
由于原始快照的原因,起始也是执行 STW,删除写屏障不适用于栈特别大的场景,栈越大,STW 扫描时间越长
混合写屏障
在 Go 语言 v1.7 版本之前,运行时会使用 Dijkstra 插入写屏障保证强三色不变性,但是运行时并没有在所有的垃圾收集根对象上开启插入写屏障。因为应用程序可能包含成百上千的 Goroutine,而垃圾收集的根对象一般包括全局变量和栈对象,如果运行时需要在几百个 Goroutine 的栈上都开启写屏障,会带来巨大的额外开销,所以 Go 团队在v1.8结合上述2种写屏障构成了混合写屏障,实现上选择了在标记阶段完成时暂停程序、将所有栈对象标记为灰色并重新扫描^[1]^。
Go 语言在 v1.8 组合 Dijkstra 插入写屏障和 Yuasa 删除写屏障构成了如下所示的混合写屏障,该写屏障会将被覆盖的对象标记成灰色并在当前栈没有扫描时将新对象也标记成灰色:
为了移除栈的重扫描过程,除了引入混合写屏障之外,在垃圾收集的标记阶段,我们还需要将创建的所有新对象都标记成黑色,防止新分配的栈内存和堆内存中的对象被错误地回收,因为栈内存在标记阶段最终都会变为黑色,所以不再需要重新扫描栈空间。总结来说主要有这几点:
GC 演进过程
o 将 unsafe.Pointer 类型转换成整数类型的值认定为不合法的,可能会造成悬挂指针等严重问题;
o 大幅度降低垃圾收集的延迟从几百 ms 降低至 10ms 以下;
o 计算垃圾收集启动的合适时间并通过并发加速垃圾收集的过程;
o 基于显式的状态机使得任意 Goroutine 都能触发垃圾收集的状态迁移;
o 使用密集的位图替代空闲链表表示的堆内存,降低清除阶段的 CPU 占用;
GC 过程
Golang GC 相关的代码在 runtime/mgc.go 文件下,可以看见gc总共分为4个阶段(翻译自golang v1.16版本源码):
1. sweep termination(清理终止)
a. 暂停程序,触发STW。所有的 P(处理器)都会进入 safe-point(安全点);
b. 清理未被清理的 span 。如果当前垃圾收集是强制触发的,需要处理还未被清理的内存管理单元;
2. the mark phase(标记阶段)
a. 将GC状态 gcphase 从 _GCoff 改成 _GCmark 、开启写屏障、启用协助线程(mutatorassists)、将根对象入队
b. 恢复程序执行,标记进程(mark workers)和协助程序会开始并发标记内存中的对象,写屏障会覆盖的重写指针和新指针(标记成灰色),而所有新创建的对象都会被直接标记成黑色;
c. GC执行根节点的标记,这包括扫描所有的栈、全局对象以及不在堆中的运行时数据结构。扫描goroutine 栈会导致 goroutine 停止,并对栈上找到的所有指针加置灰,然后继续执行 goroutine。
d. GC遍历灰色对象队列,会将灰色对象变成黑色,并将该指针指向的对象置灰。
e. 由于GC工作分布在本地缓存中,GC 会使用分布式终止算法(distributedtermination algorithm)来检测何时不再有根标记作业或灰色对象,如果没有了 GC 会转为mark termination(标记终止)
3. mark termination(标记终止)
a. STW
b. 将GC状态 gcphase 切换至 _GCmarktermination ,关闭gc工作线程和协助程序
c. 执行housekeeping,例如刷新mcaches
4. the sweep phase(清理阶段)
a. 将GC状态 gcphase 切换至 _GCoff 来准备清理阶段,初始化清理阶段并关闭写屏障
b. 恢复用户程序,从现在开始,所有新创建的对象会标记成白色;如果有必要,在使用前分配清理spans
c. 后台并发清理所有的内存管理类单元
GC过程代码示例
运行程序
栈分析
GC 触发条件
运行时会通过 runtime.gcTrigger.test 方法决定是否需要触发垃圾收集,当满足触发垃圾收集的基本条件(即满足 _GCoff 阶段的退出条件)时 — 允许垃圾收集、程序没有崩溃并且没有处于垃圾收集循环,该方法会根据三种不同方式触发进行不同的检查:
用于开启垃圾回收的方法为 runtime.gcStart ,因此所有调用该函数的地方都是触发GC的代码
申请内存触发 runtime.mallocgc
Go运行时会将堆上的对象按大小分成微对象、小对象和大对象三类,这三类对象的创建都可能会触发新的GC
1. 当前线程的内存管理单元中不存在空闲空间时,创建微对象 (noscan && size < maxTinySize)
和小对象需要调用 runtime.mcache.nextFree 从中心缓存或者页堆中获取新的管理单元,这时如果span满了就会导致返回的 shouldhelpgc=true,就可能触发垃圾收集;
2. 当用户程序申请分配 32KB 以上的大对象时,一定会构建 runtime.gcTrigger 结构体尝试触发垃圾收集;
这个时候调用 t.test() 执行的是 gcTriggerHeap 情况,只需要判断 gcController.heapLive>= gcController.trigger 的真假就可以了。 heapLive 表示垃圾收集中存活对象字节数, trigger 表示触发标记的堆内存大小的;当内存中存活的对象字节数大于触发垃圾收集的堆大小时,新一轮的垃圾收集就会开始。
1. heapLive — 为了减少锁竞争,运行时只会在中心缓存分配或者释放内存管理单元以及在堆上分配大对象时才会更新;
2. trigger — 在标记终止阶段调用`runtime.gcSetTriggerRatio` 更新触发下一次垃圾收集的堆大小,它能够决定触发垃圾收集的时间以及用户程序和后台处理的标记任务的多少,利用反馈控制的算法根据堆的增长情况和垃圾收集 CPU 利用率确定触发垃圾收集的时机。
手动触发runtime.GC
用户程序会通过 runtime.GC 函数在程序运行期间主动通知运行时执行,该方法在调用时会阻塞调用方直到当前垃圾收集循环完成,在垃圾收集期间也可能会通过 STW 暂停整个程序:
后台运行定时检查触发runtime.forcegchelper
运行时会在应用程序启动时在后台开启一个用于强制触发垃圾收集的 Goroutine,该 Goroutine调用 runtime.gcStart 尝试启动新一轮的垃圾收集:
垃圾回收区域
Java内存运行时区域的各个部分,其中程序计数器、虚拟机栈、本地方法栈3个区域随着线程而生,随着线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈的操作,每个栈帧中分配多少内存基本是在类结构确定下来时就已知的。而Java堆和方法区则不同,一个接口中的多个实现类需要的内存可能不同,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间时才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,因此,Java堆和方法区是Java垃圾收集器管理的主要区域。
go内存会分成堆区(Heap)和栈区(Stack)两个部分,程序在运行期间可以主动从堆区申请内存空间,这些内存由内存分配器分配并由垃圾收集器负责回收。栈区的内存由编译器自动进行分配和释放,栈区中存储着函数的参数以及局部变量,它们会随着函数的创建而创建,函数的返回而销毁。如果只申请和分配内存,内存终将枯竭。Go使用垃圾回收收集不再使用的span,把span释放交给mheap,mheap对span进行span的合并,把合并后的span加入scav树中,等待再分配内存时,由mheap进行内存再分配。因此,Go堆是Go垃圾收集器管理的主要区域。
触发垃圾回收的时机
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则会根据以下条件进行触发:
收集算法
当前Java虚拟机的垃圾收集采用分代收集算法,根据对象存活周期的不同将内存分为几块。比如在新生代中,每次收集都会有大量对象死去,所以可以选择“标记-复制”算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。 而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集。
当前Go的都是基于标记清除算法进行垃圾回收。
垃圾碎片的处理
由于Java的内存管理划分,因此容易产生垃圾对象,JVM这些年不断的改进和更新GC算法,JVM在处理内存碎片问题上更多采用空间压缩和分代收集的思想,例如在新生代使用“标记-复制”算法,G1收集器支持了对象移动以消减长时间运行的内存碎片问题,划分region的设计更容易把空闲内存归还给OS等设计。
由于Go的内存管理的实现,很难实现分代,而移动对象也可能会导致runtime更庞大复杂,因此Go在关于内存碎片的处理方案和Java并不太一样。
1. Go语言span内存池的设计,减轻了很多内存碎片的问题。
Go内存释放的过程如下:当 mcache 中存在较多空闲 span 时,会归还给 mcentral;而 mcentral 中存在较多空闲 span 时,会归还给 mheap;mheap 再归还给操作系统。这种设计主要有以下几个优势:
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内存碎片回收压力。
比如如下代码
运行代码如下,结果显示temp变量被分配在栈上并没有分配在堆上
当我们把上述代码更改
运行代码如下,结果显示temp变量被分配在堆上,这是由于temp传入了print函数里,编译器会认为变量之后还会被使用。因此就申请到堆上,申请到堆上面的内存才会引起垃圾回收,如果这个过程(特指垃圾回收不断被触发)过于高频就会导致 gc 压力过大,程序性能出问题。
“GC Roots” 的对象的选择
在Java中由于内存运行时区域的划分,通常会选择以下几种作为“GC Roots” 的对象:
而在Java中的不可达对象有可能会逃脱。即使在可达性分析法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑阶段”,要真正宣告一个对象死亡,至少要经历两次标记过程;此外Java中由于存在运行时常量池和类,因此也需要对运行时常量池和方法区的类进行清理。
而Go的选择就相对简单一点,即全局变量和G Stack中的引用指针,简单来说就是全局量和go程中的引用指针。因为Go中没有类的封装概念,因而Gc Root选择也相对简单一些。
写屏障
为了解决并发三色可达性分析中的悬挂指针问题,出现了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的垃圾回收机制较为完善,Java划分新生代、老年代来存储对象。对象通常会在新生代分配内存,多次存活的对象会被移到老年代,由于新生代存活率低,产生空间碎片的可能性高,通常选用“标记-复制”作为回收算法,而老年代存活率高,通常选用“标记-清除”或“标记-整理”作为回收算法,压缩整理空间。
Go是非分代的、并发的、基于三色标记和清除的垃圾回收器,它的优势要结合它tcmalloc内存分配策略才能体现出来,因为小微对象的分配均有自己的内存池,所有的碎片都能被完美复用,所以GC不用考虑空间碎片的问题。
1. Go语言设计与实现
2. 一个专家眼中的Go与Java垃圾回收算法大对比
3. Go语言问题集
4. CMS垃圾收集器
5. Golangv 1.16版本源码
6. Golang---内存管理(内存分配)
7.《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)》—机械工业出版社