jvm 实战

内存调优

内存泄漏和内存溢出

  • 内存泄漏(memory leak)

    • 定义:在程序在运行时,由于一些不再使用的对象无法被垃圾回收机制及时释放,导致已不再需要的内存仍然被占用,最终可能导致系统内存溢出。
    • 内存泄漏大多数情况是由堆内存泄漏引起的。
  • 内存溢出(memory overflow)

    • 定义:程序在运行时请求的内存超过了JVM所能提供的最大内存限制,导致程序无法继续执行,通常会抛出OutOfMemoryError异常。
    • 内存溢出并不只有内存泄漏这一种原因。

解决内存溢出问题

  • 发现问题

使用监控工具对于jvm堆内存进行监控和告警

  • 诊断问题

通过分析工具诊断问题产生原因,定位到源代码

  • 修复问题
  • 测试验证

常用内存监控

使用监控工具对于jvm堆内存进行监控和告警

常用内存监控工具

top命令

top命令是一个在Linux系统上用于查看系统实时性能数据的命令行工具。它可以显示系统的实时运行状态,包括CPU、内存、进程等信息。

进入显示页面后,按 m 键,可以按照内存使用率进行排序。

image-20240105094015543

共享内存通常包括系统库和一些共享的数据,是多个进程共享的内存,因此进程的实际占用内存为 RES(常驻内存) -SHR(共享内存)

top 命令操作简单,但只能看到基础的进程信息,无法应用程序看到每个部分的内存占用情况(堆、方法区等)和 变化趋势图,无法准确定位问题。

visualvm

visualvm 是一个开源的 jvm 监控、管理和性能分析的可视化工具,可以进行故障排查和性能分析,支持插件扩展,功能强大。

visualvm 不仅可以监控分析本地服务,同时可以监控和分析远程服务,通过 JMX (Java Management Extensions) 连接到远程 java 进程。

确保目标远程应用允许JMX连接
1
2
3
4
5
6
java -Djava.rmi.server.hostname={host_ip} \
-Dcom.sun.management.jmxremote \ # 启用JMX远程访问
-Dcom.sun.management.jmxremote.port={port_number} \ # 指定了JMX远程连接的端口(与接口访问接口不同)
-Dcom.sun.management.jmxremote.ssl=false \ # 禁用SSL和身份验证
-Dcom.sun.management.jmxremote.authenticate=false \
-jar xxx.jar
启动visualvm添加远程主机

缺点:对于分布式集群部署的java应用不易管理分析

arthas

Arthas 是一款 线上java应用 性能监控和诊断工具,可以全局实时的查看应用负载,内存清空,垃圾回收,线程状态等信息。

在不修改应用代码的情况下,对业务问题进行诊断,包括查看方法调用的出入参、异常,监测方法执行耗时,类加载信息等,大大提升线上问题排查效率。

同时 Arthas 支持应用的集群管理,可以通过 arthas tunnel 来远程管理/连接多个 Agent。

基本原理:部署一个 tunnel server 将 多个 java应用服务 注册信息到 tunnel server 中,通过 tunnel 提供的网页使用 arthas 访问注册的每一个应用。

步骤:

  • 下载部署 arthas tunnel server

    • 启动jar包
    1
    nohup java -jar -Darthas.enable-detail-pages=true arthas-tunnel-server-3.7.2-fatjar.jar &

    默认情况下,arthas tunnel server 的 web 端口是8080,arthas agent 连接的端口是7777

  • springboot添加 arthas 依赖,配置 tunnel 服务端地址进行注册

    • 添加依赖
    1
    2
    3
    4
    5
    <!-- https://mvnrepository.com/artifact/com.taobao.arthas/arthas-spring-boot-starter -->
    <dependency>
    <groupId>com.taobao.arthas</groupId>
    <artifactId>arthas-spring-boot-starter</artifactId>
    </dependency>
    • 添加配置
    1
    2
    3
    4
    5
    arthas:
    tunnel-server: ws://xxx.xx.xx.xx:7777 /ws # 指定了Arthas Tunnel服务器的地址
    app-name: ${spring.application.name} # 指定了你的Spring Boot应用程序的名称
    http-port: 8xxx # arthas http访问端口号
    telnet-port: 9xxx # arthas 远程连接端口号
  • 打开 tunnel 页面,查看进程列表,并选择进行使用 arthas 进行监控

    tunnel 应用页面是 http://xxx.xx.xx.xx:8080/apps.html

promethues

prometheus和grafana 是企业中目前最常用的监控方案,其中 prometheus 负责监控数据采集和存储,grafana 指定 prometheus 中存储指标信息作为数据源,提供可视化面板和分析工具。

这套方案支持系统级别和应用级别的监控,可以监控 linux 系统,java 应用,redis,mysql等。同时可以整合告警并允许自定义告警指标。

由于本人有文章已经详细介绍了这套监控组建的安装和配置,这里就只简单的说明

Prometheus architecture

流程:

  • java 应用暴露指标并转换为prometheus可识别的格式

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    <dependencies>
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>
    <!-- 集成micrometer,将监控数据存储到prometheus -->
    <dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-registry-prometheus</artifactId>
    </dependency>
    </dependencies>

    修改配置文件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    management:
    endpoint:
    prometheus:
    enabled: true
    metrics:
    export:
    prometheus:
    enabled: true
    tags:
    application: ${spring.application.name}

