漫谈GC —— GC基本理论和深度剖析

GC作为一个我们开箱即用的东西,很多人很少会直接的面对GC。但是在高并发的场景下,GC的性能却是我们比较关心的一个点。

概念


GC(Garbage Collection): 垃圾回收器,相信写过C/C++的同学,对下面这段代码应该不陌生:

int main(void)
{
    /*内存释放标志*/
    int flag = 0;
    char * p = (char *)malloc(MAX_BUF_SIZE);
    if (p == NULL)
    {
        /*...*/
    }
    if (flag == 0)
    {
        free(p);
    }
    free(p);
    return 0;
}

当我们初始化一个内容的时候,需要显式的告知编译器我需要多大的内存空间,当我们不需要的时候,也需要手动的释放(free),现在一些编程语言的所谓的“动态”数组,其实底层也是一个定长的数组,只是增加了一定的扩容机制,可以根据实际情况动态的扩充大小,满足我们的使用需求。

从上面我的描述大家也可以感受到了,垃圾回收器替我们完成的主要内容,就两件事情,分配内存和释放内存

毕竟GC不像我们程序员一样,知道在什么时候什么位置,释放哪些东西的内存。

那下面简单介绍一下一些垃圾回收算法的常见处理策略。

一、基本思路


1. 引用计数法

