本文首发在我的个人博客:https://jlice.top/p/7uzkg/。欢迎大家前去参观,么么哒~
Survivor的作用
当没有Survivor时,如果增加老年代空间,需要更多存活对象才能填满老年代,这样可以降低Full GC的频率;但是,随着老年代空间加大,一旦发生Full GC,执行所需要的时间更长。
如果减少老年代空间,虽然Full GC所需时间减少;但是,老年代很快被存活对象填满,Full GC频率增加。
因此,Survivor的存在意义,就是减少被送到老年代的对象,进而减少Full GC的发生。
为什么要两个Survivor
解释一:为了解决碎片化。如果只有一个Survivor,对象在Eden创建,GC时存活对象复制到Survivor,当下次GC时,没有一个空闲的Survivor,而Survivor中也会有可回收对象,这样在Survivor中就出现了大量的空闲碎片。如果有两个Survivor,每次GC将Eden和其中一个Survivor中的存活对象复制到另一个Survivor,前一个Survivor会因此空闲,于是下次GC可以重复这样的过程,从而解决了碎片化。
解释二:复制算法将内存等分为两块,每次只使用其中一块,GC时将存活对象移至另一块,前一块因此空闲。引入Eden可以视为对内存利用的优化,相当于两个Survivor共享的区域,每次GC后Eden都会因此空闲,这样相当于扩大了Survivor,避免频繁GC。
新生代空间优化
通常情况下,由于新生代空间较小,Eden区很快被填满,就会导致频繁Minor GC,因此可以通过增大新生代空间来降低Minor GC的频率。扩容新生代后,Minor GC的时间未必会显著增加。这是因为虽然扩容新生代会增加扫描时间,但一些对象会变成可回收,从而省去了一部分复制对象的时间。
对于虚拟机来说,复制对象的成本要远高于扫描成本,所以,单次Minor GC时间更多取决于GC后存活对象的数量,而非Eden区的大小。因此如果堆中短期对象很多,那么扩容新生代,单次Minor GC时间不会显著增加。
如何选择各分区大小应该依赖应用程序中对象生命周期的分布情况:如果应用存在大量的短期对象,应该选择较大的年轻代;如果存在相对较多的持久对象,老年代应该适当增大。
动态年龄计算
Hotspot遍历所有对象时,按照年龄从小到大对其所占用的大小进行累积,当累积的某个年龄大小超过了survivor区的一半时,取这个年龄和MaxTenuringThreshold中更小的一个值,作为新的晋升年龄阈值。
JVM引入动态年龄计算,主要基于如下两点考虑:
1、如果固定按照MaxTenuringThreshold设定的阈值作为晋升条件:
- a)MaxTenuringThreshold设置的过大,原本应该晋升的对象一直停留在Survivor区,直到Survivor区溢出,一旦溢出发生,Eden+Svuvivor中对象将不再依据年龄全部提升到老年代,这样对象老化的机制就失效了。
- b)MaxTenuringThreshold设置的过小,“过早晋升”即对象不能在新生代充分被回收,大量短期对象被晋升到老年代,老年代空间迅速增长,引起频繁的Major GC。分代回收失去了意义,严重影响GC性能。
2、相同应用在不同时间的表现不同:特殊任务的执行或者流量成分的变化,都会导致对象的生命周期分布发生波动,那么固定的阈值设定,因为无法动态适应变化,会造成和上面相同的问题。
总结来说,为了更好的适应不同程序的内存情况,虚拟机并不总是要求对象年龄必须达到Maxtenuringthreshhold再晋级老年代。
并发预清理
新生代中存在对老年代对象的引用,或者老年代中存在对新生代的引用,称为跨代引用。
由于跨代引用的存在,CMS在Remark阶段必须扫描整个堆。为了避免扫描时新生代有很多对象,在Remark前增加了一个可中断的并发预清理(CMS-concurrent-abortable-preclean),该阶段主要工作仍然是并发标记对象是否存活。CMS为了避免这个阶段没有等到Minor GC而陷入无限等待,提供了参数CMSMaxAbortablePrecleanTime,含义是如果可中断的预清理执行超时,不管发没发生Minor GC,都会中止此阶段,进入Remark。如果超时等不到Minor GC,Remark时新生代仍然有很多对象。CMS提供CMSScavengeBeforeRemark参数,用来保证Remark前强制进行一次Minor GC,从而降低Remark阶段的时间。
卡表
老年代可能持有新生代对象引用,所以Minor GC时也必须扫描老年代。
经过统计信息显示,老年代持有新生代对象引用的情况不足1%,根据这一特性JVM引入了卡表(card table)来实现这一目的。如下图所示:
卡表的具体策略是将老年代的空间分成大小为512B的若干张卡(card)。卡表本身是单字节数组,数组中的每个元素对应着一张卡,当发生老年代引用新生代时,虚拟机将该卡对应的卡表元素设置为适当的值。如上图所示,卡表3被标记为脏(卡表还有另外的作用,标识并发标记阶段哪些块被修改过),之后Minor GC时通过扫描卡表就可以很快的识别哪些卡中存在老年代指向新生代的引用。这样虚拟机通过空间换时间的方式,避免了全堆扫描。
总结来说,CMS的设计聚焦在获取最短的时延,为此它“不遗余力”地做了很多工作,包括尽量让应用程序和GC线程并发、增加可中断的并发预清理阶段、引入卡表等,虽然这些操作牺牲了一定吞吐量但获得了更短的回收停顿时间。
卡表具有“滞后性”,浪费一定的空间;如下图所示,Minor GC时实际上对象E可以被回收,但是由于没发生Full GC,老年代中的对象D仍存在对对象E的引用,导致E无法被回收。
参考文献
garbage collection - Java GC: why two survivor regions? - Stack Overflow
周志明《深入理解Java虚拟机》