​ 此时可以访问 http://xxx.xxx.xx.xx:xxx/actuator/prometheus

  • prometheus 定时拉取指标信息

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    scrape_configs:
    # 采集任务名称
    - job_name: 'server-node'
    # 采集时间间隔
    scrape_interval: 5s
    # 采集数据路径
    metrics_path: '/actuator/prometheus'
    # 采集服务的地址
    static_configs:
    - targets: ['xx.xx.xxx.xxx:xxxx']
  • grafana 添加 prometheus 作为数据源并添加面板

查看堆内存信息信息

image-20240105113533972

内存泄漏监控分析

正常情况
  • 在应用程序处理业务时,堆内存的曲线会上下起伏,并且始终维持在一个区间内
    • 业务对象频繁的创建导致内存升高
    • 但内存达到阈值时,导致minor gc触发,内存会下降
  • 手动执行Full GC内存会骤降并且每次Full GC后大小接近
image-20240105131218745
内存泄漏
  • 堆的内存曲线持续升高,触发minor gc后,也不会下降很多
  • 手动执行Full GC后的内存相比于上次Full GC比较大即 对象没有办法被回收
image-20240105131517631

内存泄漏常见场景

代码层面

equals()和hashCode()

在java中,equals() 和 hashCode() 是用于处理对象相等的重要方法,在定义一个新的类时,如果没有重写equals()和hashCode()方法,则会使用父类即Object类提供的实现。

当使用HashMap或者HashSet等集合的场景下,由于这些集合依赖于对象的equals()和hashCode()方法来进行元素的唯一性检查,

当对象没有正确重写这两个方法时,会导致相同数据的对象唯一性检查结果不同,相同数据的对象被保存多个。

jdk8为例,HashSet 在添加新元素时,首先调用 hashcode 方法计算出哈希值,如果不相同,则直接添加元素,如果相同,则调用 equals() 方法进行判断,如果false,添加元素,如果true,则不添加元素

当对象没有重写equals() 和 hashCode()方法时,则按照Object默认来实现,Object的hashCode()算法里面有随机数参与运算,因此导致数据相同的哈希值不一定相同,同时 Object的equals()判断的是对象的引用相等性即对象的地址是否相同。

示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
# Student 
public class Student {

private Long id;

private String name;

/**
* 模拟大对象
*/
private Byte[] bytes = new Byte[1024];

public Long getId() {
return id;
}

public void setId(Long id) {
this.id = id;
}

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}

// @Override
// public boolean equals(Object o) {
// if (this == o) return true;
// if (o == null || getClass() != o.getClass()) return false;
// Student student = (Student) o;
// return Objects.equals(id, student.id) && Objects.equals(name, student.name) && Arrays.equals(bytes, student.bytes);
// }

// @Override
// public int hashCode() {
// int result = Objects.hash(id, name);
// result = 31 * result + Arrays.hashCode(bytes);
// return result;
// }
}

public class Test {

public static long count = 0;

public static Set<Student> set = new HashSet<>();

public static void main(String[] args) throws InterruptedException {
while (true) {
// 每次循环100次 休眠10ms 防止主进程一直工作 visualvm无法监控
if (count++ % 100 == 0) {
Thread.sleep(10);
}
Student student = new Student();
student.setId(1L);
student.setName("test");
// 数据相同的对象添加到 hashset
set.add(student);
}
}
}

上面示例,当没有重写实体类的 hashcode() 和 equals() 方法时,使用 hashset 导致了内存溢出问题:

使用 object hashcode有随机值导致可能不相同,object equals比较对象内存地址,导致不同,因此 HashSet 中存放了 大量数据相同的数据。

image-20240105141858584
解决方案
  • 定义实体类时,始终重写它的equals()和hashCode()方法,重写时注意使用哪一个字段作为标识来区分对象
内部类引用外部类

非静态类的内部类的实例默认持有外部类的引用,当有地方引用了内部类,会导致也会被引用,垃圾回收时无法回收这个外部类。

匿名内部类对象如果在非静态方法中被创建,会持有调用者对象示例,垃圾回收时无法回收调用者。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class Outer {

/**
* 模拟大对象
*/
private Byte[] bytes = new Byte[1024];

class Inner {

}

public static void main(String[] args) throws InterruptedException {
int count = 0;
List<Inner> inners = new ArrayList<>();
while (true) {
// 每次循环100次 休眠10ms 防止主进程一直工作 visualvm无法监控
if (count++ % 100 == 0) {
Thread.sleep(10);
}
inners.add(new Outer().new Inner());
}

}
}
ThreadLocal

ThreadLocal 是 java 中的一个类,用于存放线程本地变量,这些变量在不同的线程中有独立的副本,互不影响。

当 ThreadLocal 对象不在使用时,需要手动调用 remove 方法进行清理,防止发生内存泄漏。

注意,当我们销毁线程时,ThreadLocal 对象也会被回收。但是如果我们使用是线程池(线程不一定回收),ThreadLocal 就不一定会被回收,从而可能导致内存泄漏。如果线程没有被回收,也没有调用 ThreadLocal 的 remove 方法则会导致 ThreadLocal 无法被回收,导致内存泄漏。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class ThreadLocalDemo {

public static ThreadLocal<Object> threadLocal = new ThreadLocal<>();

public static void main(String[] args) throws InterruptedException {
ThreadPoolExecutor threadPoolExecutor =
new ThreadPoolExecutor(1, 1, 0, TimeUnit.SECONDS, new SynchronousQueue<>());
int count = 0;
while (true) {
threadPoolExecutor.execute(() -> {
threadLocal.set(new Byte[1024]);
});
Thread.sleep(10);
}
}
}
image-20240105204455701
解决方案