相信读过一些GC相关书籍和文章的同学(《深入理解Java虚拟机》,《CLR via C#》等等),特别是做iOS的小伙伴,应该很熟悉这个内容。

这种方式下,每个对象会维护一个引用计数器,可以理解为一个int值,保存着多少对象引用他了(eg: a.info = b, 这个时候,如果只有这一处引用,那b的引用计数就是1,当一个对象的引用计数为0时,则表示这个对象没有被任何人引用。就可以释放了,GC垃圾回收器就会主动把引用计数为0的对象释放掉。

这种算法简单粗暴,但也有一个致命的问题,就是循环引用的问题,看一下下面这段伪代码:

a.Obj = b;
b.Obj = a;

这段代码可以看出来 a和b的引用计数都是1,但是整个程序中,a,b都没有被任何对象在使用。这就产生了两个无用对象(引用计数不为0),但是永远无法被程序回收

你可能觉得,那我不写这样的相互引用的代码不就好了么,循环引用不单单只两个对象,我们再看一段伪代码:

a.Obj = b;
b.Obj = c;
c.Obj = d;
d.Obj = a;

abcd 四个对象形成了一个,引用计数都是1,但是同样没有外部引用,导致四个对象无法释放。这种问题在生产环境是最难排查的,你很难注意到四个对象形成了一个环引用,面对这种情况,很多语言提供了WeakReference的方式(弱引用,产生引用关系,但是 引用计数/引用关系 不增加),暂时解决这个问题,但是需要程序员自我预知是否会有类似的问题,手动处理这种情况。破坏了GC 理论上的想法,需要程序员帮助GC识别是否释放,由此产生了下面要介绍的链路可达性分析的标记方式。

循环引用

2. 链路可达性标记算法。

目前主流的Java 和 .NET方向,采用的都是这个方式,基本思路就是引用了一个GC Root的概念,GC Root所到之处,都是可达对象不可达的就是无引用对象,自然就需要被释放了。

可达性分析

如上图,所有的GC Roots 都无法指向ABCD四个对象,所以他们在下次GC执行的时候,就会被回收掉了

这里面GC Root指的不只是一个对象而是一个set集合,对于什么对象可以作为GC Root,在不同语言内,实现的机制也不是全部相同。但大概的对象包括以下几种(不同的编程语言也不都一样,仅供参考):

  • 虚拟机栈对象(JVM,CLR等)
  • OS 栈(操作系统本地方法的栈,Native)
  • 方法区中的常量或者静态引用的对象等。

二、GC 算法的分类


在实现GC算法的时候,上面所属的只是思路,实际执行过程中,为了防止GC执行的时候,一个对象正在被释放,又突然被程序调用,所以GC一般都会暂停整个程序的执行,来释放内存,也就是很多人常说的STW(Stop-the-world)。这个时候,所有的线程都停止工作,GC接管程序,释放对象。

现代的计算机基本都具备了多核CPU的能力,所以GC也开始利用多核CPU的特性,将很多标记的操作交给后台线程操作,这样在GC STW的时候,只需要释放需要释放的对象就可以了,不需要进行一次整个堆的扫描,减少了STW的时间(当然了,不全盘扫描,还有一个重要的原因是分代策略,后面会介绍)。

那是不是STW的时间越小越好呢?很多了解GC的同学,都被这个标准所迷惑,以为STW的时间越短越好

如果需要释放的对象数量恒定,那么:

  • STW 时间越短,意味着你后台线程执行的内容越多线程的调度和切换更频繁,而且GC 在标记的过程中,还有内存搬动的操作,都是比较耗费性能的,势必会带来程序性能(吞吐量)的下降。Java中的CMS收集器就是典型的代表,他采用 (CPU 核数 + 3)/ 4 数量的线程数来处理GC的内容 ,如果是2核CPU的话,就有一个CPU专门来处理GC的内容,系统的性能直接下降50%,势必带来极大的吞吐量的下降。
  • 吞吐量优先的算法,相比更短的暂停时间,这种模式下的思路就是减小CPU的占用,但是在暂停时间上,会比一些算法时间要长,这种模式下对于很多生产环境的计算服务就很适合。

GC在暂停时间和吞吐量优先上,很难两者都做到极致,就像很多时候说的用空间换时间,还是时间换空间的思路一样。所以GC目前的分类也显而易见了:

  • 单线程GC
  • 多线程GC
    • 吞吐量优先
    • 暂停时间优先

以上分类是笔者个人心中的一个分类,可能还有很多细节上的分支,欢迎大家留意讨论。

三、GC 算法的评价标准


上面的描述中,谈到了两个重要的标准,一个是暂停时间,一个是吞吐量。
实际上任GC算法还有很多其他的标准。GC设计的时候,大多会参考一下的指标

  • GC暂停时间:这里就是GC释放的时候需要多久的暂停时间,因为GC在Full GC的时候,会暂停整个程序,那暂停的时间就尤为关键了。
  • GC暂停的频率: 多久会执行一次GC,这里多提一句,很多分带算法,就是为了降低GC暂停的频率,让STW的只在需要Full GC的时候发生,其他的时候,只在某个Region上面释放就好了。
  • 程序性能的影响:并发执行GC的时候,占用了多少CPU线程和资源。
  • 堆的开销: GC执行过程中,有很多额外的内存开销,最基本的,标记一个对象的引用,还包括需要进行内存Move的操作,防止内存碎片(下面会再提及这个),这些开销对于需要进行的GC是否足够小,小到可以忽略。
  • 暂停时间的分布情况: 这里比较容易理解了,是否每次暂停的时间可控,比如说有的时候只需要暂停20ms,有的时候需要暂停20s,这样的差距会直接导致程序的假死问题,产生线上的雪崩。这里很多GC提供了Full GC最大的时间,来控制这个结果。
  • 是否是多线程模式:单线程的模式不存在吞吐量的问题,就是在需要的时候,整个堆内存进行Full GC,所以一般只会用在一些C端和一些单片机系统的里面。
  • 伸缩性:这里就是GC是否具备自我调节的能力,比如说是否会在年轻代垃圾比较多的时候,自动调整对应堆的大小,在程序比较繁忙的时候,减小GC后台线程数,让出更多的CPU给正常的工作。
  • 调优:GC是否是开箱即用的,是否支持或者是否需要调整很多的参数, 这个标准跟上面的伸缩性是对应关系,你的伸缩性更好,那基本就不需要调优,反之就需要调整很多参数,来适应你生产环境的程序
  • 伸缩时间:这个跟伸缩性也是对应的,就是面对动态调整,需要多久能够响应调整,调整完成。这个很多程序员可能都不关注或者不关心。这里如果动态调整的过程,特别是不同带的内存大小的调整是否会导致程序的不稳定又一次的STW,都关乎我们程序的性能,所以这个指标也是很重要的一个指标

以上的指标很难全部覆盖,特别是性能和暂停时间上,这个在上面我也有提及。

四、标记过程的实现


相比手动释放垃圾,GC最重要的就两个关键字, Mark & Sweep, 中文的翻译就是标记,清除。这里在对于实现的思路在多聊几句吧。

对于不同时期,不同类型的对象(分代回收,下一节会介绍),标记清除的策略也不都是一致的。

1. 标记-清除 算法

这是一个最最基础的实现,利用上面提及的(引用计数/链路可达性分析等),分析出需要释放的对象,然后直接把对应的对象释放掉就好了。


标记清除算法

这种算法同样简单粗暴,但是有一个问题:

  • 大家仔细看上面的图片,如果这个时候,我需要一个四个格子的内存空间,你就会发现,我们无法找到一个四个格子的空间,但实际上,还有很多的空间都是空余的。这就是常说的内存碎片的问题

2. 复制算法

复制算法提供的就是一个备用空间,当空间不足的时候,把使用的这一块进行清理,存活对象放到另一个分区里面。

复制算法

上面的图片,使用的空间再也没有连续的两个格子的空间,如果申请两个格子的空间,就会触发GC,交换清理之后,就变成下面图片的样子了。

还记得上面提到的一个GC算法衡量标准 —— 堆的开销么。这种情况下,如果整个GC都采用这种机制,那你的堆内存会直接减少50%,所以在现在的垃圾回收算法里面,这种算法大部分只用在新生代或者第0代里面,因为新生代的对象,存活时间很短,这种算法跟新生代的特点很匹配。

3.标记 - 整理算法

这种算法算是整合了上面的两个思路,标记和整理的思路不变,只是整理的思路在自己的区域本身了,而不是备份空间的模式。
至于整理的方式就是把存活的对象往一端移动,存活对象移动完成之后,直接释放后面的全部对象就可以了。

image.png

由于内存空间是连续的,最后只需要释放9到空间末尾的所有内存就好了。


面对上面介绍的三种模式,我们还是要通过GC 算法的衡量标准去看待他们。

  • 第一种模式最节省性能,但是存在内存空间碎片的问题
  • 第二种模式解决了第一种模式的问题,但是内存空间利用率极低,不能应用在整个GC算法。
  • 第三种模式最后的整理过程其实也是遍历的过程,CPU性能不如第一种,但是毕竟解决了内存碎片的问题。

五、分代算法


最后在提及一下目前的一些分代算法的内容

分代没有什么特别的内容,利用分而治之的策略,解决整堆遍历标记清除的高暂停问题,特别是对于服务端,动辄几十GB的内存,全堆遍历的暂停时间恐怕是不能想象的长。

Java中分为新生代,老年代,.NET 分为0,1,2 三代,新生代创建和释放的过程很快,复制算法很好的解决这个问题。老年代包括很多 长时间存在的对象一些大对象,则会采用 标记-整理 或者 标记 - 清除的策略。分代的策略有点贪心算法的意思,每个部分最优,则结果最优

这里在提及一下大对象的问题:

很多GC算法里面,对于大对象,基本上不会存在整理的过程,所以他基本都是直接放到老年代里面,原因很简单,大对象的整理,涉及内存之间反复的移动,非常耗费性能。
每个虚拟机对于大对象的定义也不都相同,Java里面可以手动设置这个值,个人记得貌似是3MB,C#的是85000个字节,而且对于不同版本的GC算法,也都不完全一样,有兴趣的朋友可以自己去查阅一下对应语言的大对象标准值是多少。这里不多做介绍了。

不同语言分代的定义也不都一样,所以这里不多做介绍了,后续我会出一个针对不同编程语言的GC细节上的介绍,到时候在具体介绍其他的内容。这篇文章算是抛砖引玉,让大家对于GC有一个全方位的认知和了解。

六、后记


后面我会分别在介绍一下C#,Java,Go三门语言的GC特性,敬请期待。

本文为原创文章,转载请注明出处

参考链接:
Modern garbage collection

现代的垃圾回收机制(Go 垃圾回收机制概述)

《CLR via C#》

《深入理解 Java 虚拟机》

你可能感兴趣的:(漫谈GC —— GC基本理论和深度剖析)