Java教程

一文搞懂JAVA与Go垃圾回收

本文主要是介绍一文搞懂JAVA与Go垃圾回收,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

现代高级编程语言管理内存的方式分自动和手动两种。手动管理内存的典型代表是C和C++,编写代码过程中需要主动申请或者释放内存;而 PHP、Java 和 Go等语言使用自动的内存管理系统,由内存分配器和垃圾收集器来代为分配和回收内存,其中垃圾收集器就是我们常说的GC。本文中,笔者将从原理出发,介绍Java和Golang垃圾回收算法,并从原理上对他们做一个对比。

Java垃圾回收

垃圾回收区域及划分

在介绍Java垃圾回收之前,我们需要了解Java的垃圾主要存在于哪个区域。JVM内存运行时区域划分如下图所示:

图源:深入理解Java虚拟机:JVM高级特性与最佳实践(第3版) —机械工业出版社

图源:深入理解Java虚拟机:JVM高级特性与最佳实践(第3版) —机械工业出版社

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

  • 虚拟机栈:它描述的是 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 其实准确分类只有两大种:  

 

  • 部分收集 (Partial GC):

  - 新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集;

  - 老年代收集(Major GC / Old GC):只对老年代进行垃圾收集。需要注意的是 MajorGC 在有的语境中也用于指代整堆收集;

  - 混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集。

 

  • 整堆收集 (Full GC):收集整个 Java 堆和方法区。

Java堆内存常见分配策略

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

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

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

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

 - 如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小

- 如果大于,将尝试着进行一次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” 的对象有很多,比如:

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。

  • 在方法区中类静态属性引用的对象,譬如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 个条件才能算是 “无用的类”,虚拟机可以对无用类进行回收。

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

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

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

垃圾收集算法

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

标记清除算法

该算法分为“标记”和“清除”阶段:首先标记出所有不需要回收的对象,在标记完成后统一回收掉所有没有被标记的对象。

适用场合:存活对象较多的情况、适用于年老代(即旧生代)

缺点:

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

  • 效率问题,扫描了整个空间两次(第一次:标记存活对象;第二次:清除没有标记的对象)

标记复制算法

为了解决效率问题,出现了“标记-复制”收集算法。它可以将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。使用复制算法,回收过程中就不会出现内存碎片,也提高了内存分配和释放的效率

适用场合:存活对象较少的情况下比较高效、用于年轻代(即新生代)

缺点:需要一块儿空的内存空间,整理阶段,由于移动了可用对象,需要去更新引用。

标记整理算法

对于对象存活率较高的场景,复制算法要进行较多复制操作,使得效率会变低,这种场景更适合标记-整理算法,与标记-清理一样,标记整理算法先标记出对象的存活状态,但在清理时,是先把所有存活对象往一端移动,然后直接清掉边界以外的内存。

适用场合:对象存活率较高(即老年代)

缺点:整理阶段,由于移动了可用对象,需要去更新引用。

分代收集算法

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

垃圾收集器

图源:深入理解Java虚拟机:JVM高级特性与最佳实践(第3版) —机械工业出版社

图源:深入理解Java虚拟机:JVM高级特性与最佳实践(第3版) —机械工业出版社

虽然我们对各个收集器进行比较,但并非要挑选出一个最好的收集器。因为直到现在为止还没有最好的垃圾收集器出现,更加没有万能的垃圾收集器,我们能做的就是根据具体应用场景选择适合自己的垃圾收集器。

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 三部分结合起来。

图源:Golang---内存管理(内存分配)

图源: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:

  • empty:双向span链表,包括没有空闲对象的span或缓存mcache中的span。当此处的span被释放时,它将被移至non-empty span链表。

  • non-empty:有空闲对象的span双向链表。当从mcentral请求新的span,mcentral将从该链表中获取span并将其移入empty span链表。

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会申请锁), 以及多种对象大小类型,减少分配产生的内存碎片。

  • 微小对象(Tiny)size <16B:使用mcache的微小分配器分配小于16个字节的对象,并且在单个16字节块上可完成多个微小分配。

  • 小对象(尺寸16B32KB:大小在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)。分配一大批页会减少和操作系统通信的成本。

内存回收

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种:

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

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

屏障技术

为了解决上述的“对象消失”的现象,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 开始将栈上的对象全部扫描并标记为黑色;

  • GC 期间,任何在栈上创建的新对象,均为黑色;

  • 被删除的堆对象标记为灰色;

  • 被添加的堆对象标记为灰色;

GC 演进过程

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

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

  • v1.3 — 运行时基于只有指针类型的值包含指针的假设增加了对栈内存的精确扫描支持,实现了真正精确的垃圾收集;

o 将 unsafe.Pointer 类型转换成整数类型的值认定为不合法的,可能会造成悬挂指针等严重问题;

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

o   大幅度降低垃圾收集的延迟从几百 ms 降低至 10ms 以下;

o   计算垃圾收集启动的合适时间并通过并发加速垃圾收集的过程;

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

o   基于显式的状态机使得任意 Goroutine 都能触发垃圾收集的状态迁移;

o   使用密集的位图替代空闲链表表示的堆内存,降低清除阶段的 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

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 申请内存时根据堆大小触发GC

  • runtime.GC 用户程序手动触发GC

  • runtime.forcegchelper 后台运行定时检查触发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和Go GC对比

垃圾回收区域

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则会根据以下条件进行触发:

  • runtime.mallocgc 申请内存时根据堆大小触发GC

  • runtime.GC 用户程序手动触发GC

  • runtime.forcegchelper 后台运行定时检查触发GC

收集算法

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

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

垃圾碎片的处理

由于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 L1cache 的 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内存碎片回收压力。

比如如下代码

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

当我们把上述代码更改

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

