Java应用生产故障排查(内存CPU飙升、线程死锁)

本文由 简悦 SimpRead 转码, 原文地址 juejin.cn

引言

经过前述九章的 JVM 知识学习后,咱们对于 JVM 的整体知识体系已经有了全面的认知。但前面的章节中,更多的是停留在理论上进行阐述,而本章节中则更多的会分析 JVM 的实战操作。

当然,也正因为有了之前理论知识的支持,所以才能在线上环境去快速的定位故障问题、性能瓶颈,同时也能帮助咱们更加快捷的解决所遇的 "难题"。

线上排查、性能优化等概念也是面试过程中的 "常客",而对于线上遇到的 "疑难杂症",需要通过理性的思维去分析问题、排查问题、定位问题、解决问题,同时,如果解决掉所遇到的问题或瓶颈后,也可以在能力范围之内尝试最优解以及适当考虑拓展性。

在本章中会先讲明线上排查问题的思路,再接着会对于 JVM 常用的排查工具进行阐述,最后会对于 JVM 线上常遇的一些故障问题进行全面剖析。

一、JVM 线上环境时常见故障与排查思路分析

在开发过程中,如果遇到 JVM 问题时,通常都有各种各样的本地可视化工具支持查看。但开发环境中编写出的程序迟早会被部署在生产环境的服务器上,而线上环境偶尔也容易遇到一些突发状况,比如 JVM 在线上环境往往会出现以下几个问题:

  • ①JVM 内存泄漏。
  • ②JVM 内存溢出。
  • ③业务线程死锁。
  • ④应用程序异常宕机。
  • ⑤线程阻塞 / 响应速度变慢。
  • ⑥CPU 利用率飙升或 100%。

当程序在线上环境发生故障时,就不比开发环境那样,可以通过可视化工具监控、调试,线上环境往往会 "恶劣" 很多,那当遇到这类问题时又该如何处理呢?首先在碰到这类故障问题时,得具备良好的排查思路,再建立在理论知识的基础上,通过经验 + 数据的支持依次分析后加以解决。

1.1、线上排查及其解决问题的思路

相对而言,解决故障问题也好,处理性能瓶颈也罢,通常思路大致都是相同的,步骤如下:

  • ①分析问题:根据理论知识 + 经验分析问题,判断问题可能出现的位置或可能引起问题的原因,将目标缩小到一定范围。
  • ②排查问题:基于上一步的结果,从引发问题的 "可疑性" 角度出发,从高到低依次进行排查,进一步排除一些选项,将目标范围进一步缩小。
  • ③定位问题:通过相关的监控数据的辅助,以更 "细粒度" 的手段,将引发问题的原因定位到精准位置。
  • ④解决问题:判断到问题出现的具体位置以及引发的原因后,采取相关措施对问题加以解决。
  • ⑤尝试最优解(非必须):将原有的问题解决后,在能力范围内,且环境允许的情况下,应该适当考虑问题的最优解(可以从性能、拓展性、并发等角度出发)。

当然,上述过程是针对特殊问题以及经验老道的开发者而言的,作为 "新时代的程序构建者",那当然得学会合理使用工具来帮助我们快速解决问题:

  • ①摘取或复制问题的关键片段。
  • ②打开百度谷歌后粘贴搜索。
  • ③观察返回结果中,选择标题与描述与自己问题较匹配的资料进入。
  • ④多看几个后,根据其解决方案尝试解决问题。
  • ⑤成功解决后皆大欢喜,尝试无果后 "找人 / 问群"。
  • ⑥"外力" 无法解决问题时自己动手,根据之前的步骤依次排查解决。

前面给出了两套解决问题的步骤,面试 / 学习推荐前者,实际开发推荐后者,毕竟面试的时候人家问你怎么解决问题的,你总不能说靠百度。

同时还有关键一点要明白:"能够搜索出来的资料也是人写出来的,你为何不能成为写的那人呢"

1.2、线上排查的方向

通常情况下来说,系统部署在线上出现故障,经过分析排查后,最终诱发问题的根本原因无非在于如下几点:

  • 应用程序本身导致的问题
    • 程序内部频繁触发 GC,造成系统出现长时间停顿,导致客户端堆积大量请求。
    • JVM 参数配置不合理,导致线上运行失控,如堆内存、各内存区域太小等。
    • Java 程序代码存在缺陷,导致线上运行出现 Bug,如死锁 / 内存泄漏、溢出等。
    • 程序内部资源使用不合理,导致出现问题,如线程 / DB 连接 / 网络连接 / 堆外内存等。
  • 上下游内部系统导致的问题
    • 上游服务出现并发情况,导致当前程序请求量急剧增加,从而引发问题拖垮系统。
    • 下游服务出现问题,导致当前程序堆积大量请求拖垮系统,如 Redis 宕机 / DB 阻塞等。
  • 程序所部署的机器本身导致的问题
    • 服务器机房网络出现问题,导致网络出现阻塞、当前程序假死等故障。
    • 服务器中因其他程序原因、硬件问题、环境因素(如断电)等原因导致系统不可用。
    • 服务器因遭到入侵导致 Java 程序受到影响,如木马病毒 / 矿机、劫持脚本等。
  • 第三方的 RPC 远程调用导致的问题
    • 作为被调用者提供给第三方调用,第三方流量突增,导致当前程序负载过重出现问题。
    • 作为调用者调用第三方,但因第三方出现问题,引发雪崩问题而造成当前程序崩溃。

万变不离其宗,虽然上述中没有将所有可能会发生问题的位置写到,但总的来说,发生问题排查时,也就是这几个大的方向,先将发生问题的大体定位,然后再逐步推导出具体问题的位置,从而加以解决。

二、Java 提供的程序监控及性能调优工具

碰到问题时,首先要做的就是定位问题。而一般定位问题是都会基于数据来进行,比如:程序运行日志、异常堆栈信息、GC 日志记录、线程快照文件、堆内存快照文件等 。同时,数据的收集又离不开监控工具的辅助,所以当 JVM 在线上运行过程中出现问题后,自然避免不了使用一些 JDK 自带以及第三方提供的工具,如:jps、jstat、jstack、jmap、jhat、hprof、jinfo、arthas等,接下来我们逐个认识这些工具。

jps、jstat、jstack、jmap、jhat、jinfo等命令都是安装 JDK 后自带的工具,它们的功能主要是调用%JAVA_HOME%/lib/tools.jar包里面的 Java 方法来实现的,所以如果你想自己打造一个属于自己的 JVM 监控系统,那在 Java 程序内部调用该jar包的方法即可实现。

JDK 官方提供的 JDK 工具参考文档,当然,如果你不会使用这些工具,也可以通过参数:tool -help来查看它的使用方法,如:jps -help
PS:对于 JDK 提供的这些工具了解的可以直接跳到第三阶段。

2.1、进程监控工具 - jps

jps工具的主要作用是用来查看机器上运行的 Java 进程,类似于 Linux 系统的ps -aux|grep java命令。jps工具也支持查看其他机器的 Java 进程,命令格式如下:

jps [ options ] [ hostid ]

查看指令的用法:jps -help

其中[options]主要有-q、-m、-l、-v、-V几个选项:

  • jps -q:查看机器所有运行的 Java 进程,但只显示进程号(lvmid)。
  • jps -m:~,只显示传递给main方法的参数。
  • jps -l:~,只显示运行程序主类的包名,或者运行程序jar包的完整路径。
  • jps -v:~,单独显示 JVM 启动时,显式指定的参数。
  • jps -V:~,显示主类名或者 jar 包名。

