原创文章,如需转载,请注明来自:https://bigzuo.github.io/
是什么导致请求响应很慢?
最近公司的一个系统测试环境上了部分新功能后,发现测试环境服务响应很慢,很多没有任何业务逻辑功能的请求,响应也会超过10s。我们对代码加上性能日志后,发现请求的处理会莫名的“卡住”,而不是因为在等待资源产生的停顿。我们在排除掉代码的原因后,开始分析JVM参数和服务器配置是否合理。
首先我们检查了JVM参数配置,具体参数如下:
1 | -Xms768M -Xmx768M -XX:MaxNewSize=256m -XX:MaxPermSize=128m -XX:+UseConcMarkSweepGC -XX:+PrintGCTimeStamps -verbose:gc -Xloggc:logs/gc.log |
我们服务器配置是1核2G,实际可用空间是1.7G,因此结合我们的JVM配置,发现当前配置并无不合理之处。并且测试环境应用请求量并不大,所以也解释不了请求卡顿的原因。
然后,我们就查看了一下GC日志。结果从GC日志中发现应用在新生代垃圾回收时,耗时很长,因此造成服务长时间停顿。从下面的日志可以看出,一次新生代垃圾回收,居然耗时84.6s。
1 | Heap after GC invocations=139 (full 4): |
正常情况下,应用一般都会配置输出GC日志,但是输出信息都比较简单,不会像上面输出这么详细,不足以满足问题排查的需要。所以我们增加了“-XX:+PrintHeapAtGC -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintGCDateStamps” 等相关参数,增加GC日志的输出信息。
通过上面的日志可以确定是应用的GC活动造成的请求响应停顿。那么问题来了,为什么应用的垃圾回收会耗时这么长呢?
GC耗时为何这么长?
通过执行jstat -gcutil <PID> 500
命令查看各分区的使用率变化,可以看到当新生代的Eden区使用率达到100%时,触发了一次新生代的垃圾回收,并且恰好此时Survivor0和Survivor1区空间使用率也都达到100%。随着新生代垃圾回收的进行,老年代(Old区)的空间使用率也在逐渐增加,但是增加非常缓慢。然后随后因为新生代没有足够空间分配新增对象,系统还触发了一次Full GC。
1 | S0 S1 E O M CCS YGC YGCT FGC FGCT GCT |
新生代回收期间,老年代仍有剩余空间保存新生代存活对象,但是新生代的垃圾回收和老年代的空间增长都非常缓慢,基本可以确定时间是消耗在对象转移操作上。那么,此时的服务器在做什么呢?通过在终端执行vmstat 1
命令可以看到Linux系统的状态信息。从下面的输出中,我们可以看到虚拟内存使用已经超过1G(swpd参数),IO异常频繁(bi和bo参数),并且CPU大部分时间都在等待IO(wa参数)。
1 | procs -----------memory---------- ---swap-- -----io---- --system-- -----cpu----- |
看到这里的时候,就可以断定是系统大量使用了虚拟内存,并且垃圾回收期间,无效对象的回收和存活对象空间的转移引发了大量的swap空间交换,具体就是JVM进行GC时,需要对堆已使用的空间进行遍历。如果有一部分内容被交换到swap中,遍历到这部分内容时就要将其换回内存,同时如果内存空间不足,这时还需要将内存中堆的一部分换到swap中,于是遍历堆空间的过程中,极端情况会把整个堆空间轮流往swap中读写一遍。而系统读取swap空间的数据速度又非常慢,并且公司对云主机又进一步限制了IO读写的速度,所以导致垃圾回收过程非常缓慢,进而引起系统响应卡顿。
但是系统为什么会用这么多虚拟内存空间呢?在之前的理解中,一般是在物理内存不够的情况下,系统才会使用虚拟内存。我们通过top
命令输出服务器内存及swap使用率:发现在还有剩余内存的时候,系统就已经开始用swap了。这是为什么呢?Linux有一个内核参数vm.swappiness
,控制当内存空间剩余还有多少的时候,就开始使用swap。该参数值范围从0到100,当该参数=0,表示尽可能使用物理内存,避免swap空间交换;该参数=100,表示尽可能使用swap空间,这也告诉内核疯狂的将数据移出物理内存移到swap缓存中。我们通过cat /proc/sys/vm/swappiness
命令查看Linux系统这个参数设置的值是多少,我们测试环境服务器设置的值是Linux系统的默认值60。
当我们把这个参数设置成0,并把物理内存增加到4G后,再执行vmstat 1
命令,可以看到系统IO操作已经减少很多,并且CPU时间用于等待IO的比例几乎降为0,说明系统基本不再发生swap空间交换操作。
1 | [root@SHB-L0042573 bin]# vmstat 1 |
我们再看一下现在垃圾回收的时间,已经降到正常水平,基本都在0.1s以内完成。再统计请求响应时间,结果也一切正常。
1 | Heap after GC invocations=306 (full 61): |
Java进程为什么会占用这么多虚拟内存?
现在问题解决了,但是在解决问题的过程中,产生了一个疑问:Java进程为什么会占用这么多虚拟内存?我们看一下在排查问题的过程中top
命令的输出信息:
1 | top - 16:46:07 up 44 days, 6:28, 1 user, load average: 0.06, 0.45, 0.73 |
我们可以看到应用系统Java进程用了4.5G虚拟内存(VIRT参数),但是我们配置的Java进程可以使用的最大堆空间是1.5G。那么Java进程使用的空间为什么比我们设置的最大堆空间大这么多呢?
首先我们要明白,一个Linux进程所占用的虚拟内存包括了这个进程所需要的所有空间,比如堆空间、程序代码、依赖的第三方库、数据等所占用的空间,这些空间并不一定是进程实际运行所占用的空间。就像公司里面给每个人分配了一个2平方米大小的工位,但是我们每个人才实际占用约0.5平方米。其中2平方米就类似于进程占用的虚拟内存,0.5平方米就是进程运行时占用的实际空间。明白这个后,我们再继续分析Java进程的空间占用。
Java进程和运行在Linux系统上的其他进程一样,内存占用都由几部分组成:内核内存 + 代码区 + 数据区 + 堆区 + 栈区 + 未使用区域,区别在于JVM为了减少系统调用次数及内存空间和用户空间之间的数据拷贝(如Java NIO),将很多本属于操作系统管理的区域也移植到了JVM内部,并且JVM对堆空间也做了更加细致的分区。
该图引用自Linux与JVM的内存关系分析
因此,应用程序占用的内存空间 ≈ [-Xmx] + [-XX:MaxPermSize] + number_of_threads * [-Xss] + Java NIO。但是JVM本身运行也需要空间,比如GC操作、JIT优化、JNI代码等所占用的空间,所以Java进程所占用的内存空间,一定会比我们设置的最大堆空间要大。
除此之外,还有一点非常重要但是很少有人提及的是Java线程最终映射的还是Linux操作系统的线程,Linux操作系统为了解决多线程内存分配竞争的问题,在创建线程时,会为每一个线程分配一定的虚拟内存空间(也可称之为缓冲区),使得线程之间相互独立,互不干扰。然而这部分虚拟内存只有在实际使用时,才会分配物理内存。但是这部分“空间”也被统计在了虚拟内存中。在64位的Linux操作系统上,为每个线程分配的虚拟内存是64M。相信大部分Java程序都是以多线程的方式运行,因此虚拟内存的占用一般都比较高,不过一般情况下都不用在意。
小结
这次排查问题还是用了挺长时间,过程中也走了一些弯路,比如在很多次调整JVM参数都没有效果的时候,我尝试改过这个参数:
1 | -XX:CMSInitiatingOccupancyFraction=80 //老年代使用达到多少时,就触发老年代的回收 |
后来才明白,JVM默认的92%是合理的,因为老年代增长本身比较慢,改成80%后,反而会造成部分空间浪费。
其次就是思路和经验很重要,以后还要更多的积累。
参考资料
Why does my Java process consume more memory than Xmx?
Virtual Memory Usage from Java under Linux, too much memory used
Linux与JVM的内存关系分析
Java 进程占用 VIRT 虚拟内存超高的问题研究
死磕内存篇 — JAVA进程和linux内存间的大小关系
为什么linux下多线程程序如此消耗虚拟内存