点击关注上方蓝字,阅读更多干货~
1 前言
你是否遇到过JVM内存使用率阈值告警、内存溢出导致服务不可用或CPU突然持续飙高导致服务性能下降甚至不可用的场景?当遇到这些场景有没有不知道如何排查的情形?如果有,那就来看看本文吧。相信会带给你一些帮助或提示。
本文按照以下思路展开:
请注意,本文使用的jdk版本为1.8。
2 知识回顾
整个对象创建过程大致可以两个过程,类加载过程以及实例对象创建过程,如下图所示:
2.2.1.1 类加载过程
类加载过程是由java虚拟机中的类加载子系统负载, 通过加载、验证、准备、解析、类初始化五个步骤将字节码文件变为类元信息的过程。
1. 加载阶段主要是在硬盘上查找并通过IO读入字节码文件,将文件解析为内存中的一个java.lang.Class对象的过程,这个过程主要以下三个步骤
a. 查询读取类文件:根据类的全限定名在硬盘上找到对应的文件,并通过io读成二
进制流
b. 定义类对象:将类的二进制流转为Class对象
c. 记录外部依赖:记录类之间的依赖关系,如父类和接口等。
2. 验证阶段主要是用来校验类文件是否符合JVM规范,分为以下四个验证步骤
a. 文件格式验证:检查文件是否符合Class文件格式规范
b. 元数据验证:检查类是否有正确的继承关系
c. 字节码验证:检查类的方法体是否符合规范
d. 符号引用验证:检查类的符号引用是否正确解析
3. 准备阶段主要为类的静态变量分配内存并设置默认值。如int 的默认值为0,string 的默认值为null。
4. 解析阶段将类文件中的符号引用转换为直接引用。符号引用是指类定义中的字段和方法的名称及描述符,而直接引用则指向内存中的具体数据。
5. 类初始化阶段执行类的初始化方法,包括static代码块和类变量的赋值等。
实例对象创建过程一般包括类加载检查、对象内存分配、设置对象头以及对象初始化这个四个阶段
1. 类加载检查:在创建实例对象之前,JVM会检查类是否已经被加载、解析和初始化过。如果没有,那么JVM会先执行相应的类加载过程。
2. 对象内存分配:给对象分配一块内存,通常是分配在堆上。
3. 设置对象头:在分配完内存之后,JVM会在对象的起始位置写入对象头(Header),用于存储类元数据指针、哈希码、GC分代年龄等数据。如果是带有锁标志位的对象,还会包含锁状态标志、线程ID、锁计数器等。
4. 对象初始化阶段执行构造方法及类变量初始化。
2.2.2 内存分配过程
栈内分配:是指如果开启了逃逸分析和标量替换,JVM在分配内存时,会根据逃逸分析结果判断是否可以在栈空间分配或使用标量替换的方式分配对象内存空间。在栈内分配可以在方法栈帧出栈后即回收。这样做的好处是可以减少堆分配的开销,提高性能。
标量:可以与聚合量在一起理解,标量即不可再分的类型,在java中指基本数据类型等。聚合量通常是指包含多个标量的聚合体。
标量替换:是指把对象拆分成不可再分的标量。目的是为了减少对象创建和销毁带来的系统开销。
大对象判断分配:大对象是指占用内存较大的对象直接在老年代分配,可以通过JVM参数 -XX:PretenureSizeThreshold(这个参数仅在 serial 和 parNew 这两个收集器下有效) 设置,jdk1.8默认设置为0,即不开启大对象判断。通过大对象的处理可以控制大对象的分配策略,可以减少内存碎片和垃圾回收的压力。但需要谨慎设置阈值,避免因值设置过小导致全进老年代,引起频繁full gc。
年轻代晋升老年代:当对象年龄达到-XX:MaxTenuringThreshold 设置的值后,会从Survivor区晋升到老年代中存储。
因动态年龄判断机制,可能会导致对象过早晋升到老年代存储:在minor gc 过程中,如果survivor区剩余存活对象占用空间大于 -XX:TargetSurvivorRatio 设置的值(1.8默认为50%)后,会将超过这个阈值的存活对象过早晋升至老年代存储。
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.17</version>
</dependency>
@Slf4j
public class JolDemo {
public static void main(String[] args) {
ClassLayout objectLayout = ClassLayout.parseInstance(new Object());
log.info("object 对象占用内存大小:{}", objectLayout.toPrintable());
ClassLayout intLayout = ClassLayout.parseInstance(1);
log.info("int 对象占用内存大小:{}", intLayout.toPrintable());
JolObject jolObject = new JolObject();
jolObject.setName("jol");
ClassLayout jolObjectLayout = ClassLayout.parseInstance(jolObject);
log.info("jolObject 对象占用内存大小:{}", jolObjectLayout.toPrintable());
}
@Data
static class JolObject {
private String name;
private int[] array;
}
}
输出结果:
本文介绍的排查工具如下表所示:
排查工具 | 是否操作简单 | 认知成本 | 是否有依赖条件 | 是否免费 |
JVM自带指令 | 操作较简单 | 认知成本较低 | 是,需要进入线上容器或服务器内 | 是 |
arthas | 操作简单 | 认知成本较低 | 是,需要进入线上容器或服务器内 | 是 |
mat | 操作较简单 | 有一定认知成本 | 否,线下操作 | 是 |
jps 是JVM 提供的一个查看java进程的简单命令工具。
当启动java应用后,在命令终端键入jps,即可以查询出当前机器上的启动了哪些java应用。
使用命令 jmap -heap [pid] (jdk1.8之后的版本使用jhsdb jmap --heap --pid [pid] )
使用命令 jmap -histo [pid]
仅输出前x位的对象 jmap -histo [pid] | head -20
输出到文件 jmap -histo [pid] > xx路径/xxx.text
num: 序号
istances: 实例数量
bytes : 占用空间大小,单位字节
class name : 类名称,[B 代表 byte数组,[C 代表 char[] , [I 代表int数组, [S 代表 short数组,[[I 代表int[][]
3.1.2.3 下载堆信息快照
下载所有的对象:jmap -dump:format=b,file=路径/heap.hprof [pid]
下载存活的对象:jmap -dump:live,format=b,file=路径/heap.hprof [pid]
当在生产环境中不能直接进入容器内去使用命令直接查看,可以通过这个指令将堆快照文件download到本地使用分析工具进行堆内存分析。
用来查询java进程中线程的状态信息、堆栈信息、锁信息等
jstack -l [pid] 或 输出到文件 jstack -l [pid] > 路径/jstack.text
过滤线程id jstack -l [pid] | grep -A 10 [16进制线程id]
"nacos-grpc-client-executor-127" : 线程名称
#503:代表当前线程id
daemon :
prio=5: 线程优先级
tid: 为线程id
nid: 为线程对应本地线程标识id
java.lang.Thread.State: TIMED_WAITING(parking)
线程状态:
3.1.4 jstat
jstat 命令用来查看JVM运行时状态,提供了实时的统计信息,可以帮助了解JVM的内存使用情况、垃圾收集活动和其他性能指标。
jstat -gc [pid] 1000 20
表示每隔1s 输出gc 情况,一共输出20次
可以用 awk 设置显示列。如 jstat -gc pid 1000 20 | awk '{print $13,$14,$15,$16,$17}'. 这代表输出YGC后面的(含 YGC)可以评估程序内存使用及GC压力整体情况
S0C:幸存区0的大小,单位kb
S1C:幸存去1的大小,单位kb
S0U:幸存区0的使用大小,单位kb
S1U:幸存区1的使用大小,单位kb
EC:伊甸园区的大小,单位kb
EU:伊甸园区的使用大小,单位kb
OC:老年代大小,单位kb
OU:老年代使用大小,单位kb
MC:原空间大小,单位kb
MC:原空间使用大小,单位kb
CCSC:压缩类空间大小,单位kb
CCSU:压缩类空间使用大小,单位kb
YGC:Young gc回收次数
YGGT:Young gc回收消耗时间,单位s
FGC:Full gc 回收次数
FGCT:Full gc 回收消耗时间,单位s
CGC:全局垃圾搜集次数 Global Garbage Collection Count
CGCT:全局垃圾收集时间 Global Garbage Collection Time
GCT: 垃圾回收总耗时,单位s
如果通过gc日志看到gc发生频次很高,一般说明当前服务程序可能发生内存问题,需要定位分析。
arthas 是阿里提供的一款无需重启应用,就可以在生产环境中排查java问题的诊断工具,通过arthas的提供的命令可以了解应用程序的运行状态,可以帮助我们进行性能调优和问题排查定位。
官网地址:
https://arthas.aliyun.com/
启动arthas 。建议使用命令 java -jar arthas-boot.jar --repo-mirror aliyun --use-http。
启动后进入选择监听进程步骤
3. 键入要监听的选项
显示当前系统的实时面板,输入指令dashboard
用于查看当前线程信息,查看线程的堆栈
thread -n [m个线程]
显示为[internal] 不是线程id,代表为JVM内部线程
CPUUsage为采样间隔时间内的CPU使用率
deltaTime为采样间隔时间内线程的增量CPU时间,小于1ms时被取整为0ms
time为线程运行总CPU时间
thread [线程id]
thread -b
@Slf4j
public class DeadLockTest {
private static final Object LOCK1 = new Object();
private static final Object LOCK2 = new Object();
static class Thread1 extends Thread {
@Override
public void run() {
synchronized (LOCK1) {
log.info("Thread1 get lock1");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
log.warn("Thread1 sleep error", e);
}
synchronized (LOCK2) {
log.info("Thread1 get lock2");
}
}
}
}
static class Thread2 extends Thread {
@Override
public void run() {
synchronized (LOCK2) {
log.info("Thread2 get lock2");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
log.warn("Thread2 sleep error", e);
}
synchronized (LOCK1) {
log.info("Thread2 get lock1");
}
}
}
}
public static void main(String[] args) {
Thread1 thread1 = new Thread1();
Thread2 thread2 = new Thread2();
thread1.start();
thread2.start();
}
}
输出结果
thread -b 查看结果
jvisualvm 是JVM自带的一个java监控、排查可视化工具,提供以下几个主要的视图界面。
比如我们先启动一个这个java程序,然后在命令窗口内键入jvisualvm,打开jvisualvm界面
public class LeakTest {
private static final List<Object> CACHE = new ArrayList<>();
/**
* 是模拟一个不停往静态变量里添加对象的案例
*/
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 10000000; i++) {
CACHE.add(new LeakObject());
Thread.sleep(20);
}
}
static class LeakObject {
private byte[] bytes = new byte[100 * 1024];
}
}
概述视图
可以看到当前进程中的一个概览信息,包含JVM参数、系统属性等。
监视视图
监视试图可以看到CPU、内存、类、线程使用情况
线程视图
可以展示监听进程的线程信息,并可以显示线程堆栈信息
点击上图圈出的线程dump就会出现下图中的线程堆栈信息
装入下载的.hprof文件
装入步骤
点击打开
2. 在打开的窗口中选择要装入的文件
Memory Analyzer Tool 是一个强大的java内存分析工具。它可以帮助开发者检测内存泄漏、优化内存使用,并且能够分析 JVM 的堆转储文件 (.hprof) 。
官网地址:
https://eclipse.dev/mat/?spm=5176.28103460.0.0.21663da2vlJ8nh
outgoing refrences 和 incoming refrences
outgoing refrences : 引用的对象
incoming refrences:被引用的对象
Shallow heap 和 Retained heap
shallow heap: 浅堆,代表自身直接占用的内存
retained heap: 深堆,代表自身向下引用链上的每个对象占用内存之和(包含自身占用内存)
可以动手算一算A对象的深堆占用大小是多少哦。
本示例以一个LeakTest的代码来演示如何操作
public class LeakTest {
private static final List<Object> CACHE = new ArrayList<>();
/**
* 设置JVM参数为 -Xms512m -Xmx512m -XX:+HeapDumpOnOutOfMemoryError
* 通过模拟不停往静态常量集合类中添加对象
*/
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 10000000; i++) {
CACHE.add(new LeakObject());
// 为了观察内存变化
Thread.sleep(20);
}
}
static class LeakObject {
private byte[] bytes = new byte[100 * 1024];
}
}
将导出的堆快照文件装入mat
在弹出的Getting Started Wizard 窗口中勾选 Leak Suspects Report
可以先看下default_report窗口,有没有给出的内存问题猜测
圈出的地方说明mat工具给出了内存问题猜测,我们就先看这猜测
如果给出了猜测,可以先找到浅堆占用比较小,但深堆占用很大的属性或类,然后使用点击该类后选择 outgoing refrences 打开引用的对象
从上图可知类 LeakTest的属性CACHE持有了大量的深堆内存,可能是泄漏对象。
如果没有建议也可以通过Histogram页面查看浅堆占用小但深堆占用较大的对象是哪些
再使用outgoing refrences 查看对象实例
2. 如果需要佐证自己的内存泄漏猜想,可以使用Histogram 页面的比对功能
3. 如果要定位内存泄漏,可以先找浅堆占用小但深堆占用很大的对象
4. 查看对象不可达判断是否存在大量的垃圾对象还未被回收
a. 首先看下mat设置是否勾选了Keep unreachable objects
b. 点击 查看Histogram 页面中占用深堆大的类
c. 右键点击该类选择
d. 在打开的页面中可以看到该类中是否有unreachable 对象,一般unreachable对象是指垃圾对象,如果出现大量的unreachable对象,那代表程序产生了大量的垃圾对象没有被即时回收掉,那么意味着可能会因为回收垃圾导致系统性能下降。下面这个示例就说明当刻产生了大量的日志对象,如果gc频次也很高的话,说明打印的日志太多了,可以减少日志的输出。
内存溢出通常是指在创建对象过程中给对象分配内存时因存量可用内存不够或因加载类因元空间不足或在运行时分配栈帧空间因栈空间不足所抛错的现象
public class HeapOOMTest {
/**
* 演示堆内存溢出
* 通过调整堆大小以及使用死循环程序不停往list集合中添加大对象
* -Xms10m -Xmx10m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/JVM/demo/oom/heapoom/heapoom.hprof
* @param args
*/
public static void main(String[] args) {
List<OomObject> list = new ArrayList<>();
while(true) {
list.add(new OomObject());
}
}
@Data
public static class OomObject {
private byte[] bytes = new byte[1024 * 1024];
}
}
执行结果
@Slf4j
public class StackOOMTest {
private static int count = 0;
/**
* 栈溢出
* 通过未有停止条件的不良递归模拟栈空间溢出
* -Xss196k
*/
public static void main(String[] args) {
try {
method();
} catch (Throwable e) {
log.warn("StackOOMTest 溢出 count:{}", count, e);
}
}
private static void method() {
count++;
method();
}
}
执行结果
@Slf4j
public class MetadataOOMTest {
/**
* 元空间溢出
* 通过死循环不停创建代理类方式模式元空间溢出
* -XX:MaxMetaspaceSize=10m -XX:MetaspaceSize=10m
*/
public static void main(String[] args) {
try {
while (true) {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(OOMObject.class);
enhancer.setUseCache(false);
enhancer.setCallback((MethodInterceptor) (obj, method, args1, proxy) -> proxy.invokeSuper(obj, args1));
enhancer.create();
}
} catch (Throwable e) {
log.error("MetadataOOMTest 溢出", e);
}
}
static class OOMObject {
}
}
执行结果
出现内存溢出的常见场景一般有以下几种
堆内存溢出 一般是由于代码设计或编写不合理导致,那么在代码中容易发生oom的场景
由于接口入参没有做数量限制,导致一次性导入或传入大量的对象
在定时任务中或比对程序中等,没有使用分页处理,导致一次性查询大量对象
不合理使用循环,造成创建大量对象尤其是上下文等大对象或者频繁创建重复对象
不合理使用容器类,导致使用后未能被垃圾回收导致内存泄漏
JVM参数设置不合理,设置的值偏小,或者采用的垃圾回收器不合适
栈内存溢出
线程创建不合理或者线程池设置不合理,导致不停创建线程
不良递归写法,导致深度递归超出栈内存上限
JVM参数设置不合理,设置值偏小
元空间溢出
不合理使用类加载,导致加载了过量的类
pom引用类库不合理,导致加载了过量的类
不合理使用静态变量,有大量静态变量被创建
字符串等常量使用不合理,导致字符串常量池过大
参数设置设置不合理或者设置值偏小
内存泄漏通常是指堆内存泄漏,即已经分配的内存因为某种原因程序未能释放或者无法释放导致堆中可用内存变少的场景。存在内存泄漏如果该内存泄漏场景是有不断增长的趋势,会导致因gc频繁导致程序变慢,最终可能会导致堆内存溢出或程序崩溃。
public class LeakTest {
private static final List<Object> CACHE = new ArrayList<>();
/**
* 设置JVM参数为 -Xms512m -Xmx512m -XX:+HeapDumpOnOutOfMemoryError
* 通过模拟不停往静态常量集合类中添加对象
*/
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 10000000; i++) {
CACHE.add(new LeakObject());
// 为了观察内存变化
Thread.sleep(20);
}
}
static class LeakObject {
private byte[] bytes = new byte[100 * 1024];
}
}
通过这个增长可以看出占用内存一直在增长,但没有释放过。
内存泄漏出现的场景一般有以下几种
外部链接在使用后没有正确关闭
静态集合类的不合理使用
上下文对象使用不合理,如日志的MDC对象
threadLocal 使用不合理,导致线程所引用的对象跟随线程对象的生命周期,造成可能的内存泄漏
引用类型使用不当,造成对象占用内存不能被回收,如 weakRefrence或 softRefrence等类型指针
public class CPUHighTest {
public static void main(String[] args) {
// 通过死循环不停计算模拟CPU飙高场景
calculate();
}
private static void calculate() {
while (true) {
for(int i=0; i<1000000; i++) {
double result = i * i;
}
}
}
}
1. 使用arthas监听启动的程序进程
2. 在命令行中键入thread -n 3 (找出最忙的3个线程)
CPU飙高的场景一般有以下几种
线程池管理不当,导致创建过多的线程,造成线程争抢或线程等待
JVM参数设置不合理,导致频繁gc
程序设计时未考虑高并发场景,导致线程资源争抢或线程频繁调度
同步锁或同步代码块使用不合理,造成大量线程等待
锁使用不合理,造成死锁等现象
循环使用不合理,比如死循环
设计不合理,导致了繁重的CPU计算压力
如果我们碰到了内存溢出的情况,首先我们要根据报错日志判断是哪种溢出场景。
如果是堆内存溢出
如果在JVM启动命令中打开了HeapDumpOnOutOfMemoryError指令,根据溢出时自动导出的文件使用内存分析工具mat进行分析
分析排查大对象
分析对象引用链
判断是否有内存泄漏
如果没有打开HeapDumpOnOutOfMemoryError指令
建议首先根据日志判断下溢出位置,判断下代码是否有问题
如果排查不出来,可以在重启pod时打开HeapDumpOnOutOfMemoryError指令,本次启动时JVM内存参数可以相对小一点
根据得到的堆内存快照使用mat工具进行分析,判断分析大对象、对象引用链以及排查是否有内存泄漏等
根据分析结果确定优化方案
调优之后,线上观测
如果是栈内存溢出
根据日志判断溢出位置是否有不合理的方法调用,是否有不良递归写法或循环方法引用或有大量局部变量被创建等现象,如果是这种原因在优化方案落地发布后,线上持续观测
判断栈空间参数是否设置过小,如果相对较小,可以适当增加Xss设置,重启后线上观测
如果是元空间溢出
根据日志判断溢出位置是否有不良类加载的写法
排查pom文件是否有不可理引用大量第三方类库的地方
查询JVM参数,判断是否设置了元空间大小(默认为动态调整),如果设置了是否设置过小
方案调优或参数设置调优后,线上持续观测
如果配置了内存阈值告警或者full gc 次数告警,当收到告警后,或者日常线上巡查发现内存增长不合理或gc较为频繁后
查看监控面板(如grafana)告警实例中内存使用情况、JVM内存情况,gc情况等,观察当前内存水位是否为持续相对增长,尤其是即使在full gc之后也是呈现为相对上次gc之后增长的情况。
如果出现了上述情况,可能发生了内存泄漏,那么如果可以进入容器可以先用jmap -histo [pid] | head -20 这样的命令尝试查看下对象情况,如果根据对象情况分析不出来,可以用jmap -heap 或arthas heapdump 导出一份堆内存快照文件使用mat分析工具进行分析(当然执行导出堆快照时需要注意pod CPU及内存等或健康检查等限制,避免因执行导出堆内存快照导致pod重启)
为验证本次的分析结果可以再拉出一份堆内存快照,使用mat工具增量比对,来佐证自己的判断
当得出结果后,进行优化方案或解决方案设计
当优化方案落地发布后,线上持续观察内存变化,是否还存在类似情况
当收到CPU飙高告警后
通过监控面板(如grafana)观测告警实例CPU 信息是否持续保持高水位
如果持续保持高水位,我们需要进入容器内排查线程信息
如果安装了arthas
可以使用thread -n 命令查看当前繁忙的线程
再使用thread [线程id]命令查询线程运行堆栈信息进行分析
如果没有安装arthas,可以使用JVM自带的命令得到线程堆栈信息
先根据top命令得到占用CPU最高的进程id
再使用top -Hp [pid] 命令得到占用CPU高的线程id
因得出的线程id 为 10进制,故需要转成16进制,将线程id转为16进制
根据 jstack -l [pid] | grep -A 10 [16进制线程id] 得到运行堆栈信息
根据得到的堆栈信息进行分析
当调优方案发布后,线上观测
有伙伴反馈在预发环境有一个服务发生了内存溢出的现象
查看日志看溢出位置
根据溢出位置可以看出是在 02:03分左右因db list查询语句导致内存溢出。
那么根据内存溢出排查思路来看
先判断溢出位置处代码是否有问题
i. 因溢出时间为02:03且为预发环境,可以大致推断为非普通请求流量,应该为定时任务执行。
ii. 结合grafana监控页面判断执行开始时间
从图中可以看出在02:01分开始大量使用内存,故优先筛选了执行时间从02:01开始的定时任务。
iii. 根据这个时间确定了一个定时任务,查看代码确实有很多findAll的list查询且这些查询为一次性查询。且结合jvm heap监控页面可以看出,这个时刻内存使用增长了1G多且触达了阈值。
iv. 看到这里基本上可以得出是因为这种一次性大量查询造成的内存短时间内过量使用引发了内存溢出。
b. 再判断下是否存在内存泄漏的场景
i. 在服务正常使用一段时间后,导出了堆内存快照
ii. 使用mat工具打开后
1. 查看mat猜测的内存泄漏的地方
2. 点解detail
3. 点击arrayList 点击with outgoing reference
6.3 排查结果
结合排查经过可以得出结论为,本次内存溢出的场景原因为定时任务中查询操作不合理,使用一次性查询过量数据到内存导致内存溢出。
看完上面的内容相信大家对如何定位排查应该有一个了解,排查这类问题基本可以参照以下几个步骤
确定问题类型
获取问题现场快照
使用工具分析定位问题所在
确定优化方案,优化解决问题
发布后持续观测问题是否解决
在排查CPU类问题时如果可以有进入容器或服务器的权限建议可以优先使用arthas工具,这样可以比较快速定位问题。如果不能安装arthas工具再使用jps + jstack 指令排查。如果没有相应操作权限可以通过jps + jstack 命令导出线程堆栈信息到本地查看线程执行情况定位问题。
在排查内存相关问题时如果是单体环境建议可以先使用arthas或jmap查询堆和对象实例情况,不行的话再使用jps + jmap指令或arthas heapdump命令转储堆内存快照文件后,使用mat工具进行内存问题分析。如果是分布式环境建议先做服务隔离操作,这样可以保护当时现场,隔离后进行堆内存快照转储后使用mat工具分析。
本文作者
加里克,来自缦图互联网中心后端团队
--------END-------
也许你还想看