垃圾回收概论

89 阅读7分钟

背景

我们经常听过垃圾回收,很多语言比如java有复杂的回收算法,初学者往往学起来非常懵,这篇文章我从大的方向总结垃圾回收有哪些算法,哪些阶段,希望能帮助到初学者能整体理解垃圾回收算法的轮廓。至于细节,可以再找对应文章学习。

文章完成时间仓促,可能有疏漏的地方,欢迎指出。

一、如何判定垃圾

可达性分析

原理

从根对象出发,标记所有可以触达的对象,标记完后,没有被触达的对象视为垃圾

缺点

如果对象特别多,对象引用链特别长,标记过程相对比较耗时,而一般情况下,标记垃圾的过程如果不做特殊设计,是不能和用户线程同时进行的(为了保证标记的准确性)。

垃圾对象不能第一时间被回收,只有被标记后才会被回收,而标记需要每隔一段时间才触发一次,平均而言,内存占用的会更多。

解决方式

通过分区的方式将大的内存拆小,每次只标记一部分内存区域, 减少标记过程停顿时间。

标记的时候通过一些算法和设计,在某些阶段,可以和用户线程并行的方式来进行,从而来减少停顿时间。

引用计数法

原理

通过记数的方式,对象每次被引用一次,计数器+1,引用没有时,计算器-1,当计数器 = 0 时,将对象回收

垃圾对象的生存周期大大缩短

缺点

计数器增加了额外的计算逻辑,尤其在多线程场景下,还需要对引用计数进行同步。

可能会产生循环依赖的情况,比如A引用了B,B引用了A,这种情况下,两者的计数器一直都是1,对象的引用计数始终不能为0,对象不能被回收,造成内存泄露。

解决方式

通过划分强弱引用的方式,来破解循环依赖问题,但是需要开发者自己处理, objective-c,swift

结合可达性分析的方式解决剩下的循环依赖垃圾,python

另外理论上可以通过试验删除算法来判定循环依赖问题,但还没听过有实际应用

二、如何回收垃圾

标记复制

原理

将标记后不是垃圾的部分,整体复制到另外一块内存区域

缺点

理论上为了保证存活的对象都有地方进行保存,需要额外一倍的内存空间,因此内存利用率低,有50%的空间浪费,如果需要复制的对象特别多,也比较耗费资源。

解决方式

扬长避短,只用来处理生命周期非常短的对象

这种情况下,存活的对象非常少,并不需要额外一倍的空间来保存,因此内存利用率提高,同时拷贝消耗也降低了

(具体方式可以参考java虚拟机内存回收的新生代设计)

标记清除

原理

将标记的垃圾直接清除,留下需要继续存在的对象。因为清除垃圾的过程,不影响活着的对象,可以在清除时,和用户线程并行。

缺点

不断进行标记清除后,会产生大量的内存碎片,以至于无法再继续分配内存了。

解决方式

一种方式是尽可能的减少内存碎片。

另一种方式是在内存碎片比较严重的时候,进行一次标记整理操作。

标记整理

原理

将存活的对象整体转移到内存空间的一侧,

相对标记复制而言,减少了额外的内存占用

相对标记清除而言,解决了内存碎片化的问题。

缺点

对存活对象的转移过程会暂停用户线程,整个转移过程消耗比较大。

解决方式

整理时转移的过程通过特殊的设计和算法,可以做到部分并发,减少用户线程的暂停时间。

三、如何减少垃圾回收造成的停顿

分代回收

原理

针对不同生命周期的对象,划分不同的内存区域,用不同的算法回收,生命周期短的对象因为存活率很低,可以用标记复制方法回收,内存区域称为新生代。生命周期长的对象用标记清除或者标记整理的方式,内存区域称老年代。

新生代回收到一定次数仍然不能被回收的,晋升到老年代。

缺点

复杂度大大提高,主要下面几个方面

第一:新生代和老年代有互相引用的情况,回收垃圾时需要综合考虑。

第二:不同应用的对象生命周期情况不同,如何合理的控制新生代和老年代比例是一个问题。

第三:通过生命周期划定后,一般情况下,新生代的空间会比较小,这个时候对大对象的处理会比较尴尬。

解决方式

在进行老年代回收前,先进行新生代回收。

通过制定目标,程序自动进行判断分配比例。

新的大对象,超过一定大小的,先分配到老年代。

分区回收

原理

在查找垃圾和回收垃圾过程中,有一些地方需要阻塞应用线程的执行,而内存整体占用越大,这一个过程就越耗时

所以我们可以通过分区的方式,将大的内存划分成多个区域,每次只回收一部分区域,减少回收的暂停时间。

缺点

和分代回收所产生的麻烦一样,不同区域的对象可能有互相引用的情况,如果回收时要完全判断这个区域的对象没有被引用,可能要扫描其他的内存区域。

互相引用需要额外的存储空间表示,会有一定的额外内存空间占用。

解决方式

通过卡表的方式来记录不同内存区域之间存在的引用关系

标记阶段并发

原理

针对标记过程中可能和用户线程产生冲突的地方做特殊处理

这一块的主要思路是在用户线程读写指针的时候加上屏障,在屏障里做一些特殊标记,具体过程细节比较多,不展开。

转移阶段并发

原理

在标记整理中会有转移的情况,这部分也会造成用户线程停顿。针对转移过程中可能和用户线程产生冲突的地方做特殊处理

对象被转移了,又要不影响用户线程,方式就是一个对象有两个地址,然后加上标记,后面通过别的方式,把旧的地址去掉,只保留新的地址,因为比较复杂,这里也不展开。

四、go语言垃圾回收特点

内存回收后碎片少,只采用标记清除

采用的内存分配算法,不会产生太多内存碎片。所以只有标记清除就可以应对,没有使用标记整理算法。

大概思想是内存按对象的大小划分出了不同大小的格子,分配对象时分配到最合适的格子,回收时直接回收这个格子就行,所以内存碎片很少。

堆GC压力小,不进行分代处理

Go 本身对象较小,同时很多对象分配在栈上,栈上的对象一般方法结束就会自动回收,只有发生逃逸时会到堆上,总之,go进程的的堆一般不会太大。分代算法也没有必要。

go对象占用空间小,不进行分区处理

和上面一样,也没有做分区处理。