线程执行完毕后,一定要手动调用ThreadLocal的remove方法清理对象。

string intern方法

如果很多不同的字符串使用intern方法被大量调用并且引用(不被引用的话,当字符串常量池过大时,可能触发垃圾收集 - jdk6 字符串常量池回收 | jdk7 堆回收),字符串常量池会不停变大超过(jdk6 永久代| jdk7 方法区)上限会产生内存溢出问题。

jdk6时,字符串常量池处于堆内存的永久代中, jdk7及之后,字符串常量池位于堆内存中。

image-20240103154047381

1
2
3
4
5
6
7
8
9
10
public class SimpleClass {
public static void main(String[] args) {
List<String> list = new ArrayList<>();
int i = 0;
while (true) {
// list.add 保证强引用关系 避免被 gc 回收
list.add(String.valueOf(i++).intern());
}
}
}
解决方案
  • 谨慎使用 intern() 方法,过度使用可能会对性能和内存占用产生影响
  • jdk6 增大永久代空间大小,根据实际的情况和测试结果进行设置,可以设置 -XX:MaxPermSize=256M(默认为64M)
静态字段

静态变量引用的对象不会被垃圾回收,如果这些对象不再使用,就会导致内存泄露

解决方案
  • 排查掉无效的静态变量并且避免在静态变量里引用大对象
  • 使用单例模式时,如果对象不是经常被使用,可以使用懒加载来避免不使用但是去创建
  • spring的bean对象不要长期存放大对象,部分bean对象可以使用 @Lazy 懒加载(即真正需要使用该 bean 时才进行初始化)
资源没有正确关闭

连接和IO流资源对象会占用内存,如果使用完毕后没有及时关闭,这部分内存可能会导致内存泄露(不一定导致)

解决方案

  • try-catch-finally 在 finally 块中关闭不再使用的资源

  • jdk7 推荐使用 try-with-resources 语法用于自动关闭资源

    1
    2
    3
    4
    5
    try (...//申请资源对象) {

    } catch (SQLException e) {
    // 处理异常
    }

并发层面

正常情况下,用户向java应用程序发送请求来获取数据,java应用将数据加载到内存中并返回至客户端,在此之后,数据将在内存汇被释放掉。

但当用户请求并发量比较大的时候,数据处理的过程时间也比较长,导致大量的数据存在于内存中,最终超过了内存的上限即产生内存溢出。

遇到这种问题,处理思路需要首先定位问题的根源即对象产生的根源。

Jmeter 并发测试工具

apache jmeter 是一款开源测试软件,可以进行并发请求测试。

下载地址:https://github.com/apache/jmeter/releases

  • 在 测试计划 中添加线程组并配置

    image-20240106195456534 image-20240106195913237
  • 在线程组中添加http请求

    image-20240106200137536

    image-20240106200438157

  • 推荐监听器的聚合报告,启动程序观察报告

    image-20240106201034477

诊断

通过分析工具,诊断问题的产生原因,定位出现问题的源代码

当发生内存溢出时,将堆内存溢出时的整个堆内存信息保存下来,生成内存快照(Heap Profile)文件

  • 生成内存快照的jvm参数
1
2
-XX:+HeapDumpOnOutOfMemoryError # 当发生 OutOfMemoryError 错误时生成堆转储(heap dump)文件即hprof内存快照文件
-XX:HeapDumpPath=<path> # 指定hprof文件的路径
  • 使用MAT打开hprof文件并选择内存泄露检测功能,自动根据内存快照中保存的数据进行分析,从而找到内存泄露的根源

示例:

1
2
3
4
5
6
7
8
9
10
11
12
public class SimpleClass {

public static final List list = new ArrayList();

public static void main(String[] args) {
while (true) {
// 1MB
Byte[] bytes = new Byte[1024 *1024 * 1];
list.add(bytes);
}
}
}

jvm 配置如下:

image-20240107004440548

hropf 文件生成

image-20240107005005879

使用MAT打开hropf文件

image-20240107005243237

生成内存泄漏嫌疑报告

image-20240107005329337 image-20240107005731313
MAT内存泄漏检测原理

MAT 使用 支配树(Dominator Tree)用于分析对象之间支配关系。支配树显示了在java 堆中对象对其他对象具有支配权,这对于查找内存泄漏和优化内存使用非常有用。

在对象的引用图中,所有指向对象B的路径都经过对象A,则认为对象A支配对象B

image-20240107010754331

支配树的深堆(retained heap)和浅堆(shallow heap)是两个重要的概念

  • 浅堆 - Shallow Heap:指一个对象本身所占用的内存大小,而不考虑该对象引用的其他对象
    • 用于表示一个对象的直接开销,包括对象头、实例变量等
  • 深堆 - Retained Heap:指一个对象及该对象所支配的对象的总内存大小
    • 用于表示如果该对象被回收,可以释放多大的内存空间

示例

通过参数 -XX:HeapDumpBeforeFullGC 在执行 FullGC 之前生成内存快照,通过这种方式即可在不发生内存溢出的情况下生成堆内存快照

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class SimpleClass {

public static void main(String[] args) {
TestClass testClass1 = new TestClass();
TestClass testClass2 = new TestClass();
TestClass testClass3 = new TestClass();

String s1 = "str1";
String s2 = "str2";
String s3 = "str3";

testClass1.list.add(s1);

testClass2.list.add(s1);
testClass2.list.add(s2);

testClass3.list.add(s3);

s1 = null;
s2 = null;
s3 = null;

System.gc();
}
}

class TestClass {
public List<String> list = new ArrayList<>(10);
}
image-20240108151306858

使用 MAT 查看 支配树

image-20240108152737808

MAT 会根据支配树,从叶子节点向根节点遍历,如果发现深堆的大小超过了整个堆内存的一定比例阈值,则会将其标记为内存泄露的”嫌疑对象”。

运行内存快照导出和MAT分析

如何导出运行中的系统的内存快照 - 注意只需要导出标记为存活对象

  • 使用 JDK自带的 jmap 命令导出

    1
    2
    jmap -dump:live,format=b,file=/xx/xxx.hprof <PID> # live 只保存存活对象
    jmap -dump:format=b,file=/xx/xxx.hprof <PID> # 保存全部对象

    image-20240108154053835

  • 使用 arthas 的 heapdump 命令导出

    1
    heapdump --live /xx/xxx.hprof

使用 MAT 分析 hprof 文件

image-20240108154720875

问题

在实际开发场景中,在开发者电脑的内存范围之内的快照文件都可以直接在开发者电脑使用MAT打开分析,如果遇到内存快照文件过大超过开发者电脑的内存范围,开发者电脑将无法正常打开此类的内存快照,需要下载服务器操作系统对应的MAT,直接在服务器上直接生成内存分析报告。

下载地址:https://eclipse.dev/mat/downloads.php

使用MAT-Linux脚本直接生成内存检测报告

1
./ParseHeapDump.sh /xx/xxx.hprof org.eclipse.mat.api:suspects org.eclipse.mat.api:overview org.eclipse.mat.api:top_components

注意:默认MAT分析时只使用了1G的堆内存,如果快照文件超过1G,需要修改MAT目录下的 MemoryAnalyzer.ini配置文件调整最大堆内存

1
-Xmx1024m

image-20240108191031007

生成的压缩包下载解压是静态文件,可以直接用网页打开

image-20240108191639042

内存泄漏解决方案

代码层面的内存泄露只需要修改代码即可。但是并发引起的内存溢出主要可能由于 jvm参数设置不当 和 业务设计不当,需要开发者调整 jvm 参数 和 优化设计方案。

问题解决思路

  • 设置jvm启动参数,在发生OOM内存溢出时,生成内存快照
  • 使用 MAT 分析内存快照,找到内存溢出的对象
  • 尝试在开发环境中重现问题,分析代码中问题产生的原因
  • 修改代码后测试并验证结果

示例

分页查询接口

当分页接口没有限制最大单次访问条数时,并且分页单个数据字段占用过大的内存,在并发较高的场景下,会加载到内存的对象占用大量内存空间。

解决方案

  • 限制最大单次访问条数
  • 分页接口清除与业务无关的字段
  • 对于服务进行限流保护
导出大文件接口

excel文件导出如果使用Apache POI的XSSFWorkbook,在数据量比较大的场景下,会占用大量的内存。

解决方案

  • 使用Apache POI的SXSSFWorkbook,优化内存开销
  • 使用Hutool提供的BigExcelWriter减少内存开销
  • 使用easyexcel工具分批导出,对于内存进行大量的优化
ThreadLocal拦截器内存泄漏

在很多场景下,会使用拦截器在prehandle方法解析请求头中的数据,并放入到ThreadLocal中方便后续使用,当没有调用remove清理掉时,这些数据将一直保存在tomcat的核心线程中,造成了内存泄漏。

解决方法在HandlerInterceptor拦截器的afterCompletion方法中,必须调用remove方法,将ThreadLocal中的数据清理掉。

异步业务处理问题

在项目中采用异步方案解决部分耗时业务时,

我们首先想到是开启线程池,将任务提交到线程池中,但是这样的方案存在一定的问题,线程池的参数设置不当会导致大量线程的创建或者队列中保存了大量的数据,这些都会导致内存溢出;其次当没有将任务进行持久化时,队列中保存满了后任务走拒绝策略或者服务宕机或者掉电时,会导致任务丢失。

进一步优化后,我们引入阻塞队列LinkedBlockingQueue来保存任务,线程池从队列中一直获取任务执行,阻塞队列数据量过大,持久化业务设计复杂

引入消息队列组建,使用rabbitmq保存任务,拆分生产者和消费者模型,rabbitmq存储任务数据避免jvm内存溢出,同时rabbitmq实现了持久化机制,防止任务丢失。

诊断问题的两种方案对比

生成内存快照并分析

优点:通过完整的内存快照可以准确的判断问题产生的原因

缺点:内存较大时,生成内存快照较慢,生成内存的过程会导致进行阻塞即程序无法对外提供服务

​ MAT分析内存快照时,需要提供比内存快照大1.5-2倍大小的内存空间

在线定位问题

优点:无需生成内存快照,整个过程对于用户的影响比较小

缺点:无法查看详细的内存信息

​ 需要通过 arthas 和 btrace 等工具调测,发生问题产生的原因,开发者需要具备一定的经验

在线定位问题步骤
  • Arthas

    • 使用 jmap -histo:live <PID> > /xx/xxx.txt 命令将内存中存活的对象以直方图的形式保存到文件中,在此过程中会阻塞应用程序,但是相对于生成内存快照,停顿时间较短
    • 分析直方图文件中内存占用较多的对象,查找内存泄漏的嫌疑对象
    • 使用 arthas 的 stack 命令,追踪对象方法被调用的调用路径,找到对象创建即内存泄漏的根源
  • BTrace

    BTrace 是 一个 java 平台上执行的追踪工具,可以有效的用于线上运行系统的方法追踪,具有侵入性小,对性能影响微乎其微的特点。在项目中可以使用 BTrace 工具,打印出方法被调用栈信息

    • 下载 BTrace 工具,下载地址:https://github.com/btraceio/btrace/releases/

    • 编写一个 BTrace 脚本

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      import org.openjdk.btrace.core.annotations.BTrace;
      import org.openjdk.btrace.core.annotations.OnMethod;

      @BTrace
      public class Tracing {
      @OnMethod(
      clazz = "com.example.entity.DemoEntity",
      method = "/.*/"
      )
      public static void traceExecute() {
      jstack();
      }
      }
    • 将 Btrace 工具和脚本上传到服务器中,在服务器上运行 btrace <PID> 脚本文件名

      • 配置环境变量

        1
        vim /etc/profile
        1
        2
        3
        BTRACE_HOME=/xxx/xxx/btrace/bin
        PATH=$PATH:$BTRACE_HOME/bin
        export PATH BTRACE_HOME
      • 执行脚本

        1
        btrace <PID> Tracing.java
    • 观察执行结果

GC调优

GC调优 指的是 对于jvm垃圾回收(Garbage Collection)进行调优。

GC调优的目的是 避免由垃圾回收引起的程序性能下降。

GC 调优的核心分为三部分:

  • 通用 jvm 参数设置
  • 选择合适的垃圾回收器并对它进行参数设置
  • 解决频繁Full GC导致的程序性能下降的问题

注意 GC调优没有唯一的答案,如何调优与硬件,程序本身,使用情况等诸多因素相关,所以重要的是调优的工具和方法。

GC 调优核心指标

如何判断GC指标是否需要调优,需要从三个方面考虑

  • 吞吐量 - ThroughPut:

    吞吐量分为业务吞吐量和垃圾回收吞吐量

    • 业务吞吐量指的是在一段时间内,程序需要完成的业务数量
    • 垃圾回收的吞吐量指的是 CPU用于执行用户业务时间与CPU总执行时间(执行用户业务时间 + GC时间)的比值

    保证高吞吐量的常规手段包括:

    • 优化业务执行性能,减少单次业务的执行时间
    • 优化垃圾回收吞吐量
  • 延迟 - Latency:

    延迟指的是从用户发起请求到用户收到响应其中经历的时间。

    延迟 = GC 延迟 + 业务执行时间

  • 内存使用量

    内存使用量指的是java应用占用系统内存的最大值,可以通过jvm参数调整,在满足上述两个指标的前提下,该值越小越好

GC 调优方法

GC 调优步骤

发现问题:通过监控工具提前发现GC时间过长,频率过高的现象

诊断问题:通过分析工具来诊断问题产生的原因

修复问题:调整jvm参数或者修复源代码中存在的问题

测试问题:测试环境部署验证问题是否解决

发现问题
jstat

jstat 是 jdk自带的一个监控和性能分析工具,用于收集和显示与jvm相关的各种统计信息。它可以提供有关堆内存、垃圾回收、类加载、线程等方面的实时数据,帮助开发人员和系统管理员进行性能调优和故障排除

查看垃圾回收统计信息

1
2
3
4
jstat -gc <PID> <interval> <count> 
# <PID> java进程号
# <interval> 采样间隔(以毫秒为单位)
# <count> 采样次数

image-20240109085618929

  • C - capacity容量 U - used使用量
  • s - 幸存者区 E - 伊甸园区 o - 老年代区 M - 元空间
  • YGC - young gc 次数 YGCT - young gc总耗时(单位:秒)
  • FGC - full gc 次数 FGCT - full gc总耗时(单位:秒)
  • GCT - gc总耗时

以上重点关注 full gc的次数和耗时 - fgc 和 fgct

优点:操作简单,jdk 自带工具

缺点:无法精准定位gc产生时间和问题,只能用于判断gc是否存在问题

visualvm

visualvm 提供了一款 visual tool 的插件,实时监控java进程的 堆内存结构、堆内存变化趋势以及垃圾回收时间的变化趋势,同时可以监控对象晋升的直方图。

安装插件

image-20240109090951567

查看插件

image-20240109091407604

优点:只适合开发环境使用,可以直观看到堆内存和GC的变化趋势

缺点:visualvm需要收集信息,对于程序运行有一定的影响;生产环境开发者一般没有权限进行操作

prometheus和grafana

prometheus和grafana 是企业中目前最常用的监控方案,其中 prometheus 负责监控数据采集和存储,grafana 指定 prometheus 中存储指标信息作为数据源,提供可视化面板和分析工具。

image-20240109093943676

这种监控工具的内容相对于比较简单,可以发现问题,但是无法定位问题

GC日志

通过GC日志,可以将看到垃圾回收细节上数据,从而更好的发现问题。

使用方法:

  • JDK8,-XX:+PrintGCDetails -Xloggc:/xx/xxx.log
  • JDK9,-Xlog:gc*:file=/xx/xxx.log
GC Viewer

GCViewer 是一个将GC日志转换为可视化图表的小工具,地址:https://github.com/chewiebug/GCViewer/releases

使用方法: java -jar gcviewer-x.xx.jar

image-20240109104423379
GCeasy

GCeasy 是业界首款使用AI机器学习技术在线进行GC分析和诊断工具,定位内存泄漏,GC延迟高的问题,提供jvm参数优化建议,支持在线可视化工具图表战术。

官方网站:https://gceasy.io/

image-20240109110154337
分析模式

根据监控软件上的堆内存的趋势图,判断内存是否存在问题或者垃圾回收是否存在问题。

正常情况

特点:内存趋势图呈现锯齿形,对象创建后内存上升,垃圾回收后内存下降至”底部”,并且每次下降后的内存接近,存留的对象较少。

image-20240109112501363
缓存对象过多

特点:内存趋势图呈现锯齿形,对象创建后内存上升,垃圾回收后内存下降至”底部”,并且每次下降后的内存接近,但是处于比较高的位置。

产生原因:运行程序中保存了大量的缓存对象,导致gc之后无法释放,可以使用MAT或者HeapHero等工具分析内存占用的原因

image-20240109112455515
内存泄漏

特点:内存趋势图呈现锯齿形,对象创建后内存上升,垃圾回收后下降的位置越来越高,最后由于垃圾回收无法回收对象释放空间导致新创建的对象无法分配,导致内存溢出问题

问题产生原因:程序中保存了大量的内存泄漏对象,导致gc之后无法释放

image-20240109112901587
持续FullGC

特点:在某个时间段发生了多次FullGC,CPU使用率同时飙高,用户请求无法处理,吞吐量大大下降,但是过了一段时间就恢复正常,

问题产生原因:运行应用在时间段内请求量飙高,导致不量对象呗创建,垃圾收集器的回收效率无法跟上对象的创建速率,导致持续的进行Full GC。

image-20240109112952891
元空间不足

特点:堆内存的大小不是特别大,但是持续发生full gc

问题产生原因:元空间大小不足,导致持续发生full gc来回收元空间数据

image-20240109113924694
解决问题

解决GC问题的常用手段如下:

  • 优化基础jvm参数
  • 减少对象产生
  • 更换垃圾回收器
  • 优化垃圾回收器参数

其中前三种是比较推荐的手段,第四种只会在前三种都没有办法使用时选用

优化基础jvm参数

不同jdk的参数可以在官网文档中查找,地址:https://docs.oracle.com/en/java/javase/index.html

  • -Xmx和-Xms

    Xmx参数设置的是最大堆内存,Xms参数设置的初始堆内存

    计算最大堆内存时,需要将元空间,操作系统和其他软件服务占用的内存排除

    最合理的设置方式是根据最大的并发量估算出服务器的配置,然后再根据服务器的配置计算最大堆内存的值

    image-20240109131330508

    注意:建议将-Xms和-Xmx设置成一样大

    • 运行时性能更好,因为堆的扩容和缩容需要向操作系统申请内存,这样会导致程序性能短期下降
    • 可用性问题,如果扩容时,服务器中的其他程序使用大量的内存,很容易导致申请操作系统内存分配失败,导致内存溢出
    • 启动速度更快,如果初始堆大小,java 应用程序启动会变慢,因为 jvm 被迫频繁执行垃圾收集,直到堆增长到更合理的大小。为了获得最佳启动性能,应该将初始堆内存和最大堆内存设置相同。
  • -XX:MaxMetaspaceSize 和 -XX:MetaspaceSize

    -XX:MaxMetaspaceSize= 参数指的是最大元空间,默认值为-1即可以一直向操作系统空间申请内存,如果元空间发生了内存泄漏,会一直向操作系统申请内存,导致操作系统内存不可控,建议按照部署实际情况设置最大值,一般可以设置为 256m

    -XX:MetaspaceSize= 参数值的是 元空间使用量第一次达到该阈值时会触发FullGC,后续该阈值会由jvm自动计算获得,如果设置和MaxMetaspaceSize一样的大小,则元空间的对象将一直不会被回收,直到达到最大元空间,建议不用设置该值。

  • -Xss

    —Xss 参数指的是虚拟机栈大小,jvm将创建一个具有默认大小的栈,该默认大小取决于操作系统和计算机体系结构,例如 linux x86 64位默认栈的大小为1MB,正常情况下,不需要用这么大的栈内存,完全可以将此值调小来节省内存空间,合理值为 256k - 1m 之间。建议设置 -Xss256k

  • 不建议手动设置的参数

    • -Xmn 年轻代的大小,默认值为整个堆的1/3

      (很多文章表明可以根据峰值流量计算最大的年轻代大小,尽量让对象只存放在年轻代,不进入老年代中。但是在实际的场景中,接口的响应时间,创建对象的大小,程序内部的其他任务等不确定的因素都会导致这个值的大小不准确,此外,g1垃圾回收器会动态的调整年轻代的大小)

    • —XX:SurvivorRatio 伊甸园区和幸存者区的大小比例,默认值为8

    • —XX:MaxTenuringThreshold 最大晋升阈值,当对象年龄大于此值,会从年轻代进入老年代。

  • 其他参数

    • -XX:DisableExplicitGC:使代码中调用的system.gc()方法无效

    • -XX:+HeapDumpOnOutOfMemeryError:发生内存溢出OutOfMemeryError时,自动生成内存快照hprof文件

      -XX:HeapDumpPath=:指定hprof文件的输出路径

    • 打印GC日志:

      1
      2
      -XX:+PrintGCDetails -Xloggc:/xx/xxx.log #  JDK8及之前
      -Xlog:gc*:file=/xx/xxx.log # JDK9及之后

jvm参数模版

堆内存大小和栈内存大小需要根据实际情况灵活调整

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# jdk8及之前
-Xms<size>
-Xmx<size>
-Xss256k
-XX:MaxMetaspaceSize=256m
-XX:+DisableExplicitGC
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/xx/xxx.hprof
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:/xx/xxx.log
# jdk9及之后
-Xms<size>
-Xmx<size>
-Xss256k
-XX:MaxMetaspaceSize=256m
-XX:+DisableExplicitGC
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/xx/xxx.hprof
-Xlog:gc*:file=/xx/xxx.log
减少对象产生

使用合理的方式,优化代码,减少对象的产生频率和大对象的产生

更换垃圾回收器

根据具体的业务场景,选择适合的垃圾回收器

image-20240109155502560

jdk8之前版本

  • 默认采用 ParallelScavenge 和 ParallellOld 的组合,在高并发的场景下,响应时间较长,但是整体吞吐量好,建议在执行任务和数据处理的应用上使用比较好
  • 采用 ParNew 和 CMS 的组合,在高并发的场景下,整体延迟好于默认组合,但是虽然延迟低,但是GC的次数比较多。

jdk9 建议使用 g1 垃圾收集器

优化垃圾收集器参数

优化垃圾回收器的参数,在一定程度上提升GC效率

(jvm gc参数参考地址:https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html)

注意:gc 优化在一般的场景下是无需考虑的

示例 :CMS 并发模式失败

当使用CMS回收老年代即使用并发模式时,由于CMS的垃圾清理线程用户线程是并行的,如果在并发清理的过程中,老年代的空间不足以容纳新放入老年代的对象,会产生并发模式失败现象,此现象会导致jvm虚拟机使用serialold(即发生退化)单线程进行fullgc对老年代进行回收,出现长时间的停顿。

解决方案:

  • 减少对象和产生以及对象的晋升

  • 增加堆内存大小

  • 优化垃圾回收器的参数,-XX:CMSInitiatingOccupancyFraction=值当老年代大小到达该阈值时,会自动进行CMS垃圾回收,通过这个参数让jvm提前进行老年代的垃圾回收,减少其大小,防止出现发生退化卡顿现象。

    jdk8中默认这个参数值为 -1,它是根据其他几个参数计算出阈值:

    1
    ((100 - MinHeapFreeRatio) + (double)(CMSTriggerRatio * MinHeapFreeRatio) / 100.0)

    注意上述参数需要设置开启 -XX:+UseCMSInitiatingOccupancyOnly 参数才可以生效

实战案例

  • 通过在线工具gceasy分析GC日志,初步判断是否存在gc问题或者内存问题
  • 本地jmeter压测重现并查看吞吐量和响应时间,再次分析GC日志,通过visualvm连接本地服务查看内存情况
  • 通过jmap或者arthas将堆内存快照存储,可以通过在线工具heaphero或者mat分析内存问题
  • 修复问题测试,并发布到测试环境进行测试

注意事项

  • 在并发压力比较大的服务中,尽量不要存放大量的缓存或者定时任务,会影响服务的内存使用
  • 内存分析时,可以通过导出线程栈的方式来查看线程的运行情况,辅助定位到内存问题

性能调优

应用程序在运行过程中常见的性能问题有:

  • cpu占用率高 - 通过 top 命令查看
  • 单个请求服务处理时间过长 - 使用 skywalking 监控系统链路
  • 在内存和cpu正常的情况下,应用程序无法处理任何请求即出现了”假死” - 线程耗尽

发现问题

  • 线程转储文件 - Thread Dump

    线程存储提供了所有运行中的线程当前状态的快照。可以通过 jstack 和 visualvm 等工具获取,这份快照包含了 线程名、 线程ID、线程优先级、线程状态、线程栈信息等等内容。通过线程存储文件可以定位解决 CPU占用率高 、死锁等问题。

    1
    jstack <PID> > /xx/xxx.tdump

    本地测试环境可以使用 visualvm 连接后,点击 Threads 的 Thread Dump 生成线程转储文件

    image-20240115084521764

    线程转储文件的几个核心内容如下:

    • 线程名称
    • 优先级 - prio:线程的优先级(优先级越高,更容易获得cpu的时间片,由操作系统的调度算法决定)
    • java id - tid:jvm中线程的唯一id
    • 本地ID - nid:操作系统中线程的唯一id
    • 状态 - state:线程的状态
      • NEW - 新创建的线程,尚未开始执行
      • RUNNABLE - 正在运行或者准备执行
      • BLOCKED - 阻塞等待锁
      • WAITING - 无限期等待
      • TIME_WAITING - 限期等待
      • TERMINATED - 已完成执行
    • 栈追踪:显示栈帧信息

    image-20240115090043861

    线程转储文件可视化在线分析平台:jstackfastthread

诊断问题

cpu占用率高

1
2
3
4
5
top -c # 通过 top -c 找到cpu占用率高的进程和显示完整的命令行信息 获取<PID>进程ID
top -H -p <PID> # -H 以 线程 的方式显示进程
| ps -fT -p <PID>
printf '%x/n' <tid> # 将10进制表示的线程号转换为16进制的表示的线程号 - 操作系统的线程ID nid
jstack <PID> > /xx/xxx.tdump # 在线程存储文件中,找到nid相同的线程,查看线程基本信息和线程栈帧信息

服务接口响应时间长

服务接口响应过长说明该接口方法出现了性能问题,这些方法往往调用嵌套比较多,需要具体定位到哪一个调用方法出现了问题。

可以通过 arthas的trace命令 定位具体的方法

arthas trace命令:方法内部调用路径,并输出方法路径上的每个节点上耗时

使用方法:trace 类权限定名 方法名

参数:

  • –skipJDKMethod false

    输出jdk核心包中的方法以及耗时 默认true 即无需输出jdk核心包中的方法以及耗时

  • ‘#cost >

    只显示耗时超过该毫秒值的调用

  • -n 数值

    最多显示该数值条数的数据

监控结束后,使用stop结束监控:arthas 底层使用动态代理的方法,增强这些对象,从而获取接口的调用时间,这会增加方法调用的开销,降低性能。

1
trace xx.xx.xxx.SimpleClass methodName --skipJDKMethod false '#cost > <millisecond>' -n 1

可以通过 arthas的watch命令 观测 调用方法的参数和返回值

当定位到具体的方法后,可以使用 watch 查看函数执行数据观测即查看方法的参数和返回值。方便在本地测试时,模拟参数进行重现。

使用方法:watch 类权限定名 方法名 ‘{params, returnObj}’ ‘cost>毫秒值’ -x 2

参数:

  • ‘{params, returnObj}’ :打印出参数和返回值
  • -x 2:方法嵌套最多展开两层,允许设置的最大值为4

监控结束后,使用stop结束监控

1
watch xx.xx.xxx.SimpleClass methodName '{params, returnObj}' -x 2

此外 arthas的 profiler命令 提供了性能火焰图的功能,可以直观的显示所有方法的执行时间

arthas的 profiler命令 提供了性能火焰图的功能

使用方法:profiler start # 开始监控方法执行性能

​ profiler stop –format html # 结束监控方法并以html方式生成性能火焰图

火焰图分布:绿色 - java中的栈 红色 - jdk底层的方法调用

线程耗尽

在内存和cpu正常的情况下,应用程序无法处理任何请求即出现了”假死”,程序进行重启后,依然出现了出现的相同的情况。这是典型的线程被耗尽的问题。

线程被耗尽的问题,一般由执行时间过长导致的:

  • 死锁情况:两个及以上的线程争夺资源造成了互相等待的情况,无法自动解除的死锁的线程将一直阻塞下去
    • 解决方式:检测是否有死锁的情况发生
  • 慢方法情况:大量执行某一个慢方法
    • 生成线程转储文件查看是否调用某慢方法
线程死锁问题定位
  • 使用 jstack -l <pid> 打印出线程的信息(-l : 包含锁的信息)

    1
    jstack -l <PID> > /xx/xxx.tdump

    转储到文件后,搜索文件中的 deadlock 找到死锁的位置

    image-20240115134009582

  • 开发环境可以使用visualvm或者jconsole工具检测死锁

    image-20240115134211547

  • 可是使用 在线分析网站 fastthread 中的 dead lock 查看死锁情况

基准测试框架JMH

如何判断一个方法的耗时时间是多少?

接口的响应时间一般可以定义切面统计方法的执行时间,调用接口查看接口的响应时间,或者使用arthas的trace命令查看方法执行时间。但是这些很多因素影响导致结果不准确,这些因素包括对象的懒加载机制导致第一次请求时间不准确,虚拟机的JIT即时编译器优化导致方法性能得到优化等。

JMH简介

OpenJDK 提供了一种叫 JMH(Java Microbenchmark Harness) 的工具,可以准确对于 java 代码进行行基准测试 ,量化方法的执行性能。

JMH 首先会对方法进行预热,确保JIT对于代码进行了优化之后,才开始真正的迭代测试,并

量化方法的执行性能,输出最后的结果。

JMH的使用

引入依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
<version>1.36</version>
<scope>provided</scope>
</dependency>

<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>1.36</version>
<scope>provided</scope>
</dependency>

创建基准测试类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import org.openjdk.jmh.annotations.*;

@Warmup(iterations = 5, time = 1) // 预热:iterations次数 time时间
@Fork(value = 1, jvmArgsAppedn = {"-Xms1g", "-Xmx1g") // 启动进程数量配置
@BenchmarkMode(Mode.AverageTime) // 显示结果:平均时间
@OutputTimeUnit(TimeUnit.MILLISECONDS) // 显示结果单位
@State(Scope.Benchmark) // 变量共享范围
public class MyBenchmark {

@Benchmark // 测试方法
public void myTestMethod() {
// Your code to be benchmarked
}

public static void main(String[] args) throws Exception {
Options opts = new OptionsBuilder()
.include(MyBenchmark.class.getSimpleName())
.forks(1)
.resultFormat(ResultFormatType.JSON) // 结果生成json格式文件
.build();
new Runner(opts).run();
}
}

测试结果可以通过 JMH Visualizer 在线分析

image-20240115153857103