背景
我们经常听过垃圾回收,很多语言比如java有复杂的回收算法,初学者往往学起来非常懵,这篇文章我从大的方向总结垃圾回收有哪些算法,哪些阶段,希望能帮助到初学者能整体理解垃圾回收算法的轮廓。至于细节,可以再找对应文章学习。
文章完成时间仓促,可能有疏漏的地方,欢迎指出。
一、如何判定垃圾
可达性分析
原理
从根对象出发,标记所有可以触达的对象,标记完后,没有被触达的对象视为垃圾
缺点
如果对象特别多,对象引用链特别长,标记过程相对比较耗时,而一般情况下,标记垃圾的过程如果不做特殊设计,是不能和用户线程同时进行的(为了保证标记的准确性)。
垃圾对象不能第一时间被回收,只有被标记后才会被回收,而标记需要每隔一段时间才触发一次,平均而言,内存占用的会更多。
解决方式
通过分区的方式将大的内存拆小,每次只标记一部分内存区域, 减少标记过程停顿时间。
标记的时候通过一些算法和设计,在某些阶段,可以和用户线程并行的方式来进行,从而来减少停顿时间。
引用计数法
原理
通过记数的方式,对象每次被引用一次,计数器+1,引用没有时,计算器-1,当计数器 = 0 时,将对象回收
垃圾对象的生存周期大大缩短
缺点
计数器增加了额外的计算逻辑,尤其在多线程场景下,还需要对引用计数进行同步。
可能会产生循环依赖的情况,比如A引用了B,B引用了A,这种情况下,两者的计数器一直都是1,对象的引用计数始终不能为0,对象不能被回收,造成内存泄露。
解决方式
通过划分强弱引用的方式,来破解循环依赖问题,但是需要开发者自己处理, objective-c,swift
结合可达性分析的方式解决剩下的循环依赖垃圾,python
另外理论上可以通过试验删除算法来判定循环依赖问题,但还没听过有实际应用
二、如何回收垃圾
标记复制
原理
将标记后不是垃圾的部分,整体复制到另外一块内存区域
缺点
理论上为了保证存活的对象都有地方进行保存,需要额外一倍的内存空间,因此内存利用率低,有50%的空间浪费,如果需要复制的对象特别多,也比较耗费资源。
解决方式
扬长避短,只用来处理生命周期非常短的对象
这种情况下,存活的对象非常少,并不需要额外一倍的空间来保存,因此内存利用率提高,同时拷贝消耗也降低了
(具体方式可以参考java虚拟机内存回收的新生代设计)
标记清除
原理
将标记的垃圾直接清除,留下需要继续存在的对象。因为清除垃圾的过程,不影响活着的对象,可以在清除时,和用户线程并行。
缺点
不断进行标记清除后,会产生大量的内存碎片,以至于无法再继续分配内存了。
解决方式
一种方式是尽可能的减少内存碎片。
另一种方式是在内存碎片比较严重的时候,进行一次标记整理操作。
标记整理
原理
将存活的对象整体转移到内存空间的一侧,
相对标记复制而言,减少了额外的内存占用
相对标记清除而言,解决了内存碎片化的问题。
缺点
对存活对象的转移过程会暂停用户线程,整个转移过程消耗比较大。
解决方式
整理时转移的过程通过特殊的设计和算法,可以做到部分并发,减少用户线程的暂停时间。
三、如何减少垃圾回收造成的停顿
分代回收
原理
针对不同生命周期的对象,划分不同的内存区域,用不同的算法回收,生命周期短的对象因为存活率很低,可以用标记复制方法回收,内存区域称为新生代。生命周期长的对象用标记清除或者标记整理的方式,内存区域称老年代。
新生代回收到一定次数仍然不能被回收的,晋升到老年代。
缺点
复杂度大大提高,主要下面几个方面
第一:新生代和老年代有互相引用的情况,回收垃圾时需要综合考虑。
第二:不同应用的对象生命周期情况不同,如何合理的控制新生代和老年代比例是一个问题。
第三:通过生命周期划定后,一般情况下,新生代的空间会比较小,这个时候对大对象的处理会比较尴尬。
解决方式
在进行老年代回收前,先进行新生代回收。
通过制定目标,程序自动进行判断分配比例。
新的大对象,超过一定大小的,先分配到老年代。
分区回收
原理
在查找垃圾和回收垃圾过程中,有一些地方需要阻塞应用线程的执行,而内存整体占用越大,这一个过程就越耗时
所以我们可以通过分区的方式,将大的内存划分成多个区域,每次只回收一部分区域,减少回收的暂停时间。
缺点
和分代回收所产生的麻烦一样,不同区域的对象可能有互相引用的情况,如果回收时要完全判断这个区域的对象没有被引用,可能要扫描其他的内存区域。
互相引用需要额外的存储空间表示,会有一定的额外内存空间占用。
解决方式
通过卡表的方式来记录不同内存区域之间存在的引用关系
标记阶段并发
原理
针对标记过程中可能和用户线程产生冲突的地方做特殊处理
这一块的主要思路是在用户线程读写指针的时候加上屏障,在屏障里做一些特殊标记,具体过程细节比较多,不展开。
转移阶段并发
原理
在标记整理中会有转移的情况,这部分也会造成用户线程停顿。针对转移过程中可能和用户线程产生冲突的地方做特殊处理
对象被转移了,又要不影响用户线程,方式就是一个对象有两个地址,然后加上标记,后面通过别的方式,把旧的地址去掉,只保留新的地址,因为比较复杂,这里也不展开。
四、go语言垃圾回收特点
内存回收后碎片少,只采用标记清除
采用的内存分配算法,不会产生太多内存碎片。所以只有标记清除就可以应对,没有使用标记整理算法。
大概思想是内存按对象的大小划分出了不同大小的格子,分配对象时分配到最合适的格子,回收时直接回收这个格子就行,所以内存碎片很少。
堆GC压力小,不进行分代处理
Go 本身对象较小,同时很多对象分配在栈上,栈上的对象一般方法结束就会自动回收,只有发生逃逸时会到堆上,总之,go进程的的堆一般不会太大。分代算法也没有必要。
go对象占用空间小,不进行分区处理
和上面一样,也没有做分区处理。