深入理解JVM - CMS收集器
前言
上一节我们讲解分代和垃圾回收算法,这一节我们来讲解老年代重要的垃圾收集器:cms收集器。这一节的内容同样比较多。
这一节主要围绕着十分常用的CMS垃圾收集器进行讲解。
前文回顾
上一篇文章我们讲解分代的基础理论,同时讲解了新生代和老年代各自的算法复制算法和标记整理算法,之后我们总结了新生代进入老年代的条件,在最后我们介绍的引用类型,同时进行了练习的提问和相关的解答。
概述
- 讲述cms收集器之前,简单了解他的黄金搭档ParNew
- 讲解cms收集器的参数,以及核心的运行步骤部分。
- 讲解CMS收集器运行过程的一些细节以及CMS的参数的意义。
- 整理小部分常见的JVM问题
黄金搭档ParNew
作为最常用的新生代垃圾收集器ParNew,他和cms收集器的搭配在Jdk1.9之前是jdk官方推荐的配置,也是目前最常被用到的收集器组合。
ParNew收集器本身是Serial收集器的多线程版本。而Serial 收集器和Serial Old收集器因为过于古老这里不再进行介绍,但是并不是说他们已经退出了历史舞台,文章后面的内容将会提到Serial收集器的关键作用。
最后,需要注意ParNew是除了Serial之外唯一可以和cms配合的垃圾收集器
特点:
- 和Serrial只是单线程和多线程区别
- 除了Serrial之外唯一可以和cms配合的垃圾收集器
问题解答:
多线程回收器和单线程回收器那个好?
通常情况下,如果是服务端通常更加建议使用多线程收集器,而客户端则更加倾向使用单线程的收集器。因为如果是单核的机器使用多线程会带来额外的“上下文切换”的操作,性能不会提升反而会下降。同时客户端多数情况下对于多线程的要求并不是很高,所以客户端更加推荐使用单线程。
Serial 和 ParNew那个 回收器要好?
和上面的问题一样,要根据使用的机器是多核还是单核来决定。当然多数情况下会使用多线程,因为现代处理器的多线程技术已经十分成熟。
分析:
解答上面的问题首先我们要弄清楚什么是客户端模式,什么是服务端模式,客户端模式中,-client 代表了客户端的所需参数,而 -server 则是服务器需要的运行参数。
服务端模式:通常适用于多核心的环境,比如对于多线程垃圾回收具备高效利用的Parnew。
客户端模式:如果是单核性能较差的机器适合使用,因为客户端模式通常运行单核,适合Serial收集器,因为他是单线程的,没有线程切换的开销
CMS回收器
jdk9之前老年代最常使用的垃圾回收器,主要使用标记-清除算法(不完全使用标记清除算法)。为了保证运行的效率,cms会采用用户线程以及垃圾收集线程并发执行的方式进行处理,也是首款支持用户线程和垃圾回收线程并发的垃圾收集器。
之前的文章讨论过,标记清除算法会产生大量的内存碎片,为什么还要使用标记-清除算法呢?
其实cms会根据一个系统参数判定多少次垃圾回收之后执行整理动作,而这个动作需要停下当前所有的用户线程,并且开启单线程Serial收集器对于老年代的内存碎片进行整理,而这里的整理就是使用的标记-整理。
但是通常情况下cms使用的还是标记-清除的动作。
CMS收集器特点:
- 不能单独使用,需要和其他收集器配合,并且只能和Serrial、ParNew这两个收集器配合
- 为了保证运行的效率,cms会采用用户线程以及垃圾收集线程并发执行的方式进行处理。也是首款支持用户线程和垃圾回收线程并发的垃圾回收器
- 基于标记-清除的算法。
- 侧重于最短停顿时间的一款垃圾收集器
CMS主要参数:
- -XX:ParallelGCThreads:限制垃圾回收线程的数量,默认情况下线程数量为(cpu核心总数+ 3) / 4,比如8个核的线程为2个垃圾收集线程
- +UseCms-CompactAtFullCollection(jdk9开启废弃):开启之后,运行每次FullGc之后内存碎片并且进行整理的操作,而内存整理需要停止用户线程。会增加整个stop the world的时间
- -XX:CMSFullGCsBefore-Compaction(jdk9开启废弃):注意这个参数生效的前提是**+UseCms-CompactAtFullCollection**这个参数开启,用于控制多少次FullGc之后进行内存整理,默认是0次,表示每次都进行内存碎片的整理操作。
- -XX:CmsInitiatingOccupancyFranction:用于限制老年代内存占用超过多少占比之后开启垃圾回收的动作。jdk5为68%,jdk6之后为92%。
CMS的运行步骤(重点)
cms的四个回收步骤比较好理解,主要为四个步骤:
- 初始标记:这个过程十分快速,需要stop the world,遍历所有的对象并且标记初始的gc root
- 并发标记:这个过程可以和用户线程一起并发完成,所以对于系统进程的影响较小,主要的工作为在系统线程运行的时候通过gc root对于对象进行根节点枚举的操作,标记对象是否存活,注意这里的标记也是较为迅速和简单的,因为下一步还需要重新标记
- 重新标记:需要stop the world,这个阶段会继续完成上一个阶段的动作,对于上一个步骤标记的对象进行二次遍历,重新标记是否存活。
- 并发清理:和用户线程一起并发,负责将没有Gc root引用的垃圾对象进行回收。
从上面的步骤描述可以看到,cms的垃圾收集器已经有了很大的进步,可以实现并发的标记和并发的整理阶段做到和用户线程并发执行(但是比较吃系统资源),不干扰用户线程的对象分配操作,但是需要注意初始标记和重新标记阶段依然需要停顿。
初始标记
初始标记阶段:需要暂停用户线程, 开启垃圾收集线程, 但是仅仅是收集当前老年代的GC ROOT对象,整个运行过程的速度非常快,用户几乎感知不到。
这里需要注意的是哪些对象会作为GC ROOT,而哪些则不会,比如实例变量不是GC ROOT的对象,同时在根节点枚举当中如果发现没有被引用也会标记为垃圾对象。
哪些节点可以作为gc root
- 局部变量本身就可以作为GC ROOT
- 静态变量可以看作是Gc Root
- Long类型index的遍历循环会作为GT ROOT
总结:当有方法局部变量引用或者类的静态变量引用,就不会被垃圾线程回收。
并发标记
并发标记阶段:可以和用户线程一起并发执行,此时系统进程会不断往虚拟机中分配对象,而垃圾收集线程则会根据gc root对于老年代中的对象进行有效性检测,将对象标记为存活对象或者垃圾对象,这个阶段是最为耗时的,但是由于是和用户线程并发执行,影响不是很大。
注意这个这个阶段并不能完成标记出需要垃圾回收的对象,因为此时可能存在存活对象变为垃圾对象,而垃圾对象也可能变为存活对象。
补充 - 并发关系和并行关系在jvm的区别:
并行:指的是多条垃圾收集线程之间的关系
并发:垃圾收集器和用户线程之间的关系
重新标记
重新标记阶段:这个阶段同样需要stop world,作用是会继续完成上一个阶段的动作,其实是对第二个阶段已经标记的对象再次进行对象是否存活的标记和判断,这个过程是十分快的,因为是对上一个步骤的扫尾工作。
并发清理
并发清理阶段:这个阶段同样是和用户线程并发执行的,此时用户线程可以继续分配对象,而垃圾回收线程则进行垃圾的回收动作,这个阶段也是比较耗时的,但是由于是并发执行所以影响不是很大。
cms收集器引发了哪些问题
线程资源被垃圾收集线程占用(cpu资源占用问题)
因为在并发标记和并发清理这两个阶段是需要和用户线程并发的,此时需要占用整个系统一部分的资源,留给垃圾线程并发处理使用。
这里还有一个明显的问题就是如果是单核心单线程的系统,cms内部会使用抢占式多任务模拟多核并行的技术,并且开启增量式收集器实现线程方式的处理。(意思就是伪双核实现 i-cms的并发处理)但是这个收集器 i-cms 的效果不尽人意,在jdk7当中被废弃,在jdk9当中已经被完全删除。
单核心单线程的机器需要谨慎考虑是否使用CMS。
Concurrent Mode Fail
简单理解:简单理解就是cms是一个勤快的小伙子,平时有条不紊的进行垃圾回收的操作,但是当垃圾过多小伙子顶不住的时候,此时背后注视一切的老者Serrial收集器大喊一声:stop the world,并且快速进行垃圾回收动作,一切工作完成退隐幕后,让小伙子继续上班。
当然上面的案例不是个人创造,个人学习的时候看到一个非常形象的比喻,当然我们解释的时候肯定不能这么解释,这不是专业人员该说的话。
在用户线程和垃圾回收线程并发运行的同时,因为第二步和第四步是同时运行的,如果此时让老年代满了之后再回收,肯定是不行的,如果此时垃圾线程和用户线程一起工作,会导致用户线程分配内存大于老年代引发OOM的问题。所以cms默认会根据之前介绍的cms参数 -xx:cmsInitiatingOccupactAtFullCollection来指定老年代内存占用多少之后进行垃圾回收的动作。
Jdk 中 -xx:cmsInitiatingOccupactAtFullCollection参数在 jdk5 是68% ,而jdk6 调整为 92%。
这里还有一个问题,就是如果在并发清理的阶段如果用户线程分配的对象超过剩下的内存(比如最后8%的空间),而此时垃圾回收线程又在工作,那么此时会发出现 Concurrent Mode Fail ** 的问题,此时会立刻stop world 暂停用户进程并且开启Serial收集器**进行垃圾回收清理的操作。当垃圾回收完成之后,会开启用户用户线程并且恢复cms收集器的工作。
在实际使用过程中需要小心调整此比例,防止并发失败问题发生。
可以看到Serial收集器作为兜底的操作,有人会有疑问为什么兜底用Serial这种单线程垃圾收集器而不用其他的垃圾收集器。
这个问题其实很好回答,类似于redis一样,单线程不一定意味着性能差,多线程也不也意味着性能好,Serial作为老牌垃圾收集器虽然实现很简单,但是具备一个其他收集器没有的优点,就是效率高,性能好。所以这也是会为什么使用Serial作为兜底而不是使用其他垃圾收集器。
内存碎片
这个问题是由于cms本身使用标记-清除算法实现而产生,并发标记和并发清理阶段都是对于垃圾对象的直接标记和回收处理,在重新标记阶段也仅仅是对gc root已经标记的对象再进行一次判断而已,所有的过程都不会产生对象的移动操作,这就导致了内存对象是东一块西一块的,如果此时新生代出现大对象要进来,很容易造成频繁 full gc。
官方的解决办法是在每次标记整理结束之后,就对内存进行一次“标记 - 整理”的动作,此时同样需要 “stop world”暂停用户线程,同时将存活对象移动到一处,并且清理掉所有垃圾对象。
Jdk提供了:-xx:cmsFullGcBefore-Compaction 参数用于指定多少次full gc 进行一次内存整理,默认是 0 次,表示每次都进行整理操作。
问题整理:
触发老年代回收的时机有哪些?
这个点已经提了不知道多少次了,这里再次提一下,同时增加了一条使用CMS收集器的情况下触发老年代Full GC的时机。
- 老年代可用连续空间小于新生代全部对象的大小
- 老年代可用连续空间小于历次晋升老年代的新生代平均大小
- 新生代内存minor gc无法进入survior区域, 而老年代空间又不足的时候
- -xx:cmsInitiatingOccupactAtFullCollection 在 Cms垃圾收集器的情况下,如果并发清理阶段对象分配到的大小超过最后8% 的空间大小之后,会触发 concurrent Fail导致失败。
思考题:为什么老年代垃圾回收速度会比新生代慢这么多,到底慢在哪里?
- 首先老年代内存对象非常多,GC ROOT的速度是非常慢的,垃圾回收时间被拉长。
- 标记-整理的算法在清理后需要移动大量的对象到一处,同时需要更新跨代引用以及对象的引用地址等,耗时较长。而新生代复制算法的同时对象都比较小,算法直接将存活对象拷贝然后清理掉eden区域,最后留下很少的对象进入老年代。
- 如果使用标记清除算法,会导致内存的碎片。如果碎片过多,需要停止线程进行挪动和整理。
这一思考题主要从算法和对象多少两个方面入手,新生代的复制算法和老年代的标记-整理算法所需要的时间开销不一样,同时老年代本身对象过多,同时结合jvm主要采用根节点枚举的特点,必然会导致用户线程的暂停和等待,即使是最新一代收集器(ZGC和Shenadash)可以做几乎完全和用户线程并发,在根节点枚举这一步骤上还是需要暂停用户线程。由此可见,老年代回收速度慢并且我们需要竭力避免老年代触发垃圾回收。
GC回收的“无用的类”(方法区):
这里再重新强调方法区的回收标准:
1、该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例
2、加载该类的 ClassLoader已经被回收
3、该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
写在最后
垃圾收集器的细节比较多,所以这篇文章很长,cms垃圾收集器是十分重要并且值得关注的一款收集器。
从这一节可以看到老年代的回收对于cms的副作用十分大,所以下一节将会根据一个模拟的案例讲解规避老年代回收的一些思路。