前期准备
大家开始前一定要对VisualVM、Jmeter这两款工具有所了解!
1. 下载安装VisualVM,以便后续调优分析。JDK1.8及之前所在目录的bin
目录下有自带的VisualVM,JDK1.8以后需要自行手动安装下载。
下载地址:https://visualvm.github.io/download.html
IDEA插件配置:在Plugins
里搜索visualVM Launcher
即可。(也可以不用配置,直接下载客户端软件)后续只要在配置下载安装好的VisualVM程序地址即可,这样就能直接在IDEA中根据指定的类启动VisualVM了,不需要在独立的VisualVM里找指定路径装配。
2. 代码使用JDK1.8,VM参数在示例代码中自带。
3. 需要大家对Jmeter压测工具有所了解
下载链接:https://jmeter.apache.org/download_jmeter.cgi
调优步骤
一般的调优步骤大体上分为以下几个部分:
- 熟悉业务场景
- (发现问题)性能监控:
GC 频繁、CPU load过高、OOM、内存泄漏、死锁、程序响应时间较长等。 - (排查问题)性能分析:
打印GC日志,通过GCviewer或者http://gceasy.io来分析日志信息;运用命令行工具,jstack,jmap,jinfo等;dump出堆文件,使用内存分析工具分析文件使用阿里Arthas,或jconsole,VisuaIVM来实时查看JVM状态;jstack查看堆栈信息等等。 - (解决问题)性能调优:
适当增加内存,根据业务背景选择垃圾回收器;优化代码,控制内存使用;增加机器,分散节点压力合理设置线程池线程数量;使用中间件提高程序效率,比如缓存,消息队列等等。
优化案例一:逃逸分析之栈上分配、标量替换、锁清除
堆,是分配对象的唯一选择吗?
在《深入理解Java虚拟机》中关于Java堆内存有这样一段描述:随着JIT编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。
-
只有对频繁执行的代码(热点代码),JIT才能保证有正面的收益。
-
没有循环,对只执行一次的代码做JIT编译再执行,可以说是得不偿失。
-
对只执行少量次数的代码,JIT编译带来的执行速度的提升也未必能抵消掉最初编译带来的开销。
在Java虚拟机中,对象是在Java堆中分配内存的,这是一个普遍的常识。但是,有一种特殊情况,那就是如果经过逃逸分析(Escape Analysis)后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内存,也无须进行垃圾回收了。这也是最常见的堆外存储技术。
代码优化:栈上分配
使用逃逸分析,编译器可以对代码做如下优化:
- 栈上分配。将堆分配转化为栈分配。如果经过逃逸分析后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内存,也无须进行垃圾回收了。可以减少垃圾回收时间和次数。
- JIT编译器在编译期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法的话,就可能被优化成栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。这样就无须进行垃圾回收了。
示例代码:
刚开始我们先默认关闭逃逸分析(-XX:-DoEscapeAnalysis),执行代码查看结果:
/**
* 栈上分配测试
* -Xmx1G -Xms1G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails
* 只要开启了逃逸分析,就会判断方法中的变量是否发生了逃逸。如果没有发生了逃逸,则会使用栈上分配
*
* @author shkstart shkstart@126.com
*/
public class StackAllocation {
public static void main(String[] args) {
long start = System.currentTimeMillis();
for (int i = 0; i < 10000000; i++) {
alloc();
}
// 查看执行时间
long end = System.currentTimeMillis();
System.out.println("花费的时间为: " + (end - start) + " ms");
// 为了方便查看堆内存中对象个数,线程sleep
try {
Thread.sleep(1000000);
} catch (InterruptedException e1) {
e1.printStackTrace();
}
}
private static void alloc() {
User user = new User();//是否发生逃逸? 没有!
}
static class User {
}
}
代码分析:
执行并启动VisualVM查看分析:
这里可以看到我们确实已经创建了1000万个线程对象,内存占用也不小。我们顺势打开逃逸分析(-XX:+DoEscapeAnalysis或者直接删掉这个命令也是可以的,JDK6以后系统默认是打开的)
查看栈上分配:
可以看到速度已经明显得到提升了,个位级别的耗时,很夸张。
代码优化:同步省略(消除)
同步省略。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
- 线程同步的代价是相当高的,同步的后果是降低并发性和性能。
- 在动态编译同步块的时候,JIT编译器可以借助逃逸分析来判断同步块所使用的
锁对象
是否只能够被一个线程访问
而没有被发布到其他线程。如果没有,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫同步省略,也叫锁消除。
示例代码:
代码中对hollis
这个对象进行加锁,但是hollis
对象的生命周期只在f()
方法中,并不会被其他线程所访问到,所以在JIT编译阶段就会被优化掉。
/**
* 同步省略说明
* @author shkstart shkstart@126.com
*/
public class SynchronizedTest {
public void f() {
/*
* 问题:字节码文件中会去掉hollis吗?
* */
Object hollis = new Object();
synchronized(hollis) {
System.out.println(hollis);
}
/*
* 优化后:
* Object hollis = new Object();
* System.out.println(hollis);
* */
}
}
代码优化:标量替换
标量(Scalar)
是指一个无法再分解成更小的数据的数据。Java中的原始数据类型就是标量。
相对的,那些还可以分解的数据叫做聚合量(Aggregate)
,Java中的对象就是聚合量,因为他可以分解成其他聚合量和标量。
在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。这个过程就是标量替换。
示例代码:
参数-XX:+EliminateAllocations 开启了标量替换(默认打开),允许将对象打散分配在栈上
package com.atguigu.escape;
/**
* 标量替换测试
* -Xmx50m -Xms50m -XX:+DoEscapeAnalysis -XX:+PrintGCDetails -XX:-EliminateAllocations
* @author shkstart shkstart@126.com
*/
public class ScalarReplace {
public static class User {
public int id;
public String name;
}
public static void alloc() {
User u = new User();//未发生逃逸
u.id = 5;
u.name = "www.atguigu.com";
}
public static void main(String[] args) {
long start = System.currentTimeMillis();
for (int i = 0; i < 10000000; i++) {
alloc();
}
long end = System.currentTimeMillis();
System.out.println("花费的时间为: " + (end - start) + " ms");
}
}
代码分析:
未打开标量替换之后:(-XX:-EliminateAllocations)
打开标量替换之后:(-XX:+EliminateAllocations同理去掉这个参数也是可以的,系统默认会打开的)
通过对比,我们可以很轻易地看到:
- 在没有开启标量替换的时候,系统运行程序花费时间长,而且新生代(PSYoungGen)总的内存空间使用很大,主要体现在eden区对象占有率达到了恐怖的77%。
- 当我们开启标量替换的时候,系统运行程序花费时间短,这时我们所创建的对象都遵循栈上分配,导致新生代的eden区对象使用率减小,只有34%,新生代中对象内存使用也变小了。(新生代老年代都是堆空间的概念)
结论:Java中的逃逸分析,其实优化的点就在于对栈上分配的对象进行标量替换。
逃逸分析结论
逃逸分析小结:逃逸分析并不成熟
-
关于逃逸分析的论文在1999年就已经发表了,但直到JDK 1.6才有实现,而且这项技术到如今也并不是十分成熟的。
-
其根本原因就是无法保证非逃逸分析的性能消耗一定能高于他的消耗。
虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析,这其实也是一个相对耗时的过程。
-
一个极端的例子,就是经过逃逸分析之后,发现没有一个对象是不逃逸的。那这个逃逸分析的过程就白白浪费掉了。
-
虽然这项技术并不十分成熟,但是它也是即时编译器优化技术中一个十分重要的手段。
优化案例二:合理配置堆内存
推荐配置
我们说增加内存可以提高系统的性能而且效果显著,那么随之带来的一个问题就是,我们增加多少内存比较合适?
- 如果内存过大,那么如果产生Full GC的时候,GC时间会相对比较长。
- 如果内存较小,那么就会频繁的触发GC。
在这种情况下,我们该如何合理的适配堆内存大小呢?
依据的原则是根据Java Performance里面的推荐公式来进行设置。
-
Java整个堆大小设置,Xmx和 Xms设置为老年代存活对象的3-4倍,即FullGC之后的老年代内存占用的3-4倍。
-
方法区(永久代 PermSize和MaxPermSize 或 元空间 MetaspaceSize 和MaxMetaspaceSize)设置为老年代存活对象的1.2-1.5倍。
-
年轻代Xmn的设置为老年代存活对象的1-1.5倍。
-
老年代的内存大小设置为老年代存活对象的2-3倍。
但是,上面的说法也不是绝对的,也就是说这给的是一个参考值,根据多种调优之后得出的一个结论大家可以根据这个值来设置一下我们的初始化内存,在保证程序正常运行的情况下,我们还要去查看GC的回收率,GC停顿耗时,内存里的实际数据来判断,Full GC是基本上不能有的,如果有就要做内存Dump分析,然后再去做一个合理的内存分配。
但这里问题就来了,如何计算老年代存活对象大小呢?上述经验参考都是以老年代存活对象为基准的。
这里介绍两种方式:
- 第一种最稳妥的方式就是:JVM参数中添加GC日志,GC日志中会记录每次FuIGC之后各代的内存大小,观察老年代GC之后的空间大小。可观察一段时间内(比如2天)的FuIIGC之后的内存情况,根据多次的FuGC之后的老年代的空间大小数据来预估FuIGC之后老年代的存活对象大小
(可根据多次FuGC之后的内存大小取平均值)
。 - 方式1的方式比较可行,但需要更改JVM参数,并分析日志。同时,在使用CMS回收器的时候,有可能不能触发FuIIGC,所以日志中并没有记录FuIGC的日志。在分析的时候就比较难处理。所以有时候需要强制触发一次FuIIGC,来观察Full GC之后的老年代存活对象大小。
会影响线上服务,慎用!
jmap -histo:live <pid>
打印每个class的实例数目,内存占用,类全名信息.live子参数加上后,只统计活的对象数量.此时会触发FuIGC- 在性能测试环境,可以通过]ava监控工具来触发FulIGC,比如使用
VisualVM
和JConsoleVisualVM
集成了JConsole,VisualVM或者JConsole上面有一个触发GC的按钮。
案例演示
现在我们通过idea启动springboot工程,我们将内存初始化为1024M。我们这里就从1024M的内存开始分析我们的GC日志,根据我们上面的一些知识来进行一个合理的内存设置。
案例链接:https://pan.baidu.com/s/1C8IMG4ZXrqjQdYb4B-Z5gg
提取码: syhh
JVM设置如下:
-XX:+PrintGCDetails
-XX:MetaspaceSize=64m
-Xss512K
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=heap/heapdump3.hprof
-XX:SurvivorRatio=8
-XX:+PrintGCDatestamps
-Xms1024M -Xmx1024M
-Xloggc:log/gc-oom3.log
启动程序:
要测试的代码其实就是连接数据库并查询记录:
观察Full GC的次数:
接着做压测,看看有没有出现Full GC值得优化:
回到idea继续查看Full GC的次数发现依旧为0,但是如果我们想知道使用多少空间必须经过Full GC后才能分析,所以我们可以使用强制命令来强制系统进行Full GC:
jmap -histo:live 53519(PID)
可以重复执行3次Full GC,然后我们就可以查看内存使用情况了:
jmap -heap 53519(PID)
发现使用的空间只占了20M差不多,如果我们需要减小设置的堆内存初始上限和最大上限,最佳配置应该在x3—x4左右,也就是60M-80M左右,咱们差不多设置成80M即可。
重新启动程序,发现Full GC次数依旧为0,使用Jmeter进行压测50000条样例,发现Full GC依旧为0。这样我们就可以保证在系统不发生Full GC的情况下,最大化的利用当前堆内存,没有造成浪费,还提升了效率。
优化案例三:CPU占用很高的排查方案
示例代码:
/**
* <pre>
* @author : shkstart
* desc : jstack 死锁案例
* version : v1.0
* </pre>
*/
public class JstackDeadLockDemo {
private final Object obj1 = new Object();
private final Object obj2 = new Object();
public static void main(String[] args) {
new JstackDeadLockDemo().testDeadlock();
}
private void testDeadlock() {
Thread t1 = new Thread(() -> calLock_Obj1_First());
Thread t2 = new Thread(() -> calLock_Obj2_First());
t1.start();
t2.start();
}
/**
* 先synchronized obj1,再synchronized obj2
*/
private void calLock_Obj1_First() {
synchronized (obj1) {
sleep();
System.out.println("已经拿到obj1的对象锁,接下来等待obj2的对象锁");
synchronized (obj2) {
sleep();
}
}
}
/**
* 先synchronized obj2,再synchronized obj1
*/
private void calLock_Obj2_First() {
synchronized (obj2) {
sleep();
System.out.println("已经拿到obj2的对象锁,接下来等待obj1的对象锁");
synchronized (obj1) {
sleep();
}
}
}
/**
* 为了便于让两个线程分别锁住其中一个对象,
* 一个线程锁住obj1,然后一直等待obj2,
* 另一个线程锁住obj2,然后一直等待obj1,
* 然后就是一直等待,死锁产生
*/
private void sleep() {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
问题分析
那么如果是生产环境的话,是怎么样才能发现目前程序有问题呢?我们可以推导一下,如果线程死锁,那么线程一直在占用CPU,这样就会导致CPU一直处于一个比较高的占用率。所示我们解决问题的思路应该是:
1、首先使用jps
命令查看Java进程ID
2、根据进程 ID 检查当前使用异常线程的pidtop -Hp <PID>
3、把线程pid变为16进制如 31695 -> 7bcf
然后得到0x7bcf
4、jstack < 线程的pid > | grep -A20 0x7bcf
得到相关进程的代码(鉴于我们当前代码量比较小,线程也比较少,所以我们就把所有的信息全部导出来)
接下来是我们的实现上面逻辑的步骤,如下所示:
# 查看所有Java进程ID
jsp -l
# 根据进程ID检查当前使用异常线程的PID
top -Hp <PID>
然后我们就可以通过十六进制转换,把< PID >转成十六进制,继续执行:
jstack <线程的PID> | grep -A20 0x7f03
就可以找到出现问题的具体信息:
当然了,如果命令行对你来说有点繁琐的话,我们也可以直接使用VisualVM直接去分析CPU使用情况:
可以看到线程报告那边直接爆红了,Thread0 和 Thread1停止工作了(Running直接为0ms了),提示我们发生了死锁,我们看下线程dump文件分析::
解决方案
- 调整锁的顺序,保持一致
- 采用定时锁,一段时间后,如果还不能获取到锁就释放锁
优化案例四:G1并发GC线程数对性能的影响
示例代码:
/**
* @author Juechen
* @version : GCTest.java
*/
public class GCTestApp {
private static final int COUNT = 100000;
public static void main(String[] args) throws InterruptedException {
System.out.println("Starting object creation...");
long start = System.currentTimeMillis();
for (int i = 0; i < COUNT; i++) {
new DummyObject();
}
long end = System.currentTimeMillis();
System.out.println("Object creation took " + (end - start) + " ms");
System.out.println("Starting workload...");
start = System.currentTimeMillis();
Thread.sleep(1000); // 模拟一些处理时间
end = System.currentTimeMillis();
System.out.println("Workload took " + (end - start) + " ms");
}
static class DummyObject {
private byte[] placeholder = new byte[1024 * 1024]; // 占位符数据,模拟较大的对象
}
}
参数配置
后面调整JVM参数的ConcGCThreads即可,默认为2个线程,我们分别用1个、4个、8个线程(G1并发线程最多不超过8个)设置做比对,比较创建对象的时间。
-Xms20m -Xmx20m
-XX:+UseG1GC
-XX:ConcGCThreads=1
案例分析:
-
当-XX:ConcGCThreads=1时,耗时如下(等待消耗时间不计):
-
当-XX:ConcGCThreads=4时,耗时如下(等待消耗时间不计):
-
当-XX:ConcGCThreads=8时,耗时如下(等待消耗时间不计):
通过比较,我们可以得出结论,增加并发线程数有助于减少 GC 的次数和总耗时,从而提高应用的整体性能。由于现在模拟的是简单的创建对象,在高并发场景中对比耗时会更加夸张!
优化案例五:调整垃圾回收器对吞吐量的影响
1)使用 Serial GC
java -XX:+UseSerialGC
2)使用 Parallel GC
java -XX:+UseParallelGC
3)使用 CMS GC
java -XX:+UseConcMarkSweepGC
4)使用 G1 GC
java -XX:+UseG1GC
5)使用 ZGC
java -XX:+UnlockExperimentalVMOptions -XX:+UseZGC
优化案例六:日均百万订单如何设置JVM参数
对于百万级的日订单量,可能需要较大的新生代来减少Full GC的频率。
- 可以设置 -Xms 和 -Xmx 参数来确保启动时的最小堆内存与最大堆内存一致,以避免频繁的垃圾回收。
- 对于高并发应用,推荐使用如
ZGC(Z Garbage Collector)
等低延迟垃圾回收器或者G1(Garbage First)
等高吞吐量垃圾回收器。 - 通过调整参数来控制垃圾回收的频率。比如使用
-XX:MaxGCPauseMillis
指定一个期望的最大停顿时间。 - 此外,可以使用
-XX:NewRatio
来设置新生代与老年代的比例,从而间接控制新生代的大小。