JVM核心技术-28JVM问题排查分析下篇(案例实战)

28 JVM 问题排查分析下篇(案例实战)

GC 问题排查实战案例

这一部分,我们来看一个实际的案例。

假设我们有一个提供高并发请求的服务,系统使用 Spring Boot 框架,指标采集使用 MicroMeter,监控数据上报给 Datadog 服务。

当然,Micrometer支 持将数据上报给各种监控系统,例如:AppOptics、Atlas、Datadog、Dynatrace、Elastic、Ganglia、Graphite、Humio、Influx、Instana、JMX、KairosDB、New Relic、Prometh eus、SignalFx、Stackdriver、StatsD、Wavefront 等等。

有关MicroMeter的信息可参考:

https://micrometer.io/docs

问题现象描述*

最近一段时间,通过监控指标发现,有一个服务节点的最大 GC 暂停时间经常会达到 400ms 以上。

如下图所示:

85083641.png

从图中可以看到,GC 暂停时间的峰值达到了 546ms,这里展示的时间点是 2020 年 02 月 04 日 09:20:00 左右。

客户表示这种情况必须解决,因为服务调用的超时时间为 1s,要求最大 GC 暂停时间不超过 200ms,平均暂停时间达到 100ms 以内,对客户的交易策略产生了极大的影响。 CPU 负载* CPU 的使用情况如下图所示:

58517646.png

从图中可以看到:系统负载为 4.92,CPU使用率 7% 左右,其实这个图中隐含了一些重要的线索,但我们此时并没有发现什么问题。 GC 内存使用情况*

然后我们排查了这段时间的内存使用情况:

85674124.png

从图中可以看到,大约 09:25 左右 old_gen 使用量大幅下跌,确实是发生了 FullGC。

但 09:20 前后,老年代空间的使用量在缓慢上升,并没有下降,也就是说引发最大暂停时间的这个点并没有发生 FullGC。

当然,这些是事后复盘分析得出的结论。当时对监控所反馈的信息并不是特别信任,怀疑就是触发了 FullGC 导致的长时间 GC 暂停。

为什么有怀疑呢,因为 Datadog 这个监控系统,默认 10s 上报一次数据。有可能在这 10s 内发生些什么事情但是被漏报了(当然,这是不可能的,如果上报失败会在日志系统中打印相关的错误)。

再分析上面这个图,可以看到老年代对应的内存池是 “ps_old_gen”,通过前面的学习,我们知道,ps 代表的是 ParallelGC 垃圾收集器。 JVM 启动参数*

查看 JVM 的启动参数,发现是这样的:

-Xmx4g -Xms4g

我们使用的是 JDK 8,启动参数中没有指定 GC,确定这个服务使用了默认的并行垃圾收集器。

于是怀疑问题出在这款垃圾收集器上面,因为很多情况下 ParallelGC 为了最大的系统处理能力,即吞吐量,而牺牲掉了单次的暂停时间,导致暂停时间会比较长。 使用 G1 垃圾收集器*

怎么办呢?准备换成 G1,毕竟现在新版本的 JDK 8 中 G1 很稳定,而且性能不错。

然后换成了下面的启动参数:

 这个参数有问题,启动失败
-Xmx4g -Xms4g -XX:+UseG1GC -XX:MaxGCPauseMills=50ms

结果启动失败,忙中出错,参数名和参数值都写错了。

修正如下:

-Xmx4g -Xms4g -XX:+UseG1GC -XX:MaxGCPauseMillis=50

接着服务启动成功,等待健康检测自动切换为新的服务节点,继续查看指标。

55932616.png

看看暂停时间,每个节点的 GC 暂停时间都降下来了,基本上在 50ms 以内,比较符合我们的预期。

嗯!事情到此结束了?远远没有。 “彩蛋"惊喜*

过了一段时间,我们发现了个下面这个惊喜(也许是惊吓),如下图所示:

50244521.png

中奖了,运行一段时间后,最大 GC 暂停时间达到了 1300ms。

情况似乎更恶劣了。

继续观察,发现不是个别现象:

84108207.png

内心是懵的,觉得可能是指标算错了,比如把 10s 内的暂停时间全部加到了一起。 注册 GC 事件监听*

于是想了个办法,通过 JMX 注册 GC 事件监听,把相关的信息直接打印出来。

关键代码如下所示:

// 每个内存池都注册监听
for (GarbageCollectorMXBean mbean 
     : ManagementFactory.getGarbageCollectorMXBeans()) {
    if (!(mbean instanceof NotificationEmitter)) {
        continue; // 假如不支持监听...
    }
    final NotificationEmitter emitter = (NotificationEmitter) mbean;
    // 添加监听
    final NotificationListener listener = getNewListener(mbean);
    emitter.addNotificationListener(listener, null, null);
}

通过这种方式,我们可以在程序中监听 GC 事件,并将相关信息汇总或者输出到日志。 具体的实现代码在后面的章节《应对容器时代面临的挑战》中给出。

再启动一次,运行一段时间后,看到下面这样的日志信息:

{
"duration":1869,
"maxPauseMillis":1869,
"promotedBytes":"139MB",
"gcCause":"G1 Evacuation Pause",
"collectionTime":27281,
"gcAction":"end of minor GC",
"afterUsage":
 {
  "G1 Old Gen":"1745MB",
  "Code Cache":"53MB",
  "G1 Survivor Space":"254MB",
  "Compressed Class Space":"9MB",
  "Metaspace":"81MB",
  "G1 Eden Space":"0"
 },
"gcId":326,
"collectionCount":326,
"gcName":"G1 Young Generation",
"type":"jvm.gc.pause"
}

情况确实有点不妙。

这次实锤了,不是 FullGC,而是年轻代 GC,而且暂停时间达到了 1869ms。 一点道理都不讲,我认为这种情况不合理,而且观察 CPU 使用量也不高。

找了一大堆资料,试图证明这个 1869ms 不是暂停时间,而只是 GC 事件的结束时间减去开始时间。 打印 GC 日志*

既然这些手段不靠谱,那就只有祭出我们的终极手段:打印 GC 日志。

修改启动参数如下:

-Xmx4g -Xms4g -XX:+UseG1GC -XX:MaxGCPauseMillis=50 
-Xloggc:gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps

重新启动,希望这次能排查出问题的原因。

84324884.png

运行一段时间,又发现了超长的暂停时间。 分析 GC 日志*

因为不涉及敏感数据,那么我们把 GC 日志下载到本地进行分析。

定位到这次暂停时间超长的 GC 事件,关键的信息如下所示:

Java HotSpot(TM) 64-Bit Server VM (25.162-b12) for linux-amd64 JRE (1.8.0_162-b12),
 built on Dec 19 2017 21:15:48 by "java_re" with gcc 4.3.0 20080428 (Red Hat 4.3.0-8)
Memory: 4k page, physical 144145548k(58207948k free), swap 0k(0k free)
CommandLine flags: 
 -XX:InitialHeapSize=4294967296 -XX:MaxGCPauseMillis=50 -XX:MaxHeapSize=4294967296 
 -XX:+PrintGC -XX:+PrintGCDateStamps -XX:+PrintGCDetails -XX:+PrintGCTimeStamps 
 -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseG1GC 
