后浪,谈谈你对jvm性能调优的理解

时间:2022-07-23
本文章向大家介绍后浪,谈谈你对jvm性能调优的理解,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

在我们日常的研发工作中, 经常会遇到系统的性能问题,这时我们必须进行系统的性能调优。系统调优分好多种,比如架构和代码优化、jvm调优、操作系统调优、数据库调优、tomcat调优、网络调优等。架构和代码优化是效率最高的调优手段,但是并不能解决所有的性能问题。今天我们要回顾的是一个老生常谈的话题,jvm调优。

本文主要包括以下内容

  • Java内存模型回顾
  • 什么时候需要JVM调优
  • 常见的OOM异常及案例
  • JVM自带监控工具
  • JVM常用调优参数
  • JVM第三方监控工具
  • 调优案例

Java内存模型回顾

首先,我们以HotSpot回顾一下JVM的内存模型,见下图:

HotSpot内存模型分为3个部分:

类加载器

类加载器用于加载java编译后的.class文件,提取其中的类信息以某种数据结构存放在方法区。

运行时数据区

线程栈和本地方法栈用于存放线程运行时方法调用等相关信息,程序计数器记录字节码指令在主内存中的地址,这3个模块都是线程私有的。

堆中存放程序运行时创建的对象。

对于jvm规范中的方法区,java8以前,HotSpot对方法区的实现是在永久代。从java7开始,HotSpot开始移除永久代,符号引用迁移到native heap,字面量和类静态变量移动到java堆。Java8中HotSpot彻底废弃了永久代,用元空间来取代永久代实现jvm规范中的方法区。元空间用来专门存储类的元数据,并且内存分配在本地内存,不占用jvm内存。

堆内存的分布如下:

G1圾收集器的堆空间分配策略如下:

后来出现的ZGC内存分配更加动态和灵活。本文以Java8为例,不讨论G1和ZGC

顺便回顾一下常用的垃圾收集算法:

a. 清除算法:会造成内存碎片,内存分配效率低

b. 压缩算法:性能开销大

c. 复制算法:堆使用效率较低

常用垃圾收集器:

新生代:Serial,Parallel Scavenge(更加注重吞吐量,不能和CMS一起使用) 和 Parallel New,都采用标记-复制算法

老年代:Serial Old(标记-压缩算法) 和 Parallel Old(标记-压缩算法),以及 CMS(标记-清除算法,支持并发),java9中被G1取代

执行引擎

HotSpot解释执行器对加载的字节码会逐条解释成机器码进行执行,对于反复执行的热点代码,JIT Compiler会把字节码编译成机器代码后再执行。

垃圾收集器则是对死亡对象占用的堆内存空间进行回收。

在上面的JVM内存模型架构图中,紫色的3个区域是我们调优时的关注点。Heap是存放对象数据的区域,这个区域由Garbage Collector进行管理,Garbage Collector在JVM启动时可以指定。JVM调优一般都围绕着修改Heap的大小和选择最合适的Garbage Collector。而JIT Compiler虽然也会对应用的性能有大的影响,但是新版本的JVM是不需要进行优化的。

什么时候需要JVM调优

从表象来看,当应用的响应慢或者已经不能提供服务了,或应用吞吐量小,占用内存空间过大,我们就需要对应用进行调优了。这些表象一般伴随着频繁的垃圾回收,或者OOM。

JVM调优的指标一般有3个

应用占用的内存

主要是分配给jvm的堆内存,由启动jvm时-Xms和-Xmx参数指定,分别是jvm启动时分配的内存和运行时可以分配的最大内存。

吞吐量

比如每秒钟处理的事务数量,每小时完成的跑批任务数,每小时完请求数据库成功的数量。

响应延迟

从应用收到请求到返回响应所耗费的时间,或者浏览器发出请求到页面渲染的时间。

常见的OOM异常及复现方法

OOM是我们程序员最不想看到的异常,但是时常发生在我们的工作中。在jvm没有足够内存为新创建的对象分配空间,并且没有足够内存为垃圾收集器使用时就会触发,java应用就会触发OOM。当然,linux本身也有OOM killer机制,当内核监控到进程占用空间过大时,尤其是内存瞬间增大时,为了防止耗尽内存,会触发OOM杀死进程。Java中常见的OOM如下:

java.lang.OutOfMemoryError: Java heap space

这个异常的原因无非2个,内存泄漏和内存溢出。内存溢出的时候,需要调整JVM参数-Xmx配置,调大堆空间,如果是内存泄漏,就需要找出泄漏的代码,这个见后面监控工具讲解。

