近年来,我们的电脑内存都有好几个GB,也许你的电脑是4G,他的电脑是8G,公司服务器内存是32G或者64G。但是,无论内存容量有多大,总归不是无限的。实际上,随着内存容量的增加,软件的内存开销也在以同样的速率增加着。因此,最近的计算机系统会通过 “双重”幻觉 ,让我们以为内存容量是无限的。
第一重幻觉: 垃圾回收(GC)机制
在C/C++中,内存空间的分配是由人工手动进行管理的,当需要内存空间时,要请求OS进行分配,不需要的时候则需要返回给OS。如果不再需要的内存空间没有及时返还给OS,这些无法访问的内存空间就会一直保留下来,造成内存的白白浪费,最终引发性能下降和产生抖动。
将内存管理,尤其是 内存空间的释放实现自动化 ,这就是 GC 。
第二重幻觉: OS提供的虚拟内存
所谓虚拟内存,就好比是 将书桌上的比较老的文件先暂时收到抽屉里,用空出来的地方来摊开新的文件 。在计算机中,体现在在内存容量不足时将不经常访问的内存空间中的数据写入硬盘,以增加“账面上”可用内存容量的手段(想想我们的内存和硬盘容量对比就知道了)。
BUT,如果在书桌和抽屉之间频繁进行文件的交换,工作效率肯定会下降。如果每次要看一份文件都要先收拾书桌再到抽屉里面拿的话,那工作根本就无法进行了。
虚拟内存也有同样的缺点:硬盘的容量比内存大,但也只是相对的, 速度却非常缓慢 ,如果和硬盘之间的数据交换过于频繁,处理速度就会下降,表面上看起来就像卡住了一样,这种现象称为 抖动 (Thrushing)。相信很多人都有过计算机停止响应的经历,而造成死机的主要原因之一就是抖动。
标记清除是最早的GC算法,其原理是: 首先从根开始将可能被引用的对象用递归的方式进行标记,然后将没有标记到的对象作为垃圾进行回收 。
下图直观地展示了标记清除算法的大致原理:
① 初始阶段:
② 标记阶段:
其中,红色背景白色字体的对象为已标记的对象。重复这一阶段步骤,已标记的对象会被视为“存活”的对象,而没有被标记的对象就将被进行回收。
③ 清除阶段:
将前面阶段中没有被标记的对象进行回收,这一操作被称为清除阶段。在扫描的同时,还需要将存活对象的标记清除掉,以便于下一次GC操作做好准备。标记清除算法的处理时间,是和存活对象与对象总数的总和相关的。
标记清除算法的缺点: 在分配了大量对象并且其中只有一小部分存活的情况下,所消耗的时间会大大超过必要的值,这是因为在清除阶段还需要对大量死亡对象进行扫描 。
复制收集克服了标记清除的缺点,其基本原理是: 将从根开始被引用的对象复制到另外的空间中,然后再将复制的对象所能够引用的对象用递归的方式不断复制下去 。
下图直观地展示了复制手机的大致原理:
① 初始阶段:
② 复制收集阶段:
复制阶段-1
复制阶段-2
③ 清除阶段:
在清除阶段会将旧空间废弃掉,也就可以将死亡对象所占用的空间一口气全部释放出来,而没有必要再次扫描每个对象。下次GC的时候,现在的新空间也就成为了下次的旧空间。
复制收集的缺点是:和标记方式相比, 将对象复制一份所需要的开销比较大 ,因此在“存活”对象比例较高的情况下,反而比较不利。
引用计数方式是GC算法中最简单也最容易实现的一种,其基本原理是: 在每个对象中保存该对象的引用计数,当引用发生增减时对计数进行更新 。引用计数的增减,一般发生在变量赋值、对象内容更新、函数结束(局部变量不再被引用)等时间点, 当一个对象的引用计数变为0时,则说明它将来不会再被引用,因此可以释放响应的内存空间 。
下图直观地展示了引用计数方式的大致原理:
① 初始阶段:
② 引用计数阶段:
当对象引用发生变化时,引用计数也会跟着变化。在这里,由对象B到对象D的引用失效了,于是对象D的引用计数变为0。由于对象D的引用计数变为了0,因此由对象D到对象C和对象E的引用数也分别相应减少。最后,对象D和对象E引用数变为了0,所以需要被清除。
③ 清除阶段:
所有引用计数变为0的对象都将被释放,“存活”的对象则保留了下来。在整个GC处理过程中,并不需要对所有对象进行扫描。
引用计数的优点在于:易于实现(标记清除和复制收集机制实现由难度);当对象不再被引用的瞬间就会被释放(其他机制预测一个对象何时被释放很困难)。
引用计数的缺点在于:
GC的基本算法,大体上都逃不出上述三种方式以及它们的衍生品。现在,通过对这三种方式进行融合,出现了一些更加高级的方式。
由于GC和程序处理的本质是无关的,因此它所消耗的时间越短越好。分代回收的目的是为了在程序运行期间,将GC所消耗的时间尽量缩短。
分代回收的基本思路是: 大部分对象都会在短时间内成为垃圾,而经过一定时间依然存活的对象往往拥有较长的寿命。如果寿命长的对象更容易存活下来,寿命短的对象则会被很快废弃。那么,对分配不久的“年轻”对象进行重点扫描,应该就可以更有效地回收大部分垃圾 。
在分代回收方式中,对象会按照生成时间进行分代,刚刚生成不久的年轻对象划为新生代(Young generation),而存活了较长时间的对象划为老生代(Old generation)。对于不同的实现方式,可能还会划分更多的代,
在.NET中,CLR就将内存中的对象分为了三代,每执行N次0代的回收,才会执行一次1代的回收,而每执行N次1代的回收,才会执行一次2代的回收。当某个对象实例在GC执行时被发现仍然在被使用,它将被移动到下一个代中上,下图直观地展示了CLR对三个代的回收操作:
回想刚刚说到的几种基本回收方式,我们可以将其组合一下来为分代回收奠定实现基础。
(1)首先,从根开始一次常规扫描,找到“存活”对象。这个步骤可以采用标记清除或复制收集,不过大多数分代回收的实现都采用了复制收集算法。不过在扫描的过程中,如果遇到被划分到更高级别的代的对象则不对该对象继续进行递归扫描。 这样一来,需要扫描的对象数量就大幅度减少 。
(2)其次,将第一次扫描后残留下来的对象划分到更高级别的代上。具体来说,如果是用复制收集算法的话,只要将复制目标空间设置为更高级别的代就可以。而如果用标记清除算法的话,则大多采用在对象上设置某种级别标志的方式。但是,被分配到更高的级别的代上后,该对象所占用的内存空间的时间也会随之增加,如何确保及时利用和释放的平衡点也是需要考虑的。
在对实时性要求很高的程序中,往往更重视缩短GC的最大中断时间(想想车辆制动控制程序因为GC而延迟响应的话后果是不堪设想的),必须能够对GC所产生的中断时间做出预测(例如将最多只能中断10ms作为附加条件)。
因此,为了维持程序的实时性,不等到GC全部完成,而是 将GC操作细分成多个部分逐一执行 ,这种方式就被称为“增量回收”(Incremental GC)。
由于增量回收的过程是渐进式的,可以将中断时间控制在一定长度之内,另外由于由于中断操作需要消耗一定的时间, GC所消耗的总时间也会增加 。
在多核环境中,可以通过利用多线程发挥多CPU的性能,并行回收正是通过最大限度地利用多CPU的处理能力来进行GC操作的一种方式。
并行回收的基本原理是: 在原有程序运行的同时进行GC操作 。相对于在一个CPU上进行GC任务分割的增量回收来说,并行回收可以利用多CPU的性能,尽可能让这些GC任务并行(同时)进行。
不过, 要让GC操作完全并行并且一点都不影响原有程序的运行是做不到的 。因此,在GC操作的某些特定阶段,还是需要暂停原有程序的运行。
像标记清除和复制收集之类的算法是从根开始扫描以判断对象生死的算法,被称为跟踪回收(Tracing GC)。而引用计数算法则是当对象之间的引用关系发生变化时,通过对引用计数进行更新来判定对象生死。
2004年IBM研究中心发表了一篇论文,提出了一种理论: 任何一种GC算法都是跟踪回收和引用计数两种方式的组合,两者的关系正如“物质”和“反物质”一样,是相互对立的。对其中一方进行改善的技术之中,必然存在对另一方进行改善的技术,而其结果只是两者的组合而已 。
(1)本文全文源自Ruby之父 松本行弘 的《代码的未来》一书!
(2)霍旭东,《不得不知的CLR中的GC》【好文一篇,值得阅读】
(3)cposture,《GC/垃圾回收简介》
(4)周旭龙,《 .NET基础拾遗之内存管理基础 》