“GC Roots” 的对象的选择

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

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象

  • 本地方法栈(Native 方法)中引用的对象

  • 方法区中类静态属性引用的对象

  • 方法区中常量引用的对象

  • Java虚拟机内部引用

  • 所有被同步锁持有的对象

而在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垃圾回收之前,我们需要了解Java的垃圾主要存在于哪个区域。JVM内存运行时区域划分如下图所示:

图源:深入理解Java虚拟机:JVM高级特性与最佳实践(第3版) —机械工业出版社

图源:深入理解Java虚拟机:JVM高级特性与最佳实践(第3版) —机械工业出版社

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

  • 虚拟机栈:它描述的是 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 其实准确分类只有两大种:  

 

  • 部分收集 (Partial GC):

  - 新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集;

  - 老年代收集(Major GC / Old GC):只对老年代进行垃圾收集。需要注意的是 MajorGC 在有的语境中也用于指代整堆收集;

  - 混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集。

 

  • 整堆收集 (Full GC):收集整个 Java 堆和方法区。

Java堆内存常见分配策略

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

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

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

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

 - 如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小

- 如果大于,将尝试着进行一次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” 的对象有很多,比如:

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。

  • 在方法区中类静态属性引用的对象,譬如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 个条件才能算是 “无用的类”,虚拟机可以对无用类进行回收。

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

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

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

垃圾收集算法

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

标记清除算法

该算法分为“标记”和“清除”阶段:首先标记出所有不需要回收的对象,在标记完成后统一回收掉所有没有被标记的对象。

适用场合:存活对象较多的情况、适用于年老代(即旧生代)

缺点:

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

  • 效率问题,扫描了整个空间两次(第一次:标记存活对象;第二次:清除没有标记的对象)

标记复制算法

为了解决效率问题,出现了“标记-复制”收集算法。它可以将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。使用复制算法,回收过程中就不会出现内存碎片,也提高了内存分配和释放的效率

适用场合:存活对象较少的情况下比较高效、用于年轻代(即新生代)

缺点:需要一块儿空的内存空间,整理阶段,由于移动了可用对象,需要去更新引用。

标记整理算法

对于对象存活率较高的场景,复制算法要进行较多复制操作,使得效率会变低,这种场景更适合标记-整理算法,与标记-清理一样,标记整理算法先标记出对象的存活状态,但在清理时,是先把所有存活对象往一端移动,然后直接清掉边界以外的内存。

适用场合:对象存活率较高(即老年代)

缺点:整理阶段,由于移动了可用对象,需要去更新引用。

分代收集算法

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

垃圾收集器

图源:深入理解Java虚拟机:JVM高级特性与最佳实践(第3版) —机械工业出版社

图源:深入理解Java虚拟机:JVM高级特性与最佳实践(第3版) —机械工业出版社

虽然我们对各个收集器进行比较,但并非要挑选出一个最好的收集器。因为直到现在为止还没有最好的垃圾收集器出现,更加没有万能的垃圾收集器,我们能做的就是根据具体应用场景选择适合自己的垃圾收集器。

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 三部分结合起来。

图源:Golang---内存管理(内存分配)

图源: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:

  • empty:双向span链表,包括没有空闲对象的span或缓存mcache中的span。当此处的span被释放时,它将被移至non-empty span链表。

  • non-empty:有空闲对象的span双向链表。当从mcentral请求新的span,mcentral将从该链表中获取span并将其移入empty span链表。

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会申请锁), 以及多种对象大小类型,减少分配产生的内存碎片。

  • 微小对象(Tiny)size <16B:使用mcache的微小分配器分配小于16个字节的对象,并且在单个16字节块上可完成多个微小分配。

  • 小对象(尺寸16B32KB:大小在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)。分配一大批页会减少和操作系统通信的成本。

内存回收

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种:

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

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

屏障技术

为了解决上述的“对象消失”的现象,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 开始将栈上的对象全部扫描并标记为黑色;

  • GC 期间,任何在栈上创建的新对象,均为黑色;

  • 被删除的堆对象标记为灰色;

  • 被添加的堆对象标记为灰色;

GC 演进过程

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

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

  • v1.3 — 运行时基于只有指针类型的值包含指针的假设增加了对栈内存的精确扫描支持,实现了真正精确的垃圾收集;

o 将 unsafe.Pointer 类型转换成整数类型的值认定为不合法的,可能会造成悬挂指针等严重问题;

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

o   大幅度降低垃圾收集的延迟从几百 ms 降低至 10ms 以下;

o   计算垃圾收集启动的合适时间并通过并发加速垃圾收集的过程;

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

o   基于显式的状态机使得任意 Goroutine 都能触发垃圾收集的状态迁移;

o   使用密集的位图替代空闲链表表示的堆内存,降低清除阶段的 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

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 申请内存时根据堆大小触发GC

  • runtime.GC 用户程序手动触发GC

  • runtime.forcegchelper 后台运行定时检查触发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和Go GC对比

垃圾回收区域

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则会根据以下条件进行触发:

  • runtime.mallocgc 申请内存时根据堆大小触发GC

  • runtime.GC 用户程序手动触发GC

  • runtime.forcegchelper 后台运行定时检查触发GC

收集算法

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

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

垃圾碎片的处理

由于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 L1cache 的 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内存碎片回收压力。

比如如下代码

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

当我们把上述代码更改

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

“GC Roots” 的对象的选择

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

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象

  • 本地方法栈(Native 方法)中引用的对象

  • 方法区中类静态属性引用的对象

  • 方法区中常量引用的对象

  • Java虚拟机内部引用

  • 所有被同步锁持有的对象

而在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版)》—机械工业出版社


​​​​

这篇关于一文搞懂JAVA与Go垃圾回收的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!