并发标记与三色标记
三色标记
简介
在三色标记之前有一个算法叫标记清除算法。这个算法会设置一个标志位来记录对象是否被使用。最开始所有的标记为都是0,通过根可达算法发现对象存活的就会置为1,一步步执行下去就会呈现出一个类似树状的结构。等标记的步骤完成之后,会将未被标记的对象统一清理,再次把所有的未被回收的标记重置为0,反复执行。
这个算法最大的问题是,GC在执行期间需要把整个程序完全暂停,不能异步进行GC操作。因为在不同阶段标记清除算法的标志位0,1具有不同的含义,如果并发处理,则有可能会以外删除新增的存活对象。对实时性要求高的系统来说,这种需要长时间挂起的标记清除时不可以被接受的。此时就需要一个算法来这种STW的现象,这个算法就是三色标记法。
三色标记最大的好处就是可以异步执行,从而可以以中断时间极少的代价或者完全没有中断来进行GC环节。
三色标记,见字知意。使用三种颜色标记对象的一种算法。
黑色:根对象,以及该对象与它的子对象都被扫描过。
灰色:对象本身正在被扫描,其子对象还未被扫描。
白色:未被扫描的对象。如果扫描完所有对象后仍为白色,则被判定根不可达,即为垃圾。
漏标问题
三色标记的过程中,标记线程和用户线程是并发执行的,那么就有可能在我们标记过程中,用户线程修改了引用关系,把原本应该回收的对象错误标记成了存活。(简单来说就是GC已经标黑的对象,在并发过程中用户线程引用链断掉,导致实际应该是垃圾的白色对象但却依旧是黑的,也就是浮动垃圾)。这时产生的垃圾怎么办呢?答案是不怎么办,留给下次垃圾回收处理。我又啰嗦了一遍浮动垃圾。
但是漏标问题,意思就是把本来应该存活的垃圾,标记为了死亡。这就会导致非常严重的错误。那么这类垃圾是怎么产生的呢?
途中对象A被标记为了黑色,此时它所引用的两个对象B,C都在被标记的灰色阶段。此时用户线程把B->D之间的的引用关系删除,并且在A->D之间建立引用。此时B对象依然未扫描结束,而A对象又已经被扫描过了,不会继续接着往下扫描了。因此D对象就会被当做垃圾回收掉。
总结:漏标有以下两个必要的条件:
1:至少有一个黑色对象在自己被标记之后指向了白色对象。
2:所有的灰色对象在自己引用扫描完成之前删除了对白色对象的引用。
我之前一直被一个问题困扰了相当之久。就是如果为什么一定是灰色对象的引用断开,并且这些对象再被黑色对象引用才是漏标呢?那我白色后面的对象引用断开接到黑色对象上不也是漏标么?不知道是各位是否有过同样的问题哈。
现在我自己的理解是。每一次GC在STW进行初始标记时。他除了找到了GCRoots以外,他还确定了整个本次GC需要遍历的对象图。也就是后续他需要关注哪些对象其实在初始标记的时候已经知道了。如果现在遍历到灰色对象时,那么它一定即将会对这个灰色对象所引用的白色对象进行扫描。如果此时发现白色对象没有了引用,那么他就会把这个白色对象视为垃圾。而白色对象引用的白色对象引用关系断开,那么这断开引用的对象就会被排除在本次GC之外。也就是我压根儿本次GC不会管这个断开的白色对象的死活。当它再被黑色对象引用时,也被当做新增对象被TAMS指针处理,同样不会再纳入本次GC之中。
这是本人的一些理解,如有不对还希望多多交流。
所以我们主需要打破其中任意一个条件就可以处理漏标问题
对于以上问题,CMS与G1各采用了一套方案进行处理。
CMS---增量更新
增量更新打破了第一个条件。当A插入新的引用关系D时,就将这个插入的引用记录下来。等待扫描结束后重新标记阶段时,再把这些记录过的新增对象的上一层对象A变为灰色,就会重复A下的所有对象重新扫描一次。
由图可见,此方法再次执行可以处理漏标的D对象。但是B,C对象属于被重复扫描的对象。
G1---SATB
原始快照打破了第二个条件。步骤如下:
- 在初始标记的时候生成张快照图,标记存活对象。
- 在并发标记的时候出现了引用的修改,就会把这些引用的原始值捕获下来记录到一个队列当中。
- 在最终标记阶段,扫描SATB,并修正误差。
总结
CMS关注新增,但是会把新增对象的前一个引用变为灰色,重新对所有子对象进行遍历,有一定性能损耗。G1引用的打破,不需要重新遍历对象,因此效率较高。但是维护快照增加了一定的内存成本。
G1中的技术细节
TAMS
要达到与GC用户并发运行,必要要解决回收过程中新对象的分配(否则在一条GCRoots标记完成之后,新来的对象会被当做垃圾回收),所以G1在每个Region中设置了两个名为TAMS的指针,在Region划分出部分空间用于记录并发回收过程中的新对象,认为他们是存活的,不纳入垃圾回收。
跨代引用
堆空间通常被划分为新生代和老年代。由于新生代的垃圾活动通常非常频繁,如果老年代对象引用了新生代对象的话,需要跟踪老年代到新生代所有的对象。所以要避免每次YongGC的时候都去扫描整个老年代(注意:此处的误区。之前以为老年代与新生代之间有引用就不扫描了。其实跨代引用的作用只是帮我们快速定位到正确的老年代引用,而不是直接忽视老年代),减少开销。
预测流程
在判断新生代对象是否根可达时,一部分对象是朝生夕死的对象,而另一部分可能是有由相当老年代的对象引用而来的。而一般老年代的引用关系都相当复杂,为了节约扫描时间成本,我们在每个新生代对象中存入一个RSet记录卡表。在检索新生代引用即将跨代时,会根据卡表的Key,Value快速定位到正确的老年代引用,以达到减少开销的目的。
RSet(记忆集)
记录了其它Region中的对象到Region的引用。
RSet的价值在于使得垃圾回收不需要扫描整个堆,能够快速定位到真正引用它的堆对象地址。ReSet本身就是一个Hash表,存储在新生代的每个Region中。但是存储需要消耗空间,多的能达到百分之20。因此G1对内存的空间要求较高(小空间没资本玩),空间越大性能越彪悍。
CardTable
由于新生代GC时,需要扫描整个old区,效率非常低。所以old区就是用卡表的方式进行一次逻辑分区。一般一页卡表的大小是2的n次幂。每一个区域也是用Key,Value结构进行记录。每一区域记录为Key不重复,Value则记录这片区域的老年代对象与新生代对象是否存在引用关系,存在则标记为1,否则为0。记录完毕后把value为1的key作为ReSet的key进行记录,并且ReSet的value存储引用,从而提高跨代引用的查询效率。
总结
G1的设计因为这些记录,划分Region的原因,对基础内存的要求比较高。CMS也存在ReSet与CardTabe。但是因为CMS没有Region的存在,新生代只有一份,所以只需要记录一份,对空间要求不高。所以虽然G1优点很多,但是不推荐堆空间小于6G以下使用G1。
安全点与安全区域
安全点
STW,用户线程暂停,GC开始工作。但是要确保用户线程暂停的这行字节码是不会导致引用关系发生变化的。安全点选的过多,那么GC可能会过于频繁。过少所以JVM会在字节码指令中,选出一些指令作为”安全点”,比如方法调用,循环跳转,异常跳转等,才会产生安全点。
为什么叫安全点呢?GC是要暂停业务线程。而业务线程可能是超多线程,他们肯定都要达到自己的Safepoint处才能完成所谓的Stop the world。这里我们有两种方法区实现。
- 抢占式中断:当用户线程发现GC需要STW时,直接立刻停下。看看自己是不是听到Safepoint上。如果不在,再恢复线程到达最近的安全点。(好家伙,这随机能停到安全点的概率都能去买**了)。现在虚拟机已经不用这个方法了。
- 主动式中断:当要发生GC时,设置一个标志位。然后其他线程运行到安全点时,都判断一下是否需要GC,如果需要就停止,否则继续执行。(明显靠谱很多)。
安全区域
为什么叫安全区域?
要是业务线程不执行(Sleep或Blocked状态),那么程序就没办法进入安全点,对于这种情况,就必须引入安全区域。
安全区域是指能够确保在某一段代码之中,引用关系不会发生变化。因此在这个区域总任意位置开始垃圾回收都是安全的。可以把安全区域看做拉长的安全点。如果sleep结束,GC还没结束,那么用户线程会变成安全点继续等待。
低延时垃圾回收器
垃圾回收器的三项指标
传统的垃圾回收器一般情况下,内存占用,吞吐量,延时只能同时满足两个,所以就有了低延时垃圾回收器。
Eplison(了解)
这个垃圾回收器不能进行垃圾回收,是一个不干活的垃圾回收器。主要用于玻璃垃圾收集器影响的性能测试与压力测试。
这个垃圾收集器虽然不回收垃圾,但是JVM中垃圾收集器是不可或缺的一部分。如果在微服务或运行时间很小的场景下,这个垃圾收集器就能提升性能。
ZGC(了解)
与G1类似,但没有分代。
标志性的设计是染色指针。染色指针有4G的内存限制,但是效率极高,它是一种将少量信息存储在指针上的技术。(好家伙,信息不存在对象上直接存指针里)。
它可以做到几乎整个垃圾收集过程全程并发,短在的STW也至于GCRoots大小相关而与堆内存大小无关。因此可实现任何堆空间的STW时间都小于10毫秒。
ZGC的缺点。ZGC从将垃圾收集的时间用相当长的时间并发标记。而此时由于不能及时清理,就会造成很多的浮动垃圾。因此需要的堆内存就相当大。
Shenandoah(了解)
第一款非Oracle公司开发的垃圾回收器,有类似与G1的Region,但是没有分代。
也有到了染色指针,效率不如ZGC,大概几十毫秒。