垃圾搜集的算法主要有三种,分别是标记-清除算法、复制算法、标记-整理算法。
标记/清除算法
标记:标记的过程遍历所有的GC Roots。然后将GC Roots可达的对象标记为存活对象。
清除:遍历堆中的所有对象,将所有未标记的对象全部清除掉。
通俗点讲就是在程序运行过程中,如果内存被占满的话,将触发GC线程管理,将程序先暂停,先将旧对象中的存活对象先标记一遍,再将未标记的数据清除,GC管理结束程序再次重启。
这张图代表的是程序运行期间所有对象的状态,它们的标志位全部是0(也就是未标记,以下默认0就是未标记,1为已标记),假设这会儿有效内存空间耗尽了,
JVM将会停止应用程序的运行并开启GC线程,然后开始进行标记工作,按照根搜索算法,标记完以后,对象的状态如下图。
可以看到,按照根搜索算法,所有从root对象可达的对象就被标记为了存活的对象,此时已经完成了第一阶段标记。
接下来,就要执行第二阶段清除了,那么清除完以后,剩下的对象以及对象的状态如下图所示。
没有被标记的对象将会回收清除掉,而被标记的对象将会留下,并且会将标记位重新归0。
接下来就不用说了,唤醒停止的程序线程,让程序继续运行即可。
总结
标记/清除算法的原理并不复杂,原理大概就是这样,但是该算法的缺点却是显而易见的。
- 效率比较低(递归与全堆对象遍历),而且在进行GC的时候,需要停止应用程序,这会导致用户体验非常差劲。
- 这种方式清理出来的空闲内存是不连续的,这点不难理解,我们的死亡对象都是随即的出现在内存的各个角落的,现在把它们清除之后,内存的布局自然会乱七八糟。
而为了应付这一点,JVM就不得不维持一个内存的空闲列表,这又是一种开销。而且在分配数组对象的时候,寻找连续的内存空间会不太好找。
复制算法
复制算法将内存划分为两个区间,在任意时间点,所有动态分配的对象都只能分配在其中的一个区间(活动区间),而另外一个区间(空闲区间)则是空闲的。
当有效内存空间耗尽的时候,JVM暂停程序运行,开始复制GC线程。GC线程将活动区间的存活对象,全部复制到空闲区间,且严格按照内存地址依次排列,同时,GC线程将更新存活对象的内存引用地址到新的内存地址。
此时,空闲区间与活动区间交换,垃圾对象全部留在原来的活动区间,也就是现在的空闲区间,事实上,在活动区间转换为空闲区间的同事,垃圾对象已经被一次性回收。
用一幅图说明下关系:
此时内存被复制算法分成了两部分,下面我们看下当复制算法的GC线程处理之后,两个区域会变成什么样子,如下所示。
可看到:1、4被清除了,其他排列在之前的空闲区间,也就是现在活动区间,可以想象下一次的GC后右边将变成空闲区间。很明显,这弥补了标记/清除中内存混乱的缺点,不过缺点也是明显的。
总结
- 此算法浪费了一半的内存。
- 如果对象的存活率很高,我们可以极端一点,假设是100%存活,那么我们需要将所有对象都复制一遍,并将所有引用地址重置一遍。
复制这一工作所花费的时间,在对象存活率达到一定程度时,将会变的不可忽视。
所以,复制算法要是想使用的话,最起码对象的存活率要较低才行,还要克服50%的内存的浪费。
标记/整理算法
标记:它的第一个阶段与标记/清除算法是一模一样的,均是遍历GC Roots,然后将存活的对象标记。
整理:移动所有存活的对象,且按照内存地址次序依次排列,然后将末端内存地址以后的内存全部回收。因此,第二阶段才称为整理阶段。
它GC前后的图示与复制算法的图非常相似,只不过没有了活动区间和空闲区间的区别,相似,我们来看GC前内存中对象的状态与布局,如下图所示。
紧接着开始的就是标记阶段了。此阶段与标记/清除算法的标记阶段是一样一样的,我们看标记阶段过后对象的状态,如下图。
整理阶段处理完以后,内存的布局是如何的,如下图。
标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉。如此一来,当我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销。
总结
标记/整理算法不仅可以弥补标记/清除算法当中,内存区域分散的缺点,也消除了复制算法当中,内存减半的高额代价。标记/整理算法唯一的缺点就是效率也不高,不仅要标记所有存活对象,还要整理所有存活对象的引用地址。从效率上来说,标记/整理算法要低于复制算法。
三种算法的总结
- 三种算法都是基于根搜索算法去判断一个对象是否应该被回收,而支撑根搜索算法可以正常工作的理论依据,就是语法中变量作用域的相关内容。因此,要想防止内存泄露,最根本的办法就是掌握好变量作用域。
- GC线程开启时都要暂停应用程序。
效率:复制算法>标记/整理算法>标记/清除算法(此处的效率只是简单的对比时间复杂度,实际情况不一定如此)。
内存整齐度:复制算法=标记/整理算法>标记/清除算法。
内存利用率:标记/整理算法=标记/清除算法>复制算法。