深入浅出JVM实战调优

深入浅出JVM实战调优,第1张

写在前面

在工作中不可避免的会遇到JVM问题,本文就从实战的角度来一起学习下,我们分析这类问题时需要考虑到如下的因素:

可能有哪些原因导致JVM问题:如磁盘空间不足,CPU过高,堆内存溢出,线程死锁等。
可以使用哪些工具进行排查:GUI工具jconsole,jvirtualvm,linux工具arthas,jdk自带工具jmap,kcmd,jps,jstack,jinfo等。

即先确定现象,然后使用工具确定导致现象的原因,对应的思维导读如下:

可关联查看如下文章列表:

JVM工具之Arthas使用

6款工具助力分析JVM问题

JVM工具之jmap命令使用

1:磁盘空间不足

磁盘空间不足,准确来说不是JVM本身的问题,而是系统层面的问题,但是也会影响JVM的执行,所以这里也需要来看下。一般按照如下的步骤查找。首先使用df -h命令查看哪个目录磁盘空间不足,如下:

假设红框中的有问题,可以继续查看/目录,确定到底是哪个目录的文件太大了,没有用的可以直接删除了,使用的命令是du -sh *:

这样一层一层的目录查找,然后删除无用的大文件就行了。

2:CPU过高排查

CPU过高一般的原因是如线程过多导致频繁的线程上下文切换,大对象导致的频繁GC,程序中复杂的数学运算等,这里我们以复杂的数学运算作为例子来看下。

2.1:准备测试环境

注意包路径是dongshi.daddy.zhengxi

测试源码如下:

package dongshi.daddy.zhengxi;

import java.util.ArrayList;
import java.util.List;
import java.util.Random;

public class ManyCpu {

    private static List<Long> myList = new ArrayList<>();
    public static void main(String[] args) {
        new Thread(() -> {
            while (true) {
                int aa = 88888888 + new Random().nextInt(10000);
                int bb = 98888888 + new Random().nextInt(10000);
                System.out.println(aa * bb);
                System.out.println((aa + 1) * bb);
                System.out.println(aa * (bb + 1));
            }
        }, "thread-testmanycpu").start();

    }
}

*** 作步骤如下:

[root@bogon study]# pwd
/study
[root@bogon study]# ls
ManyCpu.java
[root@bogon study]# mkdir target
[root@bogon study]# javac ManyCpu.java -d target/
[root@bogon study]# cd target/
[root@bogon target]# java dongshi.daddy.zhengxi.ManyCpu
1980512723
2079405938
2069408032
...
2.2:问题定位

首先我们需要定位到到底是哪个进程占用了CPU资源。

2.2.1:top

使用top命令定位占用CPU高的进程:

可以看到进程号为11910其实就是我们在前面启动的Java进程,也可以通过jps验证,如下:

[root@bogon ~]# jps -l | grep ManyCpu
11910 dongshi.daddy.zhengxi.ManyCpu

知道了占用CPU资源的进程之后,需要进一步定位是进程中的哪个线程占用了更多的CPU资源。

2.2.2:定位线程

使用的命令是top -Hp PID

定位到的线程号是11924。因为后续定位到具体代码展示的进程号信息是16进制的,所以这里先转为16进制,方便后便查看:

[root@bogon ~]# printf '%x\n' 11924
2e94

结果为2e94,这里记录下,后面用到。

2.2.3:定位程序位置

使用命令jstack {PID}|grep {十六进制线程ID} -A 30

红框中对应的就是我们的如下代码:

像这种给线程提了具有明确业务含义名称的场景,也可以使用arthas的thread 命令直接定位线程,也是这一种方案。

3:OOM

out of memory error,内存溢出错误,主要有以下几方面的内容需要我们关注:

1:哪些区域可能出现OOM
2:出现OOM可能的原因有哪些
3:如何处理和查找OOM

详细的可以参考如下思维导图:

3.1:测试代码
public class OOM {
    private static List<User> list = new ArrayList<>();

    static class User {
    }

