前面介绍了垃圾回收算法,接下来我们介绍垃圾收集器和内存分配的策略。有没有一种牛逼的收集器像银弹一样适配所有场景?很明显,不可能有,不然我也没必要单独搞一篇文章来介绍垃圾收集器了。熟悉不同收集器的优缺点,在实际的场景中灵活运用,才是王道。
在开始介绍垃圾收集器前,我们可以剧透几点:
- 根据不同分代的特点,收集器可能不同。有些收集器可以同时用于新生代和老年代,而有些时候,则需要分别为新生代或老年代选用合适的收集器。一般来说,新生代收集器的收集频率较高,应选用性能高效的收集器;而老年代收集器收集次数相对较少,对空间较为敏感,应当避免选择基于复制算法的收集器。
- 在垃圾收集执行的时刻,应用程序需要暂停运行。
- 可以串行收集,也可以并行收集。
- 如果能做到并发收集(应用程序不必暂停),那绝对是很妙的事情。
- 如果收集行为可控,那也是很妙的事情。
希望大家带着下面的问题进行阅读,有目标的阅读,可能收获更多。
- 为什么没有一种牛逼的收集器像银弹一样适配所有场景?
- 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 以流水线方式拆分了收集周期,将耗时长的操作单元保持与应用线程并发执行。只将那些必需 STW 才能执行的操作单元单独拎出来,控制这些单元在恰当的时机运行,并能保证仅需短暂的时间就可以完成。这样,在整个收集周期内,只有 两次短暂的暂停(初始标记和重新标记),达到了近似并发的目的。
CMS 收集器 优点:并发收集、低停顿。
CMS 收集器 缺点:
- CMS 收集器对 CPU 资源非常敏感。
- CMS 收集器无法处理浮动垃圾(Floating Garbage)。
- CMS 收集器是基于标记 - 清除算法,该算法的缺点都有。
CMS 收集器之所以能够做到并发,根本原因在于 采用基于“标记 - 清除”的算法并对算法过程进行了细粒度的分解。前面篇章介绍过标记 - 清除算法将产生大量的内存碎片这对新生代来说是难以接受的,因此新生代的收集器并未提供 CMS 版本。
1.7 G1 收集器
G1 重新定义了堆空间,打破了原有的分代模型,将堆划分为一个个区域。这么做的目的是在进行收集时不必在全堆范围内进行,这是它最显著的特点。区域划分的好处就是带来了停顿时间可预测的收集模型:用户可以指定收集操作在多长时间内完成。即 G1 提供了接近实时的收集特性。
G1 与 CMS 的特征对比如下:
特征 | CMS | G1 |
---|---|---|
并发和分代 | 是 | 是 |
最大化释放堆内存 | 是 | 否 |
低延时 | 是 | 是 |
吞吐量 | 高 | 低 |
压实 | 是 | 否 |
可预测性 | 强 | 弱 |
新生代和老年代的物理隔离 | 否 | 是 |
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 秒)。
原文如下:
Applications running today with either the CMS or the ParallelOld garbage collector would benefit switching to G1 if the application has one or more of the following traits.
- More than 50% of the Java heap is occupied with live data.
- The rate of object allocation rate or promotion varies significantly.
- Undesired long garbage collection or compaction pauses (longer than 0.5 to 1 second)
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 的展望(这段英文描述比较简单,我就不翻译了):
Future:
G1 is planned as the long term replacement for the Concurrent Mark-Sweep Collector (CMS). Comparing G1 with CMS, there are differences that make G1 a better solution. One difference is that G1 is a compacting collector. G1 compacts sufficiently to completely avoid the use of fine-grained free lists for allocation, and instead relies on regions. This considerably simplifies parts of the collector, and mostly eliminates potential fragmentation issues. Also, G1 offers more predictable garbage collection pauses than the CMS collector, and allows users to specify desired pause targets.
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 岁(默认值),就会被晋升到老年代中。
对象晋升老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold设置。
接下来我们来回答为什么 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 无法容纳的对象直接进入老年代。
3. 总结脑图
脑图太大,如需高清完整大图,请留言告知。