其中[hostid]是用来连接其他机器查看 Java 进程的远程 ID。

JPS工具实际使用方式:jps [pid]

2.2、配置信息查看工具 - jinfo

jinfo工具主要用于实时查看 JVM 的运行参数,也可以在运行时动态的调整一些参数。命令格式如下:

jinfo [ option1 ] [ option2 ]

查看指令的用法:jinfo -help / jinfo -h

其中[option1]可选项如下:

  • <no option>:第一个参数不写,默认输出 JVM 的全部参数和系统属性。
  • -flag <name>:输出与指定名称<name>对应的所有参数,以及参数值。
  • -flag [+|-]<name>:开启或者关闭与指定名称<name>对应的参数。
  • -flag <name>=<value>:设置与指定名称<name>对应参数的值。
  • -flags:输出 JVM 全部的参数。
  • -sysprops:输出 JVM 全部的系统属性。

其中[option2]可选项如下:

  • <pid>:对应的 JVM 进程 ID(必需参数),指定一个jinfo要操作的 Java 进程。
  • executable <core:输出打印堆栈跟踪的核心文件。
  • [server-id@]<remote server IP or hostname>:远程操作的地址。
    • server-id:远程debug服务的进程 ID;
    • remote server IP/hostname:远程debug服务的主机名 或 IP 地址;

Jinfo工具实际使用方式:jinfo -flags [pid]
PS:对于每个不同选项的效果就不再演示了,感兴趣的小伙伴可以自行在本地开个 Java 进程,然后使用上述的选项进行调试观察。

2.3、信息统计监控工具 - jstat

jstat工全称为 "Java Virtual Machine statistics monitoring tool",该工具可以利用 JVM 内建的指令对 Java 程序的资源以及性能进行实时的命令行的监控,监控范围包含:堆空间的各数据区、垃圾回收状况以及类的加载与卸载状态。

命令格式:jstat -<option> [-t] [-h<lines>] <vmid> [<interval> [<count>]]

其中每个参数的释义如下:

  • [option]:监控参数选项。
  • -t:在输出结果中加上Timestamp列,显示系统运行的时间。
  • -h:可以在周期性数据输出的时候,指定间隔多少行数据后输出一次表头。
  • vmidVirtual Machine ID虚拟 ID,也就是指定一个要监控的 Java 进程 ID。
  • interval:每次执行的间隔时间,默认单位为ms
  • count:用于指定输出多少条数据,默认情况下会一直输出。

执行命令jstat -option后,可以看到存在很多选项,如下:

  • -class:输出类加载ClassLoad相关的信息。
  • -compiler:显示与 JIT 即时编译相关的信息。
  • -gc:显示与 GC 相关的信息。
  • -gccapacity:显示每个分代空间的容量以及使用情况。
  • -gcmetacapacity:输出元数据空间相关的信息。
  • -gcnew:显示新生代空间相关的信息。
  • -gcnewcapacity:显示新生代空间的容量大小以及使用情况。
  • -gcold:输出年老代空间的信息。
  • -gcoldcapacity:输出年老代空间的容量大小以及使用情况。
  • -gcutil:显示垃圾回收信息。
  • -gccause:和-gcutil功能相同,但是会额外输出最后一次或本次 GC 的诱因。
  • -printcompilation:输出 JIT 即时编译的方法信息。

所以jstat的实际使用方式如下:

jstat -gc -t -h30 9895 1s 300

-gc:监控 GC 的状态

-t:显示系统运行的时间

-h30:间隔 30 行数据,输出一次表头

9895:Java 进程 ID

1s:时间间隔

300:本次输出的数据行数

最终执行效果如下:

统计列各字段含义如下:

字段名称 字段释义
Timestamp 系统运行的时间
S0C 第一个Survivor区的总容量大小
S1C 第二个Survivor区的总容量大小
S0U 第二个Survivor区的已使用大小
S1U 第二个Survivor区的已使用大小
EC Eden区的总容量大小
EU Eden区的已使用大小
OC Old区的总容量大小
OU Old区的已使用大小
MC Metaspace区的总容量大小
MU Metaspace区的已使用大小
CCSC CompressedClassSpace空间的总大小
CCSU CompressedClassSpace空间的已用大小
YGC 从程序启动到采样时,期间发生的新生代 GC 次数
YGCT 从程序启动到采样时,期间新生代 GC 总耗时
FGC 从程序启动到采样时,期间发生的整堆 GC(FullGC)次数
FGCT 从程序启动到采样时,期间整堆 GC(FullGC)总耗时
GCT 从程序启动到采样时,程序发生 GC 的总耗时

而除此之外,[options]指定其他选项时,也会出现不同的统计列字段,如下:

