分代即将堆内存根据对象生存的时间来划分不同的区域,有些对象朝生夕死,有些对象存活较久,对那些朝生夕死的对象可以多进行回收,其效率和收获也将较大,对那些很难消亡的对象可以较久回收。这也引发出在不同分代区使用不同的收集算法。
一般来说我们把堆分为年轻代(又分E区和S区)、老年代,年轻代对象更迭比较频繁,老年代对象比较长久,一般在年轻代使用复制或者标记整理算法,而老年代使用标记清除算法,对应的收集器年轻代有serial收集器、parnew收集器、parallel scavenage;老年代有serial old、parallel old、CMS;G1收集器可以同时作用于年轻代和老年代。
那么不同的垃圾回收算法,其都会涉及到对象是否移动的问题,清除法不移动对象,所以会产生内存碎片,复制和整理算法移动对象,会增加内存空间且需要暂停用户线程。是否移动对象都有利弊,但从整体上来看,移动对象更加划算,因为即使不移动对象会使得收集器的效率提升一些,但因内存分配(产生内存碎片,要分配大对象就需要额外的措施解决)和访问相比垃圾收集频率要 高得多,这部分的耗时增加,总吞吐量仍然是下降的。CMS收集器利用了标记清除,但是面对内存空间碎片化问题,它也采取了可以对空间压缩的配置项解决。
在
OopMap
的协助下,
HotSpot
可以快速准确地完成
GC Roots
枚举,但一个很现实的问题随之而
来:可能导致引用关系变化,或者说导致
OopMap
内容变化的指令非常多,如果为每一条指令都生成 对应的OopMap
,那将会需要大量的额外存储空间,这样垃圾收集伴随而来的空间成本就会变得无法忍受的高昂。
实际上HotSpot
也的确没有为每条指令都生成
OopMap
,前面已经提到,只是在
“
特定的位置
”
记录
了这些信息,这些位置被称为安全点(
Safepoint)。有了安全点的设定,也就决定了用户程序执行时并非在代码指令流的任意位置都能够停顿下来开始垃圾收集,而是强制要求必须执行到达安全点后才能够暂停。因此,安全点的选定既不能太少以至于让收集器等待时间过长,也不能太过频繁以至于过分增大运行时的内存负荷。例如方法调用、循环跳转、异常跳转
等才会产生安全点。
对于安全点,另外一个需要考虑的问题是,如何在垃圾收集发生时让所有线程(这里其实不包括
执行
JNI
调用的线程)都跑到最近的安全点,然后停顿下来。这里有两种方案可供选择:抢先式中断(Preemptive Suspension
)和主动式中断(
Voluntary Suspension
),抢先式中断不需要线程的执行代码 主动去配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地 方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上。现在几乎没有虚 拟机实现采用抢先式中断来暂停线程响应GC
事件。
主动式中断的思想是当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单地设置一
个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。轮询标志的地方和安全点是重合的,另外还要加上所有创建对象和其他需要在Java
堆上分配内存的地方,这是为了检查是否即将要发生垃圾收集,避免没有足够内存分配新对象。
安全区域:
使用安全点的设计似乎已经完美解决如何停顿用户线程,让虚拟机进入垃圾回收状态的问题了,
但实际情况却并不一定。安全点机制保证了程序执行时,在不太长的时间内就会遇到可进入垃圾收集过程的安全点。但是,程序“
不执行
”
的时候呢?所谓的程序不执行就是没有分配处理器时间,典型的场景便是用户线程处于Sleep
状态或者
Blocked
状态,这时候线程无法响应虚拟机的中断请求,不能再走到安全的地方去中断挂起自己,虚拟机也显然不可能持续等待线程重新被激活分配处理器时间。对于这种情况,就必须引入安全区域(Safe Region
)来解决。
安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任
意地方开始垃圾收集都是安全的。我们也可以把安全区域看作被扩展拉伸了的安全点。当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了。当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的阶段),如果完成了,那线程就当作没事发生过,继续执行;否则它就必须一直等待,直到收到可以离开安全区域的信号为止。
记忆集与卡表
为解决对象跨代引用所带来的问题,垃圾收集器在新生代中建立了名为记忆集(Remembered Set
)的数据结构,用以避免把整个老年代加进
GC Roots
扫描范围。事实上并不只是新生代、老年代之间才有跨代引用的问题,所有涉及部分区域收集(Partial GC
)行为的垃圾收集器,典型的如G1
、
ZGC
和
Shenandoah收集器,都会面临相同的问题。卡表就是记忆集的一种具体实现,它定义了记忆集的记录精度、与堆内存的映射关系等,一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏(Dirty),没有则标识为0。在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入GC Roots中一并扫描。
三色标记法
引入三色标记(
Tri-color Marking
)
作为工具来辅 助推导,把遍历对象图过程中遇到的对象,按照“
是否访问过
”
这个条件标记成以下三种颜色:
·
白色:
表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是
白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。
·
黑色:
表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代
表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。
·
灰色:
表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。
关于可达性分析的扫描过程,把它看作对象图上一股以灰色为波峰的波纹从黑向白推进的过程,如果用户线程此时是冻结的,只有收集器线程在工作,那不会有任何问题。但如果用户线程与收集器是并发工作呢?收集器在对象图上标记颜色,同时用户线程在修改引用关系——
即修改对象图的结构,这样可能出现两种后果。一种是把原本消亡的对象错误标记为存活,这不是好事,但其实是可以容忍的,只不过产生了一点逃过本次收集的浮动垃圾而已,下次收集清理掉就好。另一种是把原本存活的对象错误标记为已消亡,这就是非常致命的后果了,程序肯定会因此发生错误。
因此,我们要解决并发扫描时的对象消失问题,只需破坏这两个条件的任意一个即可。由此分别
产生了两种解决方案:
增量更新(
Incremental Update
)和原始快照(
Snapshot At The Beginning
,SATB)。
增量更新要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新
插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。
原始快照要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删
除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。
以上无论是对引用关系记录的插入还是删除,虚拟机的记录操作都是通过写屏障实现的。在
HotSpot
虚拟机中,增量更新和原始快照这两种解决方案都有实际应用,譬如,
CMS
是基于增量更新来做并发标记的,G1
、
Shenandoah
则是用原始快照来实现。
六、CMS与G1
CMS是第一款真正意义上的并发收集器,用户线程和收集线程并发执行。
CMS大致经历以下几个阶段:
1、初始标记(STW)
标记出作为GCroots的对象
2、并发标记
用户线程和GC线程共同执行
3、重新标记(STW)
处理并发标记过程中,由于对象引用关系变动造成的漏标问题
4、并发清理
CMS在初始标记和重新标记阶段都会有Stop the world,且在并发标记及并发清理阶段产生的浮动垃圾也无法处理,需要在下一个GC处理,CMS使用标记清除算法所以将会产生内存碎片,其解决的方案为,可以通过JVM参数设置开启压缩策略,指明经过多少次FUll GC后执行标记整理算法进行内存压缩。
G1收集器基于CMS更加强大
1、初始标记(STW)
2、并发标记
3、最终标记(STW)
4、筛选清理(STW)
G1有三个阶段需要stop the world,但是筛选清理阶段可以由用户控制停顿时间,G1将内存划分成多个region,每个region都通过remember set记录跨代引用,也是用card表记录引用关系的变化,方便进行对象的标记,清理时,可以根据需要对部分region进行清理,而不是整个垃圾都清理完,整体来看在满足内存需要的前提下可以精确控制GC的时间。因为整体使用标记整理算法,所以不会产生内存碎片,清理过程对象是需要移动,用户线程必须停止,对于浮动垃圾,G1也需要等到下次GC进行清理。
在
CMS
和
G1
之前的全部收集器,其工作的所有步骤都会产生
“Stop The World”
式的停顿;CMS和
G1
分别使用增量更新和原始快照
技术,实现了标记阶段的并发,不会因管理的堆内存变大,要标记的对象变多而导致停顿时间随之增长。但是对于标记阶段之后的处理,仍未得到妥善解决。CMS
使用标记
-
清除算法,虽然避免了整理阶段收集器带来的停顿,但是清除算法不论如何优化改进,在设计原理上避免不了空间碎片的产生,随着空间碎片不断淤积最终依然逃不过“Stop The
World”
的命运。
G1
虽然可以按更小的粒度进行回收,从而抑制整理阶段出现时间过长的停顿,但毕竟也还是要暂停的。
七、Shenandoah收集器与zGC收集器
以内存占用、吞吐量、低延时为目标,Shenandoah与ZGC都具有非常出色的性能,整体来说这两款收集器都处于并发处理,少部分阶段需要停顿,停顿时间Shenandoah是几百毫秒,ZGC达到了十毫秒之内,从JDK11开始可以使用ZGC。
Shenandoah对比G1:
1、基于region堆内存布局,但是取消分代收集
2、整理算法可以与用户线程并发执行
3、取消记忆集,用链接矩阵解决跨代引用
移动对象,涉及到引用的更新,Shenandoah使用了转发指针来解决这一问题,从结构上来看,Brooks提出的转发指针与某些早期Java虚拟机使用过的句柄定位有一些相似之处,两者都是一种间接性的对象访问方式,差别是句柄通常会统一存储在专门的句柄池中,而转发指针是分散存放在每一个对象头前面。修改转发指针到新的对象引用上实现对象移动后地址引用变化的问题,然而对于写需要保持对转发指针修改的并发同步,在多线程场景下,通过CAS保证并发时对象访问的正确性。
ZGC对比G1:
1、采用region堆内存布局,暂时不设分代
2、全程与用户线程并发,少量停顿
3、取消记忆集,使用读屏障、染色指针、内存多重映射技术实现并发
染色指针是在对象头部存放该对象引用关系是否变化的标记,一旦某个Region的存活对象被移走之后,这个Region立即就能够被释放和重用掉,而不必等待整个堆中所有指向该Region的引用都被修正后才能清理。这点相比起Shenandoah是一个颇大的优势,使得理论上只要还有一个空闲Region,ZGC就能完成收集,而Shenandoah需要等到引用更新阶段结束以后才能释放回收集中的Region,这意味着堆中几乎所有对象都存活的极端情况,需要1∶1复制对象到新Region的话,就必须要有一半的空闲Region来完成收集。
并发重分配(Concurrent Relocate):重分配是ZGC执行过程中的核心阶段,这个过程要把重分
配集中的存活对象复制到新的
Region
上,并为重分配集中的每个
Region
维护一个转发表(
Forward
Table
),记录从旧对象到新对象的转向关系。得益于染色指针的支持,
ZGC
收集器能仅从引用上就明确得知一个对象是否处于重分配集之中,如果用户线程此时并发访问了位于重分配集中的对象,这次访问将会被预置的内存屏障所截获,然后立即根据Region
上的转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象,ZGC
将这种行为称为指针的
“
自愈
”
(
Self-Healing)能力。这样做的好处是只有第一次访问旧对象会陷入转发,也就是只慢一次,对比Shenandoah的
Brooks
转发指针,那是每次对象访问都必须付出的固定开销,简单地说就是每次都慢,因此ZGC
对用户程序的运行时负载要比
Shenandoah
来得更低一些。还有另外一个直接的好处是由于染色指针的存在,一旦重分配集中某个Region
的存活对象都复制完毕后,这个
Region
就可以立即释放用于新对象的分配(但是转发表还得留着不能释放掉),哪怕堆中还有很多指向这个对象的未更新指针也没有关系,这些旧指针一旦被使用,它们都是可以自愈的。