Java中9种常见的CMS GC问题分析与解决

目前,互联网上 Java 的 GC 资料要么是主要讲解理论,要么就是针对单一场景的 GC 问题进行了剖析,对整个体系总结的资料少之又少。前车之鉴,后事之师,美团的几位工程师历时一年多的时间,搜集了内部各种 GC 问题的分析文章,并结合个人的理解做了一些总结,希望能起到“抛砖引玉”的作用。

1. 写在前面

| 本文主要针对 Hotspot VM 中“CMS + ParNew”组合的一些使用场景进行总结。重点通过部分源码对根因进行分析以及对排查方法进行总结,排查过程会省略较多。另外,本文专业术语较多,有一定的阅读门槛,如未介绍清楚,还请自行查阅相关材料。

1.1 引言

自 Sun 发布 Java 语言以来,开始使用 GC 技术来进行内存自动管理,避免了手动管理带来的悬挂指针(Dangling Pointer)问题,很大程度上提升了开发效率,从此 GC 技术也一举成名。GC 有着非常悠久的历史,1960 年有着“Lisp 之父”和“人工智能之父”之称的 John McCarthy 就在论文中发布了 GC 算法,60 年以来, GC 技术的发展也突飞猛进,但不管是多么前沿的收集器也都是基于三种基本算法的组合或应用,也就是说 GC 要解决的根本问题这么多年一直都没有变过。笔者认为,在不太远的将来, GC 技术依然不会过时,比起日新月异的新技术,GC 这门古典技术更值得我们学习。

那么,GC 问题处理能力能不能系统性掌握?一些影响因素都是互为因果的问题该怎么分析?比如一个服务 RT 突然上涨,有 GC 耗时增大、线程 Block 增多、慢查询增多、CPU 负载高四个表象,到底哪个是诱因?如何判断 GC 有没有问题?使用 CMS 有哪些常见问题?如何判断根因是什么?如何解决或避免这些问题?阅读完本文,相信你将会对 CMS GC 的问题处理有一个系统性的认知,更能游刃有余地解决这些问题,下面就让我们开始吧!文中若有错误之处,还请大家不吝指正。

1.2 概览

想要系统性地掌握 GC 问题处理,笔者这里给出一个学习路径,整体文章的框架也是按照这个结构展开,主要分四大步。

img/3-1730623016950.png

  • **建立知识体系:**从 JVM 的内存结构到垃圾收集的算法和收集器,学习 GC 的基础知识,掌握一些常用的 GC 问题分析工具。

  • **确定评价指标:**了解基本 GC 的评价方法,摸清如何设定独立系统的指标,以及在业务场景中判断 GC 是否存在问题的手段。

  • **场景调优实践:**运用掌握的知识和系统评价指标,分析与解决九种 CMS 中常见 GC 问题场景。

  • **总结优化经验:**对整体过程做总结并提出笔者的几点建议,同时将总结到的经验完善到知识体系之中。

2. GC 基础

在正式开始前,先做些简要铺垫,介绍下 JVM 内存划分、收集算法、收集器等常用概念介绍,基础比较好的同学可以直接跳过这部分。

2.1 基础概念

  • **GC:**GC 本身有三种语义,下文需要根据具体场景带入不同的语义:

  • Garbage Collection:垃圾收集技术,名词。

  • Garbage Collector:垃圾收集器,名词。

  • Garbage Collecting:垃圾收集动作,动词。

  • **Mutator:**生产垃圾的角色,也就是我们的应用程序,垃圾制造者,通过 Allocator 进行 allocate 和 free。

  • **TLAB:**Thread Local Allocation Buffer 的简写,基于 CAS 的独享线程(Mutator Threads)可以优先将对象分配在 Eden 中的一块内存,因为是 Java 线程独享的内存区没有锁竞争,所以分配速度更快,每个 TLAB 都是一个线程独享的。

  • **Card Table:**中文翻译为卡表,主要是用来标记卡页的状态,每个卡表项对应一个卡页。当卡页中一个对象引用有写操作时,写屏障将会标记对象所在的卡表状态改为 dirty,卡表的本质是用来解决跨代引用的问题。具体怎么解决的可以参考 StackOverflow 上的这个问题 how-actually-card-table-and-writer-barrier-works,或者研读一下 cardTableRS.app 中的源码。

2.2 JVM 内存划分

从 JCP(Java Community Process)的官网中可以看到,目前 Java 版本最新已经到了 Java 16,未来的 Java 17 以及现在的 Java 11 和 Java 8 是 LTS 版本,JVM 规范也在随着迭代在变更,由于本文主要讨论 CMS,此处还是放 Java 8 的内存结构。

img/4-1730623016950.jpeg

