垃圾收集算法

分代收集理论,垃圾收集器的理论基础,它建立在两个分代假说之上:

  • 弱分代假说:绝大多数对象都是朝生夕灭的。
  • 强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡。

这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。

  • 如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间;
  • 如果剩下的都是难以消亡的对象,那把它们集中放在一块,虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。

Java堆划分为新生代(Young Generation)和老年代(Old Generation)两个区域。在新生代中,每次垃圾收集时都发现有大批对象死去,而每次回收后存活的少量对象, 将会逐步晋升到老年代中存放

标记-清除算法:

基础算法,后面两个算法基于此算法改进

算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。标记过程就是对象是否属于垃圾的判定过程。该算法两个缺点:

  • 执行效率不稳定
  • 内存碎片化

标记-复制算法:

适用于新生代的算法,将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。算法缺点很明显:少了一半空间

新生代中的对象有98%熬不过第一轮收集。因此并不需要按照1∶1的比例来划分新生代的内存空间。

Appel式回收:把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用 Eden 和其中一块Survivor。发生垃圾搜集时,将Eden和Survivor中仍然存活的对象一 次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor 空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1,也即每次新生代中可用内存空间为整个新生代容量的90%(Eden的80%加上一个Survivor的10%),只有一个Survivor空间,即10%的新生代是会被“浪费”的。当然,98%的对象可被回收仅仅是 “普通场景”下测得的数据,任何人都没有办法百分百保证每次回收都只有不多于10%的 对象存活,因此Appel式回收还有一个充当罕见情况的“逃生门”的安全设计,当 Survivor 空间不足以容纳一次Minor GC之后存活的对象时,就需要依赖其他内存区域 (实际上大多就是老年代)进行分配担保。

标记-整理算法:

针对老年代对象的存亡特征,1974年Edward Lueders提出了另外一种有针对性的 “标记-整理”(Mark-Compact)算法,其中的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存

为什么标记-复制适用于新生代,标记-整理适用于老年代?

新生代需要被清理的对象多,复制只需要复制少量存活对象

老年代存活的对象多,不能使用Eden Survivor那套,不然内存就不够了,当然标记-整理也是一项很负重的操作,但如果不整理,就需要额外使用页表等方式标记哪些空间可用来解决空间碎片化问题,这也会导致额外负担,所以从整个程序的吞吐量考虑,标记-整理是较好的选择

当然老年代也可以先标记-清除,等内存空间碎片化到一定程度时,进行一次标记整理

三色标记法:

可达性分析算法中,标记过程需要stop the world,保证全局获得一致性快照,这个操作可否与用户线程并发?

先来看三色标记法:把遍历对象图过程中遇到的对象,按照“是否访问过”这个条件标记成以下三种颜色:

  • 白色:表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达,需要被回收
  • 黑色:表示对象已经被垃圾收集器访问过,且所有引用了这个对象的对象都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,不用被回收,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。
  • 灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。是个中间态,当扫描完成后,不会出现这个颜色,整个图非黑即白

可达性分析算法其实就是从GC Roots出发,将图(对象直接的引用关系图)波浪式地由白色转为黑色,其中灰色是黑白之间的过渡色。

这个标记法在用户线程和收集器并发工作下可能存在问题:

  • 是把原本消亡的对象错误标记为存活,这不是好事,但其实是可以容忍的,只不过产生了一点逃过本次收集的浮动垃圾而已,下次收集清理掉就好。
  • 把原本存活的对象错误标记为已消亡,这就是非常致命的后果了,程序肯定会因此发生错误

当且仅当以下两个条件同时满足时,会产生“对象消失”的问题,即原本应该是黑色的对象被误标为白色,导致回收应该存活的对象:

  • 赋值器插入了一条或多条从黑色对象到白色对象的新引用;即对象A被其它已扫描过的且安全存活的对象链上了
  • 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。即所有引用A且正在被扫描的对象取消了对A的引用

因此,我们要解决并发扫描时的对象消失问题,只需破坏这两个条件的任意一个即可。

当标记与用户线程并发时,可能造成以上问题,为了使标记与用户线程并发,减少STW的时间,就需要解决上述问题,由此分别产生了两种解决方案:增量更新 和 原始快照

  • 增量更新要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。即出现了新的引用关系就记录下来,然后重新扫描
  • 原始快照要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再以这些灰色对象为根,重新扫描一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。(当B删除了N引用之后,B->N 的关系仍然被记录,这个动作通过一个写屏障来实现(可以理解为一个aop)。扫描结束之后再以B为根(被记录的灰色对象为根)重新扫描一次,此时的扫描的B->N的引用已经被重新记录了,即使他实际已经被删除但在这次扫描中它仍然存在。但是这可能导致N在本次垃圾回收时应该被回收,却逃过了这次,不过没关系,下次gc它逃不了)

原始快照相对于增量更新更快,但是可能产生更多的浮动垃圾

JVM—理解G1的SATB和CMS的增量更新

增量更新和原始快照这两种解决方案都有实际应用,譬如,CMS是基于增量更新来做并发标记的,G1、Shenandoah则是用原始快照来实现。

新生代Minor GC流程:

  • 当Eden区满时,触发Minor GC
  • 标记算法找到所有存活下来的对象
  • 检查老年代最大可用的连续空间是否大于新生代所有存活下来的对象的空间,如果大于,则发起 Minor GC。
  • 如果小于,则看 HandlePromotionFailure 有没有设置,如果没有设置,就发起 Full GC。
  • 如果设置了 HandlePromotionFailure,则看老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果小于,就发起 Full GC。

如果大于,发起 Minor GC。Minor GC 后,看 Survivor 空间是否足够存放存活对象,如果不够,就放入老年代,如果够放,就直接存放 Survivor 空间。如果老年代都不够放存活对象,担保失败(Handle Promotion Failure),发起 Full GC。

HandlePromotionFailure(是否允许进行晋升担保) 的作用,当设置为 true 时(默认值),JVM 会尝试继续 Minor GC,即使老年代空间不足以容纳所有需要晋升的对象。JVM 会尝试清理更多的老年代空间或者采用其他措施来应对空间不足的情况。避免因为老年代空间不足而过早触发 Full GC(全堆回收)。Full GC 通常比 Minor GC 更耗时,会导致更长时间的停顿。但该参数在JDK7开始彻底被弃用,相当于该参数变为false,这可能导致Full GC频繁发生,但JVM也开始动态调整新生代、老年代的空间大小配置,尽量减少Full GC的发生,且如果老年代空间不足,会提前执行部分清理或混合GC