下面代码是一段典型的内存泄漏的代码,启动时设置-Xmx512m

public class HeapSize {
    public static void main(String[] args){
        List<User> list = new ArrayList<>();
        User user = new User();
        while (true){
            list.add(user);
        }
    }
}

执行后等待一段时间:

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.util.Arrays.copyOf(Arrays.java:3210)
at java.util.Arrays.copyOf(Arrays.java:3181)
at java.util.ArrayList.grow(ArrayList.java:261)
at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:235)
at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:227)
at java.util.ArrayList.add(ArrayList.java:458)
at boot.oom.HeapSize.main(HeapSize.java:18)

java.lang.OutOfMemoryError: GC overhead limit exceeded

这种异常的原因是垃圾收集器GC效率很低,jvm花费超过 98%的 CPU 时间来进行一次 GC,但是回收的内存却少于 2%的堆空间大小,并且GC连续超过5次都这样

public class GcOverrhead {
    public static void main(String[] args){
        Map map = System.getProperties();
        Random r = new Random();
        while (true) {
            map.put(r.nextInt(), "value");
        }
    }
}

上面代码启动时加参数:-Xmx45m -XX:+UseParallelGC -XX:+PrintGCDetails运行一段时间,就会出现以下异常,注意:这个参数只是作者本地的环境,需要根据自己环境相应修改

Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded
at java.util.Hashtable.addEntry(Hashtable.java:435)
at java.util.Hashtable.put(Hashtable.java:476)
at boot.oom.HeapSize.main(HeapSize.java:20)

通过增加参数-XX:-UseGCOverheadLimit可以避免这个异常,但其实是自己骗自己,还是需要实际去定位解决问题。

java.lang.OutOfMemoryError: Requested array size exceeds VM limit

这个异常很容易理解,请求分配的数组大小超过jvm限制,出现这种情况的原因有2个:

请求分配的数组太大,导致jvm空间不足

请求的数组大于等于Integer.MAX_INT - 1

如下2段代码:

这段代码直接抛出Requested array size exceeds VM limit

int[] arr = new int[Integer.MAX_VALUE - 1];

这段代码先抛出 Java heap space后再抛出Requested array size exceeds VM limit

for (int i = 3; i >= 0; i--) {
    try {
        int[] arr = new int[Integer.MAX_VALUE-i];
        System.out.format("Successfully initialized an array with %,d elements.n", Integer.MAX_VALUE-i);
    } catch (Throwable t) {
        t.printStackTrace();
    }
}

结果如下:

java.lang.OutOfMemoryError: Java heap space
at boot.oom.ArraySizeExceeds.main(ArraySizeExceeds.java:12)
java.lang.OutOfMemoryError: Java heap space
at boot.oom.ArraySizeExceeds.main(ArraySizeExceeds.java:12)
java.lang.OutOfMemoryError: Requested array size exceeds VM limit
at boot.oom.ArraySizeExceeds.main(ArraySizeExceeds.java:12)
java.lang.OutOfMemoryError: Requested array size exceeds VM limit
at boot.oom.ArraySizeExceeds.main(ArraySizeExceeds.java:12)

java.lang.OutOfMemoryError: MetaSpace

元空间在前面已经讲过了,这个异常是元空间不足,解决办法是加大元空间大小,配置参数MaxMetaSpaceSize,我们在启动引用时加入参数:

-XX:MaxMetaspaceSize=2m,直接报错:

Error occurred during initialization of VM
OutOfMemoryError: Metaspace

java.lang.OutOfMemoryError: Request size bytes for reason. Out of swap space

这个异常是操作系统的swap空间不够引起的。我们知道jvm分配的最大内存由Xmx等一些参数指定,如果jvm需要的总内存超出了宿主机可以分配的最大的物理内存,就会用到swap space,如果swap space不足,jvm内存分配就会失败,从而抛出这个异常。这个异常的定位比较复杂,有可能是宿主机上面的其他进程耗用内存太多导致。所以我不建议用增加swap space这种粗暴的方式,禁用swap,做好进程的隔离是比较妥当的解决方案。

java.lang.OutOfMemoryError: Unable to create native threads

这个异常也是操作系统级别的。大家知道,java的线程是操作系统级别的,java每申请一个线程,就需要调用操作系统创建一个本地的线程,操作系统创建线程失败,会抛出上面的异常。具体原因有以下几种:

a. 内存空间不够,jvm启动时参数-Xss指定每个线程占用的堆栈大小,如果内存不够,就会创建线程失败

b. 操作系统上ulimit中max user processes参数限制,这个参数指操作系统可以创建的全局线程数量

