前面介绍了垃圾回收算法,接下来我们介绍垃圾收集器和内存分配的策略。有没有一种牛逼的收集器像银弹一样适配所有场景?很明显,不可能有,不然我也没必要单独搞一篇文章来介绍垃圾收集器了。熟悉不同收集器的优缺点,在实际的场景中灵活运用,才是王道。
在开始介绍垃圾收集器前,我们可以剧透几点:
- 根据不同分代的特点,收集器可能不同。有些收集器可以同时用于新生代和老年代,而有些时候,则需要分别为新生代或老年代选用合适的收集器。一般来说,新生代收集器的收集频率较高,应选用性能高效的收集器;而老年代收集器收集次数相对较少,对空间较为敏感,应当避免选择基于复制算法的收集器。
- 在垃圾收集执行的时刻,应用程序需要暂停运行。
- 可以串行收集,也可以并行收集。
- 如果能做到并发收集(应用程序不必暂停),那绝对是很妙的事情。
- 如果收集行为可控,那也是很妙的事情。
希望大家带着下面的问题进行阅读,有目标的阅读,可能收获更多。
- 为什么没有一种牛逼的收集器像银弹一样适配所有场景?
- CMS和G1的对比,你知道他两的区别吗?
- 为什么CMS只能用作老年代收集器,而不能应用在新生代的收集?
- 为什么JVM的分代年龄是15?而不是16,20之类的呢?
- “动态对象年龄判定”里有个“天坑”哦,是啥坑呢?
1 垃圾收集器
GC线程与应用线程保持相对独立,当系统需要执行垃圾回收任务时,先停止工作线程,然后命令GC线程工作。以串行模式工作的收集器,称为串行收集器(即Serial Collector)。与之相对的是以并行模式工作的收集器,称为并行收集器(即Paraller Collector)。
1.1 串行收集器:Serial
串行收集器采用单线程方式进行收集,且在GC线程工作时,系统不允许应用线程打扰。此时,应用程序进入暂停状态,即Stop-the-world。
Stop-the-world暂停时间的长短,是度量一款收集器性能高低的重要指标。
是针对新生代的垃圾回收器,基于标记-复制算法。
1.2 并行收集器:ParNew
并行收集器充分利用了多处理器的优势,采用多个GC线程并行收集。可想而知,多条GC线程执行显然比只使用一条GC线程执行的效率更高。一般来说,与串行收集器相比,在多处理器环境下工作的并行收集器能够极大地缩短Stop-the-world时间。
针对新生代的垃圾回收器,标记-复制算法,可以看成是Serial的多线程版本
1.3 吞吐量优先收集器:Parallel Scavenge
针对新生代的垃圾回收器,标记-复制算法,和ParNew类似,但更注重吞吐率。在ParNew的基础上演化而来的Parallel Scanvenge收集器被誉为“吞吐量优先”收集器。吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量=运行用户代码时间 /(运行用户代码时间 + 垃圾收集时间)。如虚拟机总运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。
Parallel Scanvenge收集器在ParNew的基础上提供了一组参数,用于配置期望的收集时间或吞吐量,然后以此为目标进行收集。
通过VM选项可以控制吞吐量的大致范围:
- -XX:MaxGCPauseMills:期望收集时间上限。用来控制收集对应用程序停顿的影响。
- -XX:GCTimeRatio:期望的GC时间占总时间的比例,用来控制吞吐量。
- -XX:UseAdaptiveSizePolicy:自动分代大小调节策略。
但要注意停顿时间与吞吐量这两个目标是相悖的,降低停顿时间的同时也会引起吞吐的降低。因此需要将目标控制在一个合理的范围中。
1.4 Serial Old收集器
Serial Old是Serial收集器的老年代版本,单线程收集器,使用标记-整理算法。这个收集器的主要意义也是在于给Client模式下的虚拟机使用。
1.5 Parallel Old收集器
Parallel Old是Parallel Scanvenge收集器的老年代版本,多线程收集器,使用标记-整理算法。
1.6 CMS收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。
CMS收集器仅作用于老年代的收集,是基于标记-清除算法的,它的运作过程分为4个步骤:
- 初始标记(CMS initial mark)
- 并发标记(CMS concurrent mark)
- 重新标记(CMS remark)
- 并发清除(CMS concurrent sweep)
其中,初始标记、重新标记这两个步骤仍然需要Stop-the-world。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,并发标记阶段就是进行GC Roots Tracing的过程,而重新标记阶段则是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始阶段稍长一些,但远比并发标记的时间短。
CMS收集器优点:并发收集、低停顿。
CMS收集器缺点:
- CMS收集器对CPU资源非常敏感。
- CMS收集器无法处理浮动垃圾(Floating Garbage)。
- CMS收集器是基于标记-清除算法,该算法的缺点都有。
CMS收集器之所以能够做到并发,根本原因在于采用基于“标记-清除”的算法并对算法过程进行了细粒度的分解。前面篇章介绍过标记-清除算法将产生大量的内存碎片这对新生代来说是难以接受的,因此新生代的收集器并未提供CMS版本。
1.7 G1收集器
G1重新定义了堆空间,打破了原有的分代模型,将堆划分为一个个区域。这么做的目的是在进行收集时不必在全堆范围内进行,这是它最显著的特点。区域划分的好处就是带来了停顿时间可预测的收集模型:用户可以指定收集操作在多长时间内完成。即G1提供了接近实时的收集特性。
G1与CMS的特征对比如下:
G1具备如下特点:
- 并行与并发:G1能充分利用多CPU、多核环境下的硬件优势,使用多个CPU来缩短Stop-the-world停顿的时间,部分其他收集器原来需要停顿Java线程执行的GC操作,G1收集器仍然可以通过并发的方式让Java程序继续运行。
- 分代收集
- 空间整合:与CMS的标记-清除算法不同,G1从整体来看是基于标记-整理算法实现的收集器,从局部(两个Region之间)上来看是基于“复制”算法实现的。但无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。
- 可预测的停顿:这是G1相对于CMS的一个优势,降低停顿时间是G1和CMS共同的关注点。
在G1之前的其他收集器进行收集的范围都是整个新生代或者老年代,而G1不再是这样。在堆的结构设计时,G1打破了以往将收集范围固定在新生代或老年代的模式,G1将堆分成许多相同大小的区域单元,每个单元称为Region。Region是一块地址连续的内存空间,G1模块的组成如下图所示:
G1收集器将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合。G1收集器之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在整个Java堆中进行全区域的垃圾收集。G1会通过一个合理的计算模型,计算出每个Region的收集成本并量化,这样一来,收集器在给定了“停顿”时间限制的情况下,总是能选择一组恰当的Regions作为收集目标,让其收集开销满足这个限制条件,以此达到实时收集的目的。
对于打算从CMS或者ParallelOld收集器迁移过来的应用,按照官方 的建议,如果发现符合如下特征,可以考虑更换成G1收集器以追求更佳性能:
- 实时数据占用了超过半数的堆空间;
- 对象分配率或“晋升”的速度变化明显;
- 期望消除耗时较长的GC或停顿(超过0.5——1秒)。
G1收集的运作过程大致如下:
- 初始标记(Initial Marking):仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建新对象,这阶段需要停顿线程,但耗时很短。
- 并发标记(Concurrent Marking):是从GC Roots开始堆中对象进行可达性分析,找出存活的对象,这阶段耗时较长,但可与用户程序并发执行。
- 最终标记(Final Marking):是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程Remembered Set Logs里面,最终标记阶段需要把Remembered Set Logs的数据合并到Remembered Set中,这阶段需要停顿线程,但是可并行执行。
- 筛选回收(Live Data Counting and Evacuation):首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划。这个阶段也可以做到与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。
我们可以看下官方文档对G1的展望(这段英文描述比较简单,我就不翻译了):
2 内存分配策略
对象的内存分配,往大方向上讲,就是在堆上分配(但也可能经过JIT编译后被拆散为标量类型并间接地栈上分配),对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓冲,将按线程优先在TLAB上分配。少数情况下可能会直接分配在老年代中。
2.1 对象优先在Eden分配
大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC(前面篇章中有介绍过Minor GC)。但也有一种情况,在内存担保机制下,无法安置的对象会直接进到老年代。
2.2 大对象直接进入老年代
大对象时指需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组。
虚拟机提供了一个-XX:PretenureSizeThreshold参数,令大于这个设置值的对象直接在老年代分配。目的就是避免在Eden区及两个Survivor区之间发生大量的内存复制。
2.3 长期存活的对象将进入老年代
虚拟机给每个对象定义了一个对象年龄(Age)计数器。如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并且对象年龄设为1 。对象在Survivor区中没经过一次Minor GC,年龄就加1岁,当年龄达到15岁(默认值),就会被晋升到老年代中。
接下来我们来回答为什么JVM的分代年龄为什么是15?而不是16,20之类的呢?
真的不是为什么不能是其它数(除了15),着实是臣妾做不到啊!
事情是这样的,HotSpot虚拟机的对象头其中一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32bit和64bit,官方称它为“Mark word”。
例如,在32位的HotSpot虚拟机中,如果对象处于未被锁定的状态下,那么Mark Word的32bit空间中25bit用于存储对象哈希码,4bit用于存储对象分代年龄,2bit用于存储锁标志位,1bit固定为0 。
明白是什么原因了吗?对象的分代年龄占4位,也就是0000,最大值为1111也就是最大为15,而不可能为16,20之类的了。
2.4 动态对象年龄判定
为了能更好的适应不同程序的内存状况,虚拟机并不是永远地要求兑现过的年龄必须达到了MaxTenuringThreshold才能晋升老年代。
满足如下条件之一,对象能晋升老年代:
- 1.对象的年龄达到了MaxTenuringThreshold(默认15)能晋升老年代。
- 2.如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。
很多文章都只是注意到了上面描述的情况(包括阿里中间件公众号发的一篇文章里也只是这么简单的介绍,当时给它们后台留过言说明情况),但如果只是这么认识的话,会发现在实际的内存回收中有悖于此条规定。
举个小栗子,如对象年龄5的占28%,年龄6的占20%,年龄7的占52%,按那两个标准,对象是不能进入老年代的,但Survivor都已经100%了啊?
大家可以关注这个参数TargetSurvivorRatio,目标存活率,默认为50%。大致意思就是说年龄从小到大累加,如加入某个年龄段(如栗子中的年龄7)后,总占用超过Survivor空间*TargetSurvivorRatio的时候,从该年龄段开始及大于的年龄对象就要进入老年代(即栗子中的年龄7对象)。动态对象年龄判断,主要是被TargetSurvivorRatio这个参数来控制。而且算的是年龄从小到大的累加和,而不是某个年龄段对象的大小。
2.5 空间分配担保
在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的。如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者HandlePromotionFailure设置不允许冒险,那这时也要改为进行一次Full GC 。
上面说的风险是什么呢?我们知道,新生代使用复制收集算法,但为了内存利用率,只使用其中一个Survivor空间来作为轮换备份,因此当出现大量对象在Minor GC后仍然存活的情况(最极端的情况就是内存回收后新生代中所有对象都存活),就需要老年代进行分配担保,把Survivor无法容纳的对象直接进入老年代。