GC 主要工作在 Heap 区和 MetaSpace 区(上图蓝色部分),在 Direct Memory 中,如果使用的是 DirectByteBuffer,那么在分配内存不够时则是 GC 通过 Cleaner#clean 间接管理。

任何自动内存管理系统都会面临的步骤:为新对象分配空间,然后收集垃圾对象空间,下面我们就展开介绍一下这些基础知识。

2.3 分配对象

Java 中对象地址操作主要使用 Unsafe 调用了 C 的 allocate 和 free 两个方法,分配方法有两种:

  • **空闲链表(free list):**通过额外的存储记录空闲的地址,将随机 IO 变为顺序 IO,但带来了额外的空间消耗。

  • **碰撞指针(bump  pointer):**通过一个指针作为分界点,需要分配内存时,仅需把指针往空闲的一端移动与对象大小相等的距离,分配效率较高,但使用场景有限。

2.4 收集对象

2.4.1 识别垃圾

  • **引用计数法(Reference Counting):**对每个对象的引用进行计数,每当有一个地方引用它时计数器 +1、引用失效则 -1,引用的计数放到对象头中,大于 0 的对象被认为是存活对象。虽然循环引用的问题可通过 Recycler 算法解决,但是在多线程环境下,引用计数变更也要进行昂贵的同步操作,性能较低,早期的编程语言会采用此算法。

  • **可达性分析,又称引用链法(Tracing GC):**从 GC Root 开始进行对象搜索,可以被搜索到的对象即为可达对象,此时还不足以判断对象是否存活/死亡,需要经过多次标记才能更加准确地确定,整个连通图之外的对象便可以作为垃圾被回收掉。目前 Java 中主流的虚拟机均采用此算法。

备注:引用计数法是可以处理循环引用问题的,下次面试时不要再这么说啦~ ~

2.4.2 收集算法

自从有自动内存管理出现之时就有的一些收集算法,不同的收集器也是在不同场景下进行组合。

  • **Mark-Sweep(标记-清除):**回收过程主要分为两个阶段,第一阶段为追踪(Tracing)阶段,即从 GC Root 开始遍历对象图,并标记(Mark)所遇到的每个对象,第二阶段为清除(Sweep)阶段,即回收器检查堆中每一个对象,并将所有未被标记的对象进行回收,整个过程不会发生对象移动。整个算法在不同的实现中会使用三色抽象(Tricolour Abstraction)、位图标记(BitMap)等技术来提高算法的效率,存活对象较多时较高效。

  • **Mark-Compact (标记-整理):**这个算法的主要目的就是解决在非移动式回收器中都会存在的碎片化问题,也分为两个阶段,第一阶段与 Mark-Sweep 类似,第二阶段则会对存活对象按照整理顺序(Compaction Order)进行整理。主要实现有双指针(Two-Finger)回收算法、滑动回收(Lisp2)算法和引线整理(Threaded Compaction)算法等。

  • **Copying(复制):**将空间分为两个大小相同的 From 和 To 两个半区,同一时间只会使用其中一个,每次进行回收时将一个半区的存活对象通过复制的方式转移到另一个半区。有递归(Robert R. Fenichel 和 Jerome C. Yochelson提出)和迭代(Cheney 提出)算法,以及解决了前两者递归栈、缓存行等问题的近似优先搜索算法。复制算法可以通过碰撞指针的方式进行快速地分配内存,但是也存在着空间利用率不高的缺点,另外就是存活对象比较大时复制的成本比较高。

三种算法在是否移动对象、空间和时间方面的一些对比,假设存活对象数量为 L、堆空间大小为 H,则:

img/5-1730623016950.png

把 mark、sweep、compaction、copying 这几种动作的耗时放在一起看,大致有这样的关系:

img/6-1730623016950.jpeg

虽然 compaction 与 copying 都涉及移动对象,但取决于具体算法,compaction 可能要先计算一次对象的目标地址,然后修正指针,最后再移动对象。copying 则可以把这几件事情合为一体来做,所以可以快一些。另外,还需要留意 GC 带来的开销不能只看 Collector 的耗时,还得看 Allocator 。如果能保证内存没碎片,分配就可以用 pointer bumping 方式,只需要挪一个指针就完成了分配,非常快。而如果内存有碎片就得用 freelist 之类的方式管理,分配速度通常会慢一些。

2.5 收集器

目前在 Hotspot VM 中主要有分代收集和分区收集两大类,具体可以看下面的这个图,不过未来会逐渐向分区收集发展。在美团内部,有部分业务尝试用了 ZGC(感兴趣的同学可以学习下这篇文章《新一代垃圾回收器ZGC的探索与实践》),其余基本都停留在 CMS 和 G1 上。另外在 JDK11 后提供了一个不执行任何垃圾回收动作的回收器 Epsilon(A No-Op Garbage Collector)用作性能分析。另外一个就是 Azul 的 Zing JVM,其 C4(Concurrent Continuously Compacting Collector)收集器也在业内有一定的影响力。

