1、调优的基本问题
1.1、为什么要调优?
目的是防止出现OOM,进行JVM规划和预调优;解决程序运行中各种OOM;以及减少Full GC出现的频率,解决运行慢、卡顿问题。
1.2、调优的大方向
合理的编写代码,充分并合理的使用硬件资源以及合理地进行JVM调优。
2、调优监控的依据
- 运行日志
- 异常堆栈
- GC日志
- 线程快照
- 堆转储快照
3、性能优化的步骤
3.1、第1步:熟悉业务场景
3.2、第2步(发现问题):性能监控
一种以非强行或者入侵方式收集或查看应用运营性能数据的活动。监控通常是指一种在生产、质量评估或者开发环境下实施的带有预防或主动性的活动。当应用相关干系人提出性能问题却没有提供足够多的线索时,首先我们需要进行性能监控,随后是性能分析。
监控前,设置好回收器组合,选定CPU(主频越高越好),设置年代比例,设置日志参数(生产环境中通常不会只设置一个日志文件)。比如:
-Xloggc:/opt/xxx/logs/xxx-xxx-gc-%t.log -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=5 -XX:GCLogFileSize=20M -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCCause
- GC 频繁
- CPU Load 过高
- OOM
- 内存泄漏
- 死锁
- 程序响应时间较长
3.3、第3步(排查问题):性能分析
一种以侵入方式收集运行性能数据的活动,它会影响应用的呑吐量或响应性。性能分析是针对性能问题的答复结果,关注的范围通常比性能监控更加集中。性能分析很少在生产环境下进行,通常是在质量评估、系统测试或者开发环境下进行,是性能监控之后的步骤。
- 打印GC日志,通过GCviewer或者 http://gceasy.io来分析日志信息
- 灵活运用命令行工具,jstack,jmap,jinfo等
- dump出堆文件,使用内存分析工具分析文件
- 使用阿里Arthas,或jconsole,JVisualVM来实时查看JVM状态
- jstack查看堆栈信息
3.4、第4步(解决问题):性能调优
一种为改善应用响应性或呑吐量而更改参数、源代码、属性配置的活动,性能调优是在性能监控、性能分析之后的活动。
- 适当增加内存,根据业务背景选择垃圾回收器
- 优化代码,控制内存使用
- 增加机器,分散节点压力
- 合理设置线程池线程数量
- 使用中间件提高程序效率,比如缓存,消息队列等
3.5、常用方式
- 设置必要的内存参数。比如: -Xms200m -Xmx200m -XX:+PrintGC 等
- top命令观察到问题:内存不断增长,CPU占用率居高不下的进程。
- top 查看所有的进程的cpu、内存占比。
- top -Hp pid 查看指定pid下各个线程的cpu、内存占比。关注谁比较高(有可能是gc的线程)
- jps定位具体java进程,进一步可以使用jstack pid 对进程中的各个线程查看。尤其对WAITING的多个线程要关注。
- jinfo pid
- jstat -gc 动态观察gc情况 / 阅读GC日志发现频繁GC / arthas观察 / jconsole
jstat -gc pid 500 : 每隔500毫秒打印GC的情况 - jmap -histo pid | head -20 查找有多少对象产生
- jmap -dump:format=b,file=xxx pid / jmap -histo
- 修改参数: -Xms20m -Xmx20m -XX:+UseParallelGC -XX:+HeapDumpOnOutOfMemoryError
- 使用MAT / jhat 进行dump文件分析
4、性能评价/测试指标
4.1、停顿时间(或响应时间)
提交请求和返回该请求的响应之间使用的时间,一般比较关注平均响应时间。常用操作的响应时间列表:
在垃圾回收环节中:
暂停时间:执行垃圾收集时,程序的工作线程被暂停的时间。(-XX:MaxGCPauseMillis)
4.2、吞吐量
对单位时间内完成的工作量(请求)的量度,在GC中:运行用户代码的时间占总运行时间的比例(总运行时间:程序的运行时间+内存回收的时间)
吞吐量为 1 -1/(1+n)。-XX:GCTimeRatio=n
4.3、并发数
同一时刻,对服务器有实际交互的请求数。例如1000个人同时在线,估计并发数在5% -15%之间,也就是同时并发量:50 - 150之间。
4.4、内存占用
Java 堆区所占的内存大小
4.5、相互间的关系
以高速公路通行状况为例,吞吐量就是每天通过高速公路收费站的车辆的数据(也可以理解为收费站收取的高速费);并发数就是高速公路上正在行驶的车辆的数目;对应的响应时间就是车速。
5、性能调优案例
5.1、案例一:提高服务吞吐量
初始jmeter配置:下载并导入标题栏的 jmx 资源
首先在Linux服务器中安装一个Tomcat服务,并在 ./bin 目录下配置以下脚本信息(任意新建一个 sh 脚本填写以下信息即可)。配置完成后直接启动 Tomcat 服务,参数将自动读取并生效:
#堆空间的初始大小
export CATALINA_OPTS="$CATALINA_OPTS -Xms30m"
#伊甸园的比例分配
export CATALINA_OPTS="$CATALINA_OPTS -XX:SurvivorRatio=8"
#堆空间的最大大小
export CATALINA_OPTS="$CATALINA_OPTS -Xmx30m"
#使用并行收集器
export CATALINA_OPTS="$CATALINA_OPTS -XX:+UseParallelGC"
#打印GC的详细日志信息
export CATALINA_OPTS="$CATALINA_OPTS -XX:+PrintGCDetails"
#指定元空间大小
export CATALINA_OPTS="$CATALINA_OPTS -XX:MetaspaceSize=64m"
#打印GC日志时输出当前的时间戳信息
export CATALINA_OPTS="$CATALINA_OPTS -XX:+PrintGCDateStamps"
#生成GC日志的文件路径
export CATALINA_OPTS="$CATALINA_OPTS -Xloggc:/opt/apache-tomcat-8.5.99/logs/gc.log"
30m 堆空间,5w 请求下的吞吐量:
120m堆空间,5w 请求下的吞吐量:
这边对比看,吞吐量确实有轻微提升但并不明显。如果多次压测甚至 30m 堆空间的平均吞吐量会更高一点,不过 gc 状况不是很好。而120m 堆空间的 gc 状况会很好看,几乎没有 FullGC,对前端的延迟与体验会好很多。按理正常吞吐量应该也会更高才对,不知道为什么我这边验证的并没有。可能与使用环境和软件的版本有关,我使用的环境是:
- apache-tomcat-8.5.99
- Linux服务 jdk1.8.0_352
- jmeter运行于 win10 64位,jdk1.8.0_321
- Tomcat 服务运行于 Centos7 虚机
5.2、案例二:JIT优化
5.2.1、堆是分配对象的唯一选择吗
引自《深入理解Java虚拟机中》:随着JIT编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。
在Java虚拟机中,对象是在Java堆中分配内存的,这是一个普遍的常识。但是,有一种特殊情况,那就是如果经过逃逸分析(Escape Analysis)后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内存,也无须进行垃圾回收了。这也是最常见的堆外存储技术。
此外,前面提到的基于OpenJDK深度定制的TaoBaoVM,其中创新的GCIH(GC invisible heap)技术实现off-heap,将生命周期较长的Java对象从heap中移至heap外,并且GC不能管理GCIH内部的Java对象,以此达到降低GC的回收频率和提升GC的回收效率的目的。
所以,可以说是也可以说不是;因为对象的全部信息在栈上也没法完全保存,毕竟空间也没那么大。
5.2.2、什么是逃逸
刚刚又提到一个名词叫“逃逸分析”,那什么是逃逸呢。在Java中,简单来说就是在一个方法里(也就是一个栈帧里),创建的任意对象、变量。只要是跟随这个方法的生存和死亡的,就叫没有逃逸。就是说这个方法的用到的变量、对象,没有提供给外部的任何方法使用。
而反之就是发生了逃逸,从创建的这个方法里逃到其它方法里进行使用。対这类对象的分析处理,就是逃逸分析了。
5.2.3、了解编译的开销成本
1、时间开销
解释器执行(抽象化):
输入的代码 -> [ 解释器 解释执行 ] -> 执行结果
JIT编译执行(抽象化):
输入的代码 -> [ 编译器 编译 ] -> 编译后的代码 -> [ 执行 ] -> 执行结果
说JIT比解释快,其实说的是“执行编译后的代码”比“解释器解释执行”要快,并不是说“编译”这个动作比“解释”这个动作快。JIT编译再怎么快,至少也比解释执行一次略慢一些,而要得到最后的执行结果还得再经过一个“执行编译后的代码”的过程。所以,对“只执行一次”的代码而言,解释执行其实总是比JIT编译执行要快。怎么算是`只执行一次的代码`呢?粗略说,下面条件同时满足时就是严格的`只执行一次。只有对频繁执行的代码(热点代码),JIT编译才能保证有正面的收益。
- 只被调用一次,例如类的构造器(class initializer,())
- 没有循环,对只执行一次的代码做JIT编译再执行,可以说是得不偿失。
- 对只执行少量次数的代码,JIT编译带来的执行速度的提升也未必能抵消掉最初编译带来的开销。
2、空间开销
对一般的Java方法而言,编译后代码的大小相对于字节码的大小,膨胀比达到10+是很正常的。同上面说的时间开销一样,这里的空间开销也是,只有对执行频繁的代码才值得编译,如果把所有代码都编译则会显著增加代码所占空间,导致代码爆炸。这也就解释了为什么有些JVM会选择不总是做JIT编译,而是选择用解释器+JIT编译器的混合执行引擎。
5.2.4、什么是逃逸分析
如何将堆上的对象分配到栈,这里使用的手段就是逃逸分析。逃逸分析(Escape Analysis)是目前Java虚拟机中比较前沿的优化技术。这是一种可以有效减少Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。
通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围,从而决定是否要将这个对象分配到堆上。
逃逸分析的基本行为就是分析对象动态作用域:
- 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。
- 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中。
//未发生逃逸
public void my_method() {
V v = new V();
//use v
//......
v = null;
}
没有发生逃逸的对象,则可以分配到栈上,随着方法执行的结束,栈空间就被移除。逃逸分析包括:
- 全局变量赋值逃逸
- 方法返回值逃逸
- 实例引用发生逃逸
- 线程逃逸:赋值给类变量或可以在其他线程中访问的实例变量
5.2.5、关于逃逸分析配置参数
在JDK 6u23版本之后,HotSpot中默认就已经开启了逃逸分析。如果使用的是较早的版本,开发人员则可以通过:
- 通过选项“-XX:+DoEscapeAnalysis”显式开启逃逸分析
- 通过选项“-XX:+PrintEscapeAnalysis”查看逃逸分析的筛选结果。
- 参数-XX:+EliminateAllocations:开启了标量替换(默认打开),允许将对象打散分配在栈上。
注意:这里需要留意下,因为(1和3)可以单独禁用,所以这两个配置参数必须组合并启用才会生效;在jdk 6u23之后都是默认启用的。
5.2.6、逃逸分析代码举例
示例一:
//此时发生了逃逸
public static StringBuffer createStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb;
}
//上述代码如果想要StringBuffer sb不逃出方法,可以这样写:
public static String createStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}
示例二:
package blnp.net.cn.jvm.demos.escape;
/**
* <p>逃逸分析</p>
* 如何快速的判断是否发生了逃逸分析,大家就看new的对象实体是否有可能在方法外被调用。
*
* @author lyb 2045165565@qq.com
* @createDate 2024/4/16 14:27
*/
public class EscapeAnalysis {
public EscapeAnalysis obj;
/**
* 用途:方法返回EscapeAnalysis对象,发生逃逸
* @author liaoyibin
* @date 14:28 2024/4/16
* @params []
* @param
**/
public EscapeAnalysis getInstance(){
return obj == null? new EscapeAnalysis() : obj;
}
/**
* 用途:为成员属性赋值,发生逃逸
* @author liaoyibin
* @date 14:28 2024/4/16
* @params []
* @param
**/
public void setObj(){
/**
* 思考:如果当前的obj引用声明为static的,会发生逃逸吗?
*
* 答案肯定是会的,只是此时的 obj 从成员变量升级成类的变量了。但依旧是被外部引用了
* **/
this.obj = new EscapeAnalysis();
}
/**
* 用途:对象的作用域仅在当前方法中有效,没有发生逃逸
* @author liaoyibin
* @date 14:32 2024/4/16
* @params []
* @param
**/
public void useEscapeAnalysis(){
EscapeAnalysis e = new EscapeAnalysis();
}
/**
* 用途:引用成员变量的值,发生逃逸
* @author liaoyibin
* @date 14:33 2024/4/16
* @params []
* @param
**/
public void useEscapeAnalysis1(){
//getInstance().xxx()同样会发生逃逸
EscapeAnalysis e = getInstance();
}
/**
* 用途:外部传入,也发生了逃逸
* @author liaoyibin
* @date 14:34 2024/4/16
* @params [e]
* @param e
**/
public void operate(EscapeAnalysis e){
// e
}
}
5.2.7、栈上分配验证
使用逃逸分析,编译器可以对代码做如下优化。栈上分配。将堆分配转化为栈分配。如果经过逃逸分析后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内存,也无须进行垃圾回收了。可以减少垃圾回收时间和次数。
JIT编译器在编译期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法的话,就可能被优化成栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。这样就无须进行垃圾回收了。
package blnp.net.cn.jvm.demos.escape;
/**
* <p>栈上分配测试</p>
*   启动参数:-Xmx1G -Xms1G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails
* 说明:因为栈上分配配置在jdk6u32之后就默认开启,为了测试对比区别;这里先禁用查看下效果
*
* 只要开启了逃逸分析,就会判断方法中的变量是否发生了逃逸。<h3>如果没有发生了逃逸,则会使用栈上分配</h3>
* @author lyb 2045165565@qq.com
* @createDate 2024/4/16 14:37
*/
public class StackAllocation {
public static void main(String[] args) {
long start = System.currentTimeMillis();
//创建对象个数
Integer objNums = 10000000;
for (int i = 0; i < objNums; i++) {
alloc();
}
// 查看执行时间
long end = System.currentTimeMillis();
System.out.println("花费的时间为: " + (end - start) + " ms");
try {
//为了方便查看堆内存中对象个数,线程sleep
Thread.sleep(1000000);
} catch (InterruptedException e1) {
e1.printStackTrace();
}
}
private static void alloc() {
//是否发生逃逸? 没有!
User user = new User();
}
static class User {
}
}
未启用栈上分配配置时,执行示例代码创建 1000w 个对象的耗时:
此时查看当前进程堆创建实例信息如下所示:
#查看指定进程创建实例数
jmap histo {pid}
特别提示:这里可能有人会遇到跟我一样的问题,就是使用 jvisualvm 时,打开抽样器发现 CPU、内存这块无法进行抽样操作。 这是因为权限不够的问题,将 jvisualvm 用管理员权限打开即可。
此时,我们开启下栈上分配配置。对比下执行效果,也就是将参数 “-XX:-DoEscapeAnalysis” 改为 “-XX:+DoEscapeAnalysis” 或者去掉该启动参数(因为默认就是启用的)。
对比后发现,这个有开启和没有开启简直是一个天一个地。所以非常建议开发中能使用局部变量的,就不要使用在方法外定义。
备注:这里细心的人可能会发现,我刚开始是在Windows机器里操作实验的,后面又换到Linux机器上操作了。
这是因为我在Windows机器上测试没有效果,不知道为什么启用和没启用都是一样的。目前是没有找到具体的原因,有懂的朋友方便指导一二。我用的环境信息大致如下:
- IDEA 2020.3
- Windows 10 企业版 LTSC 64位 版本号:21H2
- Oracle JDK 1.8.0_321-b07
5.2.8、同步消除测试
同步省略(也叫同步消除)。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
- 线程同步的代价是相当高的,同步的后果是降低并发性和性能。
- 在动态编译同步块的时候,JIT编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。如果没有,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫同步省略,也叫锁消除。
package blnp.net.cn.jvm.demos.escape;
/**
* <p>同步消除测试</p>
*
* @author lyb 2045165565@qq.com
* @createDate 2024/4/16 16:12
*/
public class SynchronizedTest {
/**
* 用途:代码中对hollis这个对象进行加锁,但是hollis对象的生命周期只在 fun() 方法中,
* 并不会被其他线程所访问到,所以在JIT编译阶段就会被优化掉。
* @author liaoyibin
* @date 16:13 2024/4/16
* @params []
* @param
**/
public void fun() {
/**
* 问题:字节码文件中会去掉hollis吗?
* 答案:不会,编译只是生成字节码文件。
* **/
/** 优化前 **/
Object hollis = new Object();
synchronized(hollis) {
System.out.println(hollis);
}
/** 优化后 **/
/**
* Object hollis = new Object();
* System.out.println(hollis);
* **/
}
}
5.2.9、标量替换测试
标量(Scalar)是指一个无法再分解成更小的数据的数据。Java中的原始数据类型就是标量。相对的,那些还可以分解的数据叫做聚合量(Aggregate),Java中的对象就是聚合量,因为他可以分解成其他聚合量和标量。
在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。这个过程就是标量替换。
标量替换参数设置:
参数-XX:+EliminateAllocations:开启了标量替换(默认打开),允许将对象打散分配在栈上。
代码示例一:
public static void main(String[] args) {
alloc();
}
private static void alloc() {
Point point = new Point(1,2);
System.out.println("point.x="+point.x+"; point.y="+point.y);
}
class Point{
private int x;
private int y;
}
//经过标量替换后,就会变成:
private static void alloc() {
int x = 1;
int y = 2;
System.out.println("point.x="+x+"; point.y="+y);
}
可以看到,Point这个聚合量经过逃逸分析后,发现他并没有逃逸,就被替换成两个标量了。那么标量替换有什么好处呢?就是可以大大减少堆内存的占用。因为一旦不需要创建对象了,那么就不再需要分配堆内存了。
代码示例二:
package blnp.net.cn.jvm.demos.escape;
/**
* <p>标量替换测试</p>
* 启动参数: -server -Xmx100m -Xms100m -XX:+DoEscapeAnalysis -XX:+PrintGCDetails -XX:-EliminateAllocations
* 结论:Java中的逃逸分析,其实优化的点就在于对栈上分配的对象进行标量替换。
* @author lyb 2045165565@qq.com
* @createDate 2024/4/16 16:34
*/
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 = "http://blnp.net.cn";
}
public static void main(String[] args) {
long start = System.currentTimeMillis();
int nums = 10000000;
for (int i = 0; i < nums; i++) {
alloc();
}
long end = System.currentTimeMillis();
System.out.println("花费的时间为: " + (end - start) + " ms");
}
}
禁用标量替换的效果:
启用标量替换的效果:
上述代码在主函数中进行了 1000w 次alloc。调用进行对象创建,由于User对象实例需要占据约16字节的空间,因此累计分配空间达到将近 152m。如果堆空间小于这个值,就必然会发生 GC。因此在禁用标量替换时,日志里有明显的GC操作。
- 参数-server:启动Server模式,因为在Server模式下,才可以启用逃逸分析。
- 参数 -XX:+DoEscapeAnalysis:启用逃逸分析
- 参数-Xmx100m:指定了堆空间最大为10MB
- 参数-XX:+PrintGC:将打印GC 日志。
- 参数-XX:+EliminateAllocations:开启了标量替换(默认打开),允许将对象打散分配在栈上, 比如对象拥有id和name两个字段,那么这两个字段将会被视为两个独立的局部变量进行分配。
5.3、案例三:合理配置堆内存
5.3.1、如何合理配置
之前提到过增加内存可以提高系统的性能而且效果显著,那么随之带来的一个问题就是,我们增加多少内存比较合适?如果内存过大,那么如果产生FullGC的时候,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分析,然后再去做一个合理的内存分配。
5.3.2、如何计算老年代存活对象
1、方式一:查看日志
【推荐方式】JVM参数中添加GC日志,GC日志中会记录每次FullGC之后各代的内存大小,观察老年代GC之后的空间大小。可观察一段时间内(比如2天)的FullGC之后的内存情况,根据多次的FullGC之后的老年代的空间大小数据来预估FullGC之后老年代的存活对象大小(可根据多次FullGC之后的内存大小取平均值)。
2、方式二:强制触发 FullGC
该方式会影响线上服务,慎用(会有STW)!方式1的方式比较可行,但需要更改JVM参数,并分析日志。同时,在使用CMS回收器的时候,有可能不能触发FullGC,所以日志中并没有记录FullGC的日志。在分析的时候就比较难处理。 所以,有时候需要强制触发一次FullGC,来观察FullGC之后的老年代存活对象大小。
建议的操作方式为,在强制FullGC前先把服务节点摘除,FullGC之后再将服务挂回可用节点,对外提供服务,在不同时间段触发FullGC,根据多次FullGC之后的老年代内存情况来预估FullGC之后的老年代存活对象大小。
如何强制触发Full GC?
- jmap -dump:live,format=b,file=heap.bin <pid> 将当前的存活对象dump到文件,此时会触发FullGC
- jmap -histo:live <pid> 打印每个class的实例数目,内存占用,类全名信息.live子参数加上后,只统计活的对象数量. 此时会触发FullGC
- 在性能测试环境,可以通过Java监控工具来触发FullGC,比如使用VisualVM和JConsole,VisualVM集成了JConsole,VisualVM或者JConsole上面有一个触发GC的按钮。
5.3.3、案例分析
启动参数配置:
-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
项目启动,通过jmeter访问10000次(主要是看项目是否可以正常运行)之后,查看gc状态:
jstat -gc {pid}
YGC平均耗时: 0.12s * 1000/7 = 17.14ms,FGC未产生
看起来似乎不错,YGC触发的频率不高,FGC也没有产生,但这样的内存设置是否还可以继续优化呢?是不是有一些空间是浪费的呢。
为了快速看数据,我们使用了方式2,通过命令 jmap -histo:live pid 产生几次FullGC,FullGC之后,使用的jmap -heap 来看的当前的堆内存情况。观察老年代存活对象大小:
#或者直接查看GC日志,查看一次FullGC之后剩余的空间大小
jmap -heap {pid}
可以看到存活对象占用内存空间大概13.36M,老年代的内存占用为683M左右。 按照整个堆大小是老年代(FullGC)之后的3-4倍计算的话,设置堆内存情况如下:
Xmx=14 * 3 = 42M 至 14 * 4 = 56M 之间
我们修改堆内存状态如下:
-XX:+PrintGCDetails -XX:MetaspaceSize=64m -Xss512K -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=heap/heapdump.hprof -XX:SurvivorRatio=8 -XX:+PrintGCDateStamps -Xms60M -Xmx60M -Xloggc:log/gc-oom.log
修改完之后,我们查看一下GC状态:
请求之后YGC平均耗时: 0.195s * 1000/68 = 2.87ms,FGC未产生。
整体的GC耗时减少。但GC频率比之前的 1024M 时要多了一些。依然未产生 FullGC ,所以我们内存设置为 60M 也是比较合理的,相对之前节省了很大一块内存空间,所以本次内存调整是比较合理的。依然是手动触发Full ,查看堆内存结构:
在内存相对紧张的情况下,可以按照上述的方式来进行内存的调优, 找到一个在GC频率和GC耗时上都可接受的一个内存设置,可以用较小的内存满足当前的服务需要。但当内存相对宽裕的时候,可以相对给服务多增加一点内存,可以减少GC的频率,GC的耗时相应会增加一些。 一般要求低延时的可以考虑多设置一点内存, 对延时要求不高的,可以按照上述方式设置较小内存。
如果在垃圾回收日志中观察到OutOfMemoryError,尝试把Java堆的大小扩大到物理内存的80%~90%。尤其需要注意的是堆空间导致的OutOfMemoryError以及一定要增加空间。
- 比如说,增加 -Xms 和 -Xmx 的值来解决 old 代的 OutOfMemoryError
- 增加-XX:PermSize和-XX:MaxPermSize来解决permanent代引起的OutOfMemoryError(jdk7之前);增加-XX:MetaspaceSize和-XX:MaxMetaspaceSize来解决Metaspace引起的OutOfMemoryError(jdk8之后)
记住一点Java堆能够使用的容量受限于硬件以及是否使用64位的JVM。在扩大了Java堆的大小之后,再检查垃圾回收日志,直到没有OutOfMemoryError为止。如果应用运行在稳定状态下没有OutOfMemoryError就可以进入下一步了,计算活动对象的大小。
5.3.4、估算GC的频率
正常情况我们应该根据我们的系统来进行一个内存的估算,这个我们可以在测试环境进行测试,最开始可以将内存设置的大一些,比如4G这样,当然这也可以根据业务系统估算来的。
比如从数据库获取一条数据占用128个字节,需要获取1000条数据,那么一次读取到内存的大小就是(128 B/1024 Kb/1024M)* 1000 = 0.122M ,那么我们程序可能需要并发读取,比如每秒读取100次,那么内存占用就是0.122*100 = 12.2M ,如果堆内存设置1个G,那么年轻代大小大约就是333M,那么333M*80% / 12.2M =21.84s ,也就是说我们的程序几乎每分钟进行两到三次youngGC。这样可以让我们对系统有一个大致的估算。
- 0.122M * 100 = 12.2M /秒 ---Eden区
- 1024M * 1/3(新生代:老年代=1:2) * 80% = 273M
- 273 / 12.2M = 22.38s ---> YGC 每分钟2-3次YGC
5.4、新生代与老年代比例(大流量系统注意)
JVM 参数设置为:
# 打印日志详情 打印日志打印日期 初始化内存300M 最大内存300M 日志路径
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xms300M -Xmx300M -Xloggc:log/gc.log
package blnp.net.cn.jvm.demos;
/**
* <p>使用ParallelGC的情况下,不管是否开启了UseAdaptiveSizePolicy参数,默认Eden与Survivor的比例都为:6:1:1</p>
*
* @author lyb 2045165565@qq.com
* @createDate 2024/4/17 16:34
*/
public class AdaptiveSizePolicyTest {
public static void main(String[] args) {
try {
Thread.sleep(1000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
程序启动后,我们可以先用命令查看一下堆内存分配是怎么样的:
# 查看进程ID
jps -l
# 查看对应的进程ID的堆内存分配
jmap -heap 50044
新生代 ( Young ) 与老年代 ( Old ) 的比例为 1:2,所以,内存分配应该是新生代100M,老年代 200M。结果大家可以看到:我们的SurvivorRatio= 8 但是新生代内存分配却不是 8:1:1 而是 6:1:1,这是为什么呢?
这是因为JDK 1.8 默认使用 UseParallelGC 垃圾回收器,该垃圾回收器默认启动了 AdaptiveSizePolicy,会根据GC的情况自动计算计算 Eden、From 和 To 区的大小;所以这是由于JDK1.8的自适应大小策略导致的,除此之外,我们下面观察GC日志发现有很多类似这样的FULLGC(Ergonomics),也是一样的原因。我们可以在jvm参数中配置开启和关闭该配置:
# 开启:
-XX:+UseAdaptiveSizePolicy
# 关闭
-XX:-UseAdaptiveSizePolicy
启动参数调整为:
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xms300M -Xmx300M -Xloggc:system/log/gc.log -XX:-UseAdaptiveSizePolicy
根据结果会发现,此时即使关闭了调优策略依旧还是 6:1:1。这是因为如果JDK使用的是 ParallelGC 垃圾回收器的话,单纯禁用策略是不会生效的,还必须得明文带上启动参数(-XX:SurvivorRatio=8)。关于查看JDK使用的是什么垃圾回收器,可以通过该命令:
java -XX:+PrintCommandLineFlags -version
明文指定参数后:
查看指定进程的启动参数配置信息:jcmd {pid} VM.flags
注意事项:
1、在 JDK 1.8 中,如果使用 CMS,无论 UseAdaptiveSizePolicy 如何设置,都会将 UseAdaptiveSizePolicy 设置为 false;不过不同版本的JDK存在差异;
2、UseAdaptiveSizePolicy不要和SurvivorRatio参数显示设置搭配使用,一起使用会导致参数失效;
3、由于UseAdaptiveSizePolicy会动态调整 Eden、Survivor 的大小,有些情况存在Survivor 被自动调为很小,比如十几MB甚至几MB的可能,这个时候YGC回收掉 Eden区后,还存活的对象进入Survivor 装不下,就会直接晋升到老年代,导致老年代占用空间逐渐增加,从而触发FULL GC,如果一次FULL GC的耗时很长(比如到达几百毫秒),那么在要求高响应的系统就是不可取的。
因此,对于面向外部的大流量、低延迟系统,不建议启用此参数,建议关闭该参数。如果不想动态调整内存大小,以下是解决方案:
- 保持使用 UseParallelGC,显式设置 -XX:SurvivorRatio=8。
- 使用 CMS 垃圾回收器。CMS 默认关闭 AdaptiveSizePolicy。配置参数 -XX:+UseConcMarkSweepGC
此外,关于堆内存的自适应调节有如下三个参数:调整堆是按照每次20%增长,按照每次5%收缩:
- young区增长量(默认20%):-XX:YoungGenerationSizeIncrement=<Y>
- old区增长量(默认20%):-XX:TenuredGenerationSizeIncrement=<T>
- 收缩量(默认5%):-XX:AdaptiveSizeDecrementScaleFactor=<D>
5.5、案例四:CPU占用排查
package blnp.net.cn.jvm.demos;
/**
* <p></p>
*
* @author lyb 2045165565@qq.com
* @createDate 2024/4/17 17:18
*/
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一直处于一个比较高的占用率。所示我们解决问题的思路应该是:
- 首先查看java进程ID
- 根据进程 ID 检查当前使用异常线程的pid
- 例如把线程pid变为16进制如 31695 -> 7bcf 然后得到0x7bcf
- jstack 进程的pid | grep -A20 0x7bcf 得到相关进程的代码 (鉴于我们当前代码量比较小,线程也比较少,所以我们就把所有的信息全部导出来)
实操步骤:
第一步:获取运行Java进程的ID
第二步:观察服务器CPU资源占用情况
第三步:观察Java进程子线程的使用情况
top -p {Java进程ID} -H
此时会发现子线程列表里,1872/1873比较可疑。我们这边将子线程ID进一步换算排查。
第四步:换算ID
#将最耗cpu的线程id转为16进制输出 :printf "%x \n" 1672 注:此处id是子线程pid
printf "%x \n" 1872
第五步:分析原因
#使用jstack查看分析原因
# jstack {Java进程ID} | grep {子线程换算ID} -A {查看多少行}
jstack 1862 | grep 750 -A 25
第六步:问题解决
- 调整锁的顺序,保持一致
- 或者采用定时锁,一段时间后,如果还不能获取到锁就释放自身持有的所有锁
6、面题场景
6.1、场景一
问题一:有一个50万PV的资料类网站(从磁盘提取文档到内存)原服务器是32位的,1.5G的堆,用户反馈网站比较缓慢。因此公司决定升级,新的服务器为64位,16G的堆内存,结果用户反馈卡顿十分严重,反而比以前效率更低了!
6.1.1、为什么原网站慢?
频繁的GC,STW时间比较长,响应时间慢!
6.1.2、为什么会更卡顿?
内存空间越大,FGC时间更长,延迟时间更长
6.1.3、如何解决?
- 垃圾回收器:parallel GC ; ParNew + CMS ; G1
- 配置GC参数:-XX:MaxGCPauseMillis 、 -XX:ConcGCThreads
- 根据log日志、dump文件分析,优化内存空间的比例
6.2、场景二
6.2.1、系统CPU经常100%,如何调优?
详见《5.5、案例四:CPU占用排查》,注意: 工作中有时候是工作线程100%占用了CPU,还有可能是垃圾回收线程占用了100%。
7、代码优化注意事项
7.1、尽可能使用局部变量
调用方法时传递的参数以及在调用中创建的临时变量都保存在栈中速度较快,其他变量,如静态变量、实例变量等,都在堆中创建,速度较慢。另外,栈中创建的变量,随着方法的运行结束,这些内容就没了,不需要额外的垃圾回收。
7.2、尽量减少对变量的重复计算
明确一个概念,对方法的调用,即使方法中只有一句语句,也是有消耗的。所以例如下面的操作:
for (int i = 0; i < list.size(); i++)
{
//todo
}
建议替换为:
int length = list.size();
for (int i = 0, i < length; i++)
{
//todo
}
这样,在list.size()很大的时候,就减少了很多的消耗。
7.3、尽量采用懒加载的策略,即在需要的时候才创建
String str = "aaa";
if (i == 1){
list.add(str);
}
//建议替换成
if (i == 1){
String str = "aaa";
list.add(str);
}
7.4、异常不应该用来控制程序流程
异常对性能不利。抛出异常首先要创建一个新的对象,Throwable接口的构造函数调用名为fillInStackTrace()的本地同步方 法,fillInStackTrace()方法检查堆栈,收集调用跟踪信息。只要有异常被抛出,Java虚拟机就必须调整调用堆栈,因为在处理过程中创建了一个新的对象。异常只能用于错误处理,不应该用来控制程序流程。
7.5、不要将数组声明为public static final
因为这毫无意义,这样只是定义了引用为 static final,数组的内容还是可以随意改变的,将数组声明为 public 更是一个安全漏洞,这意味着这个数组可以被外部类所改变。
7.6、不要创建一些不使用的对象,不要导入一些不使用的类
这毫无意义,如果代码中出现 "The value of the local variable i is not used"、"The import java.util is never used",那么请删除这些无用的内容。
7.7、程序运行过程中避免使用反射
反射是Java提供给用户一个很强大的功能,功能强大往往意味着效率不高。不建议在程序运行过程中使用尤其是频繁使用反射机制,特别是 Method的invoke方法。
如果确实有必要,一种建议性的做法是将那些需要通过反射加载的类在项目启动的时候通过反射实例化出一个对象并放入内存。
7.8、使用数据库连接池和线程池
这两个池都是用于重用对象的,前者可以避免频繁地打开和关闭连接,后者可以避免频繁地创建和销毁线程。
7.9、容器初始化时尽可能指定长度
容器初始化时尽可能指定长度,如:new ArrayList<>(10); new HashMap<>(32); 避免容器长度不足时,扩容带来的性能损耗。
7.10、ArrayList随机遍历快,LinkedList添加删除快
7.11、使用 Entry 遍历 Map
Map<String,String> map = new HashMap<>();
for (Map.Entry<String,String> entry : map.entrySet()) {
String key = entry.getKey();
String value = entry.getValue();
}
//避免使用这种方式:
Map<String,String> map = new HashMap<>();
for (String key : map.keySet()) {
String value = map.get(key);
}
7.12、String尽量少用正则表达式
正则表达式虽然功能强大,但是其效率较低,除非是有需要,否则尽可能少用。replace() 不支持正则,replaceAll() 支持正则。如果仅仅是字符的替换建议使用replace()。
7.13、对资源的close()建议分开操作
try{
XXX.close();
YYY.close();
}catch (Exception e){
...
}
// 建议改为
try{
XXX.close();
}catch (Exception e){
...
}
try{
YYY.close();
}catch (Exception e){
...
}