重读 JVM - ParNew & CMS GC

这次来复习一下常用的 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 日志,回收新生代的日志格式如下:

1
2020-02-20T11:02:44.255+0800: 3346835.272: [GC (Allocation Failure) 2020-02-20T11:02:44.255+0800: 3346835.273: [ParNew: 1123543K->11470K(1258304K), 0.0113857 secs] 2211645K->1099617K(4054528K), 0.0118211 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]

刚开始对于 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
1
[GC (CMS Initial Mark) [1 CMS-initial-mark: 2058107K(2796224K)] 2189383K(4054528K), 0.0167010 secs] [Times: user=0.00 sys=0.00, real=0.02 secs]

并发回收会由「初始标记」开始,这个阶段会暂停所有的应用程序线程(也就是 Stop The World),该阶段的任务时找到堆中所有的垃圾回收根节点对象(GC Roots)

第一组数字 2058107K(2796224K):表示老年代使用了 2058MB,整个老年代大小为 2796MB(简单计算,除以 1000)。

第二组数字 2189383K(4054528K):表示整个堆的大小为括号中的 4054MB,被使用了 2180MB

0.0167010 secs:表示用户线程被暂停了 0.0167010 秒

  • 标记阶段 concurrent-mark-start
1
2
[CMS-concurrent-mark-start]
[CMS-concurrent-mark: 0.436/0.441 secs] [Times: user=0.95 sys=0.03, real=0.44 secs]

标记阶段耗时 0.44 秒(以及 0.95 秒的 CPU 时间)。该阶段进行的工作仅仅是标记,不会对堆的使用情况造成实质性的影响。

同时该阶段,应用程序还在持续运行着,所以如果有其它日志输出,有可能是在这 0.44s 内新生代对象进行了分配。

  • 预清理 preclean
1
2
[CMS-concurrent-preclean-start]
[CMS-concurrent-preclean: 0.008/0.009 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]

预清理阶段,应用程序也是在持续运行着

在预清理阶段,在书籍里面没有找到具体解释,所以查询后,觉得资料二中说的比较好,引用一下

此阶段标记从新生代晋升的对象、新分配到老年代的对象以及在并发阶段被修改了的对象。

介绍起来有点太复杂,涉及到 jvm 底层保存对象时使用到的数据结构,感兴趣的请好好看下第二条资料~

  • 重新标记 rescan
1
2
3
4
5
6
7
8
9
10
11
12
[CMS-concurrent-abortable-preclean-start]
CMS: abort preclean due to time 2020-02-18T17:32:02.230+0800: 3197393.247:
[CMS-concurrent-abortable-preclean: 5.638/5.968 secs]
[Times: user=9.84 sys=0.15, real=5.96 secs]
[GC (CMS Final Remark) [YG occupancy: 586303 K (1258304 K)]
2020-02-18T17:32:02.234+0800: 3197393.251: [Rescan (parallel) , 0.0904047 secs]
2020-02-18T17:32:02.325+0800: 3197393.342: [weak refs processing, 0.0029463 secs]
2020-02-18T17:32:02.328+0800: 3197393.345: [class unloading, 0.0942777 secs]
2020-02-18T17:32:02.422+0800: 3197393.439: [scrub symbol table, 0.0275075 secs]
2020-02-18T17:32:02.450+0800: 3197393.467: [scrub string table, 0.0036712 secs]
[1 CMS-remark: 2199444K(2796224K)] 2785747K(4054528K), 0.2585849 secs] [Times: user=0.46 sys=0.00, real=0.26 secs]

该阶段不是并发的,将会阻塞用户线程,也就是 STW

其中出现的 abortable clean 表示「可中断清理」,使用它的原因是希望尽量缩短停顿的时间,避免连续的停顿

前面已经出现了「初始标记」,当时只是简单的标记一下 GC Roots 能直接关联到的对象,速度比较快。

「并发标记」阶段就是从 GC Roots 的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行

「重新标记」阶段,使用它是为了修正在并发标记期间,因为用户线程继续运行而导致的新生代对象分配或者对象修改引用,会造成原有对象的标记记录变动

  • 并发清除 sweep
1
2
[CMS-concurrent-sweep-start]
[CMS-concurrent-sweep: 1.613/1.618 secs] [Times: user=2.30 sys=0.01, real=1.62 secs]

清理 sweep 阶段与用户线程是并发运行的,不会 STW

也有可能出现这种场景:

con-sweep 阶段中,发生了新生代 GC,说明新生代的 GC 和老年代的 GC 可以并发进行

  • 并发重置 concurrent reset
1
2
[CMS-concurrent-reset-start]
[CMS-concurrent-reset: 0.008/0.008 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]

该阶段也是并发的,不会中断用户线程

该阶段的日志出现,表示 CMS GC 的周期到此结束,老年代没有被引用的对象将会被回收。

总结 和 调优建议

新生代的垃圾回收比较简单,回收过程中,会短暂的 STW,而老年代的 GC 比较复杂,经历了下面的阶段:

  • 初步标记(有 STW)
  • 并发标记(并发)
  • 再次标记(有 STW)
  • 并发清理(并发)

由于 CMS 算法不会对老年代进行压缩整理,碎片空间越来越多,如果出现老年代空间不足以让新生代的对象晋升,CMS 收集器将无法回收,那么老年代将会退化到 Full GC(由于手上暂时没有例子,所以不展示了)

目前来说,默认参数配置已经够用了,例如下面这个:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
MEM_OPTS="
-server # 以服务端形式运行
-Xms4096m # 起始堆大小
-Xmx4096m # 最大堆大小
-XX:MetaspaceSize=256m # 元空间大小
-XX:MaxMetaspaceSize=256m # 最大元空间大小
-XX:NewRatio=2 # 新生代 : 老年代 = 1 : 2,该数值要注意,2 是老年代占的比例
-XX:SurvivorRatio=8 # Eden : Survivor = 8 : 1,表示一个 Survivor 占新生代的 1/10
"
GC_OPTS="
-XX:+UseConcMarkSweepGC # 使用 CMS
-XX:+UseCMSCompactAtFullCollection # 在 Full GC 时进行压缩整理
-XX:CMSInitiatingOccupancyFraction=70 # 老年代触发 GC 的百分比
-XX:MaxTenuringThreshold=15 # 最大老年代回收线程数量,回收线程不是越多越好,要结合服务器性能一起评估,具体算法请查相关文章
-XX:+DisableExplicitGC # 禁止在代码中显式调用 System.gc()
-XX:+CMSParallelRemarkEnabled # 开启并发重标记
-verbose:gc # 设置 gc 输出的日志参数...
-XX:+PrintGCDateStamps
-XX:+PrintGCDetails
-Xloggc:${LOGS_DIR}/gc.log -XX:+UseGCLogFileRotation
-XX:NumberOfGCLogFiles=5
-XX:GCLogFileSize=20M"

下面是几个调优建议

1 升级配置

如果你的应用之前运行在 2C4G 的服务器上,发现相应速度越来越慢,那这个时候可以升级到 4C8G,配置越高,服务器的性能当然也会更好,这时你就可以可以调整 MEM_OPTS,将内存参数放大

先别喷这个建议,有时候业务量上来了,原有的机器性能的确扛不住,这样的话升级硬件配置也是理所当然的

2 调整 CMSInitiatingOccupancyFraction 参数

CMSInitiatingOccupancyFraction 默认情况下是 70,老年代占用达到 70%后将会触发 CMS GC,但这个时候有可能出现新生代在不断分配对象,然后有对象能够晋升到老年代,将会出现老年代空间不足而触发的 Full GC。

所以可以适当减小这个值,让并发后台线程尽早运行,去回收老年代不再使用的对象。

3 优化代码逻辑

除去服务器配置问题,业务代码上如果出现大量耗时操作,例如频繁的数据库交互,大数据计算,这样 GC 将会更加频繁,并且时间可能越来越长,导致用户线程被占用,系统中断时间增加,会造成用户不好的使用体验。

所以根本上,需要从应用代码着手,例如做以下几个方面的优化

  • 将频繁的数据库操作改成批处理,一次性获取数据或修改数据
  • 简化代码计算逻辑,去掉无用计算量
  • 减少嵌套循环,优化数据结构

还有更多 GC 的内容没有记录,所以强烈建议大家去看下周志明写的《深入理解 JVM》第三个章节,继续深入学习这些经典的 GC 算法。同时,我们在调优过程中,都是在吞吐量和应用停顿时间,这两者之间在做平衡,所以具体调整方案需要在我们了解 GC 细节后,选择合适的算法和配置参数,来达到预期的效果。

参考资料

  1. GC(Allocation Failure)引发的一些JVM知识点梳理
  2. 详解CMS垃圾回收机制
  3. JVM 探究(三):垃圾回收算法和垃圾回收器