img/7-1730623016950.jpeg

备注:值得一提的是,早些年国内 GC 技术的布道者 RednaxelaFX (江湖人称 R 大)曾就职于 Azul,本文的一部分材料也参考了他的一些文章。

2.5.1 分代收集器

  • **ParNew:**一款多线程的收集器,采用复制算法,主要工作在 Young 区,可以通过 -XX:ParallelGCThreads 参数来控制收集的线程数,整个过程都是 STW 的,常与 CMS 组合使用。

  • **CMS:**以获取最短回收停顿时间为目标,采用“标记-清除”算法,分 4 大步进行垃圾收集,其中初始标记和重新标记会 STW ,多数应用于互联网站或者 B/S 系统的服务器端上,JDK9 被标记弃用,JDK14 被删除,详情可见 JEP 363

2.5.2 分区收4集器

  • **G1:**一种服务器端的垃圾收集器,应用在多处理器和大容量内存环境中,在实现高吞吐量的同时,尽可能地满足垃圾收集暂停时间的要求。

  • **ZGC:**JDK11 中推出的一款低延迟垃圾回收器,适用于大内存低延迟服务的内存管理和回收,SPECjbb 2015 基准测试,在 128G 的大堆下,最大停顿时间才 1.68 ms,停顿时间远胜于 G1 和 CMS。

  • **Shenandoah:**由 Red Hat 的一个团队负责开发,与 G1 类似,基于 Region 设计的垃圾收集器,但不需要 Remember Set 或者 Card Table 来记录跨 Region 引用,停顿时间和堆的大小没有任何关系。停顿时间与 ZGC 接近,下图为与 CMS 和 G1 等收集器的 benchmark。

img/8-1730623016950.jpeg

2.5.3 常用收集器

目前使用最多的是 CMS 和 G1 收集器,二者都有分代的概念,主要内存结构如下:

img/9-1730623016950.png

2.5.4 其他收集器

以上仅列出常见收集器,除此之外还有很多,如 Metronome、Stopless、Staccato、Chicken、Clover 等实时回收器,Sapphire、Compressor、Pauseless 等并发复制/整理回收器,Doligez-Leroy-Conthier 等标记整理回收器,由于篇幅原因,不在此一一介绍。

2.6 常用工具

工欲善其事,必先利其器,此处列出一些笔者常用的工具,具体情况大家可以自由选择,本文的问题都是使用这些工具来定位和分析的。

2.6.1 命令行终端

  • 标准终端类:jps、jinfo、jstat、jstack、jmap

  • 功能整合类:jcmd、vjtools、arthas、greys

2.6.2 可视化界面

  • 简易:JConsole、JVisualvm、HA、GCHisto、GCViewer

  • 进阶:MAT、JProfiler

命令行推荐 arthas ,可视化界面推荐 JProfiler,此外还有一些在线的平台 gceasyheapherofastthread ,美团内部的 Scalpel(一款自研的 JVM 问题诊断工具,暂时未开源)也比较好用。

3. GC 问题判断

在做 GC 问题排查和优化之前,我们需要先来明确下到底是不是 GC 直接导致的问题,或者应用代码导致的 GC 异常,最终出现问题。

3.1 判断 GC 有没有问题?

3.1.1 设定评价标准

评判 GC 的两个核心指标:

  • **延迟(Latency):**也可以理解为最大停顿时间,即垃圾收集过程中一次 STW 的最长时间,越短越好,一定程度上可以接受频次的增大,GC 技术的主要发展方向。

  • **吞吐量(Throughput):**应用系统的生命周期内,由于 GC 线程会占用 Mutator 当前可用的 CPU 时钟周期,吞吐量即为 Mutator 有效花费的时间占系统总运行时间的百分比,例如系统运行了 100 min,GC 耗时 1 min,则系统吞吐量为 99%,吞吐量优先的收集器可以接受较长的停顿。

目前各大互联网公司的系统基本都更追求低延时,避免一次 GC 停顿的时间过长对用户体验造成损失,衡量指标需要结合一下应用服务的 SLA,主要如下两点来判断:

img/10-1730623016950.jpeg

简而言之,即为一次停顿的时间不超过应用服务的 TP9999,GC 的吞吐量不小于 99.99%。举个例子,假设某个服务 A 的 TP9999 为 80 ms,平均 GC 停顿为 30 ms,那么该服务的最大停顿时间最好不要超过 80 ms,GC 频次控制在 5 min 以上一次。如果满足不了,那就需要调优或者通过更多资源来进行并联冗余。(大家可以先停下来,看看监控平台上面的 gc.meantime 分钟级别指标,如果超过了 6 ms 那单机 GC 吞吐量就达不到 4 个 9 了。)

