JVM调优

发布时间 2023-10-18 14:51:07作者: 肖德子裕

JVM调优

JVM调优目的

当程序卡顿、请求吞吐量(QPS)变慢、stop the word(STW)停顿时间过长、内存溢出(OOM)时,如误写死循环或本地开发内存不足,这时我们首先导出JVM内存使用情况日志进行分析(可以使用mat工具进行分析),主要分析堆内存的使用情况。然后我们输出GC日志,通过在线工具GC Easy或jconsole、jvisualvm分析吞吐量、停顿时间和垃圾回收频率,最后我们通过分析的情况调整堆内存的大小和分配比例,不断调式使GC的吞吐量和停顿时间到达稳定高速的状态。

优质文章推荐:
面试回答:https://juejin.cn/post/7034669867286396958
调优流程:https://juejin.cn/post/6844903506093015053

JVM调优案例

问题呈现:项目页面刷新无法加载页面,接口访问等待超时。

问题分析:最开始认为接口查询条件引发错误导致nginx挂起,故先调节nginx为15秒失效2次则认为项目实例失效,保证项目正常运行再解决接口问题,但是发现并没有解决该问题。注:nginx轮询2个后端实例,默认10秒内访问一次失败则认为实例无效,多次访问失败则nginx挂起,访问后端超时。

问题解决:发现项目部署的服务器内存为4核8G,并且多个(3个)项目部署在服务器上,故开始查看CPU使用率。主要如下三个步骤:
1、找到最耗CPU的进程
通过top命令查看进程的cpu占用情况,运行top命令后再键入P(大写p),进程会按照CPU使用率排序。也可以通过top -c命令进行实时查看。确实发现项目java进程CPU飙升至300%,这时肯定项目OOM内存溢出了。

2、找到这个进程中最耗CPU的线程
根据上面获取的PID进行查看最耗CPU的线程:
top -Hp ${进程的PID} 或 ps -mp ${进程的PID} -o THREAD,tid,time
运行以上命令后再键入P(大写p),线程会按照CPU使用率排序

3、查看堆栈信息,定位线程的什么操作消耗了大量CPU,定位对应代码
堆栈里,线程id是用16进制表示的,所以需要将上面获取的线程PID转化为16进制:
printf "%x\n" 2611 输出:a33
打印进程堆栈信息(注意2601是进程的PID),通过线程id,过滤得到线程堆栈:
jstack 2601 | grep a33 -A 20
通过不断查看各个消耗CPU线程的堆栈信息确认是否存在代码死锁的情况,最后发现JVM多次进行full gc,并且繁忙。

根据上面获取的信息,开始查看JVM内存使用情况:
1、通过jps -l查看java进程ID
2、执行jmap -heap pid查看JVM内存消耗,pid替换为自己的pid即可,如:jmap -heap 20677,发现新生代和老年代使用率90%以上。
3、通过命令jmap -histo:live 20677 | more查看当前存活对象数量和大小,发现保存在本地缓存的对象数量偏多(航线点位数据),缓存失效时间1小时。点位数量最大能到190万个对象,1小时过期,一直访问不会过期,无法回收。

最终解决方案:增加项目服务器内存,4核8G改为4核16G。其实不应该把大量数据保存至本地缓存,但是由于查询点位信息缓慢,所以暂时不修改保存至本地缓存的方案。后期可以适当调整-Xmx和-Xms参数保证JVM更加有效的运行。

jmap -heap 20677展示案例图:

image-20231018105916840

jmap -histo:live 20677 | more展示案例图:

image-20231018110016393

缓存代码展示:

image-20231018110044784

后来查看系统之前的日志发现OOM异常:

微信图片_20231018110321

OOM异常说明:

OutOfMemoryError是java.lang.VirtualMachineError的子类,当JVM资源利用出现问题时抛出,更具体地说,这个错误是由于JVM花费太长时间执行GC且只能回收很少的堆内存时抛出的。根据Oracle官方文档,默认情况下,如果Java进程花费98%以上的时间执行GC,并且每次只有不到2%的堆被恢复,则JVM抛出此错误。换句话说,这意味着我们的应用程序几乎耗尽了所有可用内存,垃圾收集器花了太长时间试图清理它,并多次失败。在这种情况下,用户会体验到应用程序响应非常缓慢,通常只需要几毫秒就能完成的某些操作,此时则需要更长的时间来完成,这是因为所有的CPU正在进行垃圾收集,因此无法执行其他任务。理想的解决方案是通过检查可能存在内存泄漏的代码来发现应用程序所存在的问题,这时需要考虑:
1)应用程序中哪些对象占据了堆的大部分空间
2)这些对象在源码中的哪些部分被使用
我们可以使用自动化图形工具,比如JVisualVM、JConsole,它可以帮助检测代码中的性能问题,包括java.lang.OutOfMemoryError。最后一种方法是通过更改JVM启动配置来增加堆大小,或者在JVM启动配置里增加-XX:-UseGCOverheadLimit选项来关闭GC Overhead limit exceeded。例如,以下JVM参数为Java应用程序提供了1GB堆空间:
java -Xmx1024m com.xyz.TheClassName
以下JVM参数不仅为Java应用程序提供了1GB堆空间,也增加-XX:-UseGCOverheadLimit选项来关闭GC Overhead limit exceeded:
java -Xmx1024m -XX:-UseGCOverheadLimit com.xyz.TheClassName
但增加-XX:-UseGCOverheadLimit选项的方式治标不治本,JVM最终会抛出java.lang.OutOfMemoryError: Java heap space错误。总之,如果实际的应用程序代码中存在内存泄漏,那么以上列举的方法并不能解决问题,相反,我们将推迟这个错误。因此,更明智的做法是彻底重新评估应用程序的内存使用情况。

后话:最后使用SonarQube对项目代码进行了全文扫描,发现存在很多代码隐患和漏洞,只能逐步修复代码。

OOM异常分析

1)第一类内存溢出,也是大家认为最多的,第一反应认为是的内存溢出,也就是堆栈溢出:
java.lang.OutOfMemoryError: ......java heap space.....
原因:当你看到heap相关的时候就肯定是堆栈溢出了,此时如果代码没有问题的情况下,适当调整-Xmx和-Xms是可以避免的,不过一定是代码没有问题的前提,为什么会溢出呢,要么代码有问题,要么访问量太多并且每个访问的时间太长或者数据太多,导致数据释放不掉,因为垃圾回收器是要找到哪些是垃圾才能回收,这里它不会认为这些东西是垃圾,自然不会去回收了;注意这个溢出之前,可能系统会提前先报错关键字为:
java.lang.OutOfMemoryError:GC over head limit exceeded
原因:这种情况是当系统处于高频的GC状态,而且回收的效果依然不佳的情况,就会开始报这个错误,这种情况一般是产生了很多不可以被释放的对象,有可能是引用使用不当导致,或申请大对象导致,但是java heap space的内存溢出有可能提前不会报这个错误,也就是可能内存就直接不够导致,而不是高频GC。

2)第二类内存溢出,PermGen的溢出或者PermGen满了的提示,你会看到这样的关键字:
java.lang.OutOfMemoryError: PermGen space
原因:系统的代码非常多或引用的第三方包非常多、或代码中使用了大量的常量、或通过intern注入常量、或者通过动态代码加载等方法,导致常量池的膨胀,虽然JDK 1.5以后可以通过设置对永久带进行回收,但是我们希望的是这个地方是不做GC的,它够用就行,所以一般情况下今年少做类似的操作,所以在面对这种情况常用的手段是:增加-XX:PermSize和-XX:MaxPermSize的大小。

3)第三类内存溢出,在使用ByteBuffer中的allocateDirect()的时候会用到,很多javaNIO的框架中被封装为其他的方法。溢出关键字:
java.lang.OutOfMemoryError: Direct buffer memory
原因:如果你在直接或间接使用了ByteBuffer中的allocateDirect方法的时候,而不做clear的时候就会出现类似的问题,常规的引用程序IO输出存在一个内核态与用户态的转换过程,也就是对应直接内存与非直接内存,如果常规的应用程序你要将一个文件的内容输出到客户端需要通过OS的直接内存转换拷贝到程序的非直接内存(也就是heap中),然后再输出到直接内存由操作系统发送出去,而直接内存就是由OS和应用程序共同管理的,而非直接内存可以直接由应用程序自己控制的内存,JVM垃圾回收不会回收掉直接内存这部分的内存,所以要注意了哦。如果经常有类似的操作,可以考虑设置参数:-XX:MaxDirectMemorySize

