本文的读者是技术支持人员。阅读本文后,你将理解 jboss 的启动脚本文件( run.sh )中有一系列的 JVM 配置参数的含义,以及如何调整它们,从而使得 MegaEyes 中心管理服务器的性能得到优化。
MegaEyes 中心管理服务器的性能调优涉及到系统的多个方面,包括 MegaEyes 应用本身、应用服务器( jboss )、数据库和 java 虚拟机( JVM )等等。本文重点介绍 JVM 的性能优化。
需要注意的是, JVM 性能调优具有应用独特性( application specific ),就是说,不同的应用情形应该有不同的调整方案,这就要求你首先要观察 JVM 的运行状态,然后根据观察结果调整参数。没有一个通用的调优方案可以适用于所有的 MegaEyes 应用。
对性能调优,不同的人有不同的理解,本文是指对下列指标最大化:
n 并发用户( concurrent users ),在服务请求失败或请求响应超过预期时间之前,系统支持的最大并发用户数量。
n 系统容量( throughput ),可以用每秒处理的事务( transaction )数量计算。
n 可靠性( reliability )
换句话说,我们想对更多的用户提供更快捷的、不会中断的服务。
JVM 的性能调优的重点是垃圾回收( gc , garbage collection )和内存管理。垃圾回收的时候会导致整个虚拟机暂停服务,因此,应该尽可能地缩短垃圾回收的处理时间。
JVM 占用的内存称为堆( heap ),它被分为三个区:年轻( young ,又称为 new )、老( tenured ,又称为 old )和永生( perm )。这三个区是按照 java 对象的生存期划分的,在 new 区的对象生存期最短,很快就会被 gc 回收; perm 区的对象生存期最长,与 JVM 同生死。 Perm 区的对象不会被 gc 回收。
new 区又被分为三个部分:伊甸园( eden )和两个幸存者( survivor )。对象的创建总是在 eden 部分(这大概就是命名该部分为 eden 的原因吧)。两个 survivor 中总有一个是空的,它作为另一个 survivor 的缓冲区。当 gc 发生时,所有 eden 和 survivor 中活下来的对象被移动到另一个 survivor 中。对象会在两个 survivor 之间不断移动,直到活得足够久,然后移动到 old 区。我们可以猜想,之所以如此划分使用内存,肯定是为了缩短 gc 的执行时间,提高 gc 的执行效率。
除了默认的垃圾回收算法外, JVM 还提供了两个:并行( parallel )和并发( concurrent ),前者作用在 new 区,后者作用在 old 区。两者可以同时使用。
并行算法会产生多个线程以提高执行效率。当有多个 cpu 的时候,它会显著缩短 gc 的工作时间。
并发算法可以在 JVM 不中断对应用的服务的情况下执行(通常情况下,在 gc 工作的时候 JVM 停止对应用的服务)。
参数 |
含义 |
说明 |
-Xms |
Heap 的最小尺寸 |
|
-Xmx |
Heap 的最大尺寸 |
作为一个通行的准则,设置 Xms 和 Xmx 的尺寸一样,以减少 gc 的次数。 要将它们设置足够大,否则就会产生 out of memory 错误,但又不能设置过大,过大会增加 gc 的工作时间。 |
-Xmn |
new 的尺寸 |
|
-XX:PermSize |
Perm 的最小尺寸 |
|
-XX:MaxPermSize |
Perm 的最大尺寸 |
类似 heap 的设置,应该将 perm 设置为固定尺寸,即最大和最小尺寸一样。 |
-XX:SurvivorRatio |
New 区中 eden 与 Survivor 区的比值 |
|
-XX:+UseParallelGC |
使用 parallel gc |
|
-XX:ParallelGCThreads |
Parallel gc 的线程个数 |
与 cpu 个数相同,使得所有 cpu 都参与 gc 工作。 |
JVM 的参数主要由 -X 和 -XX 类型的选项组成,上边列出了一些对内存和 gc 的性能影响比较大的。
要调整参数首先要观察它们。观察 JVM 内存和 gc 的工具很多, jdk 本身也提供了一些,这些工具简单、实用,而且不需要安装。其中,最常用的是 jps 和 jstat ,前者用来查看 JVM 的进程 id ( pid ),后者用这个 pid 作为参数来得到内存和 gc 的状态,就是说,在执行 jstat 之前必须用 jps 得到 JVM 的 pid 。 Jstat 的例子::
jstat -gcutil 21308 250 10
其中, 21308 是(运行 jboss )的 JVM 的 pid ; 250 是采样间隔,单位是毫秒,即 250 毫秒采集一次数据; 10 是采样次数。
上述命令的执行结果如下:
S0 S1 E O P YGC YGCT FGC FGCT GCT
0.00 0.00 11.39 5.29 13.57 328 1.955 327 133.126 135.081
0.00 0.00 11.39 5.29 13.57 328 1.955 327 133.126 135.081
0.00 0.00 11.39 5.29 13.57 328 1.955 327 133.126 135.081
0.00 0.00 11.39 5.29 13.57 328 1.955 327 133.126 135.081
0.00 0.00 11.39 5.29 13.57 328 1.955 327 133.126 135.081
0.00 0.00 11.39 5.29 13.57 328 1.955 327 133.126 135.081
0.00 0.00 11.39 5.29 13.57 328 1.955 327 133.126 135.081
0.00 0.00 11.39 5.29 13.57 328 1.955 327 133.126 135.081
0.00 0.00 11.39 5.29 13.57 328 1.955 327 133.126 135.081
0.00 0.00 11.39 5.29 13.57 328 1.955 327 133.126 135.081
列标题 |
含义 |
说明 |
S0 |
Survivor |
|
S1 |
Survivor |
|
E |
Eden |
|
O |
Old |
|
P |
Perm |
以上数据都是百分比。 |
YGC |
Young ( new )区完成的 gc 的次数 |
|
YGCT |
YGC 消耗的总时间(秒) |
|
FGC |
整个 heap 完成的 gc 的次数 |
如果采用了 parallel gc ,你会看到 YGC 明显大于 FGC 。 |
FGCT |
FGC 消耗的总时间(秒) |
|
GCT |
YGCT + FGCT |
|
我们可以将采样次数设置足够大,这样就可以看到内存和 gc 的变化了。
从上述数据可以看出,内存各区域的占用率都不高, gc 的执行时间都不长,不过, perm 区有些太大,太浪费了。因为 perm 区的对象与 JVM 的生命周期是一样的,对象数量不会动态变化,所以,我们可以把这个区域的尺寸设置为原尺寸的二分之一,这样, perm 的占用率将从 13 %左右增加到 26 %左右。
从上述数据还可以看出, new 区的 gc 明显比真个 heap 的 gc 快得多。通常, FGC 应该不超过 400 毫秒,否则,将严重影响 java 应用的正常运行。
---------------------------------分割线------------------------------------
一、JVM内存模型及垃圾收集算法
1.根据Java虚拟机规范,JVM将内存划分为:
其中New和Tenured属于堆内存,堆内存会从JVM启动参数(-Xmx:3G)指定的内存中分配,Perm不属于堆内存,有虚拟机直接分配,但可以通过-XX:PermSize -XX:MaxPermSize 等参数调整其大小。
New又分为几个部分:
2.垃圾回收算法
垃圾回收算法可以分为三类,都基于标记-清除(复制)算法:
JVM会根据机器的硬件配置对每个内存代选择适合的回收算法,比如,如果机器多于1个核,会对年轻代选择并行算法,关于选择细节请参考JVM调优文档。
稍微解释下的是,并行算法是用多线程进行垃圾回收,回收期间会暂停程序的执行,而并发算法,也是多线程回收,但期间不停止应用执行。所以,并发算法适用于 交互性高的一些程序。经过观察,并发算法会减少年轻代的大小,其实就是使用了一个大的年老代,这反过来跟并行算法相比吞吐量相对较低。
还有一个问题是,垃圾回收动作何时执行?
另一个问题是,何时会抛出OutOfMemoryException,并不是内存被耗空的时候才抛出
满足这两个条件将触发OutOfMemoryException,这将会留给系统一个微小的间隙以做一些Down之前的操作,比如手动打印Heap Dump。
二、内存泄漏及解决方法
1.系统崩溃前的一些现象:
之后系统会无法响应新的请求,逐渐到达OutOfMemoryError的临界值。
2.生成堆的dump文件
通过JMX的MBean生成当前的Heap信息,大小为一个3G(整个堆的大小)的hprof文件,如果没有启动JMX可以通过Java的jmap命令来生成该文件。
3.分析dump文件
下面要考虑的是如何打开这个3G的堆信息文件,显然一般的Window系统没有这么大的内存,必须借助高配置的Linux。当然我们可以借助X-Window把Linux上的图形导入到Window。我们考虑用下面几种工具打开该文件:
使 用这些工具时为了确保加载速度,建议设置最大内存为6G。使用后发现,这些工具都无法直观地观察到内存泄漏,Visual VM虽能观察到对象大小,但看不到调用堆栈;HeapAnalyzer虽然能看到调用堆栈,却无法正确打开一个3G的文件。因此,我们又选用了 Eclipse专门的静态内存分析工具:Mat。
4.分析内存泄漏
通过Mat我们能清楚地看到,哪些对象被怀疑为内存泄漏,哪些对象占的空间最大及对象的调用关系。针对本案,在ThreadLocal中有很多的JbpmContext实例,经过调查是JBPM的Context没有关闭所致。
另,通过Mat或JMX我们还可以分析线程状态,可以观察到线程被阻塞在哪个对象上,从而判断系统的瓶颈。
5.回归问题
Q:为什么崩溃前垃圾回收的时间越来越长?
A:根据内存模型和垃圾回收算法,垃圾回收分两部分:内存标记、清除(复制),标记部分只要内存大小固定时间是不变的,变的是复制部分,因为每次垃圾回收都有一些回收不掉的内存,所以增加了复制量,导致时间延长。所以,垃圾回收的时间也可以作为判断内存泄漏的依据
Q:为什么Full GC的次数越来越多?
A:因此内存的积累,逐渐耗尽了年老代的内存,导致新对象分配没有更多的空间,从而导致频繁的垃圾回收
Q:为什么年老代占用的内存越来越大?
A:因为年轻代的内存无法被回收,越来越多地被Copy到年老代
三、性能调优
除了上述内存泄漏外,我们还发现CPU长期不足3%,系统吞吐量不够,针对8core×16G、64bit的Linux服务器来说,是严重的资源浪费。
在CPU负载不足的同时,偶尔会有用户反映请求的时间过长,我们意识到必须对程序及JVM进行调优。从以下几个方面进行:
1.Java线程池(java.util.concurrent.ThreadPoolExecutor)
大多数JVM6上的应用采用的线程池都是JDK自带的线程池,之所以把成熟的Java线程池进行罗嗦说明,是因为该线程池的行为与我们想象的有点出入。Java线程池有几个重要的配置参数:
Java线程池需要传入一个Queue参数(workQueue)用来存放执行的任务,而对Queue的不同选择,线程池有完全不同的行为:
SynchronousQueue:
一个无容量的等待队列,一个线程的insert操作必须等待另一线程的remove操作,采用这个Queue线程池将会为每个任务分配一个新线程
LinkedBlockingQueue :
无界队列,采用该Queue,线程池将忽略
maximumPoolSize参数,仅用corePoolSize的线程处理所有的任务,未处理的任务便在LinkedBlockingQueue中排队
ArrayBlockingQueue: 有界队列,在有界队列和
maximumPoolSize的作用下,程序将很难被调优:更大的Queue和小的maximumPoolSize将导致CPU的低负载;小的Queue和大的池,Queue就没起动应有的作用。其实我们的要求很简单,希望线程池能跟连接池一样,能设置最小线程数、最大线程数,当最小数<任务<最大数时,应该分配新的线程处理;当任务>最大数时,应该等待有空闲线程再处理该任务。
但线程池的设计思路是,任务应该放到Queue中,当Queue放不下时再考虑用新线程处理,如果Queue满且无法派生新线程,就拒绝该任务。设计导致 “先放等执行”、“放不下再执行”、“拒绝不等待”。所以,根据不同的Queue参数,要提高吞吐量不能一味地增大maximumPoolSize。
当然,要达到我们的目标,必须对线程池进行一定的封装,幸运的是ThreadPoolExecutor中留了足够的自定义接口以帮助我们达到目标。我们封装的方式是:
2.连接池(org.apache.commons.dbcp.BasicDataSource)
在使用org.apache.commons.dbcp.BasicDataSource的时候,因为之前采用了默认配置,所以当访问量大时,通过JMX 观察到很多Tomcat线程都阻塞在BasicDataSource使用的Apache ObjectPool的锁上,直接原因当时是因为BasicDataSource连接池的最大连接数设置的太小,默认的BasicDataSource配 置,仅使用8个最大连接。
我还观察到一个问题,当较长的时间不访问系统,比如2天,DB上的Mysql会断掉所以的连接,导致连接池中缓存的连接不能用。为了解决这些问题,我们充分研究了BasicDataSource,发现了一些优化的点:
3.JVM参数
在JVM启动参数中,可以设置跟内存、垃圾回收相关的一些参数设置,默认情况不做任何设置JVM会工作的很好,但对一些配置很好的Server和具体的应用必须仔细调优才能获得最佳性能。通过设置我们希望达到一些目标:
前两个目前是相悖的,要想GC时间小必须要一个更小的堆,要保证GC次数足够少,必须保证一个更大的堆,我们只能取其平衡。
(1)针对JVM堆的设置一般,可以通过-Xms -Xmx限定其最小、最大值,为了防止垃圾收集器在最小、最大之间收缩堆而产生额外的时间,我们通常把最大、最小设置为相同的值
(2)年轻代和年老代将根据默认的比例(1:2)分配堆内存,可以通过调整二者之间的比率NewRadio来调整二者之间的大小,也可以针对回收代,比如 年轻代,通过 -XX:newSize -XX:MaxNewSize来设置其绝对大小。同样,为了防止年轻代的堆收缩,我们通常会把-XX:newSize -XX:MaxNewSize设置为同样大小
(3)年轻代和年老代设置多大才算合理?这个我问题毫无疑问是没有答案的,否则也就不会有调优。我们观察一下二者大小变化有哪些影响
(4)在配置较好的机器上(比如多核、大内存),可以为年老代选择并行收集算法: -XX:+UseParallelOldGC ,默认为Serial收集
(5)线程堆栈的设置:每个线程默认会开启1M的堆栈,用于存放栈帧、调用参数、局部变量等,对大多数应用而言这个默认值太了,一般256K就足用。理论上,在内存不变的情况下,减少每个线程的堆栈,可以产生更多的线程,但这实际上还受限于操作系统。
(4)可以通过下面的参数打Heap Dump信息
通过下面参数可以控制OutOfMemoryError时打印堆的信息
请看一下一个时间的Java参数配置:(服务器:Linux 64Bit,8Core×16G)
JAVA_OPTS="$JAVA_OPTS -server -Xms3G -Xmx3G -Xss256k -XX:PermSize=128m -XX:MaxPermSize=128m -XX:+UseParallelOldGC -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/usr/aaa/dump -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -Xloggc:/usr/aaa/dump/heap_trace.txt -XX:NewSize=1G -XX:MaxNewSize=1G"
经过观察该配置非常稳定,每次普通GC的时间在10ms左右,Full GC基本不发生,或隔很长很长的时间才发生一次
通过分析dump文件可以发现,每个1小时都会发生一次Full GC,经过多方求证,只要在JVM中开启了JMX服务,JMX将会1小时执行一次Full GC以清除引用,关于这点请参考附件文档。
4.程序算法调优:本次不作为重点
参考资料:
http://java.sun.com/javase/technologies/hotspot/gc/gc_tuning_6.html