前言
前面我们学习了Java对象分配及垃圾回收机制,接下来我们来看看JVM中常见的垃圾回收器有哪些。
垃圾回收器
新生代垃圾回收器比较
老年代垃圾回收器比较
垃圾回收器工作示意图
Serial/Serial Old
JVM刚诞生就只有这种,最古老的,单线程,独占式,成熟,适合单CPU,一般用在客户端模式下。
这种垃圾回收器只适合几十兆到一两百兆的堆空间进行垃圾回收(可以控制停顿时间再100ms左右)
,但是对于超过这个大小的内存回收速度很慢,已经被抛弃。
Stop The World(STW)
单线程进行垃圾回收时,必须暂停所有的工作线程,直到它回收结束。这个暂停称之为“Stop The World”。
Parallel Scavenge(ParallerGC)/Parallel Old
为了提高回收效率,从JDK1.3开始,JVM使用了多线程的垃圾回收机制,关注吞吐量的垃圾收集器,高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。
ParNew
多线程垃圾回收器,与CMS进行配合,对于CMS(CMS只回收老年代),新生代垃圾回收器只有Serial与ParNew可以选。和Serial基本没区别,唯一的区别:多线程,多CPU的,停顿时间比Serial少。(在JDK9以后,把ParNew合并到了CMS了,后续版本已经接近淘汰。)
多线程收集(Parallel Scavenge/Parallel Old/ParNew)
并发垃圾回收器-CMS
回收过程
收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网站或者B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS收集器就非常符合这类应用的需求。
- 初始标记-短暂,仅仅只是标记一下GC Roots能直接关联到的对象,速度很快。
- 并发标记()-和用户的应用程序同时进行,进行GC Roots追踪的过程,标记从GCRoots开始关联的所有对象开始遍历整个可达分析路径的对象。这个时间比较长,所以采用并发处理(垃圾回收器线程和用户线程同时工作)
- 重新标记-短暂,为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。
- 并发清除由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以,从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。
CMS中的细节
预清理与并发可中断预清理:
这两个处理都是并发的,所以如果是比较泛的讲的话,都可以说成并发标记阶段,如果是要抓细节,那么并发标记阶段后续还有这两个处理。 因为 CMS 的终极目标是降低垃圾回收时的暂停时间,所以在该阶段要尽最大的努力去处理,如果能够在并发阶段处理被应用线程更新的老年代对象,这 样在暂停的重新标记阶段就可以少处理一些,暂停时间也会相应的降低。
预清理:
通过参数 CMSPrecleaningEnabled 选择关闭该阶段,默认启用;
主要做两件事情:
1、 在并发阶段(并发阶段是不暂停的),在 Eden 区中分配了一个 A 对象,A 对象引用了一个老年代对象 B(这个 B 之前没有被标记),在这个阶段就会标记对象 B 为活跃对象。
2、 在并发标记阶段,如果老年代中有对象内部引用发生变化,会把所在的 Card 标记为 Dirty(其实这里并非使用 CardTable,而是一个类似的数据结构, 叫 ModUnionTalble)通过扫描这些 Table,重新标记那些在并发标记阶段引用被更新的对象。
并发可中断预清理
该阶段发生的前提是,新生代 Eden 区的内存使用量大于参数,CMSScheduleRemarkEdenSizeThreshold,默认是 2M,如果新生代的对象太少,就没有 必要执行该阶段,直接执行重新标记阶段。 在该阶段,主要循环的做两件事:
1、 处理 From 和 To 区的对象,标记可达的老年代对象,类似于预处理。
2、预清理的第二个阶段。
在并发标记阶段,如果老年代中有对象内部引用发生变化,会把所在的 Card 标记为 Dirty(其实这里并非使用 CardTable,而是一个类似的数据结构, 叫 ModUnionTalble)通过扫描这些 Table,重新标记那些在并发标记阶段引用被更新的对象。
这个逻辑不会一直循环下去,打断这个循环的条件有三个(满足一个即可):
1.可以设置最多循环的次数 CMSMaxAbortablePrecleanLoops,默认是 0,意思没有循环次数的限制。
2.如果执行这个逻辑的时间达到了阈值 CMSMaxAbortablePrecleanTime,默认是 5s,会退出循环。
3.如果新生代 Eden 区的内存使用率达到了阈值 CMSScheduleRemarkEdenPenetration,默认 50%,会退出循环。
CMS存在的问题
CPU敏感:
CMS 对处理器资源敏感,毕竟采用了并发的收集、当处理核心数不足 4 个时,CMS 对用户的影响较大。浮动垃圾:
由于 CMS 并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS 无法 在当次收集中处理掉它们,只好留待下一次 GC 时再清理掉。这一部分垃圾就称为“浮动垃圾”。 由于浮动垃圾的存在,因此需要预留出一部分内存,意味着 CMS 收集不能像其它收集器那样等待老年代快满的时候再回收。
-会产生空间碎片:
标记 - 清除算法会导致产生不连续的空间碎片。
碎片带来了两个问题:
1、空间分配效率较低;
2、空间利用效率变低;
垃圾回收器退化
如果发生了,Promotion Failed,那么 CMS 会退化,单线程串行 GC 模式,一般会使用 Serial Old ,因为 Serial Old 是一个单线程,所以如果内存空间 很大、且对象较多时,CMS 发生这样情况会很卡。 Serial 使用使用标记整理算法,单线程全暂停的方式,对整个堆进行垃圾收集,暂停时间要长于 CMS。
CMS 总结
CMS 问题比较多,所以现在没有一个版本默认是 CMS,只能手工指定。但是它毕竟是第一个并发垃圾回收器,对于了解并发垃圾回收具有一定意义,所 以我们必须了解。 为什么 CMS 采用标记-清除,在实现并发的垃圾回收时,如果采用标记整理算法,那么还涉及到对象的移动(对象的移动必定涉及到引用的变化,这个需 要暂停业务线程来处理栈信息,这样使得并发收集的暂停时间更长),所以使用简单的标记-清除算法才可以降低 CMS 的 STW 的时间。 该垃圾回收器适合回收堆空间几个 G~ 20G 左右。
JVM调优
堆空间如何设置
在分代模型中,各分区的大小对 GC 的性能影响很大。如何将各分区调整到合适的大小,分析活跃数据的大小是很好的切入点。 活跃数据的大小:应用程序稳定运行时长期存活对象在堆中占用的空间大小,也就是 Full GC 后堆中老年代占用空间的大小。 可以通过 GC 日志中 Full GC 之后老年代数据大小得出,比较准确的方法是在程序稳定后,多次获取 GC 数据,通过取平均值的方式计算活跃数据的大小。
例如,根据 GC 日志获得老年代的活跃数据大小为 300M,那么各分区大小可以设为: 总堆:1200MB = 300MB × 4
新生代:450MB = 300MB × 1.5 老年代:750MB = 1200MB - 450MB
扩容新生代能提供GC效率吗
通常情况下,由于新生代空间较小,Eden 区很快被填满,就会导致频繁 Minor GC,因此可以通过增大新生代空间来降低 Minor GC 的频率。
例如在相同 的内存分配率的前提下,新生代中的 Eden 区增加一倍,Minor GC 的次数就会减少一半。扩容 Eden 区虽然可以减少 Minor GC 的次数,但会增加单次 Minor GC 时间啊,单次时间增加了,是不是也白忙活了!!! 单次 Minor GC 时间由以下两部分组成:T1(扫描新生代)和 T2(复制存活对象到 Survivor 区)如下图:
扩容前:新生代容量为 R ,假设对象 A 的存活时间为 750ms,Minor GC 间隔 500ms,那么本次 Minor GC 时间= T1(扫描新生代 R)+T2(复制对象 A 到 S)。 扩容后:新生代容量为 2R ,对象 A 的生命周期为 750ms,那么 Minor GC 间隔增加为 1000ms,此时 Minor GC 对象 A 已不再存活,不需要把它复制到 Survivor 区,那么本次 GC 时间 = 2 × T1(扫描新生代 R),没有 T2 复制时间。 可见,扩容后,Minor GC 时增加了 T1(扫描时间),但省去 T2(复制对象)的时间,更重要的是对于虚拟机来说,复制对象的成本要远高于扫描成本, 所以,单次 Minor GC 时间更多取决于 GC 后存活对象的数量,而非 Eden 区的大小。 所以当 JVM 服务中存在大量短期临时对象,扩容新生代空间后,Minor GC 频率降低,对象在新生代得到充分回收,只有生命周期长的对象才进入老年 代。这样老年代增速变慢,Major GC 频率自然也会降低。 但是如果堆中短期对象很多,那么扩容新生代,单次 Minor GC 时间不会显著增加。
总结的经验就是:如果应用存在大量的短期对象,应该选择较大的年轻代;如果存在相对较多的持久对象,老年代应该适当增大。
JVM是如何避免Minor GC时扫描全堆的?
新生代 GC 和老年代的 GC 是各自分开独立进行的。 新生代对象持有老年代中对象的引用,老年代也可能持有新生代对象引用,这种情况称为“跨代引用”。 因它的存在,所以 Minor GC 时也必须扫描老年代。 JVM 是如何避免 Minor GC 时扫描全堆的? 经过统计信息显示,老年代持有新生代对象引用的情况不足 1%,根据这一特性 JVM 引入了卡表(card table)来实现这一目的。
卡表的具体策略是将老年代的空间分成大小为 512B 的若干张卡(card)。卡表本身是单字节数组,数组中的每个元素对应着一张卡,当发生老年代引用 新生代时,虚拟机将该卡对应的卡表元素设置为适当的值。如上图所示,卡表 3 被标记为脏,之后 Minor GC 时通过扫描卡表就可以很快的识别哪些卡中 存在老年代指向新生代的引用。这样虚拟机通过空间换时间的方式,避免了全堆扫描。