【JVM入门食用指南-03】JVM垃圾回收器以及性能调优

1,288 阅读11分钟

这是我参与 8 月更文挑战的第 6 天,活动详情查看:8 月更文挑战

前言

在上一篇文章【JVM入门食用指南-02】对象分配&垃圾回收机制中,从对象创建过程到其内存分布,并通过算法了解JVM中判断对象存活的机制,通过对象存活机制,进行垃圾回收四种垃圾回收算法。本文我们主要对 JVM 中的垃圾回收器和利用JVM调优进行叙述,加深对 JVM 的基础理解。

即将学会

  • 常见垃圾回收器
  • 多线程垃圾回收 STW
  • CMS垃圾回收器
  • JVM调优

开始之前,让我们了解一下垃圾回收器类型

垃圾回收器分类

  • 吞吐量 (吞吐量优先垃圾回收器)

    • 吞吐量 :JVM 在 GC 线程中 执行 GC 。执行 GC 时,将与 应用程序线程 争用当前CPU的时钟周期,这里的吞吐量指应用程序线程占用程序总用时的比例。我们可以通过设置参数-XX:GCTimeRatio来控制吞吐量,如默认值99,则 GC 占用总时间为1%,吞吐量99%。如设置19,则 GC 占用1/(19+1) 既5%的时间,吞吐量为95%。高吞吐量会让程序的用户感觉只有应用程序在进行工作,吞吐量高程序运行越快,而暂停时间短,可以提升用户体验,不会导致应用程序暂停而形成卡顿的现象,比如游戏开发,你的程序隔断时间回收个垃圾,用户界面就卡一下,游戏体验十分差。
  • 暂停时间最短 (响应度优先垃圾回收器)

    • 暂停时间:指一个时间段内应用程序线程让 GC 线程执行进行导致应用线程完全暂停(stop the world(砸瓦鲁多)如 GC 期间100ms的暂停时间,这100ms内是没有应用程序是活动的,

    不过高吞吐量和低暂停时间是矛盾的, GC 需要一定前提条件以便安全地运行。必须保证应用程序线程 在 GC线程判断哪些对象可达时不修改对象的状态 。因此,这个时候需要暂停应用程序线程以保证GC线程的正确访问,而 GC 开销较大,会导致暂停时间,从而减小吞吐量,因此,需要尽可能减少GC来增大吞吐量,

    然而,仅仅偶尔运行GC意味着每当GC运行时将有许多工作要做,因为在此期间积累在堆中的对象数量很高,单个GC需要花费更多时间来完成,从而导致更高的平均和最大开销时间,因此,考虑到低暂停时间,最好频繁的运行GC以便快速完成,这反过来又增加了开销并导致吞吐量增加。

常见垃圾回收器

常见垃圾回收器

单线程垃圾回收器

图片.png

  • Serial 搭配Serial Old,需要暂停所有程序
    • 单线程 串行 新生代 复制算法
  • Serial Old
    • 单线程 串行 老年代 标记整理算法

多线程并行垃圾回收器

图片.png

  • Parallel Scavenge 搭配Parallel Old (简称PS)以吞吐量优先
    • 新生代 复制算法 并行多线程回收器
  • Parallel Old
    • 老年代 标记整理算法 并行的多线程回收器
  • ParNew 搭配CMS
    • 新生代 复制算法 并行的多线程收集器

并发垃圾回收器

图片.png

  • CMS 垃圾回收器 concurrent Mark Sweep 标记清除算法
    • 单独针对老年代的垃圾回收器 可配置ParNew垃圾回收器

CMS垃圾回收器

CMS 整体包含四个阶段 初始标记、并发标记、重新标记、并发清理。

初始标记

过程较为短暂、仅仅标记一下GC roots能直接关联到的第一个对象,速度很快

并发标记

和用户的应用程序同时进行,进行 GC roots 追踪的过程,标记从 GC roots 开始关联的所有对象开始遍历整个可达性分析路径的对象,时间比较长,所以采用并发(垃圾回收器线程和用户线程同时工作

重新标记

短暂,为了修正并发标记期间因用户程序继续运作而导致标记发生变动的那一部分对象的标记记录,这个阶段的用户程序停顿时间会比初始标记阶段稍长一些,但远比并发标记的时间短。

并发清除

这里采用了标记清除算法 把不可达的对象释放 并不会影响业务线程(业务线程中只有GC roots的相关的才可以执行)而和GC roots没有关系的话,对象(既不可达对象)本身可能就是垃圾了

因为CMS垃圾回收器是以用户高响应为基准的,而在CMS中,用户程序暂停只涉及两个阶段,一个是初始标记、另一个是重新标记。而初始标记的直接影响因素是根,而 GC roots改变不了(和程序有关)。因此,我们需要关注重新标记阶段,如果可以在并发标记中,把重新标记的一些事情进行提前处理,那么,重新标记不用做这么多事情,从而程序暂停时间减少,进而可以降低系统响应。然后进行优化

而JVM也在并发标记中做了以下过程来优化

并发标记 -预清理

一次

并发标记阶段 业务线程是不暂停的,如果此时new出了一个对象指向了老年代未标记的对象(垃圾), 既新生代eden区引用到老年代没有标记的对象,需要对该老年代进行标记

当老年代 内部发生引用变化时(业务线程),我们需要对其进行标记,利用类似卡表的结构,对引用变化的对象进行记录,可以避免在重新标记阶段进行处理(重新标记阶段一定需要找到变动的对象),从而使重新标记阶段不至于遍历整个老年代对象。从而达到优化。

并发标记 -并发可中断预清理

先决条件

Eden区已使用内存 达到 2M

是循环的 可中断的

预处理Eden区,并发可中断预处理进行以下处理

处理From区和to区的对象 导致老年代的并发标记中的引用变化 类比预处理

老年代中内部引用变化时,利用类似卡表的结构, 对引用发生变化的对象进行标记 类比预处理

  • 中断条件
    • 循环次数 CMSMaxAbortablePrecleanLoops
    • 循环时间 CMSMaxAbortablePrecleanTime
    • Eden区内存使用占用比 CMSScheduleRemarkEdenPenetration

CMS日志

下面是输出的一段日志

[GC (CMS Initial Mark) [1 CMS-initial-mark: 68287K(68288K)] 99007K(99008K), 0.0031153 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[CMS-concurrent-mark-start]
[CMS-concurrent-mark: 0.017/0.017 secs] [Times: user=0.03 sys=0.00, real=0.02 secs] 
[CMS-concurrent-preclean-start]
[CMS-concurrent-preclean: 0.000/0.000 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[CMS-concurrent-abortable-preclean-start]
[CMS-concurrent-abortable-preclean: 0.000/0.000 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (CMS Final Remark) [YG occupancy: 30719 K (30720 K)][Rescan (parallel) , 0.0035498 secs][weak refs processing, 0.0000284 secs][class unloading, 0.0003008 secs][scrub symbol table, 0.0003731 secs][scrub string table, 0.0001169 secs][1 CMS-remark: 68287K(68288K)] 99007K(99008K), 0.0044921 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[CMS-concurrent-sweep-start]
[CMS-concurrent-sweep: 0.011/0.011 secs] [Times: user=0.02 sys=0.00, real=0.01 secs] 
[CMS-concurrent-reset-start]
[CMS-concurrent-reset: 0.000/0.000 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 

图片.png

CMS 问题

  • CPU敏感 有并发机制,在用户线程运行 同时还需要做清理。CPU核心数不足4个时,CMS对用户影响较大。
  • 浮动垃圾 CMS 并发清理阶段用户线程还在运行,伴随着程序运行会不断产生新的垃圾,这一部分垃圾出现在标记过程后, CMS 无法在此次对新产生的垃圾进行处理,只能等待下一次GC进行清理,这一部分垃圾称之为 浮动垃圾。因为,为了保存这部分浮动垃圾, CMS 中老年代的空间使用率阈值不能达到100%,一般按照92%计算
  • 内存碎片 标记-清除算法(位置不连续产生碎片,但可以不暂停)会导致不连续的空间碎片,而不连续的空间碎片,需要逐个使用空闲链表来访问,查找可以存放新建对象的地址,但是连续的空间可以指针碰撞的方式分配。且因为碎片化问题,大对象很难找到连续的空间存放。 而因为不连续的空间碎片导致的Promotion Failed。则 CMS 此时会退化,改用serial old垃圾回收器,而 serial old 是单线程,如果内存空间很大,且对象较多时,会很卡,Serial 使用标记整理算法,单线程全暂停的方式,对整个堆进行垃圾收集,因此暂停时间要长于CMS

JVM调优

对GC优化后,系统效率可以提高,减少了因GC造成的系统暂停,从而减少内存抖动

JVM内存的分代划分

分代模型中,各分区的大小堆GC的性能影响很大。如何将各分区调整到合适的大小,分析活跃数据的大小是很好的切入点。活跃数据的大小是指应用程序稳定运行时长期存活对象在堆中占用的空间大小。就是Full GC后堆中老年代占用空间的大小。可以通过GC日志中,老年代数据大小得出,或者在程序稳定后,多次获取GC数据,通过取平均值得方式计算活跃数据的大小。

图片.png

空间倍数
总大小3~4倍活跃数据大小
新生代1~1.5倍活跃数据大小
老年代2~3倍活跃数据大小
永久代/元空间1.2-1.5 倍Full GC后的永久代空间占用

如 我们通过GC日志获得老年代的活跃数据大小为300M,则各分区的大小可以设置为 总堆:300M * 4 = 1200M 新生代:300M * 1.5 = 450M 老年代:1200M - 450M = 750M 永久代:300M * 1.5 = 450M

扩容新生代

新生代进行垃圾回收时,会采用复制算法,将对象从Eden区复制到S区,不过在复制之前,需要扫描Eden区,来判断对象是否存活。 图片.png 依据图像,设Eden区容量为R。A存活时间为600ms,Minor GC间隔为500ms,此时 T = T1(扫描新生代)+T2(对象复制) 若将Eden区容量扩容至2R,A存活时间依旧为600ms(和我们代码有关),Minor GC因Eden区扩大,Minor GC间隔时间随之增大一倍至1000ms(Eden区扩大,对象扩大一倍,对象复制数量扩大一部 长期来说是一倍,虽然间隔时间拉长了,整体来说是没变的),但是这个时候,A存活时间只有600ms,因为时间间隔得拉长,导致对象死亡,此时T = 2T1。不需要复制了,这个时候少了2个T2。这个时候减少了复制时间。因此,如果新生代对象存活时间不长,是可以提高GC效率的。对长期存活对象GC效率提升不大,不过扩容并不会给JVM增加太多负担,反而拉大了GC间隔时间,因此,可以对Eden区扩大进行调优。 而对GC优化后,系统效率自然提高了,减少了因GC造成的系统暂停。

JVM避免Minor GC扫描全堆

图片.png 当我们对Eden区对象扫描时,若老年代对Eden区存在跨代引用情况,而GC时要先采用可达性分析,这个时候,就要判断老年代对象是否是GC roots,因此,需要在老年代收集GC roots信息。这个时候,基本进行了全堆扫描。为了减少整堆扫描,JVM采用card table(既卡表)。将引用到的对象进行标识做为脏数据,然后扫描的时候就可以只扫描新生代和老年代中被标识的对象就可以了。 卡表: 将老年代的空间分为大小为512B的若干张卡。

Cardtable123456789
CardtableTrueTruefalseTrueTrueTrueTrueTrueTrue

如上表,将3处标记标记为false,既该对象为脏数据,扫描的时候要对该对象和新生代进行扫描。

参考材料

人类工程学 (oracle.com)

JVM 参数

[HotSpot VM] JVM调优的"标准参数"的各种陷阱 - 讨论 - 高级语言虚拟机 - ITeye群组

高级合资企业和 GC 调谐 (pivotal.io)

java - 卡表和作家屏障是如何工作的?- 堆栈溢出 (stackoverflow.com)

java (oracle.com)