在JVM实现中,往往不是采用单一的一种算法进行回收,而是采用几种不同的算法组合使用,来达到垃圾的回收。
最基础的收集算法---------标记/清除算法
标记/清除算法是GC算法中最基础的算法,后续的收集算法都是基于这种思路进行改进而得到的。标记/清除算法分为“标记”和“清除”两个阶段:首先标记出所有要回收的对象,在标记完成之后回收所有被标记的对象。
标记阶段:标记过程就是可达性算法的过程,遍历所有的GC Roots对象,对从GC Roots对象可达的对象都打上一个标识,一般是在对象的header中,将其记录为可达对象;
清除阶段:清除过程就是对堆内存进行遍历,如果发现某个对象没有被标记为可达对象(通过读取对象的header信息),则将其回收。
从上图就可以看出,标记阶段,从对象GC Root 1可以访问到B对象,从B对象又可以访问到E对象,因此从GC Root 1到B、E都是可达的,同理,对象F、G、J、K都是可达对象;到了清除阶段,所有不可达对象都会被回收。
在垃圾收集器进行GC时,必须停止所有java执行线程(“Stop The World”),原因是在标记性阶段可进行可达性分析时,不可以出现分析过程中对象引用关系还在发生变化,否则可达性性分析结果就无法得到保证。只有等待标记清除结束后,应用线程才会恢复运行。
标记/清除存在两个缺点:
1、效率问题。标记和清除两个阶段的效率都不高,因为这两个阶段都需要遍历内存中的对象,很多时候内存中的对象实例数据量是非常庞大的,这无疑很耗费时间,而且GC时需要停止应用程序,这回导致非常差的用户体验。
2、空间问题。标记清除之后会产生大量不连续的内存碎片,内存碎片太多可能会导致以后在程序运行过程中需要分配较大的对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾回收。
复制算法
为了解决标记/清除存在的问题,复制算法出现了,复制算法的原理是:将可用的内存按容量进行分为大小相同等的两块,每次使用其中的一块。当这一块内存用完了,就将还存活的对象复制到另一块内存(保留区域)上,然后把这一块内存的所有对象全部清理掉,即将存活的对象转移到另一块内存上,这样就会减少了内存存在碎片的问题。
复制算法每次都会对半个区域的内存中的对象进行回收,这样减少了标记对象遍历的时间,在清除使用区域的对象时,不用遍历,直接进行使用区域的清空,而且将存活对象复制到保留区域时也是按照地址顺序存储的,也就解决了内存碎片的问题,在分配对象内存时不用考虑内存碎片等复杂问题,只需要按顺序分配内存即可。
复制算法优化了标记/清除的效率低、内存碎片多的问题,但也有缺点;
1、将内存缩小为原来的一半、浪费了一半的空间,代价高。
2、如果对象的存活率很高,极端一点的情况假设对象存活率为100%,那么我们需要将所有存活的对象复制一遍,耗费时间的代价也是不容忽视的。
基于以上复制算法的缺点,由于新生代中的对象几乎都是“朝生夕死”的(达到98%),现在的商业虚拟机都采用复制算法来回收新生代。由于新生代的对象存活率低,所以并不需要按照1:1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的From Survivor空间、To Survivor空间,三者的比例为8:1:1。每次使用Eden和From Survivor区域,To Survivor作为保留空间。GC开始时,对象只会存在于Eden区和From Survivor区,To Survivor区是空的。GC进行时,Eden区中所有存活的对象都会被复制到To Survivor区,而在From Survivor区中,仍存活的对象会根据它们的年龄值决定去向,年龄值达到年龄阀值(默认为15,新生代中的对象每熬过一轮垃圾回收,年龄值就加1)的对象会被移到老年代中,没有达到阀值的对象会被复制到To Survivor区。接着清空Eden区和From Survivor区,新生代中存活的对象都在To Survivor区。接着, From Survivor区和To Survivor区会交换它们的角色,也就是新的To Survivor区就是上次GC清空的From Survivor区,新的From Survivor区就是上次GC的To Survivor区,总之,不管怎样都会保证To Survivor区在一轮GC后是空的。GC时当To Survivor区没有足够的空间存放上一次新生代收集下来的存活对象时,需要依赖老年代进行分配担保,将这些对象存放在老年代中。
当然这里所说的老年代区域,是另一块区域,图中并没有显示出来,下面来一张JVM内存的分布
标记/整理算法
复制算法和标记/清除都会存在一些不足。如果复制算法在对象存活率比较高时,就要进行较多的复制操作,效率变得很低,另一个问题,就是会浪费50%的内存空间。因此,对于老年代对象,由于它们的存活率非常高,这样的话,复制算法就不合适了。这样,标记/整理算法就迎刃而出了。标记/整理算法和标记/清除算法很像,它的标记过程和标记清除一样,但接下来并不是对对象进行回收,而是让所有存活的对象都向一端移动,然后直接清理掉端变线以外的内存。
可以看到,回收之后,存活的对象规则排列存放在内存中。当我们给新对象分配内存时,jvm只需要持有内存的起始地址即可。这种方法消除了标记/清除的内存碎片问题,也消除了复制算法的内存减半的高额代价。
对以上三种算法的原理,进行简单的排序
效率:复制算法>标记/整理算法>标记/清除算法(标记/清除算法有内存碎片问题,给大对象分配内存时可能会触发新一轮垃圾回收)
内存整齐率:复制=标记/整理算法>标记/清除算法
内存利用率:标记/整理算法=标记/清除算法>复制算法
分代收集算法
当前商业虚拟机都采用分代收集算法,它结合了前几种算法的优点,将算法组合使用进行垃圾回收。分代收集算法的思想是按照对象的存活周期不同将内存分为几块,一般是把java堆分为新生代和老年代(还有一个永久代,是HotSpot特有实现,其他的虚拟机实现没有这一概念,永久代收集效果很差,一般很少对永久代进行垃圾回收)。
新生代:里面的对象朝生夕灭,每次垃圾收集时都会都有大量的对象死去,只有少量存活,Eden内存不够时,发起Minor GC,选用复制算法,只要付出少量存活对象的复制成本就可以完成收集,分配担保机制。 老年代:对象的存活时间较长,没有额外的分配担保机制,内存不够时发起Full GC,使用“标记-清除”或“标记-整理”来进行垃圾回收。
以上几种算法的共同的特点是:当GC线程启动时,要停止应用程序。