4)第四类内存溢出错误。溢出关键字:
java.lang.StackOverflowError
原因:这个参数直接说明一个内容,就是-Xss太小了,我们申请很多局部调用的栈针等内容是存放在用户当前所持有的线程中的,线程在jdk 1.4以前默认是256K,1.5以后是1M,如果报这个错,只能说明-Xss设置得太小,当然有些厂商的JVM不是这个参数,本文仅仅针对Hotspot VM而已;不过在有必要的情况下可以对系统做一些优化,使得-Xss的值是可用的。

5)第五类内存溢出错误。溢出关键字:
java.lang.OutOfMemoryError: unable to create new native thread
原因:上面第四种溢出错误,已经说明了线程的内存空间,其实线程基本只占用heap以外的内存区域,也就是这个错误说明除了heap以外的区域,无法为线程分配一块内存区域了,这个要么是内存本身就不够,要么heap的空间设置得太大了,导致了剩余的内存已经不多了,而由于线程本身要占用内存,所以就不够用了。

6)第六类内存溢出。溢出关键字
java.lang.OutOfMemoryError: request {} byte for {}out of swap
原因:这类错误一般是由于地址空间不够而导致。

总结:六大类常见溢出已经说明JVM中99%的溢出情况,要逃出这些溢出情况非常困难,除非一些很怪异的故障问题会发生,比如由于物理内存的硬件问题,导致了code cache的错误(在由byte code转换为native code的过程中出现,但是概率极低),这种情况内存会被直接crash掉,类似还有swap的频繁交互在部分系统中会导致系统直接被crash掉,OS地址空间不够的话,系统根本无法启动;JNI的滥用也会导致一些本地内存无法释放的问题,所以尽量避开JNI;socket连接数据打开过多的socket也会报类似:IOException: Too many open files等错误信息。JNI就不用多说了,尽量少用,这种内存如果没有在被调用的语言内部将内存释放掉(如C语言),那么在进程结束前这些内存永远释放不掉,解决办法只有一个就是将进程kill掉。另外GC本身是需要内存空间的,因为在运算和中间数据转换过程中都需要有内存,所以你要保证GC的时候有足够的内存,如果没有的话GC的过程将会非常的缓慢。很多参数没啥建议值,建议值是自己在现场根据实际情况科学计算和测试得到的综合效果,建议值没有绝对好的,而且默认值很多也是有问题的,因为不同的版本和厂商都有很大的区别,默认值没有永久都是一样的,就像-Xss参数的变化一样。

Map集合双花括号初始化导致的内存泄漏问题

Map<String, String> map = new HashMap() {{
    put("map1", "value1");
    put("map2", "value2");
    put("map3", "value3");
}};

可能内存泄漏分析:
用双括号的代码其实是创建了匿名内部类,然后再进行初始化代码块。这一点我们可以使用命令 javac将代码编译成字节码之后发现,我们发现之前的一个类被编译成两个字节码(.class)文件。在Java语言中非静态内部类会持有外部类的引用,从而导致GC无法回收这部分代码的引用,以至于造成内存溢出。

为什么要持有外部类:
这个就要从匿名内部类的设计说起了,在Java语言中,非静态匿名内部类的主要作用有两个:
1、当匿名内部类只在外部类(主类)中使用时,匿名内部类可以让外部不知道它的存在,从而减少了代码的维护工作。
2、当匿名内部类持有外部类时,它就可以直接使用外部类中的变量了,这样可以很方便的完成调用,如下代码所示,在HashMap的方法内部,可以直接使用外部类的变量userName:
public class DoubleBracket {
    private static String userName = "hello world";
    public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException {
        Map<String, String> map = new HashMap() {{
            put("map1", "value1");
            put("map2", "value2");
            put("map3", "value3");
            put(userName, userName);
        }};
    }
}