    public static void main(String[] args) {
        for (int i = 0; i < Integer.MAX_VALUE; i++) {
            list.add(new User());
        }
    }
}
3.2:参数设置

为了便于测试OOM设置参数-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=d:\test\oom,在idea中配置如下:

3.3:运行测试

运行程序,如果一切正确,则会很快输出如下信息:

查看生成的堆转储pdrof文件:

3.4:定位问题

使用jvisualVM工具,打开工具后文件->装入,打开后如下图:

然后点击标签,进入下图:

可以看到红框中的类就是造成OOM的原因,然后就可以到代码中进一步定位原因了。

4:栈溢出

栈溢出对应的错误信息信息是java.lang.StackOverflowError,一般造成的原因是方法调用栈过深,或者是方法过长,局部变量过多,导致局部变量表过大,对应的设置参数是-Xss,如下测试代码就很容易复现这种问题:

public class StackOverflowErrorTest {

    public static void main(String[] args) {
        test();
    }
    private static void test() {
        test();
    }
}

错误如下:

Exception in thread "main" java.lang.StackOverflowError
	at java.nio.Buffer.(Buffer.java:201)
	at java.nio.CharBuffer.(CharBuffer.java:281)
	at java.nio.HeapCharBuffer.(HeapCharBuffer.java:70)
	at java.nio.CharBuffer.wrap(CharBuffer.java:373)
	at sun.nio.cs.StreamEncoder.implWrite(StreamEncoder.java:265)
	at sun.nio.cs.StreamEncoder.write(StreamEncoder.java:125)
	at java.io.OutputStreamWriter.write(OutputStreamWriter.java:207)
	at java.io.BufferedWriter.flushBuffer(BufferedWriter.java:129)
	at java.io.PrintStream.write(PrintStream.java:526)
	at java.io.PrintStream.print(PrintStream.java:669)
	at java.io.PrintStream.println(PrintStream.java:806)
	at dongshi.daddy.zhengxi.StackOverflowErrorTest.test(StackOverflowErrorTest.java:10)

解决办法是调整程序逻辑,如果是程序逻辑没有问题的话可以适当调整-Xss参数。

5:死锁

当发生了线程的循环等待时就会出现死锁,如下测试代码:

public class DeadLock {

    public static Object lock1 = new Object();
    public static Object lock2 = new Object();

    public static void main(String[] args) {
        Thread a = new Thread(new Lock1(), "DeadLock1");
        Thread b = new Thread(new Lock2(), "DeadLock2");
        a.start();
        b.start();
    }
}

