JVM 线上问题定位实战(CPU 飙升)
问题描述
很多时候我们的系统在使用过程中经常出现卡顿的情况,或者请求变慢(延迟高)等等情况,然后运维人员跑来给你说写的什么垃圾代码,CPU 飙升,经常 100%,内存飙升。
如果你不知道怎么定位问题,觉得自己代码也找不出什么问题,那就说重启试试咯!!!
当然了,这种处理方式可以解决,但并不能从根本上解决问题,试想一下如果对于高并发的系统,你重启项目肯定是不行的,所以我们要找到问题的源头,因为我们程序都是跑在 JVM 里面的,所以上面的 2 种情况很可能是我们的代码出现了问题,比如常见的死循环,递归等等,当然这种就是比较明显的错误。大多数情况下我们能察觉出来,而对于比较难以察觉的代码问题,则需要我们在 JVM 的层面去解决,也就是所谓的 JVM 调优。
问题如下:
系统 CPU 经常 100%,如何调优?
系统内存飙升,如何定位问题?
当然,有人会说,加内存,加服务器等等,我们要理解调优的目的,其目的是为了利用现有的内存来获取高吞吐量或者低延迟,说白了就是花更少的钱做更多的事。
场景模拟
之前在公司做过一个差旅系统(类似携程,但针对的是企业用户),大概意思是年终要定时统计年终的差旅费用,包括机票,酒店费用报销等等,把数据从 MySQL 中取出来计算汇总,如下:
大概步骤:
从数据库中批量读取用户信息;
利用线程池多线程执行任务计算。
代码如下:
问题复现
我们在虚拟机上运行上面这个 main 方法,这里的虚拟机配置如下:
然后把上面的程序打为 jar 包,在虚拟机中运行如下:
对于上面的程序,我们是知道具体的代码的,而一般情况下,你是不知道别人的代码是怎么写的,所以我们一般可以通过 top 命令来查看 CPU,内存等使用情况。
如下:
可以看到其中的 Java 程序 cpu 飙升,甚至超过了 100%,且内存占用也比较高,然后在去看 GC 日志,如下:
可以看到,存在非常频繁的 Full GC。
注意:并不是一开始就超过 100%,或者 OOM,频繁 GC 等,一般程序可能会过一段时间才会出现。
问题定位
对于上面存在的问题,我们分为两种情况来定位:
CPU 占用过高
内存占用过高
此外,JDK 也为我们提供了如下性能诊断工具,详情参考:JDK自带性能诊断工具
CPU 占用过高
top
命令是我们在 Linux 下最常用的命令之一,它可以实时显示正在执行进程的 CPU 使用率、内存使用率以及系统负载等信息。其中上半部分显示的是系统的统计信息,下半部分显示的是进程的使用率统计信息。
具体步骤如下:
1、通过 top
命令来找到消耗 CPU 很高的进程 id
为什么要使用 top
而不使用 jdk 自带的诊断工具,是因为我们要先确定是不是 Java 程序导致的,如下:
2、单独监控该进程
利用如下命令单独监控,如下:
然后在该界面输入 H
,则展示该进程下所有的线程信息,如下:
3、找到消耗 cpu 特别高的线程编号
4、执行 jstack 对当前的进程做 dump,输出所有的线程信息
注意是进程,即上面的 4337,如下:
可以看到它会输出所有线程的堆栈信息,并且存在一些 16 进制的 nid。
5、线程进制转换
然后我们把第 4 步得到的线程 id 4340,4339 进行 16 进制转换:
把转换的进制在第 4 步的线程信息中查找,如下:
从图中可以看出,造成 CPU100% 的原因是在由于垃圾回收线程导致的,而可能不是我们业务线程导致的。
PS:jdk1.8 默认垃圾收集器 Parallel Scavenge(新生代)+Parallel Old(老年代)
可通过以下命令查看默认垃圾回收器:
由于一般情况下我们的项目在运行过程中是不会开启 GC 日志的,我们可以jstat –gc pid
来统计,达到类似的效果,如下:
查询结果如下:
说明:
当然了,其实我们只需要展示需要的列:
从上图可以看出,YoungGC
只进行了 19 次,而进行了大量的 Full GC
(7000 多次且继续递增),同时PrintGC
还显示抛出了 OutOfMemory
。
结论
这里的 CPU 占用过高不是业务线程导致,而是 GC 线程占用过高导致的,JVM 在疯狂的进行垃圾回收,JVM 中默认的垃圾回收器是多线程的,所以多线程在疯狂回收,导致 CPU 占用过高。
内存占用过高
那么,它为什么会进行疯狂的垃圾回收?很显然,内存不够,我们通过jmap
看一下堆的内存情况,如下:
很明显的可以看到,老年代已经被塞得满满的了,也就是说 GC 好像没有作用了,有人说为什么没有自动扩容(代码中有注释,设置了 -Xmx=200M,目的是快速测试),就算没有设置,随着时间最后也会塞满。
因此,问题的根本很可能发生了内存泄漏。
我们可以通过 jmap
导出堆的快照(即 dump 文件),但是导出 dump 文件过大会对程序造成影响,因此我们可以先jmap -histo pid
,它可以打印每个 class 的实例数目,内存占用,类全名信息。如下:
可以看到,存在很多个对象,大约 60 万个(其实这个时候程序已经死掉了,但是还是占用 CPU)。
一般来说,前面这几行,就可以看出,到底是哪些对象占用了内存。我们知道对象都是朝生夕死的,那么为什么这些对象不死呢?回收不掉呢?其结果导致了 FullGC
和 OutOfMemory
。
我们再回顾上面的代码,我们把线程池的最大线程数设置为 50,而我们的任务数为 100,任务数多于线程数,那么任务会进入阻塞队列,由于任务数一直多于线程数,所以每 0.1s 就会有 50 个任务进入阻塞队列,没有执行,但同时这些对象又被线程池对象 Executor 引用,且 Executor 是一个 GCroots
,所以堆中有 60 万个对象(UserInfo),阻塞队列中有 60 万个任务(ScheduledFutureTask),并且这些对象还无法回收。
因此,我们可以定位到代码,要么加大线程池的线程数,要么减少批量任务数。
总结
在 JVM 出现性能问题的时候,表现上是 CPU100%,实际是内存一直占用导致。
如果 CPU 的 100%,要从两个角度出发,一个有可能是业务线程疯狂运行,比如说很多死循环。还有一种可能性,就是 GC 线程在疯狂的回收,因为 JVM 中垃圾回收器主流也是多线程的,所以很容易导致 CPU 的 100%。
在遇到内存泄漏的问题的时候,一般情况下我们要查看系统中哪些对象占用得比较多,在实际的业务代码中,通过分析找到对应的对象,分析对应的类,找到为什么这些对象不能回收的原因(可达性分析算法)。
另外,如果观察一个系统,每次进行 FullGC 发现堆空间回收的比例比较小,尤其是老年代,同时对象越来越多,这个时候可以判断是有可能发生内存泄漏,而内存泄漏就和代码有很大的关系,因此需要我们去检查代码。
上面的例子只是一次相对简单的调优经历,可以使用 jmap –histo
这种命令去分析哪些对象占据着我们的堆空间,如果遇到更加复杂的情况,命令的方式是看不出来的,这个时候我们必须要借助一下工具(如 MAT,其实也是最主要的分析工具,后续再讲),当然前提是通过 jmap
命令把整个堆内存的数据 dump 下来。
内存泄漏和内存溢出
内存溢出
实实在在的内存空间不足导致,即程序运行所需要的内存大于所提供的内存。
内存泄漏
该释放的对象没有释放,常见于使用容器保存元素的情况下,程序执行完成后对象没有被回收,处于一直存活的状态,但这个对象又没有用了,JVM 又回收不了。
两者关系
内存泄漏过多之后就会造成内存溢出。怎么理解?多线程执行同一个内存泄漏的程序,也就是占用过多的内存之后,超出了规定的内存大小,自然就溢出了。
因此,往往很多情况下,内存溢出往往是内存泄漏造成的。
所谓优化,经常有句玩笑话就是加钱加内存,因此,我们一般优化的思路有以下几点:
程序优化,效果通常非常大;
扩容,如果金钱的成本比较小,不要和自己过不去;
参数调优,在成本、吞吐量、延迟之间找一个平衡点。
版权声明: 本文为 InfoQ 作者【Ayue、】的原创文章。
原文链接:【http://xie.infoq.cn/article/b6ab2f6faadd2c5a51b0b7037】。文章转载请联系作者。
评论