备注:除了这两个指标之外还有 Footprint(资源量大小测量)、反应速度等指标,互联网这种实时系统追求低延迟,而很多嵌入式系统则追求 Footprint。

3.1.2 读懂 GC Cause

拿到 GC 日志,我们就可以简单分析 GC 情况了,通过一些工具,我们可以比较直观地看到 Cause 的分布情况,如下图就是使用 gceasy 绘制的图表:

img/11-1730623016950.png

如上图所示,我们很清晰的就能知道是什么原因引起的 GC,以及每次的时间花费情况,但是要分析 GC 的问题,先要读懂 GC Cause,即 JVM 什么样的条件下选择进行 GC 操作,具体 Cause 的分类可以看一下 Hotspot 源码:src/share/vm/gc/shared/gcCause.hpp 和 src/share/vm/gc/shared/gcCause.cpp 中。

const char* GCCause::to_string(GCCause::Cause cause) {
  switch (cause) {
    case _java_lang_system_gc:
      return "System.gc()";    case _full_gc_alot:
      return "FullGCAlot";    case _scavenge_alot:
      return "ScavengeAlot";    case _allocation_profiler:
      return "Allocation Profiler";    case _jvmti_force_gc:
      return "JvmtiEnv ForceGarbageCollection";    case _gc_locker:
      return "GCLocker Initiated GC";    case _heap_inspection:
      return "Heap Inspection Initiated GC";    case _heap_dump:
      return "Heap Dump Initiated GC";    case _wb_young_gc:
      return "WhiteBox Initiated Young GC";    case _wb_conc_mark:
      return "WhiteBox Initiated Concurrent Mark";    case _wb_full_gc:
      return "WhiteBox Initiated Full GC";    case _no_gc:
      return "No GC";    case _allocation_failure:
      return "Allocation Failure";    case _tenured_generation_full:
      return "Tenured Generation Full";    case _metadata_GC_threshold:
      return "Metadata GC Threshold";    case _metadata_GC_clear_soft_refs:
      return "Metadata GC Clear Soft References";    case _cms_generation_full:
      return "CMS Generation Full";    case _cms_initial_mark:
      return "CMS Initial Mark";    case _cms_final_remark:
      return "CMS Final Remark";    case _cms_concurrent_mark:
      return "CMS Concurrent Mark";    case _old_generation_expanded_on_last_scavenge:
      return "Old Generation Expanded On Last Scavenge";    case _old_generation_too_full_to_scavenge:
      return "Old Generation Too Full To Scavenge";    case _adaptive_size_policy:
      return "Ergonomics";    case _g1_inc_collection_pause:
      return "G1 Evacuation Pause";    case _g1_humongous_allocation:
      return "G1 Humongous Allocation";    case _dcmd_gc_run:
      return "Diagnostic Command";    case _last_gc_cause:
      return "ILLEGAL VALUE - last gc cause - ILLEGAL VALUE";    default:
      return "unknown GCCause";
  }
  ShouldNotReachHere();
}

重点需要关注的几个GC Cause:

  • **System.gc():**手动触发GC操作。

  • **CMS:**CMS GC 在执行过程中的一些动作,重点关注 CMS Initial Mark 和 CMS Final Remark 两个 STW 阶段。

  • **Promotion Failure:**Old 区没有足够的空间分配给 Young 区晋升的对象(即使总可用内存足够大)。

  • **Concurrent Mode Failure:**CMS GC 运行期间,Old 区预留的空间不足以分配给新的对象,此时收集器会发生退化,严重影响 GC 性能,下面的一个案例即为这种场景。

  • **GCLocker Initiated GC:**如果线程执行在 JNI 临界区时,刚好需要进行 GC,此时 GC Locker 将会阻止 GC 的发生,同时阻止其他线程进入 JNI 临界区,直到最后一个线程退出临界区时触发一次 GC。

什么时机使用这些 Cause 触发回收,大家可以看一下 CMS 的代码,这里就不讨论了,具体在 /src/hotspot/share/gc/cms/concurrentMarkSweepGeneration.cpp 中。

shouldConcurrentCollect

bool CMSCollector::shouldConcurrentCollect() {
  LogTarget(Trace, gc) log;  if (_full_gc_requested) {
    log.print("CMSCollector: collect because of explicit  gc request (or GCLocker)");
    return true;
  }  FreelistLocker x(this);
  //
© 版权声明
THE END
喜欢就支持一下吧
点赞13 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容