class Lock1 implements Runnable {
    @Override
    public void run() {
        try {
            while (true) {
                synchronized (DeadLock.lock1) {
                    System.out.println("Waiting for lock2");
                    Thread.sleep(3000);
                    synchronized (DeadLock.lock2) {
                        System.out.println("Lock1 acquired lock1 and lock2 ");
                    }
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

class Lock2 implements Runnable {
    @Override
    public void run() {
        try {
            while (true) {
                synchronized (DeadLock.lock2) {
                    System.out.println("Waiting for lock1");
                    Thread.sleep(3000);
                    synchronized (DeadLock.lock1) {
                        System.out.println("Lock2 acquired lock1 and lock2");
                    }
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

运行程序如下即为出现死锁:

Waiting for lock2
Waiting for lock1

下面我们通过不同的方式来排查死锁原因。

5.1:jps+stack

首先使用jps定位进程号:

[root@localhost ~]# jps | grep "DeadLock"
1213 DeadLock

然后使用stack -PID定位死锁原因:

[root@localhost ~]# jstack 1213 | grep "Found one Java-level deadlock" -A 100
Found one Java-level deadlock:
=============================
"DeadLock2":
  waiting to lock monitor 0x00007f654c0062c8 (object 0x00000000f0c5f650, a java.lang.Object),
  which is held by "DeadLock1"
"DeadLock1":
  waiting to lock monitor 0x00007f654c004e28 (object 0x00000000f0c5f660, a java.lang.Object),
  which is held by "DeadLock2"

Java stack information for the threads listed above:
===================================================
"DeadLock2":
	at dongshi.daddy.zhengxi.Lock2.run(DeadLock.java:44)
	- waiting to lock <0x00000000f0c5f650> (a java.lang.Object)
	- locked <0x00000000f0c5f660> (a java.lang.Object)
	at java.lang.Thread.run(Thread.java:748)
"DeadLock1":
	at dongshi.daddy.zhengxi.Lock1.run(DeadLock.java:25)
	- waiting to lock <0x00000000f0c5f660> (a java.lang.Object)
	- locked <0x00000000f0c5f650> (a java.lang.Object)
	at java.lang.Thread.run(Thread.java:748)

Found 1 deadlock.
5.2:jvisualvm

其中红色方块的就是可能死锁的,另外在上方会提示"检测到死锁!生成一个线程 Dump 以获取更多信息。",我们点击线程Dump,进入如下页面:

可以看到和5.1:jps+stack结果类似。

5.3:arthas的thread命令

关于arthas的基础使用可以参考[这篇文章](https://blog.csdn.net/wang0907/article/details/124343264) 。

重点需要关注线程状态为BLOCKED的线程,提示没有5.1:jps+stack5.2:jvisualvm清晰,但也是一种选择。到这里关于问题的定位我们已经分析完毕了,接下来看下如何进行JVM的优化,主要是针对堆的优化。

6:调优实战 6.1:测试代码
class OOM11 {
	private static List<User> list = new ArrayList<>();

	static class User{
		private String name;
		private int age;

		public User(String name, int age){
			this.name = name;
			this.age = age;
		}

	}

	public static void main(String[] args) throws InterruptedException {
		for (int i = 0; i < Integer.MAX_VALUE; i++) {
		     Thread.sleep(5);
			StringBuffer sb = new StringBuffer();
			IntStream.rangeClosed(1, 300).forEach(v -> {
				sb.append(v);
			});
			String sbStr = sb.toString();
			list.add(new User(sbStr + i, i));
		}
	}
}
6.2:模拟内存设置过小导致GC次数多

设置参数-Xms50m -Xmx50m -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+UseGCLogFileRotation -XX:+PrintHeapAtGC -XX:NumberOfGCLogFiles=5 -XX:GCLogFileSize=50M -Xloggc:d:\test\emps-gc-%t.log -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=d:\test\oom11,主要参数作用如下:

-Xms50m -Xmx50m:设置堆的最大和最小内存都是50m。
-XX:+PrintGCDetails:打印GC详细信息。
-XX:+PrintGCDateStamps:打印GC的系统时间信息。
-XX:+UseGCLogFileRotation:滚动生成GC日志。
-XX:NumberOfGCLogFiles=5:GC日志文件数为5个。
-XX:GCLogFileSize=50M:GC日志文件大小为50M。
-Xloggc:d:\test\emps-gc-%t.log:设置GC日志文件的路径和名称。
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=d:\test\oom11:OOM时生成堆转储。

idea设置如下:

通过jvisualvm查看GC情况如下图:

可以看到一段时间内发生的YGC的次数是33次,耗时72.763ms,FGC发生的次数是442次,耗时4.778秒。从图中的每个区域的内容,可以看出,是因为内存设置的太小了,接下来我们增加内存内存试一下,参数修改为-Xms2048m -Xmx2048m -Xss256k -XX:+UseConcMarkSweepGC -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+UseGCLogFileRotation -XX:+PrintHeapAtGC -XX:NumberOfGCLogFiles=5 -XX:GCLogFileSize=50M -Xloggc:d:\test\emps-gc-%t.log -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=d:\test\oom11,主要是将堆内存增加到2g,运行一会大概十几分钟如下图:

可以看到在十几分钟的时间内就发生了4次YGC,耗时239.195ms(从当前的内存图看,一个小时左右就会发生FGC,时间关系我就没再等了),单次YGC耗时59.79875,而修改参数前YGC的次数是33次,耗时72.763ms,单次YGC耗时2.2ms,次数虽然减少了,但是单次的耗时却明显增加了,原因是参数-XX:+UseConcMarkSweepGC,CMS垃圾回收器使用的是复制算法,而我们的的测试程序都是存活的对象(实际情况,百分之九十以上的对象都是朝生夕死的,这里只是为了测试),所以实际情况我们还是要不断的调整参数,去获取一个最优的平衡点。优化的过程,对于垃圾回收器的选择异常重要,因此我们就重点看下常用的垃圾回收器以及各自的特点和使用场景,具体参考7:常用的垃圾回收器

7:常用的垃圾回收器

垃圾收集器的核心是垃圾收集算法,因此我们先来看下垃圾收集算法,再来看下常见的垃圾收集器。

7.1:垃圾收集算法

垃圾收集算法主要有如下4中:

标记-清除算法
标记-整理算法
复制算法
分代收集算法

具体如下思维导图:

接下来我们每种看下,并详细分析下其适用的场景。

7.1.1:标记-清除

标记清除算法,是将内存分为整个一块,首先会从GC ROOT对可达对象进行标记,然后对没有标记的对象进行清除,这个过程如下图:

这种算法会产生内存碎片,降低内存利用率。如果是对象死亡率很高的区域该算法既可以降低内存碎片的影响(对象回收后,内存虽然不连续,但是是大块内存),又可以避免对象移动的开销。

需要注意清除,并非置空,而是将垃圾对象占用的内存空间地址维护到一个闲置列表中,待有新对象需要分配内存时可分配闲置列表对应的内存空间。

7.1.2:标记-整理

标记-整理算法,是将内存分为整个一块,首先会从GC ROOT对可达对象进行标记,然后将标记对象存活对象移动到一端,最后清理掉另一端的可回收对象内存,如下图:

这种算法可以避免内存碎片的问题,但是需要移动对象。如果是对象的死亡率很低。可以考虑使用这种算法,最大优势就是避免了内存碎片问题。

7.1.3:复制算法

复制算法,是将内存均分为2块,只有其中一块用于存储对象,另一块不存储对象,其中存储对象的内存块叫做from块,不存储对象的内存块叫做to块,是一种典型的空间换时间的算法。算法工作过程如下图:

这种算法需要移动对象,不会产生内存碎片,但是内存利用率减半,适度的空间浪费换取更快速的GC时长有时也是值得的。

7.1.4:分代收集

分代收集算法,将内存空间分为年轻代(eden+s0+s1),年老代,其中年轻代和年老代默认比例1:2,eden和s0,s1比例8:1:1,另外不同的区域根据各自对象的特点使用不同的算法。

8:常用的垃圾回收器

我们下来看一张思维导图,有一个整体的认识:

接下来我们分别看下每种垃圾收集器。

8.1:Serial

这是一种单线程的垃圾收集器,在计算机的单核时代,该类型垃圾收集器使用较多,当前,计算机基本都是多核的了,所以该种类型的垃圾收集器也基本淘汰,不再使用,但是为了完整性,还是来看下。该垃圾收集器使用算法是7.1.3:复制算法,如果需要开启可以使用参数-XX+UseSerialGC进行设置。

8.2:ParNew

8.1:Serial的多线程版本,除了除了支持多线程GC外,其他一样,主要的参数如下:

开启:-XX:+UseParNewGC
设置GC线程数:-XX:ParallelGCThreads={线程数},默认系统核数。
8.3:Parallel Scavenge

这是一种吞吐量优先的垃圾收集器,因此又叫做吞吐量收集器,使用的算法是7.1.3:复制算法,对吞吐量要求高的场景,如和用户无直接交互的后台应用程序,主要参数如下:

设置最大暂停时间ms:-XX:MaxGCPauseMillis。
设置GC时长占比(1到100):-XX:GCTimeRatio。
自动调整(获取最大吞吐量):-XX:+UseAdptiveSizePolicy。

以上用于年轻代的主要垃圾收集器就分析完毕了,可以看到因为年轻代的对象每次垃圾回收死亡率很高有资料说这个数字是98%,因此使用算法都是更加适合这种场景的7.1.3:复制算法。接下来我们看下老年代常用的垃圾回收器。

8.4:Serial Old

这是8.1:Serial的老年代版本,采用的也是单线程,因为老年代的对象存活率高,所以采用的收集算法是7.1.2:标记-整理

8.5:Parallel Old

和年轻代的8.3:Parallel Scavenge算法对应,也是吞吐量优先的垃圾回收器,采用的垃圾回收算法是7.1.2:标记-整理,该垃圾回收器可以和Parallel Scavenge配合使用,已达到理论上的最大吞吐量。

8.6:CMS

用于老年代的垃圾回收器,使用到算法是标记-清除,是一种以低停顿时间为目标的垃圾收集器,和Parallel Old刚好相反,Parallel Old是以高吞吐量为目标的垃圾收集器,因为CMS以低停顿时间为目标,所以其很适合用于和用于交互比较多的C/S,B/S系统,该垃圾收集器也是我们使用的比较多的,下面我们再来详细看下其工作的详细过程:

1:初始标记,会STW,耗时短,标记出GC ROOT直接引用的对象(注意是直接引用的对象,而非所有可达的对象)。
2:并发标记,不会STW,耗时长,标记出GC ROOT所有可达的对象(注意区别于初始标记,是GC可达对象)。
3:重新标记:会STW,标记2并发标记的新增GC可达对象,因为不会很多,所以时间不会很长。
4:并发清除:不会STW,清除垃圾对象,耗时较久。
5:碎片整理:根据参数-XX:CMSFullGCsBeforeCompaction设置多少次Full GC整理一次碎片。

在实际应用中CMS一般配合ParNew一起使用,可用的配置模板如下:

-Xms7500m -Xmx7500m 最小和最大堆大小一致,避免申请内存空间开销
-Xmn3000m 年轻代3000m,按照默认的年轻代和年老代比例1:2,该值默认为7500m/3=2500m
-Xss512k 线程堆栈大小
-XX:MaxMetaspaceSize=512m 限制元空间大小,避免过渡使用本地内存,导致影响其他应用程序
-Dfile.encoding=UTF-8 设置JVM编码,避免乱码
-XX:+UseConcMarkSweepGC 老年代使用CMS
-XX:UseParNewGC 年轻代使用ParNew
-XX:CMSFullGCsBeforeCompaction=0 每次Full GC前都整理内存碎片
-XX:CMSInitiatingOccupancyFraction=92 老年代占比92%触发Full GC
-XX:SurvivorRatio=8 eden:s0:s1=8:1:1
-XX:+DisableExplicitGC 防止某些”大神“在代码里边执行 System.gc()
-XX:+CMSParallelInitialMarkEnabled 初始标记 多线程并行 一般没必要,初始标记都很快
-XX:ParallelGCThreads=$CPU_Count 并行收集器的线程数 此值最好配置与处理器数目相等 同样适用于CMS
-Xloggc:${PROJECT_DIR}/logs/gc.log 
-XX:+PrintGCDateStamps
-XX:+PrintGCDetails
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=${PROJECT_DIR}/logs/heapdump.hprof
8.7:G1

加个堆划分为多个区域,可以在大内存时控制GC时长,是一种支持老年代和年轻代混合收集的垃圾回收器,主要参数如下:

-XX:+UseG1GC 使用G1垃圾回收器
-XX:MaxGCPauseMills  设置停顿时间
写在后面

参考文章列表:

深入浅出JVM实战调优 。

GC中的高吞吐量 和短暂停时间为什么是矛盾的? 。

标记-清除算法原理及优缺点 。

JVM常用的8种垃圾回收器:主要特点,使用场景及优化建议 。

小白学JVM系列-CMS

欢迎分享,转载请注明来源:内存溢出

原文地址: http://outofmemory.cn/yw/927019.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-05-16
下一篇 2022-05-17

发表评论

登录后才能评论

评论列表(0条)

保存