浅谈常见编程语言中的垃圾收集算法

Posted by Yinode on Thursday, March 21, 2019

TOC

垃圾回收(英语:Garbage Collection,缩写为GC),在计算机科学中是一种自动的存储器管理机制。当一个计算机上的动态存储器不再需要时,就应该予以释放,以让出存储器,这种存储器资源管理,称为垃圾回收。垃圾回收器可以让程序员减轻许多负担,也减少程序员犯错的机会。垃圾回收最早起源于LISP语言。当前许多语言如Smalltalk、Java、C#和D语言都支持垃圾回收器。

事实上早期的一些语言是需要程序员手动释放内存的,但是对于程序员来说实在太累了,所以现在大部分的语言都是支持自动的垃圾回收的。

垃圾回收的几种方式

事实上,在关于垃圾回收的方式上,有不止一种的实现,并且这些实现各有优势,接下来我会一一介绍这些方法。

引用计数

引用计数是最为早期的垃圾收集算法。

简单来讲,每一个对象都会拥有一个引用计数器,在一开始这个计数器初始值为0,随后每当有一个变量指向这个对象后引用计数+1,一但释放引用引用计数器-1

如此,我们只要每隔一段时间检查所有对象,清除那些引用计数器为0的对象。

但是这种方法有一个比较明显的缺点,一但出现循环引用,就可能导致永远无法释放内存,造成内存泄露

大致的示例类似于下面这样,即使我们手动释放了一开始的变量ab,但是这两个对象仍然在互相引用,并且其引用计数永远不会清零。

// 假设js使用了引用计数的方法
let a  = {}
let b = {}
a.b = b
b.a = a

a = null
b = null

优点 简单,能够精准控制每个对象的回收,所以整个过程的是平滑收集,高性能的。 缺点 容易出现内存泄露,消耗程序员的脑力

当然这个问题也不是不可以解决,比如IOS APP使用的Object-c事实上就使用了引用计数的变种(自动引用计数ARC),他里面使用一种叫做弱引用的办法来解决这个问题,这里就一笔带过了。

接下来要介绍的几种算法,最大的特点就是自动化程度更高,他会每隔一段时间就执行,进行GC过程,但是这个GC的过程是很有可能会出现堵塞,无响应的。是一种触发GC颗粒度更加粗的做法,可以认为是丢弃一部分的性能,换来更高的开发效率,毕竟不可兼得嘛!

标记清除

标记清除法事实上和引用计数一样古老。

标记清除法的原理和遍历树的过程有点类似,首先我们需要定义一个根对象,找到在根上能够访问到的所有对象,然后对这些对象进行相同的操作,也就是不断的从根开始标记一切能够访问到的对象,如此一来,剩下的那些没有被标记的对象就是任何情况下都无法再次访问的对象,也就是说,这个对象可以被回收了。

这种方法在现在仍旧被大量使用,不过他也有一些问题,主要就是当整个内存非常大的时候,遍历的过程是非常慢的,因为这个一棵树。有可能会造成一些性能上的问题。并且整个GC的过程是无法响应其他操作的,类似整个程序进入一种拒绝执行的阻塞状态

适合相对较低的销毁率+不高频访问的情况

标记紧缩

标记紧缩事实上是标记清除的一个变种,他改变了其中清除动作的部分。

标记清除的清除方法是直接进行内存释放,而标记紧缩会让存活的对象紧紧的靠拢在一起,也就是说,他需要移动对象在内存中的位置。这样最大的好处就是内存访问变得更加高效(因为连续的内存有助于进行快速访问,类似数组,直接计算偏移即刻)

但是显而易见的就是移动内存的过程会成为整个清除过程非常大的一个时间开销点,也就说,如果你的变量不是特别的稳定(这里不知道用什么词来描述,大致就是你的变量很少被回收),那么中间的移动成本会很高,可能会得不偿失。

适合低销毁率+需要访问加速的情况

标记复制

这个方法在<<松本行弘的程序世界>>一书中称之为『复制』,但我认为加上一个标记来描述可能会更加的准确。

首先,这个方法标记一个对象是否存活的办法和标记清除法是一样的,也就是通过一个根对象递归检查的方法。

其次,我们的标记清除与标记事实上比较适合变量的销毁没有那么频繁的情况,因为我们需要去手动销毁那些没有被引用的变量,一但需要销毁的对象过多,就可能会造成性能上的问题,那么,复制方法就是进行反向思路,尽可能去操作那些需要保留的对象,以此获得在很多对象需要销毁情况下更高的性能。

我们将内存分为两大块,分别是新空间旧空间,分配变量只分配在旧空间里,一但旧空间爆满,就立即触发一次内存回收,回收的具体办法就是把旧空间里面的所有存活的变量复制到新空间中,直接废弃旧空间,接下来反转两者的角色,新空间变成旧空间,旧空间变成新空间,如此一来,我们每次在GC的时候,只需要复制那些存活的变量,在高销毁率的情况下,性能上是非常快的。

适合高销毁率的情况 比如一个函数结束后 内部变量的回收(因为基本全部不要了)

实际上的内存回收

现在介绍了这几种主流的内存GC算法,我们就会发现这些算法其实是各有优势的,有些可能适合回收没有那么激烈的情况,有些适合大量回收的情况,所以在实际的运用中,这些算法会被组合起来利用,这里就介绍一种分代垃圾收集技术。

分代垃圾收集(Node.js示例)

据我所知,Java和Node.js都利用了这种技术,简单来说,这种技术的最大目的就是组合利用不同的内存回收算法,取长补短,达到综合效果提升的目的。

接下来我就从Node.js的角度去聊聊这种技术。

分带垃圾收集的基本思想是利用程序和对象的性质,一般的程序都拥有这样一个性质,几乎所有的对象都在比较短的时间内变成垃圾,存活时间超过一定程度的对象总是拥有更长的寿命。

所以,我们将内存分为两个部分,新生代老生代(也有一些语言是分为三代的,这里不做考虑),新生代使用标记复制回收方法,而老生带则使用标记清除算法+标记紧缩算法。一个新变量永远生成在新生代中,一但这个变量达成某种晋升条件,就会把这个变量分配到老生代中。

在Node.js中,晋升的条件分为两种,一是新生代完成一次内存GC,并且这个变量没有被回收,二是新生代内部新空间的占比已经超过25%,当然不用关心这个具体的值。

如此一来,就完美利用了不同内存回收算法的特性,达到一个非常棒的结果。短期变量存在新生代中,可以更快的销毁。长期变量存放在老生带中,可以减少回收的频率。这种方法其实在CS中非常参加,就是利用不同算法的不同特性,组合起来,最终能够得到比较好的结果,就好比TLS技术,用非对称密钥传输对等密钥。

总结

一种技术永远不是非黑即白的,我们需要看清技术的优劣点,才能更好的运用,服务我们。

参考

  • 《松本行弘的程序世界》
  • 《深入浅出Node.js》
  • wiki百科 - 内存回收