写点什么

JVM 线上问题定位实战(CPU 飙升)

作者:Ayue、
  • 2022 年 5 月 26 日
  • 本文字数:4153 字

    阅读完需:约 14 分钟

问题描述

很多时候我们的系统在使用过程中经常出现卡顿的情况,或者请求变慢(延迟高)等等情况,然后运维人员跑来给你说写的什么垃圾代码,CPU 飙升,经常 100%,内存飙升。


如果你不知道怎么定位问题,觉得自己代码也找不出什么问题,那就说重启试试咯!!!


当然了,这种处理方式可以解决,但并不能从根本上解决问题,试想一下如果对于高并发的系统,你重启项目肯定是不行的,所以我们要找到问题的源头,因为我们程序都是跑在 JVM 里面的,所以上面的 2 种情况很可能是我们的代码出现了问题,比如常见的死循环,递归等等,当然这种就是比较明显的错误。大多数情况下我们能察觉出来,而对于比较难以察觉的代码问题,则需要我们在 JVM 的层面去解决,也就是所谓的 JVM 调优


问题如下:


  1. 系统 CPU 经常 100%,如何调优?

  2. 系统内存飙升,如何定位问题?


当然,有人会说,加内存,加服务器等等,我们要理解调优的目的,其目的是为了利用现有的内存来获取高吞吐量或者低延迟,说白了就是花更少的钱做更多的事。

场景模拟

之前在公司做过一个差旅系统(类似携程,但针对的是企业用户),大概意思是年终要定时统计年终的差旅费用,包括机票,酒店费用报销等等,把数据从 MySQL 中取出来计算汇总,如下:



大概步骤:


  1. 从数据库中批量读取用户信息;

  2. 利用线程池多线程执行任务计算。


代码如下:


/** * VM参数: -XX:+PrintGC -Xms200M -Xmx200M * GC调优---生产服务器推荐开启(默认是关闭的) * -XX:+HeapDumpOnOutOfMemoryError */public class FullGCProblem {    //线程池    private static ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor(50, new ThreadPoolExecutor.DiscardOldestPolicy());
public static void main(String[] args) throws Exception { //50个线程 executor.setMaximumPoolSize(50); while (true) { calc(); // 休眠 100ms 模拟计算耗时 Thread.sleep(100); } }
/** * 模拟多线程执行任务计算 */ private static void calc() { List<UserInfo> taskList = getAllCardInfo(); taskList.forEach(userInfo -> { executor.scheduleWithFixedDelay(() -> { userInfo.other(); }, 2, 3, TimeUnit.SECONDS); }); }
/** * 模拟从数据库读取数据,返回 */ private static List<UserInfo> getAllCardInfo() { List<UserInfo> taskList = new ArrayList<>(); for (int i = 0; i < 100; i++) { UserInfo userInfo = new UserInfo(); taskList.add(userInfo); } return taskList; }
private static class UserInfo { String name = "ayue"; int age = 18; BigDecimal money = new BigDecimal(999999.99);
// 模拟计算逻辑 public void other() { System.out.println(Thread.currentThread().getName() + ":计算中..."); } }}
复制代码

问题复现

我们在虚拟机上运行上面这个 main 方法,这里的虚拟机配置如下:



然后把上面的程序打为 jar 包,在虚拟机中运行如下:


java -cp jvm.jar -XX:+PrintGC -Xms200M -Xmx200M net.javatv.gc.FullGCProblem
复制代码



对于上面的程序,我们是知道具体的代码的,而一般情况下,你是不知道别人的代码是怎么写的,所以我们一般可以通过 top 命令来查看 CPU,内存等使用情况。


如下:



可以看到其中的 Java 程序 cpu 飙升,甚至超过了 100%,且内存占用也比较高,然后在去看 GC 日志,如下:



可以看到,存在非常频繁的 Full GC。


注意:并不是一开始就超过 100%,或者 OOM,频繁 GC 等,一般程序可能会过一段时间才会出现。

问题定位

对于上面存在的问题,我们分为两种情况来定位:


  1. CPU 占用过高

  2. 内存占用过高


此外,JDK 也为我们提供了如下性能诊断工具,详情参考:JDK自带性能诊断工具


CPU 占用过高

top 命令是我们在 Linux 下最常用的命令之一,它可以实时显示正在执行进程的 CPU 使用率、内存使用率以及系统负载等信息。其中上半部分显示的是系统的统计信息,下半部分显示的是进程的使用率统计信息。


具体步骤如下:


1、通过 top命令来找到消耗 CPU 很高的进程 id


为什么要使用 top而不使用 jdk 自带的诊断工具,是因为我们要先确定是不是 Java 程序导致的,如下:



2、单独监控该进程


利用如下命令单独监控,如下:


top -p 4337
复制代码



然后在该界面输入 H,则展示该进程下所有的线程信息,如下:



3、找到消耗 cpu 特别高的线程编号



4、执行 jstack 对当前的进程做 dump,输出所有的线程信息


注意是进程,即上面的 4337,如下:


jstack 4337
复制代码



可以看到它会输出所有线程的堆栈信息,并且存在一些 16 进制的 nid。


5、线程进制转换


然后我们把第 4 步得到的线程 id 4340,4339 进行 16 进制转换:


 4340---10F4 4339---10F3
复制代码


把转换的进制在第 4 步的线程信息中查找,如下:



从图中可以看出,造成 CPU100% 的原因是在由于垃圾回收线程导致的,而可能不是我们业务线程导致的。


PS:jdk1.8 默认垃圾收集器 Parallel Scavenge(新生代)+Parallel Old(老年代)


可通过以下命令查看默认垃圾回收器:


java -XX:+PrintCommandLineFlags -version
复制代码


由于一般情况下我们的项目在运行过程中是不会开启 GC 日志的,我们可以jstat –gc pid 来统计,达到类似的效果,如下:


//每间隔 2s 总共查询 10 次 GC 情况jstat -gc 4337 2000 10
复制代码


查询结果如下:



说明:


S0C:第一个幸存区(From 区)的大小S1C:第二个幸存区(To 区)的大小S0U:第一个幸存区的使用大小S1U:第二个幸存区的使用大小EC:伊甸园(Eden)区的大小EU:伊甸园(Eden)区的使用大小OC:老年代大小OU:老年代使用大小MC:方法区大小MU:方法区使用大小CCSC:压缩类空间大小 CCSU:压缩类空间使用大小 YGC:年轻代垃圾回收次数YGCT:年轻代垃圾回收消耗时间FGC:老年代垃圾回收次数FGCT:老年代垃圾回收消耗时间GCT:垃圾回收消耗总时间
复制代码


当然了,其实我们只需要展示需要的列:


// 每间隔 2s 总共查询 10 次 GC 情况,且只显示13~17列的数据。jstat -gc 4337 2000 10 | awk '{print $13,$14,$15,$16,$17}'
复制代码



从上图可以看出,YoungGC 只进行了 19 次,而进行了大量的 Full GC(7000 多次且继续递增),同时PrintGC还显示抛出了 OutOfMemory



结论


这里的 CPU 占用过高不是业务线程导致,而是 GC 线程占用过高导致的,JVM 在疯狂的进行垃圾回收,JVM 中默认的垃圾回收器是多线程的,所以多线程在疯狂回收,导致 CPU 占用过高。

内存占用过高

那么,它为什么会进行疯狂的垃圾回收?很显然,内存不够,我们通过jmap看一下堆的内存情况,如下:


jmap –heap 4337
复制代码



很明显的可以看到,老年代已经被塞得满满的了,也就是说 GC 好像没有作用了,有人说为什么没有自动扩容(代码中有注释,设置了 -Xmx=200M,目的是快速测试),就算没有设置,随着时间最后也会塞满。


因此,问题的根本很可能发生了内存泄漏。


我们可以通过 jmap导出堆的快照(即 dump 文件),但是导出 dump 文件过大会对程序造成影响,因此我们可以先jmap -histo pid,它可以打印每个 class 的实例数目,内存占用,类全名信息。如下:


//把JVM中的对象全部打印出来, 但是这样太多了,那么我们选择前 20 的对象展示出来jmap –histo 4337 | head -20
复制代码



可以看到,存在很多个对象,大约 60 万个(其实这个时候程序已经死掉了,但是还是占用 CPU)。


一般来说,前面这几行,就可以看出,到底是哪些对象占用了内存。我们知道对象都是朝生夕死的,那么为什么这些对象不死呢?回收不掉呢?其结果导致了 FullGCOutOfMemory


我们再回顾上面的代码,我们把线程池的最大线程数设置为 50,而我们的任务数为 100,任务数多于线程数,那么任务会进入阻塞队列,由于任务数一直多于线程数,所以每 0.1s 就会有 50 个任务进入阻塞队列,没有执行,但同时这些对象又被线程池对象 Executor 引用,且 Executor 是一个 GCroots,所以堆中有 60 万个对象(UserInfo),阻塞队列中有 60 万个任务(ScheduledFutureTask),并且这些对象还无法回收。


因此,我们可以定位到代码,要么加大线程池的线程数,要么减少批量任务数

总结

在 JVM 出现性能问题的时候,表现上是 CPU100%,实际是内存一直占用导致。


  1. 如果 CPU 的 100%,要从两个角度出发,一个有可能是业务线程疯狂运行,比如说很多死循环。还有一种可能性,就是 GC 线程在疯狂的回收,因为 JVM 中垃圾回收器主流也是多线程的,所以很容易导致 CPU 的 100%。

  2. 在遇到内存泄漏的问题的时候,一般情况下我们要查看系统中哪些对象占用得比较多,在实际的业务代码中,通过分析找到对应的对象,分析对应的类,找到为什么这些对象不能回收的原因(可达性分析算法)。


另外,如果观察一个系统,每次进行 FullGC 发现堆空间回收的比例比较小,尤其是老年代,同时对象越来越多,这个时候可以判断是有可能发生内存泄漏,而内存泄漏就和代码有很大的关系,因此需要我们去检查代码。


上面的例子只是一次相对简单的调优经历,可以使用 jmap –histo 这种命令去分析哪些对象占据着我们的堆空间,如果遇到更加复杂的情况,命令的方式是看不出来的,这个时候我们必须要借助一下工具(如 MAT,其实也是最主要的分析工具,后续再讲),当然前提是通过 jmap 命令把整个堆内存的数据 dump 下来。


内存泄漏和内存溢出


  1. 内存溢出

  2. 实实在在的内存空间不足导致,即程序运行所需要的内存大于所提供的内存。

  3. 内存泄漏

  4. 该释放的对象没有释放,常见于使用容器保存元素的情况下,程序执行完成后对象没有被回收,处于一直存活的状态,但这个对象又没有用了,JVM 又回收不了。

  5. 两者关系

  6. 内存泄漏过多之后就会造成内存溢出。怎么理解?多线程执行同一个内存泄漏的程序,也就是占用过多的内存之后,超出了规定的内存大小,自然就溢出了。

  7. 因此,往往很多情况下,内存溢出往往是内存泄漏造成的。


所谓优化,经常有句玩笑话就是加钱加内存,因此,我们一般优化的思路有以下几点:


  1. 程序优化,效果通常非常大;

  2. 扩容,如果金钱的成本比较小,不要和自己过不去;

  3. 参数调优,在成本、吞吐量、延迟之间找一个平衡点。

发布于: 刚刚阅读数: 4
用户头像

Ayue、

关注

🏆 InfoQ写作平台-签约作者 🏆 2019.10.16 加入

个人站点:javatv.net | 学习知识,目光坚毅

评论

发布
暂无评论
JVM 线上问题定位实战(CPU 飙升)_JVM_Ayue、_InfoQ写作社区