大部分的开发者都觉得垃圾回收是一件再正常不过的事情,根本没有认真研究过到底垃圾回收是如何实现的。但是如果直接看实际代码的话,就会发现十分晦涩难懂。
所以这篇文章带你初步认识5种最基本的垃圾回收算法,并且为每个算法配上一段生动形象的动画。
1.运行结束时统一回收(没有自动GC)
这是最朴素的垃圾回收算法,在函数调用结束后直接把栈清空。如果你能把程序仔细地划分为一个个相互独立的子任务,每个子任务完成时自动删除其占用的内存空间。
比如,Apache Web
服务器对于每一个请求会分配一个内存池,请求处理完成之后直接把这个内存池销毁即可。
上图的小动画表示的是某个运行中的程序,整个大黑框代表机器(或者虚拟机)的全部内存。随着程序的运行,黑框逐渐被一些黄绿色的方块填充,也就是程序所占用的内存。
2.引用计数垃圾回收
这也是一个较为朴素的解决方案,对于内存中的每一个对象都保存它们的引用计数,当某个对象的引用计数减为0
时,就把它所占用的内存空间回收。
红色方块的闪烁表示引用计数的变化,当变为0
时则该部分的黄绿方块变回黑色。
但是引用计数会带来一堆其他的问题,最严重的应该就是循环引用,一部分对象其实已经没办法再使用,理应被回收内存空间,但是由于它们之间相互引用并形成一个回环,所以大家的引用计数都大于0
,所以一直不能被回收,进而造成内存泄漏。
第二个问题就是造成大量的资源浪费,正如上面动画中的,就算程序所占用的内存空间不再增加,红色方块依旧在闪烁,说明程序在不断地读取并修改对象的引用计数,也就是造成了不必要的内存访问和修改。
第三个问题就是在多线程环境下,很难保证这些引用计数是线程安全的。
但是引用计数能保证大部分的"垃圾"能在第一时间被检测出来,然后在第一时间加以回收,有良好的时间分散性,是程序的表现性能更加平滑。
3.标记-清除算法
虽然标记-清除算法可以解决上面提到的引用计数的三个问题,但是它不能像引用计数回收算法那样马上检测出垃圾的存在,他一般会在内存占用到达某个比例时开始进行。
正如上面动画所呈现的那样,会有相当一段时间没有出现红点闪烁,然后又会有一段时间出现红点,这表示某些内存对象被标记为垃圾(一般是通过可达性分析算法来检测)。标记结束后,会进行一次全体扫描然后清理掉所有被标记为"垃圾"的内存对象,然后出现一段段不连续的黑块。
标记-清除算法的一大弱点在于它必须全面扫描一遍内存才能准确判断哪些对象才是真正的垃圾,然后把它标记出来。这对于那些内存不断增长,但真正的垃圾对象所占的比例并不大的程序来说,会极大地增加不必要的负担。
4.标记-整理算法
相信细心的你已经发现了,无论是引用计数还是标记-清除算法都有一个明显的问题,那就是对象被分配内存空间之后,它的位置就不会发生改变,这样随着垃圾回收的不断进行,内存空间就会出现许许多多的碎片,这些碎片是十分不方便被重新分配利用的。
而且计算机组成原理告诉我们,连续的内存空间能使cache缓存的命中率大大增加,从而减少许多直接内存访问。
标记-整理算法就是在专门为了解决这个问题而被提出的,他不仅清除垃圾对象,而且会想办法把这些腾出来的空间马上利用起来。
正如上图所示,每一次整理内存空间后,各存活对象按照相对的前后位置从初始位置开始重新连续地分配内存空间。
但是,因为程序中各对象之间会有十分复杂的相互引用关系,你要保证移动这些对象的位置之后,这些引用地址也能同时正确地更新。为此,必然需要大量额外的逻辑运算处理,从而不可避免地会增加系统负载。
同时,不可预测的内存位置改变会给调试程序增加难度。
5.复制算法
这是Java
虚拟机分代垃圾回收算法的基础,它会像标记-整理那样根据情况重新调整存活对象的内存位置,但它实现起来会简单清晰很多。
它一般会把内存空间划分为两个以上,对应着不同的世代。刚创建的对象被分配到其中一个指定的内存空间(年轻代),当它被确认存活时会被转移到另外一个内存空间(年老代)。
从上图看出,每一次扫描转移都会导致这个内存空间中的对象要么被清除,要么被转移到另外一个内存空间。
由于复制算法要操作的对象是存活对象,那么一旦发现这个对象可达,马上复制,扫描之后直接整体清空内存空间即可,而不需要像标记-清除和标记-整理那样要在扫描全过程结束后,才能最终确认哪些对象不可达。
这样,额外的计算量只会由存活对象决定,垃圾对象直接清除即可。
同时,在对象进行空间转移的过程中,原有的数据(对象的参照地址)仍保留完整,而不像标记-整理算法那样,一边整理对象一边破坏原有的数据。那么这些信息可以,大大减少了计算各引用对象新地址的时间复杂度。
当然,这个算法最大的问题就是很难保证内存的使用率,因为你必须保证有多出一倍的额外空间让你进行拷贝。