17

重读 JVM - ParNew & CMS GC

 4 years ago
source link: https://www.sevenyuan.cn/2020/02/21/java/2021-02-21-reread-jvm2/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

这次来复习一下常用的 ParNew 和 CMS GC 的概念和一些调优建议

GC

GC 全称是 Garbage Collect ,译为 「垃圾回收」,在代码编写过程中,我们new 一个对象后,在使用和结束阶段,都可以不需要关注内存分配和内存回收,因为 jvm 会自动识别到哪些对象不再被使用,然后进行清理,同时在内存中释放掉这些空间。

判断对象不可用的方法

  • 引用计数 Reference Count

简单理解就是,每个对象都有一个计数器,如果该对象被其它对象引用后,计数器加一,如果计数器不为 0,表示它还在使用,不能被清理。

这样会有个弊端,例如 A <-> B ,如果存在互相引用,但没有被第三方继续引用,那么这两个对象其实没有其他使用,但由于计数器不为 0,无法得到清理。

  • 可达性分析 Reachability Analysis

GC Roots 为起点,根据引用关系往下搜索,搜索过程中走过的路称为”引用连“(Reference Chain),如果与 GC Roots 对象可连接,说明对象还在使用,反之表示不可达,说明这些对象不使用,可以被回收掉。

qmIBbe6.png!web

其中关于 GC Roots 这些对象,有我们熟悉的,各个线程中调用的方法堆栈中的参数、局部变量、临时变量等,还有其它作为根路径的对象,可以参考书籍 3.2 章节

画图,说明 ParNew 和 CMS 回收垃圾的流程

前面说了有哪些方法可以说明对象不可用,这里来说下用什么垃圾回收器去回收:recycle:

在看完第二版之后,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 来开启或者关闭使用该收集器。

6VNVzqR.png!web

顺便来介绍一下其它几个 jvm 参数:

  1. -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。

  1. –XX:NewRatio

该参数表示的是新生代与老年代的比值

例如如果设置 -XX:NewRatio=2 ,新生代(Eden + 2 * Survivor):老年代 = 1 :2,所以新生代占堆的 1/3,老年代占堆的 2/3。

  1. -XX:MetaspaceSize -XX:MaxMetaspaceSize

在 jdk8 之前,存在一个区域叫「永久代」(Permgen),与 jdk8 之后出现的「元空间」(Metaspace)作用一样,主要功能是存储类实例的具体信息(即类对象),这部分也叫做「类的元数据」,只对编译器或者 JVM 的运行时有用。

不同于元空间,永久代里还存储了一些与类数据无关的杂项对象(miscellaneous object),这些对象在 jdk8 的时候,被挪回了普通的堆空间。除此之外,jdk8 开始从根本上改变了保存在这个特殊区域的元数据的类型。

作为开发,可能不需要太关注里面存储了什么信息,不过得知道为啥「元空间」取代了「永久代」,翻看资料,发现了之前默认情况下,永久代大多分配的大小最多只有 82MB,如果遇到特别复杂的应用,加载的类特别多,所以存储的类信息也会很多。在开发中的应用服务器(或者任何需要频繁重新载入类的环境)上经常会碰到由于永久代空间空间耗尽触发的 Full GC。

默认情况下,元空间是没有大小限制的,不过还是建议分配一个初始和最大值,例如 -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=256m ,虽然还是有可能触发 Full GC,这个时候就要排查定位出什么类导致元空间这么庞大,然后进行解决。

新生代 GC

可以看下新生代 GC 前后的内存分布情况:(蓝色表示使用情况)

BRJ7zym.png!web

验证了前面说的场景,拷贝 Eden 区和其中一块使用的 Survivor 区 S1 中的还在使用对象到另一个 Survivor 区 S0,如果新生代放不下或者对象熬过多次垃圾回收,就会进入到老年代。

翻看 gc 日志,回收新生代的日志格式如下:

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

第二次出现的数字串, 前三个数字分别表示老年代当前使用的容量,回收后的容量,以及老年代分配的总大小

  • [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 垃圾回收期。

zmMne2N.png!web

老年代回收

-XX:CMSInitiatingOccupancyFraction=70 默认情况下,当老年代的使用空间达到 70% 时,将会触发老年代回收

aUBfIjY.png!web

Concurrent Mark Sweep ,并发标记清理,CMS 收集器是用于老年代GC,从上图可以看出,使用 CMS 收集器后,老年代回收对象之后,不会进行压缩整理,所以老年代出现了不连续的内存空间。

6BvENvF.png!web

上面是 CMS 收集时的日志格式,同时从时间上可以看出,1~2 天才出现一次老年代的 GC,表示老年代的 GC 频率不高,验证了大多数对象都是朝生夕死的,在 Minor GC 就被回收掉了,下面来记录每个字段的含义。

  • 初始标记 Initial Mark
[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
[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
[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
[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
[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

也有可能出现这种场景:

vqQvuiv.png!web

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

  • 并发重置 concurrent reset
[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(由于手上暂时没有例子,所以不展示了)

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

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 ,将内存参数放大

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

  1. 调整 CMSInitiatingOccupancyFraction 参数

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

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

  1. 优化代码逻辑

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

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

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

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK