JVM(4) JVM调优
2024-11-13 15:41:36 # Language # Java

1. 内存调优

1.1 内存泄漏与溢出

内存泄漏(memory leak): 在 Java 中如果不再使用一个对象,但是该对象依然在GC ROOT的引用链上,这个对象就不会被垃圾回收器回收,这种情况就称之为内存泄漏。内存泄漏绝大多数情况都是由堆内存泄漏引起的。

内存溢出:如果发生持续的内存泄露,就会导致内存溢出,但内存溢出并不只有内存泄漏一种原因

1.2 内存泄露的常见场景

  1. 大型的Java后端应用中,在处理用户的请求之后,没有及时将用户的数据删除

  2. 分布式任务调度系统如Elastic-job、Quartz等进行任务调度时,被调度的Java应用在调度任务结束中出现了内存泄漏,最终导致多次调度之后内存溢出。

1.3 监控Java内存的工具

top

top命令是linux下用来查看系统信息的一个命令,可以实时查看系统的资源,比如执行时的进程、线程和系统参数等信息。

load average:过去1、5、15分钟的系统负载

进程使用的内存为 RES(常驻内存) - SHR(共享内存)

image-20241105102939419

VisualVM

VisualVM是多功能合一的Java故障排除可视化工具,整合了命令行 JDK 工具和轻量级分析功能,实时监控CPU、内存、线程等信息

对大量集群化部署的Java进程需要手动进行管理

image-20241105111207709

image-20241105160723301

Arthas

Arthas 是一款线上监控诊断产品,通过全局视角实时查看应用 load、内存gc、线程的状态信息,并能在不修改应用代码的情况下,对业务问题进行诊断,包括查看方法调用的出入参、异常,监测方法执行耗时,类加载信息等。支持应用的集群管理

使用 arthas tunnel 管理所有需要监控的程序

  1. 在Spring Boot程序中添加arthas的依赖(支持Spring Boot 2),在配置文件中添加tunnel服务端的地址,便于tunnel去监控所有的程序。
  2. 将tunnel服务端程序部署在某台服务器上并启动。
  3. 打开tunnel的服务端页面(ip:port/apps.html),查看所有的进程列表,并选择进程进行arthas的操作
1
2
3
4
5
6
7
8
arthas:
#tunnel地址,目前是部署在同一台服务器,正式环境需要拆分
tunnel-server: ws://localhost:7777/ws
#tunnel显示的应用名称,直接使用应用名
app-name: ${spring.application.name)
#arthas htp访问的端口和远程连接的端口, 不同应用不能重复
http-port: 8888
telnet-port: 9999

image-20241105113058988

Prometheus + Grafana

Prometheus + Grafana 是企业中运维常用的监控方案,其中Prometheus用来采集系统或者应用的相关数据,同时具备告警功能。Grafana 可以将 Prometheus 采集到的数据以可视化的方式进行展示。

支持系统级别和应用级别的监控,比如linux操作系统、Redis、MySQL、Java进程。

支持告警并允许自定义告警指标。

https://juejin.cn/post/7095578954660053006

image-20241105155112653

image-20241105155613522

image-20241105155634147

1.4 堆内存状况对比

image-20241105160038053

1.5 内存泄漏产生原因

代码中的内存泄漏

  1. equals()hashcode(): 不正确的 equals()hashcode() 实现导致内存泄漏

  2. 内部类引用外部类:非静态的内部类(会默认持有外部类对象)和匿名内部类(在非静态方法中被创建会持有调用者对象)的错误使用导致内存泄漏

  3. ThreadLocal使用: 由于线程池中的线程不被回收导致的 ThreadLocal 内存泄漏

  4. Stringintern() 方法:由于JDK6中的字符串常量池位于永久代,intern被大量调用并保存产生的内存泄漏

  5. 通过静态字段保存对象:大量的数据在静态变量中被引用,但是不再使用,成为内存泄漏
  6. 资源没有正常关闭:资源没有调用close方法正常关闭

并发请求问题

用户通过发送请求向Java应用获取数据,正常情况下Java应用将数据返回之后,这部分数据就可以在内存中被释放掉。但是由于用户的并发请求量有可能很大,同时处理数据的时间很长,导致大量的数据存在于内存中,最终超过了内存的上限,导致内存溢出。这类问题的处理思路和内存泄漏类似,首先要定位到对象产生的根源。

1.6 诊断原因

内存快照

当堆内存溢出时,需要在堆内存溢出时将整个堆内存保存下来,生成内存快照(Heap Profile)文件。

生成内存快照的Java虚拟机参数:

  • -XX:+HeapDumpOnOutOfMemoryError: 发生 OutOfMemoryError 错误时,自动生成 hprof 内存快照文件
  • -XX:+HeapDumpBeforeFullGC: 在 Full GC 之前生成内存快照
  • -XX:HeapDumpPath=<path>/xxx.hprof: 指定 hprof 文件的输出路径。

使用 MAT 打开 hprof 文件,并选择内存泄漏检测功能,MAT 会自行根据内存快照中保存的数据分析内存泄漏的根源。

导出运行中系统的内存快照

  • JDK自带的jmap命令:jmap -dump:[live],format=b,file=文件路径和文件名 进程id
  • arthas heapdump命令:heapdump --live 文件路径和文件名

MAT内存泄漏检测原理

支配树

MAT提供了称为支配树(Dominator Tree)的对象图。支配树展示的是对象实例间的支配关系。在对象引用图中,所有指向对象B的路径都经过对象A,则认为对象A支配对象B。

image-20241106161501006

深堆和浅堆

支配树中对象本身占用的空间称之为浅堆(Shallow Heap)

支配树中对象的子树就是所有被该对象支配的内容,这些内容组成了对象的深堆(Retained Heap),也称之为保留集(Retained Set)。深堆的大小表示该对象如果可以被回收,能释放多大的内存空间。

某个对象的深堆包括其自身

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

image-20241106170728645

在线定位问题

  1. 使用 jmap -histo:live {进程ID} > {文件名} 将内存中存活对象以直方图的形式保存到文件中,这个过程会影响用户的时间,但是时间比较短暂。
  2. 分析内存占用最多的对象,一般这些对象就是造成内存泄漏的原因。
  3. 使用arthas的 stack {类名} [方法名] 命令,追踪对象创建的方法被调用的调用路径,找到对象创建的根源。也可以使用btrace工具编写脚本追踪方法执行的过程。

2. GC调优

GC调优指的是对垃圾回收(Garbage collection)进行调优。GC调优的主要目标是避免由垃圾回收引起程序性能下降。

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

  1. 通用 JVM 参数的设置
  2. 特定垃圾回收器的 JVM 参数的设置
  3. 解决由频繁的 FULL GC 引起的程序性能问题

2.1 调优指标

吞吐量(Throughput)

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

  • 业务吞吐量指的在一段时间内,程序需要完成的业务数量。

  • 垃圾回收吞吐量指的是 CPU 用于执行用户代码的时间与 CPU 总执行时间的比值,即吞吐量 = 执行用户代码时间/(执行用户代码时间 + GC时间)

延迟(Latency)

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

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

内存使用量

内存使用量指的是Java应用占用系统内存的最大值,一般通过JVM参数调整,在满足上述两个指标的前提下这个值越小越好。

2.2 常用工具

jstat

jstat 是 JDK 自带的一款监控工具,可以提供各种垃圾回收、类加载、编译信息等不同的数据。

jstat -gc 进程ID 每次统计的间隔(毫秒) 统计次数

image-20241107165654378

  • C 代表 capacity 容量,U 代表 Used 使用量
  • S-幸存者区,E-伊甸园区,0-老年代,M-元空间
  • YGC、YGCT: 年轻代 GC 次数和 GC 耗时(单位:秒)
  • FGC、FGCT: FuLL GC次数和Full GC耗时
  • GCT: GC总耗时

visual tool

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

Prometheus + Grafana

GC日志

通过GC日志,可以更好的看到垃圾回收细节上的数据,同时也可以根据每款垃圾回收器的不同特点更好地发现存在的问题。

使用方法(JDK 8及以下): -XX:+PrintGCDetails -Xloggc:输出目录

使用方法(JDK 9+): -Xlog:gc*:file=输出目录

GC Viewer

GC Viewer是一个将 GC 日志转换成可视化图表的小工具

java -jar gcviewer_1.3.4.jar 日志文件.log

GCeasy

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

2.3 常见的GC模式

正常情况

  • 呈现锯齿状,对象创建之后内存上升,一旦发生垃圾回收之后下降到底部,并且每次下降之后的内存大小接近,存留的对象较少。

缓存对象过多

  • 呈现锯齿状,对象创建之后内存上升,一旦发生垃圾回收之后下降到底部,并且每次下降之后的内存大小接近,处于比较高的位置。

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

内存泄漏

  • 呈现锯齿状,每次垃圾回收之后下降到的内存位置越来越高,最后由于垃圾回收无法释放空间导致对象无法分配产生OutOfMemory的错误。
  • 程序中保存了大量的内存泄漏对象,导致GC之后无法释放,可以使用MAT或者HeapHero等工具进行分析是哪些对象产生了内存泄漏。

持续的FullGC

  • 在某个时间点产生多次Full GC,CPU使用率同时飙高,用户请求基本无法处理。一段时间之后恢复正常。
  • 在该时间范围请求量激增,程序开始生成更多对象,同时垃圾收集无法跟上对象创建速率,导致持续地在进行FULL GC。

元空间不足导致的FullGC

  • 堆内存的大小并不是特别大,但是持续发生FullGC。
  • 元空间大小不足,导致持续FullGC回收元空间的数据。

2.4 基础JVM参数的设置

解决GC问题的手段包括

  • 优化基础JVM参数
  • 减少对象产生
  • 更换垃圾回收器
  • 优化垃圾回收器参数(仅在前三种无法解决时使用)

-Xmx

根据最大并发量估算服务器的配置,然后再根据服务器配置计算最大堆内存的值。

-Xms

建议将-Xms设置的和-Xmx一样大,有以下几点好处:

  • 运行时性能更好,堆的扩容是需要向操作系统申请内存的,这样会导致程序性能短期下降。
  • 可用性问题,如果在扩容时其他程序正在使用大量内存,很容易因为操作系统内存不足分配失败。
  • 启动速度更快,如果初始堆太小,Java 应用程序启动会变得很慢,因为 JVM 被迫频繁执行垃圾收集,直到堆增长到更合理的大小。为了获得最佳启动性能,请将初始堆大小设置为与最大堆大小相同。

-XX:MaxMetaspaceSize

最大元空间大小,默认值比较大,如果出现元空间内存泄漏会让操作系统可用内存不可控,建议根据测试情况设置最大值,一般设置为256m。

-XX:MetaspaceSize

指的是到达这个值之后会触发FULLGC,后续什么时候再触发JVM会自行计算。如果设置为和MaxMetaspaceSize一样大,就不会FULLGC,但是对象也无法
回收。

-Xss

虚拟机栈大小,如果我们不指定栈的大小,JVM 将创建一个具有默认大小的栈。大小取决于操作系统和计算机的体系结构。比如Linux x86 64位: 1MB,如果不需要用到这么大的栈内存,完全可以将此值调小节省内存空间,合理值为256k - 1m之间。

不建议手动设置的参数

由于JVM底层设计极为复杂,一个参数的调整也许让某个接口得益,但同样有可能影响其他更多接口。

  • -Xmn: 年轻代的大小,默认值为整个堆的1/3,可以根据峰值流量计算最大的年轻代大小,尽量让对象只存放在年轻代,不进入老年代。但是实际的场景中,接口的响应时间、创建对象的大小、程序内部还会有一些定时任务等不确定因素都会导致这个值的大小并不能仅凭计算得出,如果设置该值要进行大量的测试。G1垃圾回收器尽量不要设置该值,G1会动态调整年轻代的大小。
  • -XX:SurvivorRatio: 伊甸园区和幸存者区的大小比例,默认值为8。
  • -XX:MaxTenuringThreshold: 最大晋升阈值,年龄大于此值之后,会进入老年代。另外JVM有动态年龄判断机制: 将年龄从小到大的对象占据的空间加起来,如果大于survivor区域的50%,然后把等于或大于该年龄的对象放入到老年代。

