在我们日常的研发工作中, 经常会遇到系统的性能问题,这时我们必须进行系统的性能调优。系统调优分好多种,比如架构和代码优化、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
代码语言:javascript复制public class HeapSize {
public static void main(String[] args){
List<User> list = new ArrayList<>();
User user = new User();
while (true){
list.add(user);
}
}
}
执行后等待一段时间:
代码语言:javascript复制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次都这样
代码语言:javascript复制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运行一段时间,就会出现以下异常,注意:这个参数只是作者本地的环境,需要根据自己环境相应修改
代码语言:javascript复制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
代码语言:javascript复制int[] arr = new int[Integer.MAX_VALUE - 1];
这段代码先抛出 Java heap space后再抛出Requested array size exceeds VM limit
代码语言:javascript复制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();
}
}
结果如下:
代码语言:javascript复制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,直接报错:
代码语言:javascript复制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
代码语言:javascript复制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:见下图:
下面这段代码模拟高并发
代码语言:javascript复制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异常案例来介绍
代码语言:javascript复制public static void test(){
List<User> list = new ArrayList<>();
User user = new User();
while (true){
list.add(user);
}
}
启动应用命令:
代码语言:javascript复制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
工具地址:
代码语言:javascript复制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
这个工具是找出堆内存泄漏的一款图形化工具,界面如下:
官网地址:
代码语言:javascript复制https://www.ibm.com/support/pages/ibm-heapanalyzer
这款工具目前IBM已经不再更新,并且官网推荐使用MAT
调优案例
死锁诊断
下面代码是一段经典的死锁案例
代码语言:javascript复制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的日志,我们来分析一下
代码语言:javascript复制[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)
综上取最大分析值,启动参数为:
代码语言:javascript复制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调优是一个永久的话题,本人能力有限,欢迎大家批评指正
参考文章:
代码语言:javascript复制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
文中源代码地址:
代码语言:javascript复制https://github.com/jinjunzhu/spring-boot-mybatis