ulimit -a | grep 'max user processes'命令可以查看,如下图:

ulimit -u可以修改这个参数,比如ulimit -u 10000,则操作系统可以创建10000个线程。

c. 参数sys.kernel.threads-max限制,我们可以通过命令

cat /proc/sys/kernel/threads-max来查看,如下图:

想要修改这个参数,需要在/etc/sysctl.conf文件,加入sys.kernel.threads-max = 10000

d. 参数sys.kernel.pid_max限制,这个参数只是每创建一个线程,都需要分配一个pid,当pid的值大于这个值时,就会创建失败。查看命令:cat /proc/sys/kernel/pid_max

想要修改这个参数,需要在/etc/sysctl.conf文件,加入sys.kernel.pid_max =10000

private static void crateSlowThread(){
        try {
            System.out.println(Thread.currentThread());
            Thread.currentThread().sleep(15000);
        } catch (InterruptedException e)
            e.printStackTrace();
        }
    }

    public static void test1() {
        while (true){
            new Thread(() -> crateSlowThread()).start();
        }
    }

看上面这个代码,在死循环内部不停地创建线程,最后就会复现这个OOM:见下图:

下面这段代码模拟高并发

public static void test() {
        for (int i = 0; i < 20; i ++){
            System.out.println(Thread.currentThread());
            new Thread(() -> crateSlowThread()).start();
        }
    }

    private static void crateSlowThread(){
        try {
            System.out.println(Thread.currentThread());
            Thread.currentThread().sleep(15000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    @RequestMapping("/createNativeThreads1")
    public String createNativeThreads1(){
        System.out.println("createNativeThreads test1");
        CreateNativeThreads.test1();
        return "Sucess!";
    }

在jmeter中进行测试:

JVM自带监控工具

JPS列出目标虚拟机上的所有进程

使用示例:jps -mlvV

主要参数-m 打印传递给主类的参数-l 打印模块名以及包名-v 打印jvm启动参数,比如-XX:+HeapDumpOnOutOfMemoryError-V 输出通过标记的文件传递给JVM的参数

jstat主要监控虚拟机的性能数据

使用示例:jstat -gc -h 2 44074 1s 5

基本参数:-t展示从虚拟机运行到现在的性能数据-h n 当n大于0是每隔几行展示行头部信息vmid 展示虚拟机表示interval 展示性能采样数据的间隔时间count 展示性能指标的次数性能参数:Class 类加载器统计信息Compiler 即时编译器统计信息Gc 堆垃圾回收信息Gccapacity 各代的空间信息Gccause 同gcutilGcnew 新生代统计信息Gcnewcapacity 展示新生代空间占用情况Gcold 老年代统计信息Gcoldcapacity 展示老年代空间占用情况Gcmetacapacity:meta space 空间大小信息Gcutil 统计垃圾收集汇总信息Printcompilation:Displays Java HotSpot VM compilation method statistics.

jmap展示指定进程的对象共享内存或堆内存信息

使用示例:

将堆中所有存活对象导出

jmap -dump:live,format=b,file=filename.bin

打印堆中存活对象

jmap -histo:live 44074

主要参数如下:-clstats 展示被加载类的信息-finalizerinfo 展示所有待 finalize 的对象-histo 展示各个类的实例数目以及占用内存,并按照内存使用量从多至少的顺序排列-histo:live 展示堆中的存活对象-dump 导出 Java 虚拟机堆的快照-dump:live 只保存堆中的存活对象

当系统OOM后,如果服务已经挂了,或者监控系统监控到服务关闭后重启,时候通过 jmap命令已经不能导出堆快照了,所以我们需要在启动虚拟机时加入下面2个参数:

-XX:+HeapDumpOnOutOfMemoryError

-XX:HeapDumpPath=dump

jinfo查看或修改Java 进程的参数

使用示例:

展示参数配置信息jinfo 44074

修改进程参数jinfo -flag +HeapDumpAfterFullGC 44074

主要参数:-flag name 打印参数名是name的参数值-flag [+|-]name 更改bool类型参数值-flag name=value 增加参数对-flags 打印传递给jvm的参数对-sysprops 打印java系统参数对

jstack打印java进程的线程栈信息,已经线程持有的锁

示例:jstack 44074

输出如下:

常用参数:-F -l参数无响应是强制打印快照信息-l 打印有关锁的额外信息比如Locked ownable synchronizers-m Prints a mixed mode stack trace that has both Java and native C/C++ frames.

JVM常用调优参数

堆空间设置: -Xmx4g 进程占用的最大堆空间大小,超出后会OOM -Xms2g 初始化堆空间大小 -Xmn1g 年轻代大小,官方推荐配置为整个堆的3/8 -XX:NewRatio=n 年轻代和老年代空间大小比值 -Xss512k 每个线程占用内存大小 -XX:SurvivorRatio=n:年轻代中Eden区与Survivor区的比值。比如n=4,则Eden和Survivor比值为4:2,survivor占年轻代一半 -XX:MetaspaceSize=512m 元空间大小 -XX:MaxMetaspaceSize=512m 这个参数用于限制Metaspace增长的上限,防止因为某些情况导致Metaspace无限的使用本地内存 -XX:MinMetaspaceFreeRatio=N 当进行过Metaspace GC之后,会计算当前Metaspace的空闲空间比,如果空闲比小于这个参数,那么虚拟机将增长Metaspace的大小。在本机该参数的默认值为40,也就是40%。设置该参数可以控制Metaspace的增长的速度,太小的值会导致Metaspace增长的缓慢,Metaspace的使用逐渐趋于饱和,可能会影响之后类的加载。而太大的值会导致Metaspace增长的过快,浪费内存 -XX:MaxMetasaceFreeRatio=N 当进行过Metaspace GC之后, 会计算当前Metaspace的空闲空间比,如果空闲比大于这个参数,那么虚拟机会释放Metaspace的部分空间。在本机该参数的默认值为70,也就是70%。 -XX:MaxMetaspaceExpansion=N Metaspace增长时的最大幅度 垃圾收集器设置 -XX:+UseSerialGC 设置串行收集器 -XX:+UseParallelGC 设置并行收集器 -XX:+UseParalledlOldGC 设置并行年老代收集器 -XX:+UseConcMarkSweepGC 设置并发收集器 -XX:ParallelGCThreads=n 设置并行收集器收集时使用的线程数 -XX:MaxGCPauseMillis=n 设置并行收集最大暂停时间 -XX:GCTimeRatio=n 设置垃圾回收时间占程序运行时间的百分比,1/(1+n) -XX:+DisableExplicitGC 禁止外部调用System.gc() -XX:MaxTenuringThreshold 年轻代复制多少次才会进入老年代 垃圾回收统计信息 -XX:+PrintGC -XX:+PrintGCDetails -XX:+PrintGCTimeStamps 打印每次垃圾回收前,程序未中断的执行时间 -Xloggc:filename 把gc日志存入文件 -XX:+PrintGCApplicationStoppedTime 打印垃圾回收期间程序暂停的时间 -XX:+PrintGCApplicationConcurrentTime 打印每次垃圾回收前,程序未中断的执行时间 -XX:+PrintHeapAtGC 打印GC前后的详细堆栈信息 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dump

JVM第三方监控工具

eclipse mat

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

eclipse mat是分析java应用非常常用的工具,可以集成在eclipse,也可以单独安装。我们还是以之前的一个OOM异常案例来介绍

public static void test(){
        List<User> list = new ArrayList<>();
        User user = new User();
        while (true){
            list.add(user);
        }
    }

启动应用命令:

java -jar -XX:+PrintGC -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=./ spring-boot-mybatis-1.0-SNAPSHOT.jar

启动后调用这个方法,程序抛出了OOM,生成了堆转存文件:java_pid46242.hprof,接着我们打开mat工具,导入刚刚的对转存文件,如下图:

MAT 计算对象占据内存方式有2种。第一种是 Shallow heap,统计对象占用内存。第二种是 Retained heap,统计当对象不再被引用时,垃圾回收器所能回收的总内存,包括对象自身所占据的内存,以及仅能够通过该对象引用到的其他对象所占据的内存。上面的饼状图便是基于 Retained heap 的。

从上面的报告中,我们看出有内存泄漏的情况,点进去后就能找到内存泄漏点,如下图:

阿里诊断工具 Arthas

工具地址:

https://alibaba.github.io/arthas/arthas-tutorials?language=cn&id=arthas-basics
https://alibaba.github.io/arthas/arthas-tutorials?language=cn&id=arthas-advanced

使用场景:

找到类所在jar包

找出异常的原因

找到代码没有执行到的原因

线上debug

全局监控系统状态

实时监控 JVM 运行状态

IBM heap anolyzer

这个工具是找出堆内存泄漏的一款图形化工具,界面如下:

官网地址:

https://www.ibm.com/support/pages/ibm-heapanalyzer

这款工具目前IBM已经不再更新,并且官网推荐使用MAT

调优案例

死锁诊断

下面代码是一段经典的死锁案例

public static void test() {
        Object lockA = new Object();
        Object lockB = new Object();

        new Thread(() ->{
            synchronized (lockA){
                try {
                    Thread.sleep(2000);
                }catch (InterruptedException e){
                    e.printStackTrace();
                }
                synchronized (lockB){
                    System.out.println("thread 1");
                }
            }
        }).start();

        new Thread(() ->{
            synchronized (lockB){
                synchronized (lockA){
                    System.out.println("thread 2");
                }
            }
        }).start();
    }

在main函数启动后一直不能执行结束,用http方式调用这个方法,发现一直没有返回结果。输入命令:jstack 45309 > deadlock.txt 然后查看生产的文件,能看到BOLOCKED状态的线程,如下图:

堆内存参数设置

我们在java应用启动时加入下面2个参数,就会在日志里面打印详细的垃圾收集信息

-XX:+PrintGC

-XX:+PrintGCDetails

如下是一个Full GC的日志,我们来分析一下

[Full GC (Allocation Failure) [PSYoungGen: 0K->0K(150528K)] [ParOldGen: 243998K->142439K(172032K)] 243998K->142439K(322560K), [Metaspace: 47754K->47754K(1093632K)], 3.6879500 secs] [Times: user=3.91 sys=0.00, real=3.69 secs]

Full GC:表示是一个Full GC垃圾回收,如果不带Full,那就表示是Minor GC

Allocation Failure:本次垃圾回收的原因是因为年轻代没有足够的内存分配给新的对象

[PSYoungGen: 0K->0K(150528K)]:这3个数值分别代表年轻代垃圾收集前占用的堆内存大小,年轻代垃圾收集后占用的堆内存大小,年轻代占用堆内存总大小

[ParOldGen: 243998K->142439K(172032K)]:这3个数值分别代表老年代垃圾收集前占用的堆内存大小,老年代垃圾收集后占用的堆内存大小,老年代占用堆内存总大小

243998K->142439K(322560K):这2个数值分别代表堆内存垃圾收集前使用量,堆内存垃圾收集后使用量,堆空间总大小

[Metaspace: 47754K->47754K(1093632K)]:这3个数值分别代表元空间垃圾收集前占用的内存大小,元空间垃圾收集后占用的内存大小,元空间总大小

3.6879500 secs:本次GC持续的时间

[Times: user=3.91 sys=0.00, real=3.69 secs]:这3个时间表示GC线程消耗的CPU时间,GC过程系统调用和等待花费的时间,应用程序暂停的时间。

堆内存大小设置

上面分析可知,老年代垃圾收集后占用的堆内存大小是142439K=139M

我们根据这个数值来指定堆空间大小,我们的应用中建议-Xms和-Xmx参数设置为一样大小,这样可以减少启动初期的GC次数,同时避免JVM在运行过程中向OS申请内存。这2个参数建议设置为老年代垃圾收集后占用的堆内存大小的3~4倍,本案例中即139M*(3~4),官方建议年轻代设置为堆内存总大小的3/8,所以年轻代大小为-Xmn139M*(3~4) * 3/8

元空间大小设置

上面分析可知,元空间垃圾收集后占用的内存大小是47754K=47M

-XX:MetaspaceSize -XX:MaxMetaspaceSize这2个值建议设置为上面值的1.2~1.5倍,即47M * (1.2~1.5)

综上取最大分析值,启动参数为:

java -jar -Xms556m -Xmx556m -Xmn208m -XX:MetaspaceSize=70m -XX:MaxMetaspaceSize=70m -XX:+PrintGC -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=./ spring-boot-mybatis-1.0-SNAPSHOT.jar

垃圾收集时间

对于Minor GC耗费时间跟年轻代空间大小成正比,Minor GC触发频率跟年轻代空间大小成反比。示例如下图:

我们在日志中取样,在10s中时间内Minor GC触发了8次,频率为 次/0.8s,这8次GC的平均时间为:0.05s=50ms

如果我们系统调优指标是40ms,那就需要减小年轻代大小,上面案例中,我们年轻代大小减少20%,208m * 80%

最后,JVM调优是一个永久的话题,本人能力有限,欢迎大家批评指正

参考文章:

https://docs.oracle.com/javase/8/docs/technotes/tools/unix/index.html
https://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/index.html
https://www.oracle.com/technetwork/tutorials/tutorials-1876574.html#t1s2
https://plumbr.io/outofmemoryerror
http://openjdk.java.net/jeps/122

文中源代码地址:

https://github.com/jinjunzhu/spring-boot-mybatis