字段名称 字段释义
S0 第一个Survivor区的使用率(S0U/S0C
S1 第二个Survivor区的使用率(S1U/S1C
E Eden区的使用率(EU/EC
O Old区的使用率(OU/OC
M Metaspace区的使用率(MU/MC
CCS CompressedClassSpace区的使用率(CCSU/CCSC
NGCMN 新生代空间初始容量
NGCMX 新生代空间最大容量
S0CMN 第一个Survivor区的初始容量
S0CMX 第一个Survivor区的最大容量
S1CMN 第二个Survivor区的初始容量
S1CMX 第二个Survivor区的最大容量
OGCMN 年老代空间初始容量
OGCMX 年老代空间最大容量
MCMN 元数据空间初始容量
MCMX 元数据空间最大容量
CCSMN 类压缩空间初始容量
CCSMX 类压缩空间最大容量
TT 对象晋升的最小年龄阈值
MTT 对象晋升的最大年龄阈值
DSS 期望的Survivor区总大小

CCS 全称为 "CompressedClassSpace",主要是指存储类压缩指针的空间,具体可以看这个

除开堆空间和 GC 相关的统计列信息之外,jstat工具还可以类加载与卸载的状态、监控 JIT 即时编译,执行jstat -class [pid]jstat -compiler [pid]指令即可,效果如下:

类加载与卸载相关的监控数据统计列字段解读:

字段名称 字段释义
Loaded JVM 已经装载的类数量
Bytes 已装载的类占用字节数大小
Unloaded 已经卸载的类数量
Bytes 已卸载的类占用字节数大小
Time 卸载和装载类共耗时

JIT 即时编译相关的监控数据统计列字段解读:

字段名称 字段释义
Compiled 编译任务执行的总次数
Failed 编译任务执行失败的次数
Invalid 编译任务执行失效的次数
Bytes 已卸载的类占用字节数大小
Time 所有编译任务的总耗时
FailedType 最后一个编译失败的任务类型
FailedMethod 最后一个编译失败的任务所在的类及方法

对于jstat工具执行不同指令后,每个统计列的含义都已在上述中解释清楚,如若之后在线上环境采用jstat工具排查性能瓶颈时,对于不理解的统计列皆可参考如上释义。

2.4、堆内存统计分析工具 - jmap

jmap是一个多功能的工具,主要是用于查看堆空间的使用情况,通常会配合jhat工具一起使用,它可以用于生成 Java 堆的Dump文件。但除此之外,也可以查看finalize队列、元数据空间的详细信息,Java 堆中对象统计信息,如每个分区的使用率、当前装配的 GC 收集器等。

命令格式:> jmap [ option1 ] [ option2 ]

其中[option1]可选项有:

  • [no option]:查看进程的内存映像信息,与Solaris pmap类似。
  • -heap:显示 Java 堆空间的详细信息。
  • -histo[:live]:显示 Java 堆中对象的统计信息。
  • -clstats:显示类加载相关的信息。
  • -finalizerinfo:显示F-Queue队列中等待Finalizer线程执行finalizer方法的对象。
  • -dump:<dump-options>:生成堆转储快照。
  • -F:当正常情况下-dump-histo执行失效时,前面加-F可以强制执行。
  • -help:显示帮助信息。
  • -J<flag>:指定传递给运行jmap的 JVM 参数。

其中[option2]jinfo工具的相差无几,可选项如下:

  • <pid>:对应的 JVM 进程 ID(必需参数),指定一个jinfo要操作的 Java 进程。
  • executable <core:输出打印堆栈跟踪的核心文件。
  • [server-id@]<remote server IP or hostname>:远程操作的地址。
    • server-id:远程debug服务的进程 ID;
    • remote server IP/hostname:远程debug服务的主机名 或 IP 地址;

jmap工具实际使用方式:jmap -clstats [pid]jmap -dump:live,format=b,file=Dump.phrof [pid]等。

堆快照导出命令解析:
live:导出堆中存活对象快照;format:指定输出格式;file:指定输出的文件名及其格式(.dat、.phrof等格式)。

当然,具体的每个选项的效果也不再演示,大家感兴趣可以自行调试后观测。

不过值得一提的是:大部分 JDK 提供的工具与 JVM 通信方式都是通过的Attach机制实现的,该机制可以针对目标 JVM 进程进行一些操作,比如获取内存Dump、线程Dump、类信息统计、动态加载Agent、动态设置 JVM 参数、打印 JVM 参数、获取系统属性等。有兴趣可以去深入研究一下,具体源码位置位于:com.sun.tools.attach包,里面存在一系列Attach机制相关的代码。

在最后对于histo选项做个简单调试,histo选项主要作用是打印堆空间中对象的统计信息,包括对象实例数、内存空间占用大小等。因为在histo:live前会进行FullGC,所以带上live只会统计存活对象。因此,不加live的堆大小要大于加live堆的大小(因为带live会强制触发一次FullGC),如下:

上图中,class name是对象的类型,但有些是缩写,对象的缩写类型与真实类型对比如下:

缩写类型 B C D F I J Z [ L + 类型
真实类型 byte char double float int long boolean 数组 其他对象

2.5、堆内存快照分析工具 - jhat

jhat工具一般配合jmap工具使用,主要用于分析jmap工具导出的Dump文件,其中也内嵌了一个微型的HTTP/HTML服务器,所以当jhat工具分析完Dump文件后,可以支持在浏览器中查看分析结果。

不过在线上环境中一般不会直接使用jhat工具对Dump文件进行解析,因为jhat解析Dump文件,尤其是大体积的Dump时,是一个非常耗时且占用硬件资源的过程。所以为了防止占用服务器过多的资源,通常都会将Dump文件 copy 到其他机器或本地中分析。

不过话说回来,到了本地一般也不会使用jhat,因为分析之后生成的结果通过浏览器观察时很难看,一般都会选择 MAT(Eclipse Memory Analyzer)、IBM HeapAnalyzerVisualVMJprofile等工具。

jhat命令格式:jhat [-stack <bool>] [-refs <bool>] [-port <port>] [-baseline <file>] [-debug <int>] [-version] [-h|-help] <file>

jhat的这条指令有点长,其中可以选择填写很多参数,释义如下:

  • -stack:默认为true,是否开启对象分配调用栈跟踪。
  • -refs:默认为true,是否开启对象引用跟踪
  • -port:默认为7000,设置jhat工具浏览器访问的端口号。
  • -baseline:指定基准堆转储Dump文件,在两个Dump文件中有相同对象时,会被标记为旧对象,不同的对象会被标记为新对象,主要用于对比分析两个不同的Dump文件。
  • -debug:默认为0,设置 debug 级别,0 表示不输出调试信息,值越大信息越详细。
  • -version:显示版本信息。
  • -help:查看帮助信息。
  • <file>:要分析的Dump文件。
  • -J<flag>jhat工具实际上也是启动了一个 JVM 进程来执行的,可以通过-J指令为该 JVM 传递一些 JVM 参数,如:-J-Xmx128m这类的。

jhat实际应用方式:jhat HeapDump.dat,效果如下:

erlang 复制代码
> jmap -dump:live,format=b,file=HeapDump.dat 7452
Dumping heap to HeapDump.dat ...
Heap dump file created

> jhat HeapDump.dat
Reading from HeapDump.dat...
Dump file created Wed Mar 09 14:50:06 CST 2022
Snapshot read, resolving...
Resolving 7818 objects...
Chasing references, expect 1 dots.
Eliminating duplicate references.
Snapshot resolved.
Started HTTP server on port 7000
Server is ready.

上述过程中,首先通过jmap工具导出了 Java 堆的内存dump文件,紧接着使用jhat工具对导出的dump文件进行分析,分析完成后可以打开浏览器,输入http://localhost:7000查看jhat分析后生成的结果,如下:

其中提供了不少选项,从上至下分别为:

  • ①按照包路径查看不同类的具体对象实例。
  • ②查看堆中的所有Roots节点的集合。
  • ③查看所有类的对象实例数量(包括了 JVM 自身的类)。
  • ④查看所有类的对象实例数量(除去了 JVM 自身的类)。
  • ⑤查看 Java 堆中实例对象的统计直方图(和jmap的对象统计信息差不多)。
  • ⑥查看 JVM 的finalizer相关信息。
  • ⑦通过jhat工具提供的QQL对象查询语言获取指定对象的实例信息。
    • QQL具体的语法可以直接访问http://localhost:7000/oqlhelp查看。

其实本质上而言,jhat提供的浏览器界面也不怎么方便我们去排除问题。因此,实际分析堆Dump文件时,通常都会采用一些更为直观方便的工具,如:MAT、Jconsole、IBM HeapAnalyzer、visualVm等。

2.6、堆栈跟踪工具 - jstack

jstack工具主要用于捕捉 JVM 当前时刻的线程快照,线程快照是 JVM 中每条线程正在执行的方法堆栈集合。在线上情况时,生成线程快照文件可以用于定位线程出现长时间停顿 的原因,如线程死锁、死循环、请求外部资源无响应等等原因导致的线程停顿。

当线程出现停顿时,可以通过jstack工具生成线程快照,从快照信息中能查看到 Java 程序内部每条线程的调用堆栈情况,从调用堆栈信息中就可以清晰明了的看出:发生停顿的线程目前在干什么,在等待什么资源等。

同时,当 Java 程序崩溃时,如果配置好了参数,生成了core文件,咱们也可以通过jstack工具从core文件中提取 Java 虚拟机栈相关的信息,从而进一步定位程序崩溃的原因。

jstack工具命令格式:jstack [-F] [option1] [option2]

其中[option1]可选项为:

  • -l:除开显示堆栈信息外,额外输出关于锁相关的附加信息(用于排查死锁问题)。
  • -m:如果线程调用到本地方法栈中的本地方法,也显示C/C++的堆栈信息。

其中[option2]可选项如下:

  • <pid>:对应的 JVM 进程 ID(必需参数),指定一个jinfo要操作的 Java 进程。
  • executable <core:输出打印堆栈跟踪的核心文件。
  • [server-id@]<remote server IP or hostname>:远程操作的地址。
    • server-id:远程debug服务的进程 ID;
    • remote server IP/hostname:远程debug服务的主机名 或 IP 地址;

jstack工具实际使用方式:jstack -l [pid]

同时,jstack工具的-F参数与jmap的作用相同,当正常执行失效时,加上-F可以强制执行jstack指令。

最后,jstack工具导出的Dump日志值得留意的状态:

状态 释义
Deadlock 线程出现死锁
Runnable 线程正在执行中
Waiting on condition 线程等待资源
Waiting on monitor entry 线程等待获取监视器锁
Suspended 线程暂停
Object.wait()、TIMED_WAITING 线程挂起
Blocked 线程阻塞
Parked 线程停止

2.7、JVM 排查工具小结

上述分析的工具都是 JDK 自带的工具,每个不同的工具都拥有各自的作用,可以在不同维度对 JVM 运行时的状况进行监控,也能够帮助我们在线上环境时快速去定位排除问题。但除开 JDK 官方提供的一些工具之外,也有非常多第三方工具用起来非常顺手,如arthas、jprofilter、perfino、Yourkit、Perf4j、JProbe、MAT、Jconsole、visualVm等,这些工具往往都比前面分析提到的那些 JDK 工具更实用且功能更加强大。

三、JVM 线上故障问题 "大合集" 与排查实战

程序上线后,线上遇到突发状况无疑是一件令人头疼的事情,但作为一位合格的开发者,不是仅会敲出一手流利的代码就足够了,线上排错这项技能也额外重要。但线上排错的能力强弱更取决于经验的丰富与否,丰富的实操经验与理论知识储备 + 理性的排错思路才是线上排查中最为重要的。

接下来会对线上环境中发生最为频繁的故障问题进行全方位剖析及实战,如 JVM 内存泄漏、内存溢出、业务线程死锁、应用程序异常宕机、线程阻塞 / 响应速度变慢、CPU 利用率飙升或 100% 等。

3.1、线上排查的 "前夕"

在排查问题时,诱发问题的原因也有可能来自于上下游系统。因此,当出现问题时,首先得定位出现问题的节点,然后针对该节点进行排错。但无论是哪个节点(Java 应用、DB、上下游 Java 系统等),出现问题的原因无非就几个方向:代码、CPU、磁盘、内存以及网络问题 ,所以遇到线上问题时,合理采用 OS 与 JVM 提供的工具(如df、free、top、jstack、jmap、ps等),将这些方面依次排查一遍即可。

不过需要额外注意:JVM 提供的大部分工具在使用时会影响性能,所以如果你的程序是以单机的模式部署,那最好在排查问题之前做好流量迁移(改 DNS、Nginx 配置等)。如果你的程序是以集群模式部署,那么可以将其中一台机器隔离出来,用于保留现场,也为了更方便的调试问题。

同时,如果线上的机器已经无法提供正常服务,那么在排查问题之前首先要做到的是 "及时止损",可以采用版本回滚、服务降级、重启应用等手段让当前节点恢复正常服务。

3.2、JVM 内存溢出(OOM)

先来理解一下内存溢出:

举例:一个木桶只能装40L水,但此时往里面倒入50L水,多出来的水会从桶顶溢出。换到程序的内存中,这种情况就被称为内存溢出。

内存溢出(OOM)在线上排查中是一个比较常见的问题,同时在 Java 内存空间中,也会有多块区域会发生 OOM 问题,如堆空间、元空间、栈空间等,具体可参考前面的深入理解 JVM 运行时数据区这一章节。通常情况下,线上环境产生内存溢出的原因大致上有三类:

  • ①为 JVM 分配的内存太小,不足以支撑程序正常执行时的数据增长。
  • ②编写的 Java 程序内部存在问题、有 Bug,导致 GC 回收速率跟不上分配速率。
  • ③自己的代码或引入的第三方依赖存在内存溢出问题,导致可用内存不足。

上述②③问题皆是由于编写的 Java 程序代码不严谨导致的 OOM,由于 Java 内存中产生了大量垃圾对象,导致新对象没有空闲内存分配,从而产生的溢出。

在排查 OOM 问题时,核心是:哪里 OOM 了?为什么 OOM 了?怎么避免出现的 OOM?

同时,在排查过程中,应当要建立在数据的分析之上,也就是指Dump数据。

获取堆Dump文件方式有两种:

①启动时设置-XX:HeapDumpPath,事先指定 OOM 出现时,自动导出Dump文件。

②重启并在程序运行一段时间后,通过工具导出,如前面的jmap或第三方工具。

3.2.1、Java 线上 OOM 排查实操

模拟案例如下:

typescript 复制代码
// JVM启动参数:-Xms64M -Xmx64M -XX:+HeapDumpOnOutOfMemoryError 
// -XX:HeapDumpPath=/usr/local/java/java_code/java_log/Heap_OOM.hprof
public class OOM {
    // 测试内存溢出的对象类
    public static class OomObject{}
    
    public static void main(String[] args){
        List<OomObject> OOMlist = new ArrayList<>();
        // 死循环:反复往集合中添加对象实例
        for(;;){
            OOMlist.add(new OomObject());
        }
    }
}

在 Linux 上,先以后台运行的方式启动上述的 Java 程序:

css 复制代码
root@localhost ~]# java -Xms64M -Xmx64M -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/usr/local/java/java_code/java_log/Heap_OOM.hprof OOM &
[1] 78645

等待一段时间后,可以看到在/usr/local/java/java_code/java_log/目录下,已经自动导出了堆Dump文件,接下来我们只需要把这个Dump文件直接往Eclipse MAT(Memory Analyzer Tool)工具里面一丢,然后它就能自动帮你把 OOM 的原因分析出来,然后根据它分析的结果改善对应的代码即可。

其实上述这个案例中,你运行之后过一会儿就会给你输出一句 OOM 异常信息:

php 复制代码
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
	at java.util.Arrays.copyOf(Arrays.java:3210)
	at java.util.Arrays.copyOf(Arrays.java:3181)
	at java.util.ArrayList.grow(ArrayList.java:261)
	at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:235)
	at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:227)
	at java.util.ArrayList.add(ArrayList.java:458)
	at OOM.main(OOM.java:13)

在最后面都已经明确告诉了你,导致 OOM 的代码位置,因此这个案例没有太大的参考价值,其实也包括大部分他人的 OOM 排查过程,相对来说参考价值都并非太大,因为排查 OOM 问题时只需要自己具备理性的思维,步骤都大概相同的,所以接下来重点阐明排查 OOM 的思路即可。

线上 OOM 问题排查思路:

  • ①首先获取Dump文件,最好是上线部署时配置了,这样可以保留第一现场,但如若未配置对应参数,可以调小堆空间,然后重启程序的时候重新配置参数,争取做到 "现场" 重现。
  • ②如果无法通过配置参数获得程序 OOM 自然导出的Dump文件,那则可以等待程序在线上运行一段时间,并协调测试人员对各接口进行压测,而后主动式的通过jmap等工具导出堆的Dump文件(这种方式没有程序自动导出的Dump文件效果好)。
  • ③将Dump文件传输到本地,然后通过相关的Dump分析工具分析,如 JDK 自带的jvisualvm,或第三方的MAT工具等。
  • ④根据分析结果尝试定位问题,先定位问题发生的区域,如:确定是堆外内存还是堆内空间溢出,如果是堆内,是哪个数据区发生了溢出。确定了溢出的区域之后,再分析导致溢出的原因(后面会列出一下常见的 OOM 原因)。
  • ⑤根据定位到的区域以及原因,做出对应的解决措施,如:优化代码、优化 SQL 等。

3.2.2、线上内存溢出问题小结

Java 程序在线上出现问题需要排查时,内存溢出问题绝对是 "常客",但通常情况下,OOM 大多是因为代码问题导致的,在程序中容易引发 OOM 的情况:

  • ①一次性从外部将体积过于庞大的数据载入内存,如 DB 读表、读本地报表文件等。
  • ②程序中使用容器 (Map/List/Set等) 后未及时清理,内存紧张而 GC 无法回收。
  • ③程序逻辑中存在死循环或大量循环,或单个循环中产生大量重复的对象实例。
  • ④程序中引入的第三方依赖中存在 BUG 问题,因此导致内存出现故障问题。
  • ⑤程序中存在内存泄露问题,一直在蚕食可用内存,GC 无法回收导致内存溢出。
  • ⑥第三方依赖加载大量类库,元空间无法载入所有类元数据,因而诱发 OOM。
  • ⑦........

上述都是程序内代码引发 OOM 的几种原因,在线上遇到这类情况时,要做的就是定位问题代码,而后修复代码后重新上线即可。同时,除开代码诱发的 OOM 情况外,有时因为程序分配的内存过小也会引发 OOM,这种情况是最好解决的,重新分配更大的内存空间就能解决问题。

不过 Java 程序中,堆空间、元空间、栈空间等区域都可能出现 OOM 问题,其中元空间的溢出大部分原因是由于分配空间不够导致的,当然,也不排除会存在 "例外的类库" 导致 OOM。真正意义上的栈空间 OOM 在线上几乎很难遇见,所以实际线上环境中,堆空间 OOM 是最常见的,大部分需要排查 OOM 问题的时候,几乎都是堆空间发生了溢出。

3.3、JVM 内存泄漏

先来理解一下内存泄漏:

举例:一个木桶只能装40L水,但此刻我往里面丢块2KG的金砖,那该水桶在之后的过程中,最多只能装38L的水。此时这种情况换到程序的内存中,就被称为内存泄漏。

PS:不考虑物体密度的情况,举例说明不要死磕!

内存泄漏和内存溢出两个概念之间,总让人有些混淆,但本质上是两个完全不同的问题。不过在发生内存溢出时,有可能是因为内存泄漏诱发的,但内存泄漏绝对不可能因为 OOM 引发。

线上的 Java 程序中,出现内存泄漏主要分为两种情况:

  • ①堆内泄漏:由于代码不合理导致内存出现泄漏,如垃圾对象与静态对象保持着引用、未正确的关闭外部连接等。
  • ②堆外泄漏:申请buffer流后未释放内存、直接内存中的数据未手动清理等。

而一般在线上排查时并不能直接检测出内存泄漏问题,因为是否存在内存溢出问题除非监控了堆空间的对象变化,否则在正常情况下很难发觉。因此,通常情况下线上遇到泄漏问题时,都是伴随着 OOM 问题出现的,也就是:

排查 OOM 问题时,发现是由于内存泄漏一直在蚕食可用的空闲内存,最终导致新对象分配时没有空闲内存可用于分配,而造成的内存溢出。

3.3.1、JVM 内存泄漏排查小实战

内存溢出的模拟案例:

typescript 复制代码
// JVM启动参数:-Xms64M -Xmx64M -XX:+HeapDumpOnOutOfMemoryError 
// -XX:HeapDumpPath=/usr/local/java/java_code/java_log/Heap_MemoryLeak.hprof
// 如果不做限制,想要观测到内存泄漏导致OOM问题需要很长时间。
public class MemoryLeak {
    // 长生命周期对象,静态类型的root节点
    static List<Object> ROOT = new ArrayList<>();

    public static void main(String[] args) {
        // 不断创建新的对象,使用后不手动将其从容器中移除
        for (int i = 0;i <= 999999999;i++) {
            Object obj = new Object();
            ROOT.add(obj);
            obj = i;
        }
    }
}

先启动该程序:

css 复制代码
root@localhost ~]# java -Xms64M -Xmx64M -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/usr/local/java/java_code/java_log/Heap_MemoryLeak.hprof OOM &
[1] 78849

等待片刻后,也会出现异常信息如下:

php 复制代码
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
	at java.util.Arrays.copyOf(Arrays.java:3210)
	at java.util.Arrays.copyOf(Arrays.java:3181)
	at java.util.ArrayList.grow(ArrayList.java:261)
	at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:235)
	at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:227)
	at java.util.ArrayList.add(ArrayList.java:458)
	at MemoryLeak.main(MemoryLeak.java:14)

乍一看,这跟之前分析的 OOM 问题没啥区别,但却并非如此。在 Java 程序中,理论上那些创建出来的Object对象在使用完成后,内存不足时,GC 线程会将其回收,不过由于这些创建出来的对象在最后与静态的成员ROOT建立起了引用关系,而静态成员在 JVM 中又被作为GcRoots节点来对待的。

因此,所有创建出来的Object对象在使用完成后,因为与ROOT成员存在引用关系,所以都是可以通过根可达的对象,最终导致 GC 机制无法回收这些 "无效" 对象。

该案例中,从程序的执行结果来看,表象是内存溢出,但实则却是内存泄漏。

当然,上述案例只是简单模拟复现内存泄漏这种情况,实际开发过程中可能会更为复杂很多,如:

一个对象在某次业务逻辑执行过程中,与某个静态成员建立了连接,但该对象使用一次后不会再次使用,但因为没有手动去断开与静态成员的引用,因此导致这个 "废弃对象" 所占用的内存空间一直不会被 GC 回收。

所以,大家在开发编码过程中,应当刻意留意:当自己创建出的对象需要与静态对象建立连接,但使用一次之后明确清楚该对象不会再被使用,应当手动清空该对象与静态节点的引用,也就是手动置空或移除 。如上述案例中,最后应该要ROOT.remove(obj)才可。

3.3.2、线上内存泄漏问题小结

如果线上遇到因内存泄露而造成的 OOM 问题时,应当首先确认是堆内存泄漏,还是堆外内存泄漏,毕竟堆空间和元空间都有可能存在内存泄漏的隐患,搞清楚内存溢出的位置后再进行排查,处理问题会事半功倍。

常见的内存泄漏例子:

①外部临时连接对象使用后未合理关闭,如 DB 连接、Socket 连接、文件 IO 流等。

②程序内新创建的对象与长生命周期对象建立引用使用完成后,未及时清理或断开连接,导致新对象一直存在着引用关系,GC 无法回收。如:与静态对象、单例对象关联上了。

③申请堆外的直接内存使用完成后,未手动释放或清理内存,从而导致内存泄漏,如:通过魔法类 Unsafe 申请本地内存、或使用 Buffer 缓冲区后未清理等。

不过在理解内存泄漏时有个误区,大家千万不要被误导,先来看这么个说法:

"在 Java 中,多个非根对象之间相互引用,保持着存活状态,从而造成引用循环,导致 GC 机制无法回收该对象所占用的内存区域,从而造成了内存泄漏。"

上述这句话乍一听好像没太大问题,但实则该说法在 Java 中并不成立。因为 Java 中 GC 判断算法采用的是可达性分析算法,对于根不可达的对象都会判定为垃圾对象,会被统一回收。因此,就算在堆中有引用循环的情况出现,也不会引发内存泄漏问题。

3.4、业务线程死锁

死锁是指两个或两个以上的线程(或进程)在运行过程中,因为资源竞争而造成相互等待的现象,若无外力作用则不会解除等待状态,它们之间的执行都将无法继续下去。举个栗子:

某一天竹子和熊猫在森林里捡到一把玩具弓箭,竹子和熊猫都想玩,原本说好一人玩一次的来,但是后面竹子耍赖,想再玩一次,所以就把弓一直拿在自己手上,而本应该轮到熊猫玩的,所以熊猫跑去捡起了竹子前面刚刚射出去的箭,然后跑回来之后便发生了如下状况:

熊猫道:竹子,快把你手里的弓给我,该轮到我玩了.....

竹子说:不,你先把你手里的箭给我,我再玩一次就给你.....

最终导致熊猫等着竹子的弓,竹子等着熊猫的箭,双方都不肯退步,结果陷入僵局场面.......

这个情况在程序中发生时就被称为死锁状况,如果出现后则必须外力介入,然后破坏掉死锁状态后推进程序继续执行。如上述的案例中,此时就必须第三者介入,把 "违反约定" 的竹子手中的弓拿过去给熊猫,从而打破"僵局"。

3.4.1、线上死锁排查小实战

上个简单例子感受一下死锁情景:

java 复制代码
public class DeadLock implements Runnable {
    public boolean flag = true;

    // 静态成员属于class,是所有实例对象可共享的
    private static Object o1 = new Object(), o2 = new Object();

    public DeadLock(boolean flag){
        this.flag = flag;
    }

    @Override
    public void run() {
        if (flag) {
            synchronized (o1) {
                System.out.println("线程:" + Thread.currentThread()
                        .getName() + "持有o1....");
                try {
                    Thread.sleep(500);
                } catch (Exception e) {
                    e.printStackTrace();
                }
                System.out.println("线程:" + Thread.currentThread()
                        .getName() + "等待o2....");
                synchronized (o2) {
                    System.out.println("true");
                }
            }
        }
        if (!flag) {
            synchronized (o2) {
                System.out.println("线程:" + Thread.currentThread()
                        .getName() + "持有o2....");
                try {
                    Thread.sleep(500);
                } catch (Exception e) {
                    e.printStackTrace();
                }
                System.out.println("线程:" + Thread.currentThread()
                        .getName() + "等待o1....");
                synchronized (o1) {
                    System.out.println("false");
                }
            }
        }
    }

    public static void main(String[] args) {
        Thread t1 = new Thread(new DeadLock(true),"T1");
        Thread t2 = new Thread(new DeadLock(false),"T2");
        // 因为线程调度是按时间片切换决定的,
        // 所以先执行哪个线程是不确定的,也就代表着:
        //  后面的t1.run()可能在t2.run()之前运行
        t1.start();
        t2.start();
    }
}

如上是一个简单的死锁案例,在该代码中:

  • flag==true时,先获取对象o1的锁,获取成功之后休眠500ms,而发生这个动作的必然是t1,因为在main方法中,我们将t1任务的flag显式的置为了true
  • 而当t1线程睡眠时,t2线程启动,此时t2任务的flag=false,所以会去获取对象o2的锁资源,然后获取成功之后休眠500ms
  • 此时t1线程睡眠时间结束,t1线程被唤醒后会继续往下执行,然后需要获取o2对象的锁资源,但此时o2已经被t2持有,此时t1会阻塞等待。
  • 而此刻t2线程也从睡眠中被唤醒会继续往下执行,然后需要获取o1对象的锁资源,但此时o1已经被t1持有,此时t2会阻塞等待。
  • 最终导致线程t1、t2相互等待对象的资源,都需要获取对方持有的资源之后才可继续往下执行,最终导致死锁产生。

执行结果如下:

erlang 复制代码
D:\> javac -encoding utf-8 DeadLock.java  
D:\> java DeadLock  
线程:T1持有o1....
线程:T2持有o2....
线程:T2等待o1....
线程:T1等待o2....

在上述案例中,实际上T1永远获取不到o1,而T2永远也获取不到o2,所以此时发生了死锁情况。那假设如果在线上我们并不清楚死锁是发生在那处代码呢?其实可以通过多种方式定位问题:

  • ①通过jps+jstack工具排查。
  • ②通过jconsole工具排查。
  • ③通过jvisualvm工具排查。

当然你也可以通过其他一些第三方工具排查问题,但前面方式都是 JDK 自带的工具,不过一般 Java 程序都是部署在Linux系统上,所以对于后面两种可视化工具则不太方便使用。因此,线上环境中,更多采用的是第一种jps+jstack方式排查。

接下来我们用jps+jstack的方式排查死锁,此时保持原先的cmd/shell窗口不关闭,再新开一个窗口,输入jps指令:

makefile 复制代码
D:\> jps
19552 Jps
2892 DeadLock

jps 作用是显示当前系统的 Java 进程情况及其进程ID,可以从上述结果中看出:ID2892的进程是刚刚前面产生死锁的 Java 程序,此时我们可以拿着这个ID再通过jstack工具查看该进程的dump日志,如下:

makefile 复制代码
D:\> jstack -l 2892

显示结果如下:

可以从dump日志中明显看出,jstack工具从该进程中检测到了一个死锁问题,是由线程名为T1、T2的线程引起的,而死锁问题的诱发原因可能是DeadLock.java:41、DeadLock.java:25行代码引起的。而到这一步之后其实就已经确定了死锁发生的位置,我们就可以跟进代码继续去排查程序中的问题,优化代码之后就可以确保死锁不再发生。

PS:偷了个懒,死锁的排查小实战是从之前的《深入并发之线程、进程、纤程、协程、管程与死锁、活锁、锁饥饿详解》文章中复制过来的,当时的环境是基于 Windows 系统,但 Linux 系统的操作也是同样的步骤。

3.4.2、死锁问题小结

Java 程序中的死锁问题通常都是由于代码不规范导致的,所以在排查死锁问题时,需要做的就是定位到引发死锁问题的具体代码,然后加以改进后重新上线即可。

3.5、应用程序异常宕机

Java 应用被部署上线后,程序宕机情况在线上也不是个稀罕事,但程序宕机的原因可能是由于多方面引起的,如:机房环境因素、服务器本身硬件问题、系统内其他上下游节点引发的雪崩、Java 应用自身导致(频繁 GC、OOM、流量打崩等)、服务器中被植入木马或矿机脚本 等情况,都有可能导致程序出现异常宕机问题。

处理这类宕机情况,由于原因的不确定性,这个问题更多的是由开发、运维和网安人员来协同解决的,我们需要做的就是能够保证出现情况时,确保程序可以立即重启且能够及时通知运维人员协助排错。所以这种情况下,你可以采用keepalived来解决该问题。

3.5.1、线上 Java 应用宕机处理小实战

keepalived是个做热备、高可用不错的程序,大家可以自行安装一下,该程序的主要功能:可定期执行脚本、出现故障时给指定邮箱发送信件、主机宕机可以做漂移等,我们主要使用它的警报以及定期执行脚本功能。

安装keepalived完成后,可以使用vi命令编辑一下keeplived.conf文件,然后将其内部的监控脚本配置的模块改为如下:

bash 复制代码
vrrp_script chk_nginx_pid {
    # 运行该脚本,脚本内容:Java程序宕机以后,自动开启服务
    script "/usr/local/src/scripts/check_java_pid.sh" 
    interval 4 #检测时间间隔(4秒)
    weight -20 #如果条件成立的话,则权重 -20
}

check_java_pid.sh文件的脚本代码如下:

bash 复制代码
java_count=`ps -C java --no-header | wc -l`
if [ $java_count -eq 0 ];then
    java /usr/local/java_code/HelloWorld
    sleep 1
    
    # 这个是用来做漂移的(不用管)
    if [ `ps -C java --no-header | wc -l` -eq 0 ];then
        /usr/local/src/keepalived/etc/rc.d/init.d/keepalived stop
    fi
fi

HelloWorld.java文件代码如下:

typescript 复制代码
public class HelloWorld{
    public static void main(String[] args){
       System.out.println("hello,Java!");
       for(;;){}
    }
}

上述的环境搭建完成后,可以测试效果,先启动一个 Java 应用HelloWorld

ini 复制代码
# 启动Java应用
[root@localhost ~]# java /usr/local/java_code/HelloWorld

# 查看Java进程
[root@localhost ~]# ps aux | grep java
root  69992  0.1  0.7  153884  7968  ?  SS  16:36  0:21  java
root  73835  0.0 0.0  112728  972  pts/0  S+ 16:37  0:00  grep --color=auto java

然后再开启脚本执行权限并启动keeplived

csharp 复制代码
# 开启脚本执行权限(我的是root用户,这步其实可以省略)
[root@localhost ~]# chmod +x /usr/local/src/scripts/check_java_pid.sh

# 进入到keepalived安装目录并启动keepalived应用
[root@localhost ~]# cd /usr/local/src/keepalived/
[root@localhost keepalived]# keepalived-1.2.22/bin/keepalived etc/keepalived

# 查看keepalived后台进程
[root@localhost keepalived]# ps aux | grep keepalived
root  73908  0.0  0.1  42872  1044 ?  Ss  17:01  0:00 keepalived
root  73909  0.0  0.1  42872  1900 ?  S   17:01  0:00 keepalived
root  73910  0.0  0.1  42872  1272 ?  S   17:01  0:00 keepalived

前面所有程序都跑起来之后,现在手动默认 Java 应用宕机,也就是使用kill杀掉 Java 进程,如下:

ini 复制代码
# kill -9 69992:强杀Java进程(69992是前面启动Java应用时的进程ID)
[root@localhost ~]# kill -9 69992

# 查询Java后台进程(此时已经没有Java进程了,因为刚刚被kill了)
[root@localhost ~]# ps aux | grep java
root  76621  0.0 0.0  112728  972  pts/0  S+ 17:03  0:00  grep --color=auto java

# 间隔三秒左右再次查询Java后台进程
[root@localhost ~]# ps aux | grep java
root  79921  0.1  0.7  153884  7968  ?  SS  17:08  0:21  java
root  80014  0.0 0.0  112728  972  pts/0  S+ 17:08  0:00  grep --color=auto java

此时你可以观测到结果,本来被强杀的 Java 进程,过了几秒后再次查询,会发现后台的 Java 应用再次复活了!

3.5.2、线上 Java 应用宕机小结

keepalived是一个比较好用的工具,你还可以配置它的邮件提醒服务,当出现问题或重启时,都可以发送邮件给指定邮箱。但这种重启是治标不治本的手段,如果要彻底解决宕机的问题,还需要从根源点出发,从根本上解决掉导致程序宕机的原因。

3.6、线程阻塞 / 响应速度变慢

响应速度变慢和线程出现阻塞,这两者之间的关系的密不可分的,Java 服务中的线程由于执行过程中遇到突发状况导致阻塞,那么对于客户端而言,直接反馈过去的就是响应的速度变慢,所以线程阻塞时必然会造成客户端响应缓慢甚至无响应,但反过来,线程阻塞却不是造成响应速度变慢唯一原因。

响应速度变慢和 Java 应用宕机同样,属于 "复合型" 的问题,Java 应用中线程阻塞、TCP 连接爆满、SQL 执行时间过长、硬件机器硬盘 / CPU / 内存资源紧张、上游系统流量过大、第三方中间件或接口出现异常情况、应用并非处理静态资源或同一时刻加载资源过多等情况都可能造成响应速度变慢,所以排查这类问题时,也是个靠经验来处理的问题。不过排查无响应或响应速度过慢问题时,也有规律可言:

  • ①系统整体响应缓慢:如果程序整体响应过慢,那么则是由于压力过大、下游系统存在异常情况、当前 Java 应用存在问题、当前机器存在问题(网络 / 硬件 / 所在环境)、当前程序所在系统存在问题等等情况导致的。也就是说,只有当应用系统中某一个层面出现全面瘫痪或故障,才有可能导致程序整体出现响应缓慢的问题。
  • ②单个接口响应缓慢:如果程序中某个接口或某类接口响应速度过慢,但其他接口响应正常,这点毫无疑问,绝对是因为 SQL 问题、接口内部实现存在问题等原因导致的,如查询的数量过大、内部调用的第三方接口出现问题、内部代码逻辑不正确导致线程阻塞、线程出现死锁情况等等。

上述两种其实可以理解为点和面的区别,一个是 "全面" 性质的,而另外一种则是 "单点" 性质的。除开可以从范围角度区分外,也可以从发生阶段的角度划分,如可分为:持续性响应缓慢、间接性响应缓慢、偶发性响应缓慢

因为响应缓慢这个问题,诱发的原因有多种,所以在线上遇到这类情况时,理性的分析出问题诱发的原因,再在不同层面根据不同情况加以优化,如:多线程执行、异步回调通知、引入缓存中间件、MQ 削峰填谷、读写分离、静态分离、集群部署、加入搜索引擎.......,都可被理解成是优化响应速度的方案。

3.7、CPU 利用率居高不下或飙升 100%

CPU 飙升 100% 和 OOM 内存溢出是 Java 面试中老生常谈的话题,CPU100% 倒是个比较简单的线上问题,因为毕竟范围已经确定了,CPU100% 就只会发生在程序所在的机器上,因此省去了确定问题范围的步骤,所以只需要在单台机器上定位具体的导致 CPU 飙升的进程,然后再排查问题加以解决即可。

3.7.1、线上 CPU100% 排查小实战

模拟的 Java 案例代码如下:

scss 复制代码
public class CpuOverload {
    public static void main(String[] args) {
        // 启动十条休眠线程(模拟不活跃的线程)
        for(int i = 1;i <= 10;i++){
            new Thread(()->{
                System.out.println(Thread.currentThread().getName());
                try {
                    Thread.sleep(10*60*1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            },"InactivityThread-"+i).start();
        }
        
        // 启动一条线程不断循环(模拟导致CPU飙升的线程)
        new Thread(()->{
            int i = 0;
            for (;;) i++;
        },"ActiveThread-Hot").start();
    }
}

首先新建一个shell-SSH窗口,启动该 Java 应用模拟 CPU 飙升的情景:

csharp 复制代码
[root@localhost ~]# javac CpuOverload.java
[root@localhost ~]# java CpuOverload

紧接着再在另外一个窗口中,通过top指令查看系统后台的进程状态:

yaml 复制代码
[root@localhost ~]# top
top - 14:09:20 up 2 days, 16 min,  3 users,  load average: 0.45, 0.15, 0.11
Tasks:  98 total,   1 running,  97 sleeping,   0 stopped,   0 zombie
%Cpu(s):100.0 us,  0.0 sy,  0.0 ni,  0.0 id,  0.0 wa,  0.0 hi,  0.0 si,  0.0 st
KiB Mem :   997956 total,   286560 free,   126120 used,   585276 buff/cache
KiB Swap:  2097148 total,  2096372 free,      776 used.   626532 avail Mem 

   PID USER      PR  NI    VIRT    RES    SHR S %CPU %MEM     TIME+ COMMAND
 77915 root      20   0 2249432  25708  11592 S 99.9  2.6   0:28.32 java
   636 root      20   0  298936   6188   4836 S  0.3  0.6   3:39.52 vmtoolsd
     1 root      20   0   46032   5956   3492 S  0.0  0.6   0:04.27 systemd
     2 root      20   0       0      0      0 S  0.0  0.0   0:00.07 kthreadd
     3 root      20   0       0      0      0 S  0.0  0.0   0:04.21 ksoftirqd/0
     5 root       0 -20       0      0      0 S  0.0  0.0   0:00.00 kworker/0:0H
     7 root      rt   0       0      0      0 S  0.0  0.0   0:00.00 migration/0
     8 root      20   0       0      0      0 S  0.0  0.0   0:00.00 rcu_bh
     9 root      20   0       0      0      0 S  0.0  0.0   0:11.97 rcu_sched
     .......

从如上结果中不难发现,PID77915的 Java 进程对 CPU 的占用率达到99.9%,此时就可以确定,机器的 CPU 利用率飙升是由于该 Java 应用引起的。

此时可以再通过top -Hp [PID]命令查看该 Java 进程中,CPU 占用率最高的线程:

perl 复制代码
[root@localhost ~]# top -Hp 77915
.....省略系统资源相关的信息......
   PID USER      PR  NI    VIRT    RES    SHR S %CPU %MEM     TIME+ COMMAND
 77935 root      20   0 2249432  26496  11560 R 99.9  2.7   3:43.95 java
 77915 root      20   0 2249432  26496  11560 S  0.0  2.7   0:00.00 java
 77916 root      20   0 2249432  26496  11560 S  0.0  2.7   0:00.08 java
 77917 root      20   0 2249432  26496  11560 S  0.0  2.7   0:00.00 java
 77918 root      20   0 2249432  26496  11560 S  0.0  2.7   0:00.00 java
 77919 root      20   0 2249432  26496  11560 S  0.0  2.7   0:00.00 java
 77920 root      20   0 2249432  26496  11560 S  0.0  2.7   0:00.00 java
 77921 root      20   0 2249432  26496  11560 S  0.0  2.7   0:00.01 java
 .......

top -Hp 77915命令的执行结果中可以看出:其他线程均为休眠状态,并未持有 CPU 资源,而 PID 为77935的线程对 CPU 资源的占用率却高达99.9%

到此时,导致 CPU 利用率飙升的 "罪魁祸首" 已经浮现水面,此时先将该线程的PID转换为16进制的值,方便后续好进一步排查日志信息:

csharp 复制代码
[root@localhost ~]# printf %x 77935
1306f

到目前为止,咱们已经初步获得了 "罪魁祸首" 的编号,而后可以再通过前面分析过的jstack工具查看线程的堆栈信息,并通过刚刚拿到的16进制线程 ID 在其中搜索:

ini 复制代码
[root@localhost ~]# jstack 77915 | grep 1306f
"ActiveThread-Hot" #18 prio=5 os_prio=0 tid=0x00007f7444107800
            nid=0x1306f runnable [0x00007f7432ade000]

此时,从线程的执行栈信息中,可以明确看出:ID 为1306f的线程,线程名为ActiveThread-Hot。同时,你也可以把线程栈信息导出,然后在日志中查看详细信息,如下:

ruby 复制代码
[root@localhost ~]# jstack 77915 > java_log/thread_stack.log
[root@localhost ~]# vi java_log/thread_stack.log
-------------然后再按/,输入线程ID:1306f-------------
"ActiveThread-Hot" #18 prio=5 os_prio=0 tid=0x00007f7444107800
            nid=0x1306f runnable [0x00007f7432ade000]
   java.lang.Thread.State: RUNNABLE
        at CpuOverload.lambda$main$1(CpuOverload.java:18)
        at CpuOverload$$Lambda$2/531885035.run(Unknown Source)
        at java.lang.Thread.run(Thread.java:745)

在线程栈的log日志中,对于线程名称、线程状态、以及该线程的哪行代码消耗的 CPU 资源最多,都在其中详细列出,接下来要做的就是根据定位到的代码,去 Java 应用中修正代码重新部署即可。

当然,如果执行jstack 77915 | grep 1306f命令后,出现的是""VM Thread" os_prio=0 tid=0x00007f871806e000 nid=0xa runnable"这类以"VM Thread"开头的信息,那么则代表这是 JVM 执行过程中,虚拟机自身的线程造成的,这种情况有需要进一步排查 JVM 自身的线程了,如 GC 线程、编译线程等。

3.7.2、CPU100% 排查小结

CPU100% 问题排查步骤几乎是死的模板:

  • top指令查看系统后台进程的资源占用情况,确定是否是 Java 应用造成的。
  • ②使用top -Hp [pid]进一步排查 Java 程序中,CPU 占用率最高的线程。
    • 保存 CPU 占用率最高的线程PID,并将其转换为 16 进制的值。
  • ③通过jstack工具导出 Java 应用的堆栈线程快照信息。
  • ④通过前面转换的 16 进制线程 ID,在线程栈信息中搜索,定位导致 CPU 飙升的具体代码。
  • ⑤确认引发 CPU 飙升的线程是虚拟机自带的 VM 线程,还是业务线程。
  • ⑥如果是业务线程就是代码问题,根据栈信息修改为正确的代码后,将程序重新部署上线。
  • ⑦如果是VM线程,那可能是由于频繁 GC、频繁编译等 JVM 的操作导致的,此时需要进一步排查。

CPU 飙升这类问题,一般而言只会有几种原因:

①业务代码中存在问题,如死循环或大量递归等。

②Java 应用中创建的线程过多,造成频繁的上下文切换,因而消耗 CPU 资源。

③虚拟机的线程频繁执行,如频繁 GC、频繁编译等。

3.8、其他线上问题浅谈

前面的内容中详细的阐述了线上的多种故障问题及其解决方案,但实则线上也同样还会出现各种各样的 "毛病",如磁盘使用率 100%、DNS 劫持、数据库被勒索、木马病毒入侵、矿机脚本植入、网络故障等等 。同时,处理这些问题的手段都需要从经验中去积累,这也是开发者在工作中应当学习的 "宝贵财富"

四、线上排查总结

线上排查这项技能更多的是根据经验而谈的,经验越丰富的开发者遇到这类问题时,处理起来会更为得心应手,当线上排查的经验丰富后,就算遇到一些没碰到过的问题,也能排查一二,而不会茫然的束手无策。

总归而言,线上排查各类问题,没有所谓的千篇一律的方法可教,丰富的经验 + 强大的工具 + 理性的思维才是处理这类问题的唯一办法,但排查的思路却是不会变化的,步骤也大致相同,也既是开篇所提及到的:

分析问题、排查问题、定位问题、解决问题、尝试最优解

相关推荐
2401_857610036 分钟前
Spring Boot框架:电商系统的技术优势
java·spring boot·后端
杨哥带你写代码2 小时前
网上商城系统:Spring Boot框架的实现
java·spring boot·后端
camellias_2 小时前
SpringBoot(二十一)SpringBoot自定义CURL请求类
java·spring boot·后端
背水2 小时前
初识Spring
java·后端·spring
晴天飛 雪3 小时前
Spring Boot MySQL 分库分表
spring boot·后端·mysql
weixin_537590453 小时前
《Spring boot从入门到实战》第七章习题答案
数据库·spring boot·后端
AskHarries3 小时前
Spring Cloud Gateway快速入门Demo
java·后端·spring cloud
Qi妙代码3 小时前
MyBatisPlus(Spring Boot版)的基本使用
java·spring boot·后端
宇宙超级勇猛无敌暴龙战神4 小时前
Springboot整合xxl-job
java·spring boot·后端·xxl-job·定时任务