为什么说可能内存溢出:
这是因为当此map被赋值为其他类属性时,可能会导致GC收集时不清理此对象,这时候才会导致内存泄漏。要想保证双花扣号不泄漏,办法也很简单,只需要将map对象声明为static静态类型的就可以了,代码如下:
public static Map createMap() {
    Map map = new HashMap() {{
        put("map1", "value1");
        put("map2", "value2");
        put("map3", "value3");
    }};
    return map;
}

为什么静态内部类不会持有外部类的引用:
原因其实很简单,因为匿名内部类是静态的之后,它所引用的对象或属性也必须是静态的了,因此就可以直接从JVM的Method Area(方法区)获取到引用而无需持久外部对象了。

即使声明为静态的变量可以避免内存泄漏,但依旧不建议这样使用,为什么呢:
原因很简单,项目一般都是需要团队协作的,假如那位老兄在不知情的情况下把你的static给删掉了,这就相当于设置了一个隐形的“坑”,其他不知道的人,一不小心就跳进去了,所以我们可以尝试一些其他的方案,比如Java8中的Stream API和Java9中的集合工厂等。即使不泄漏,也不够直观,不方便他人进行代码维护。

堆调优参数

# 常用参数
-Xms:初始堆大小,JVM启动的时候,给定堆空间大小。 

-Xmx:最大堆大小,JVM运行过程中,如果初始堆空间不足的时候,最大可以扩展到多少。 

-Xmn:设置堆中年轻代大小。整个堆大小=年轻代大小+年老代大小+持久代大小。 

-XX:NewSize=n 设置年轻代初始化大小大小 

-XX:MaxNewSize=n 设置年轻代最大值

-XX:NewRatio=n 设置年轻代和年老代的比值。如: -XX:NewRatio=3,表示年轻代与年老代比值为1:3,年轻代占整个年轻代+年老代和的1/4 

-XX:SurvivorRatio=n 年轻代中Eden区与两个Survivor区的比值。注意Survivor区有两个。两个Survivor:eden=2:8,即一个Survivor占年轻代的1/10,比值默认就为8

-Xss:设置每个线程的堆栈大小。JDK5后每个线程Java栈大小为1M,以前每个线程堆栈大小为 256K。

-XX:ThreadStackSize=n 线程堆栈大小

-XX:PermSize=n 设置持久代初始值	

-XX:MaxPermSize=n 设置持久代大小
 
-XX:MaxTenuringThreshold=n 设置年轻带垃圾对象最大年龄。如果设置为0的话,则年轻代对象不经过Survivor区,直接进入年老代。

# 不常用参数
-XX:LargePageSizeInBytes=n 设置堆内存的内存页大小

-XX:+UseFastAccessorMethods 优化原始类型的getter方法性能

-XX:+DisableExplicitGC 禁止在运行期显式地调用System.gc(),默认启用	

-XX:+AggressiveOpts 是否启用JVM开发团队最新的调优成果。例如编译优化,偏向锁,并行年老代收集等,jdk6纸之后默认启动

-XX:+UseBiasedLocking 是否启用偏向锁,JDK6默认启用	

-Xnoclassgc 是否禁用垃圾回收

-XX:+UseThreadPriorities 使用本地线程的优先级,默认启用

设置参数的方式

1)可以在IDEA,Eclipse,JVM工具里设置

2)如果上线了是WAR包的话可以在Tomcat设置

3)如果是Jar包直接执行命令进行设置,如:
java -Xms1024m -Xmx1024m -jar springboot_app.jar

GC调优参数

-XX:+UseSerialGC 设置串行收集器,年轻带收集器。

-XX:+UseParNewGC 设置年轻代为并行收集。可与CMS收集同时使用。JDK5.0以上,JVM会根据系统配置自行设置,所以无需再设置此值。

-XX:+UseParallelGC 设置并行收集器,目标是目标是达到可控制的吞吐量。

-XX:+UseParallelOldGC 设置并行年老代收集器,JDK6.0支持对年老代并行收集。 

-XX:+UseConcMarkSweepGC 设置年老代并发收集器。

-XX:+UseG1GC 设置G1收集器,JDK1.9默认垃圾收集器

-XX:MaxGCPauseMillis 设置目标停顿时间