2020-02-24T18:02:31.853+0800: 2411.124: [GC pause (G1 Evacuation Pause) (young), 1.8683418 secs]
   [Parallel Time: 1861.0 ms, GC Workers: 48]
      [GC Worker Start (ms): Min: 2411124.3, Avg: 2411125.4, Max: 2411126.2, Diff: 1.9]
      [Ext Root Scanning (ms): Min: 0.0, Avg: 0.3, Max: 2.7, Diff: 2.7, Sum: 16.8]
      [Update RS (ms): Min: 0.0, Avg: 3.6, Max: 6.8, Diff: 6.8, Sum: 172.9]
         [Processed Buffers: Min: 0, Avg: 2.3, Max: 8, Diff: 8, Sum: 111]
      [Scan RS (ms): Min: 0.0, Avg: 0.2, Max: 0.5, Diff: 0.5, Sum: 7.7]
      [Code Root Scanning (ms): Min: 0.0, Avg: 0.0, Max: 0.1, Diff: 0.1, Sum: 0.3]
      [Object  (ms): Min: 1851.6, Avg: 1854.6, Max: 1857.4, Diff: 5.8, Sum: 89020.4]
      [Termination (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.6]
         [Termination Attempts: Min: 1, Avg: 1.0, Max: 1, Diff: 0, Sum: 48]
      [GC Worker Other (ms): Min: 0.0, Avg: 0.3, Max: 0.7, Diff: 0.6, Sum: 14.7]
      [GC Worker Total (ms): Min: 1858.0, Avg: 1859.0, Max: 1860.3, Diff: 2.3, Sum: 89233.3]
      [GC Worker End (ms): Min: 2412984.1, Avg: 2412984.4, Max: 2412984.6, Diff: 0.5]
   [Code Root Fixup: 0.0 ms]
   [Code Root Purge: 0.0 ms]
   [Clear CT: 1.5 ms]
   [Other: 5.8 ms]
      [Choose CSet: 0.0 ms]
      [Ref Proc: 1.7 ms]
      [Ref Enq: 0.0 ms]
      [Redirty Cards: 1.1 ms]
      [Humongous Register: 0.1 ms]
      [Humongous Reclaim: 0.0 ms]
      [Free CSet: 2.3 ms]
   [Eden: 2024.0M(2024.0M)->0.0B(2048.0K) 
    Survivors: 2048.0K->254.0M 
    Heap: 3633.6M(4096.0M)->1999.3M(4096.0M)]
 [Times: user=1.67 sys=14.00, real=1.87 secs] 

前后的 GC 事件都很正常,也没发现 FullGC 或者并发标记周期,但找到了几个可疑的点。 physical 144145548k(58207948k free):JVM 启动时,物理内存 137GB,空闲内存 55GB。

  • [Parallel Time: 1861.0 ms, GC Workers: 48]:垃圾收集器工作线程 48 个。

我们前面的课程中学习了怎样分析 GC 日志,一起来回顾一下。 user=1.67:用户线程耗时 1.67s; sys=14.00:系统调用和系统等待时间 14s; real=1.87 secs:实际暂停时间 1.87s; GC 之前,年轻代使用量 2GB,堆内存使用量 3.6GB,存活区 2MB,可推断出老年代使用量 1.6GB; GC 之后,年轻代使用量为 0,堆内存使用量 2GB,存活区 254MB,那么老年代大约 1.8GB,那么"内存提升量为 200MB 左右”。

这样分析之后,可以得出结论: 年轻代转移暂停,复制了 400MB 左右的对象,却消耗了 1.8s,系统调用和系统等待的时间达到了 14s。 JVM 看到的物理内存 137GB。 推算出 JVM 看到的 CPU 内核数量 72个,因为 GC 工作线程 72* 5/8 ~= 48 个。

看到这么多的 GC 工作线程我就开始警惕了,毕竟堆内存才指定了 4GB。

按照一般的 CPU 和内存资源配比,常见的比例差不多是 4 核 4GB、4 核 8GB 这样的。

看看对应的 CPU 负载监控信息:

4600411.png

通过和运维同学的沟通,得到这个节点的配置被限制为 4 核 8GB。

这样一来,GC 暂停时间过长的原因就定位到了: K8S 的资源隔离和 JVM 未协调好,导致 JVM 看见了 72 个 CPU 内核,默认的并行 GC 线程设置为 72* 5/8 ~= 48 个,但是 K8S 限制了这个 Pod 只能使用 4 个 CPU 内核的计算量,致使 GC 发生时,48 个线程在 4 个 CPU 核心上发生资源竞争,导致大量的上下文切换。

处置措施为: 限制 GC 的并行线程数量

事实证明,打印 GC 日志确实是一个很有用的排查分析方法。 限制 GC 的并行线程数量*

下面是新的启动参数配置:

 -Xmx4g -Xms4g
 -XX:+UseG1GC -XX:MaxGCPauseMillis=50 -XX:ParallelGCThreads=4
 -Xloggc:gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps

这里指定了 -XX:ParallelGCThreads=4,为什么这么配呢?我们看看这个参数的说明。

-XX:ParallelGCThreads=n

设置 STW 阶段的并行 worker 线程数量。 如果逻辑处理器小于等于 8 个,则默认值 n 等于逻辑处理器的数量。

如果逻辑处理器大于 8 个,则默认值 n 大约等于处理器数量的 5/8。在大多数情况下都是个比较合理的值。如果是高配置的 SPARC 系统,则默认值 n 大约等于逻辑处理器数量的 5/16。

-XX:ConcGCThreads=n

设置并发标记的 GC 线程数量。默认值大约是 ParallelGCThreads 的四分之一。

一般来说不用指定并发标记的 GC 线程数量,只用指定并行的即可。

重新启动之后,看看 GC 暂停时间指标:

81569009.png

红色箭头所指示的点就是重启的时间点,可以发现,暂停时间基本上都处于 50ms 范围内。

后续的监控发现,这个参数确实解决了问题。

那么还有没有其他的办法呢?请关注后续的章节《应对容器时代面临的挑战》。 小结*

通过这个案例,我们可以看到,JVM 问题排查和性能调优主要基于监控数据来进行。

还是那句话:没有量化,就没有改进。

简单汇总一下这里使用到的手段: 指标监控 指定 JVM 启动内存 指定垃圾收集器 打印和分析 GC 日志 GC 和内存是最常见的 JVM 调优场景,还记得课程开始时我们介绍的 GC 的性能维度吗? 延迟,GC 中影响延迟的主要因素就是暂停时间。 吞吐量,主要看业务线程消耗的 CPU 资源百分比,GC 占用的部分包括:GC 暂停时间,以及高负载情况下并发 GC 消耗的 CPU 资源。 系统容量,主要说的是硬件配置,以及服务能力。

只要这些方面的指标都能够满足,各种资源占用也保持在合理范围内,就达成了我们的预期。 参考

文章列表

更多推荐

更多
  • Ansible2实战-五、消费和创建模块 技术要求,使用命令行执行多个模块,查看模块索引,从命令行访问模块文档,模块返回值,开发定制模块,避免常见的陷阱,测试和记录您的模块,模块清单,向上游投稿–提交 GitHub 拉取请求,摘要,发现插件类型,问题,进一步, 在这本书里
    Apache CN

  • Ansible2实战-十、容器和云管理 技术要求,使用行动手册设计和构建容器,管理多个容器平台,使用可扩展容器部署到 Kubernetes,用 Ansible 管理 Kubernetes 对象,安装 Ansible 的库本内特依赖项,用 Ansible 列出 Kubernet
    Apache CN

  • Ansible2实战-九、使用 Ansible 的网络自动化 技术要求,为什么要自动化网络管理?,了解 ansible 如何管理网络设备,实现网络自动化,查看可用的 Ansible 网络模块,连接到网络设备,网络设备的环境变量,网络设备的条件语句,摘要,问题,进一步, 多年前,标准做法是手工
    Apache CN

  • Ansible2实战-十二、Ansible Tower 入门 技术要求,安装 AWX,运行你的第一个剧本从 AWX,创建 AWX 项目,创建库存,创建作业模板,运行作业,控制进入 AWX,创建用户,创建团队,创建组织,在 AWX 分配权限,摘要,问题, Ansible 非常强大,但它确实需要
    Apache CN

  • Ansible2实战-十一、故障排除和测试策略 技术要求,挖掘行动手册执行问题,使用主机事实诊断故障,用剧本测试,使用检查模式,解决主机连接问题,通过命令行界面传递工作变量,限制主机的执行,刷新代码缓存,检查错误的语法,摘要,问题,进一步, 与任何其他类型的代码类似,Ansib
    Apache CN

  • Ansible2实战-第三部分:在企业中使用 Ansible 在本节中,我们将从实际出发,看看如何在企业环境中最大限度地利用 Ansible。在开始使用 Ansible 管理云和容器环境之前,我们将首先了解如何使用 Ansible 自动化您的网络设备。然后,我们将了解一些更高级的测试和故障
    Apache CN

  • Ansible2实战-第二部分:扩展 Ansible 的能力 在这一节中,我们将介绍 Ansible 插件和模块的重要概念。我们将讨论它们的有效使用,以及如何通过编写自己的插件和模块来扩展 Ansible 的功能。我们甚至会考虑将您的模块和插件提交回官方 Ansible 项目的要求。我们还
    Apache CN

  • Ansible2实战-第一部分:学习 Ansible 的基础知识 在本节中,我们将了解 Ansible 的基本原理。我们将从安装 Ansible 的过程开始,然后我们将掌握基础知识,包括语言和特殊命令的基础知识。然后,我们将探索 Ansible 清单,然后再考虑编写我们的第一个行动手册和角色来
    Apache CN

  • Ansible2实战-十三、答案 第一章,第二章,第三章,第四章,第五章,第六章,第七章,第八章,第九章,第十章,破产重组保护,第十二章,第一章1.甲,乙 2.C 3.A 第二章1.C 2.B 3.A 第三章1
    Apache CN

  • Ansible2实战-八、高级 Ansible 主题 技术要求,异步与同步操作,控制滚动更新的播放执行,配置最大故障百分比,设置任务执行委托,使用一次性运行选项,在本地运行行动手册,使用代理和跳转主机,在游戏和任务中放置标签,使用可加密保管库保护数据,摘要,问题,进一步, 到目前为止
    Apache CN

  • 近期文章

    更多
    文章目录

      推荐作者

      更多