这次来复习一下常用的 ParNew 和 CMS GC 的概念和一些调优建议
GC
GC 全称是 Garbage Collect
,译为 「垃圾回收」,在代码编写过程中,我们new 一个对象后,在使用和结束阶段,都可以不需要关注内存分配和内存回收,因为 jvm 会自动识别到哪些对象不再被使用,然后进行清理,同时在内存中释放掉这些空间。
判断对象不可用的方法
- 引用计数 Reference Count
简单理解就是,每个对象都有一个计数器,如果该对象被其它对象引用后,计数器加一,如果计数器不为 0,表示它还在使用,不能被清理。
这样会有个弊端,例如 A <-> B
,如果存在互相引用,但没有被第三方继续引用,那么这两个对象其实没有其他使用,但由于计数器不为 0,无法得到清理。
- 可达性分析 Reachability Analysis
以 GC Roots
为起点,根据引用关系往下搜索,搜索过程中走过的路称为”引用连“(Reference Chain),如果与 GC Roots 对象可连接,说明对象还在使用,反之表示不可达,说明这些对象不使用,可以被回收掉。
其中关于 GC Roots 这些对象,有我们熟悉的,各个线程中调用的方法堆栈中的参数、局部变量、临时变量等,还有其它作为根路径的对象,可以参考书籍 3.2 章节
画图,说明 ParNew 和 CMS 回收垃圾的流程
前面说了有哪些方法可以说明对象不可用,这里来说下用什么垃圾回收器去回收♻️
在看完第二版之后,jdk8 之前的常用 gc 算法基本掌握,大多基于「分代收集」Generational Collection
,主要分为了新生代 Young
区,存储一些朝生夕死的对象,另一个是老年代 Old
区,存储一些使用时间比较长,熬过了多次垃圾回收的对象
回收核心的步骤:
- 标记出可以回收的对象
- 清理被标志不可用的对象
在清理过程中,还会出现复制的操作,这是细化的操作,要看具体使用的哪个 GC 算法。
目前用的比较熟悉的是 [ParNew + CMS]
垃圾回收器,所以来简单记录这两者使用到的 gc 算法和回收流程。
区别于初始版本的线性 Serial 垃圾回收器,Serial 只能单线程操作,目前常用的都是多线程操作,跟多一双手多一份力一样,多线程能够提高垃圾回收的速度,常看到的 ParNew 和 Parallel Scanvenge,其中 Parallel
表示并行的意思,并行操作以降低用户线程(应用)因垃圾收集而导致的停顿。
ParNew 收集器
ParNew
收集器用于回收新生代资源,是 Serial 收集器的并行版本。
为何要选择 ParNew 作为新生代的回收器,答案是目前好像除了 Serial 收集器外,只有它能够与老年代的 CMS 回收器搭配使用,在 jvm 启动参数中可以通过 +XX:+/-UseParNewGC
来开启或者关闭使用该收集器。
顺便来介绍一下其它几个 jvm 参数:
- -XX:SurvivorRatio
在新生代 Young Generation
中,分为了一个 Eden 区和两个 Survivor(From & TO),这是一种更优的半区复制分代策略,每次分配使用 Eden 区 + 其中一个 Survivor 区,发生垃圾回收时,将 Eden 和 Survivor 中还存活的对象拷贝到另一个 Survivor 区( From —> TO),然后清理掉刚才的 Eden 和一块刚才使用过的 Survivor 区中数据。
该参数的默认数值是 8,表示的是 Eden :Survivor 比值,因为有两个 Survivor 区域,所以一块 Survivor 占新生代 1/10,Eden 占有 8/10。
- –XX:NewRatio
该参数表示的是新生代与老年代的比值
例如如果设置 -XX:NewRatio=2
,新生代(Eden + 2 * Survivor):老年代 = 1 :2,所以新生代占堆的 1/3,老年代占堆的 2/3。
- -XX:MetaspaceSize -XX:MaxMetaspaceSize
在 jdk8 之前,存在一个区域叫「永久代」(Permgen),与 jdk8 之后出现的「元空间」(Metaspace)作用一样,主要功能是存储类实例的具体信息(即类对象),这部分也叫做「类的元数据」,只对编译器或者 JVM 的运行时有用。
不同于元空间,永久代里还存储了一些与类数据无关的杂项对象(miscellaneous object),这些对象在 jdk8 的时候,被挪回了普通的堆空间。除此之外,jdk8 开始从根本上改变了保存在这个特殊区域的元数据的类型。
作为开发,可能不需要太关注里面存储了什么信息,不过得知道为啥「元空间」取代了「永久代」。
翻看资料,发现了之前默认情况下,永久代大多分配的大小最多只有 82MB,如果遇到特别复杂的应用,加载的类特别多,所以存储的类信息也会很多。
在 jdk8 之前应用服务器(或者任何需要频繁重新载入类的环境)上经常会碰到由于永久代空间空间耗尽触发的 Full GC。
而在 jdk8 之后,元空间并不在虚拟机中,而是使用本地内存,所以它的大小仅受到本地内存,也就是系统实际可用空间控制。
默认情况下,元空间是没有大小限制的,不过还是建议分配一个初始和最大值,例如 -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=256m
,虽然还是有可能触发 Full GC,这个时候就要排查定位出什么类导致元空间这么庞大,然后进行解决。
新生代 GC
可以看下新生代 GC 前后的内存分布情况:(蓝色表示使用情况)
验证了前面说的场景,拷贝 Eden 区和其中一块使用的 Survivor 区 S1 中的还在使用对象到另一个 Survivor 区 S0,如果新生代放不下或者对象熬过多次垃圾回收,就会进入到老年代。
翻看 gc 日志,回收新生代的日志格式如下:
|
|
刚开始对于 Failure
有点敏感,以为是错误的,搜索资料后发现,原来是新生代空间不足,触发了 Minor GC
,属于正常现象,顺便也来复习一下各个字段的含义。
- Allocation Failure:
表示新生代没有足够的空间分配新对象,于是需要进行新生代对象回收,准备下一次分配
- 2020-02-20T11:02:44.255+0800: 3346835.273
表示完成的时间戳,后面的数字 3346835.273 表示程序开始多少秒
- ParNew:
表示这次发生的是 Minor GC
是在新生代触发的,使用的是 ParNew
收集器,使用的是 「标记-复制」算法,同时该期间将会停止用户线程,也就是 Stop The World
(常用 STW
表示)
- 1123543K->11470K(1258304K), 0.0113857 secs
k
表示使用的单位为 KB
,前三个数字分别表示新生代当前使用的容量,回收后的容量,以及新生代分配的总大小。
后面的时间表示新生代 GC 耗时,sec 表示 second(秒)
- 2211645K->1099617K(4054528K), 0.0118211 secs
第二次出现的数字串,前三个数字分别表示堆 heap 当前使用的容量,回收后的容量,以及整个堆 heap 空间分配的总大小
- [Times: user=0.00 sys=0.00, real=0.01 secs]
分别表示用户态耗时(user),内核态耗(sys)时和总耗时(real)
因为多线程的原因,通常来说总耗时 real 会比前两个少,所以我们实际关注更多的地方在 real 字段上,实际对用户线程造成了多少中断。
CMS 收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。
目前很大一部分的Java应用集中在互联网网站或者基于浏览器的B/S系统的服务端上,这类应用通常都会较为关注服务的响应速度,希望系统停顿时间尽可能短,以给用户带来良好的交互体验。出于稳定性,G1 垃圾回收器有点超前,出了问题的维护成本会比较大,所以希望尽可能短的停顿时间,目前我们在用的是 CMS 垃圾回收期。
老年代回收
-XX:CMSInitiatingOccupancyFraction=70
默认情况下,当老年代的使用空间达到 70% 时,将会触发老年代回收
Concurrent Mark Sweep
,并发标记清理,CMS 收集器是用于老年代GC,从上图可以看出,使用 CMS 收集器后,老年代回收对象之后,不会进行压缩整理,所以老年代出现了不连续的内存空间。
上面是 CMS 收集时的日志格式,同时从时间上可以看出,1~2 天才出现一次老年代的 GC,表示老年代的 GC 频率不高,验证了大多数对象都是朝生夕死的,在 Minor GC
就被回收掉了,下面来记录每个字段的含义。
- 初始标记 Initial Mark
|
|
并发回收会由「初始标记」开始,这个阶段会暂停所有的应用程序线程(也就是 Stop The World),该阶段的任务时找到堆中所有的垃圾回收根节点对象(GC Roots)
第一组数字 2058107K(2796224K):表示老年代使用了 2058MB,整个老年代大小为 2796MB(简单计算,除以 1000)。
第二组数字 2189383K(4054528K):表示整个堆的大小为括号中的 4054MB,被使用了 2180MB
0.0167010 secs:表示用户线程被暂停了 0.0167010 秒
- 标记阶段 concurrent-mark-start
|
|
标记阶段耗时 0.44 秒(以及 0.95 秒的 CPU 时间)。该阶段进行的工作仅仅是标记,不会对堆的使用情况造成实质性的影响。
同时该阶段,应用程序还在持续运行着,所以如果有其它日志输出,有可能是在这 0.44s 内新生代对象进行了分配。
- 预清理 preclean
|
|
预清理阶段,应用程序也是在持续运行着
在预清理阶段,在书籍里面没有找到具体解释,所以查询后,觉得资料二中说的比较好,引用一下
此阶段标记从新生代晋升的对象、新分配到老年代的对象以及在并发阶段被修改了的对象。
介绍起来有点太复杂,涉及到 jvm 底层保存对象时使用到的数据结构,感兴趣的请好好看下第二条资料~
- 重新标记 rescan
|
|
该阶段不是并发的,将会阻塞用户线程,也就是 STW
其中出现的 abortable clean
表示「可中断清理」,使用它的原因是希望尽量缩短停顿的时间,避免连续的停顿
前面已经出现了「初始标记」,当时只是简单的标记一下 GC Roots 能直接关联到的对象,速度比较快。
「并发标记」阶段就是从 GC Roots 的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行
「重新标记」阶段,使用它是为了修正在并发标记期间,因为用户线程继续运行而导致的新生代对象分配或者对象修改引用,会造成原有对象的标记记录变动
- 并发清除 sweep
|
|
清理 sweep 阶段与用户线程是并发运行的,不会 STW
也有可能出现这种场景:
在 con-sweep
阶段中,发生了新生代 GC,说明新生代的 GC 和老年代的 GC 可以并发进行
- 并发重置 concurrent reset
|
|
该阶段也是并发的,不会中断用户线程
该阶段的日志出现,表示 CMS GC 的周期到此结束,老年代没有被引用的对象将会被回收。
总结 和 调优建议
新生代的垃圾回收比较简单,回收过程中,会短暂的 STW,而老年代的 GC 比较复杂,经历了下面的阶段:
- 初步标记(有 STW)
- 并发标记(并发)
- 再次标记(有 STW)
- 并发清理(并发)
由于 CMS 算法不会对老年代进行压缩整理,碎片空间越来越多,如果出现老年代空间不足以让新生代的对象晋升,CMS 收集器将无法回收,那么老年代将会退化到 Full GC(由于手上暂时没有例子,所以不展示了)
目前来说,默认参数配置已经够用了,例如下面这个:
|
|
下面是几个调优建议
1 升级配置
如果你的应用之前运行在 2C4G 的服务器上,发现相应速度越来越慢,那这个时候可以升级到 4C8G,配置越高,服务器的性能当然也会更好,这时你就可以可以调整 MEM_OPTS
,将内存参数放大
先别喷这个建议,有时候业务量上来了,原有的机器性能的确扛不住,这样的话升级硬件配置也是理所当然的
2 调整 CMSInitiatingOccupancyFraction 参数
CMSInitiatingOccupancyFraction
默认情况下是 70,老年代占用达到 70%后将会触发 CMS GC,但这个时候有可能出现新生代在不断分配对象,然后有对象能够晋升到老年代,将会出现老年代空间不足而触发的 Full GC。
所以可以适当减小这个值,让并发后台线程尽早运行,去回收老年代不再使用的对象。
3 优化代码逻辑
除去服务器配置问题,业务代码上如果出现大量耗时操作,例如频繁的数据库交互,大数据计算,这样 GC 将会更加频繁,并且时间可能越来越长,导致用户线程被占用,系统中断时间增加,会造成用户不好的使用体验。
所以根本上,需要从应用代码着手,例如做以下几个方面的优化
- 将频繁的数据库操作改成批处理,一次性获取数据或修改数据
- 简化代码计算逻辑,去掉无用计算量
- 减少嵌套循环,优化数据结构
- …
还有更多 GC 的内容没有记录,所以强烈建议大家去看下周志明写的《深入理解 JVM》第三个章节,继续深入学习这些经典的 GC 算法。同时,我们在调优过程中,都是在吞吐量和应用停顿时间,这两者之间在做平衡,所以具体调整方案需要在我们了解 GC 细节后,选择合适的算法和配置参数,来达到预期的效果。