其他参数

  • -XX:+DisableExplicitGC: 禁止在代码中使用 System.gc(),System.gc() 可能会引起 FULLGC,在代码中尽量不要使用。使用DisableExplicitGc参数可以禁止使用 System.gc() 方法调用
  • -XX:+HeapDumpOnoutofMemoryError: 发生 OutOfMemoryError 错误时,自动生成hprof内存快照文件
  • -XX:HeapDumpPath=<path>: 指定hprof文件的输出路径
  • 打印GC日志
    • JDK8及之前: -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:文件路径
    • JDK9及之后: -Xlog:gc*:file=文件路径

示例模板:

java -jar

-Xmx1g -Xms1g

-XX:MaxMetaspaceSize=512m

-Xss256k

-XX:+DisableExplicitGC

-XX:+HeapDumpOnoutofMemoryError -XX:HeapDumpPath=/opt/logs/my-service.hprof

-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:文件路径

test.jar

2.5 更换垃圾回收器

JDK8

  • 默认PS + PO

  • ParNew + CMS:-XX:+UseParNewGC -XX:+UseConcMarkSweepGC

  • G1: -XX:+UseG1GC

JDK11 默认G1

优化参数

例如对于CMS的并发模式失败问题:

  • 减少对象的产生以及对象的晋升
  • 增加堆内存大小
  • 优化垃圾回收器的参数,比如-XX:CMSInitiatingOccupancyFraction=值,当老年代大小到达该阈值时,会自动进行CMS垃圾回收,通过控制这个参数提前进行老年代的垃圾回收,减少其大小
    • JDK8中默认这个参数值为-1,根据其他几个参数计算出阈值:
      ((100 - MinHeapFreeRatio) + (double)(CMSTriggerRatio * MinHeapFreeRatio) / 100.0)
    • 该参数设置完是不会生效的,必须开启 -XX:+UseCMSInitiatingOccupancyOnly 参数

3. 性能调优

应用程序在运行过程中经常会出现性能问题,比较常见的性能问题现象是:

  1. 通过top命令查看CPU占用率高,接近100甚至多核CPU下超过100都是有可能的。
  2. 请求单个服务处理时间特别长,多服务使用skywalking等监控系统来判断是哪一个环节性能低下。
  3. 程序启动之后运行正常,但是在运行一段时间之后无法处理任何的请求(内存和GC正常)。

3.1 性能调优的方法

线程转储

线程转储(Thread Dump)提供了对所有运行中的线程当前状态的快照。线程转储可以通过jstack、visualvm等工具获取。

线程转储(Thread Dump)中的几个核心内容

  • 名称: 线程名称,通过给线程设置合适的名称更容易“见名知意”
  • 优先级(prio): 线程的优先级
  • Java ID(tid): JVM中线程的唯一ID
  • 本地 ID(nid): 操作系统分配给线程的唯一ID
  • 状态: 线程的状态,分为:
    • NEW - 新创建的线程,尚未开始执行
    • RUNNABLE - 正在运行或准备执行
    • BLOCKED - 等待获取监视器锁以进入或重新进入同步块/方法
    • WAITING - 等待其他线程执行特定操作,没有时间限制
    • TIMED_WAITING - 等待其他线程在指定时间内执行特定操作
    • TERMINATED - 已完成执行
  • 栈追踪: 显示整个方法的栈帧信息

线程转储的可视化在线分析平台: https://jstack.review/, https://fastthread.io/

案例

案例1: 通过top命令查看CPU占用率高

  • top -c 按cpu占用率排序,得到进程id
  • top -Hp 进程id 查看特定进程的线程级详细信息,得到线程id,从线程转储文件中查找

案例2: 请求单个服务处理时间特别长,需要快速定位到是哪一个方法的代码执行过程中出现了性能问题

  • arthas trace 类名 方法名 展示出整个方法的调用路径以及每一个方法的执行耗时
    • 添加 --skipJDKMethod false 参数可以输出JDK核心包中的方法及耗时
    • 添加 '#cost > 毫秒值' 参数,只会显示耗时超过该毫秒值的调用
    • 添加 -n 数值 参数,最多显示该数值条数的数据
    • 所有监控都结束之后,输入stop结束监控,重置arthas增强的对象
  • arthas watch 类名 方法名 '{params,returnobj}' '#cost>毫秒值' -x 2
    • '{params,returnobj}' 打印参数和返回值
    • -x 打印的结果中如果有嵌套(比如对象里有属性),最多只展开2层。允许设置的最大值为4。
  • arthas profile 生成性能监控的火焰图
    • profiler start 开始监控方法执行性能
    • profiler stop --format html 以HTML方式生成火焰图

案例3:程序启动之后运行正常,但是在运行一段时间之后无法处理任何的请求(线程耗尽问题)

  • 线程耗尽问题一般由于执行时间过长,先检测是否有死锁产生,再打印线程栈检测线程正在执行哪个方法
  • jstack -l 进程id > 文件名,在文件中搜索 deadlock 即可找到死锁位置
  • 开发环境中使用visual vm或者Jconsole工具,都可以检测出死锁。使用线程快照生成工具就可以看到死锁的根源。生产环境的服务一般不会允许使用这两种工具连接。
  • 使用fastthread自动检测线程问题

3.2 基准测试

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

官网地址: https://github.com/openidk/jmh

JMH会首先执行预热过程,确保JIT对代码进行优化之后再进行真正的迭代测试,最后输出测试的结果

案例:对比 Date 和 LocalDateTime 格式化时间的性能

JMH环境搭建:

  • 创建基准测试项目

    1
    2
    3
    4
    5
    6
    7
    mvn archetype:generate \
    -DinteractiveMode=false \
    -DarchetypeGroupId=org.openjdk.jmh \
    -DarchetypeArtifactId=jmh-java-benchmark-archetype \
    -DgroupId=org.sample \
    -DartifactId=test \
    -Dversion=1.0
  • 修改POM文件中的JDK版本号和JMH版本号,JMH最新版本参考github

编写代码

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
//预热次数 时间
@Warmup(iterations = 5, time = 1)
//启动多少个进程
@Fork(value = 1, jvmArgsAppend = {"-Xms1g","-Xmx1g"})
//指定显示结果
@BenchmarkMode(Mode.AverageTime)
//指定显示结果单位
@OutputTimeUnit(TimeUnit.NANOSECONDS)
//变量共享范围
@State(scope.Benchmark)
public class DateBenchmark{

private static string format = "yyyy-MM-dd HH:mm:ss";
private Date date = new Date();
private LocalDateTime localDateTime = LocalDateTime.now();
private static ThreadLocal<simpleDateFormat> simpleDateFormatThreadLocal = new ThreadLocal<>();
private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern(format);

// 初始化方法
@Setup
public void setup() {
SimpleDateFormat simpleDateFormat = new SimpleDateFormat(format);
simpleDateFormatThreadLocal.set(simpleDateFormat);
}

@Benchmark
public void testDate(Blackhole bh){
String str = simpleDateFormatThreadLocal.get().format(date);
bh.consume(str);
}

@Benchmark
public void testLocalDateTime(Blackhole bh){
String str = localDateTime.format(formatter);
bh.consume(str);
}
}

测试

  • mvn clean verify 生成jar包
  • java -jar benchmarks.jar

如果不打包,也可以在main方法中执行(不建议),生成的json文件可以在 https://jmh.morethan.io 分析

1
2
3
4
5
6
7
8
public static void main(string[] args) throws RunnerException {
Options options = new OptionsBuilder()
.include(MyBenchmark.class.getSimpleName())
.forks(1)
.resultFormat(ResultFormatType.JSON)
.build();
new Runner(options).run();
}

死代码问题:编写测试方法时,如果对变量进行操作需要返回,否则代码可能会被编译器优化掉

黑洞:如果有多个变量需要返回,在测试方法中添加参数Blackhole bh, 调用 bh.consume() 即可, 避免了死代码, 不需要返回