本文是GC专家系列中的第五篇。在第一篇理解Java垃圾回收中我们学习了几种不同的GC算法的处理过程,GC的工作方式,新生代与老年代的区别。所以,你应该已经了解了JDK 7中的5种GC类型,以及每种GC对性能的影响。
在第二篇Java垃圾回收的监控中介绍了在真实场景中JVM是如何运行GC,如何监控GC数据以及有哪些工具可用来方便进行GC监控。
在第三篇GC 调优中基于真实案例介绍了可用于GC调优的最佳选项。同时也描述了如何通过降低移动到老年代中对象的数量来缩短Full GC耗时,以及如何设置GC类型及内存大小。
在第四篇 Apache的MaxClients设置及其对Tomcat Full GC的影响 中介绍了Apache对 MaxClients 选项在系统发生GC时对整体性能的影响。
在本文中我将会介绍Java应用性能优化的一般原则。具体来说,我会介绍性能优化的必要条件、判断是否需要优化的步骤,同时也会列出在性能优化过程中经遇到的一些问题。在文章结尾,我会给你一些在性能优化过程中如何做出最优决定的建议。
概述
不是每个应用都需要优化。如果系统的运行状况正如你的期望,你就没必要花费更多精力在额外的性能提升上。然而,在调试过程中就期望系统能达到它的目标性能往往会比较困难。这时就需要做系统优化的工作了。不管使用哪种语言,性能优化都要有较高的专业技能和高度专注。另外,因为每个应用都有自己独特的操作和不同的资源使用情况,在优化两个不同系统中可能需要使用不同的具体方法。所以与开发应用相比,性能优化更需要有扎实的基础知识,例如需要具有虚拟机、操作系统甚至计算机体系结构的相关知识。基于这些基础,再面对系统进行优化时,成功的机率就会更高。
一些Java应用的优化只需要调整JVM的选项,例如改变垃圾回收类型,不过有时也是需要去调整源码。不管使用哪种方式,你首先都需要去监控Java应用的执行处理过程。基于此,本文主要涵盖的内容如下:
- 如何监控Java应用
- 如何设置JVM选项
- 如何判断是否有必要修改应用代码
Java性能优化必备的基础知识
Java应用在JVM中运行,因此优化Java应用,你需要理解JVM的运行过程。在前面的文章深入理解JVM你可以找到一些关于JVM重要概念的介绍。
在本文中关于JVM运行过程的讲解着重于垃圾收集(GC)和 Hotspot相关知识。为了构造一个使JVM 运行良好的环境,你需要理解操作如何为进程分配资源。所以即便是优化Java应用,你也需要像熟悉JVM一样去熟悉操作系统甚至硬件知识。
与Java语言相关的知识也十分重要。同样理解锁和并发、熟悉类的加载与对象创建都是应该具备的技能。
一旦将Java应用优化付诸行动,你就需要综合利用上面提到的相关知识进行全面分析。
Java性能优化的流程
图1摘取自Charlie Hunt和Binu John合著的《Java性能》,描述了Java应用性能优化的处理流程。
图1: Java应用性能优化流程
上图并不是一个一次性流程,在性能优化完成之前你可能需要重复其中的过程。此过程同样适用于如何选取一个期望的性能指标。在优化过程中,有时需要降低性能指标的预期值,有时则需要提高性能指标的预期值。
JVM部署模型
JVM部署模型关系到如何决定是否把应用部署到单个或多个JVM上运行。这可以从系统的可用性、响应速度和可维护性上来做取舍。即便是决定了使用多个JVM,你也还需要确定在单台服务器上运行多个JVM或者是每台服务器上运行一个JVM。例如,对每台服务器,你面临着为单个JVM分配8GB堆内存和运行4个JVM并为每个JVM分配2GB堆内存的选择。当然单台服务器运行的JVM的数量也取决于CPU的核数以及应用本身的特点。在对比以上两个配置的响应速度时,具有2GB堆空间的方案可能更有优势,因为使用2GB的堆空间比使用8GB堆空间在Full GC时耗时更短。不过话说回来,使用8GB堆空间却可以减少Full GC的频率。另外也可以通过提高应用内部缓存命中率的方式来提高系统响应速度。所以,最终选择部署模型需要综合考虑应用的特点和所选方案对应用带来的优劣对比。
JVM体系结构
选择JVM时还需要面临 32位JVM 和 64位JVM 。同样条件下,应该优化选择32位JVM,因为32位JVM比64位的表现更优。不过32位JVM能使用堆内存最大理论值只有4GB。(事实上,32位操作系统和64位操作系统能分配的空间大小都只有2-3GB)。当堆空间需求更大时,使用64位JVM会是更好的选择。
表 1:性能对比
Benchmark | Time (sec) | Factor |
---|---|---|
C++ Opt | 23 | 1.0x |
C++ Dbg | 197 | 8.6x |
Java 64-bit | 134 | 5.8x |
Java 32-bit | 290 | 12.6x |
Java 32-bit GC* | 106 | 4.6x |
Java 32-bit SPEC GC* | 89 | 3.7x |
Scala | 82 | 3.6x |
Scala low-level* | 67 | 2.9x |
Scala low-level GC* | 58 | 2.5x |
Go 6g | 161 | 7.0x |
Go Pro* | 126 | 5.5x |
接下来要做的就是运行应用并衡量其性能。这些过程包括GC调优、调整操作系统设置以及修改应用代码。在这些过程中,你需要使用一些系统监控工具或者程序分析工具来帮你完成任务。
值得注意的是为响应速度的优化和为吞吐量的优化途径可能会截然不同。例如,不时发生的stop-the-world会降低响应速度,而Full GC则会导致单位时间内的吞吐量量大幅减少。所以其中必定会有所权衡。当然这些权衡不只发生于响应速度和呑吐量之间,你可能需要使用更多的CPU资源来减少内存使用来以避免响应速度或吞吐量的降低。与此相反的场景也同样会发生,你需要按一定的优先顺序来解决。
图1中的性能优化流程图适用于包括Swing应用在内的几乎所有Java应用。尽管如此,这个流程并不太适用于我们NHN公司为网络服务编写服务器应用的场景。下 图2 是针对NHN公司并基于 图1 制定的一个简化的处理流程。
图2:NHN公司的推荐的Java应用优化过程
上图中的 选择JVM(Select JVM) 是说通常32位JVM就足够了,除非你需要使用JVM维护几个GB的缓存数据。
好了,基于 图 2 中的流程,你将开始学到处理每一步中所需应对的事情。
JVM选项
我将主要介绍如何为Web应用服务器设置合适的JVM参数。尽管不能穷尽所有案例,但 最优的GC算法,尤其针对Web应用,通常是CMS GC,这主要是因为Web应用的低延迟要求决定的。当然在使用CMS过程中,有时会遇到因为过多的内存碎片导致的较长时间的stop-the-world现象发生。不过这个问题可以通过调整新生代大小或者碎片比例进行优化。
设置 新生代大小 和设置 整个堆大小 一样重要。最好通过 -XX:NewRatio 参数设置新生代空间与整个堆空间的大小比例,或者通过 -XX:NewSize 来单独设置期望的新生代空间。设置新生代空间的重要性是因为大多数对象的存活时间很短。在Web应用中,除了缓存之外的大多数对象,是在与 HttpRequest 相应的 HttpResponse 创建的时候产生的,而这个过程很少会超过1秒,也就是说其中的对象的生命周期也不会超过1秒。如果新生代空间设置不够大,当需要创建新对象时,旧的对象就需要移到老年代。老年代的GC开销却比新生代GC开销大得多,因此设置恰当的新生代空间是十分重要的。
尽管如此,如果新生代空间超过一定比例,系统的影响速度将会降低。因为新生代垃圾回收的基本过程就把对象从一个存活区(Survivor area)复制到另外一个存活区。所以像老年代一样,在新生代执行GC过程中也同样会发生stop-the-world现象。如果新生代设置变大,存活区的空间相应也会增加,结果就是需要复制的数据空间将增加。基于这些特点,根据操作系统不同,通过 NewRatio 选项为HotSpot JVM设置合适的新生代空间是很有必要的。
表2: 不同操作系统与JVM选项的NewRatio默认值
OS and option | Default -XX:NewRatio |
---|---|
Sparc -server | 2 |
Sparc -client | 8 |
x86 -server | 8 |
x86 -client | 12 |
如果设置了 NewRatio ,则将有 1/(NewRatio + 1) 的堆空间属于新生代。你会发现上表中 Sparc -server 的 NewRatio 的值非常小,因为当使用上面的默认值时,Sparc系统是用在比 x86更高端的场景中。因为x86性能的提升,目前使用x86 server也变得更为常见,像 Sparc -server 一样设置 NewRatio 的值为2或3也更为合理。
除此之外,你也可以使用 NewSize 和 MaxNewSize 作为 NewRatio 的替代使用。新生代空间初始大小由 NewSize 设定,并且随着内存消耗,新生代空间最大可扩展到 MaxNewSize 的大小。随着 NewRatio 的变化,Eden和Survivor区域的大小也在发生变化。正如通过相同 -Xms 和 -Xmx 为堆空间设置固定值,为新生代设置相同的 MaxSize 和 MaxNewSize 也是一个不错的选择。
如果同时设置了 NewRatio 和 NewSize ,其中较大的值会起作用。所以当一个堆空间创建之后,就可以通过如下公式计算初始新生代空间的大小:
min(MaxNewSize, max(NewSize, heap/(NewRatio + 1)))
不过在优化过程中,无乎不可能一下子就为堆大小和新生代大小找到了恰当的值。基于我在NHN运行Web应用程序的经验,我推荐在启动Java应用时使用如下JVM选项。在经过对这些选项的性能监控结果分析之后,你会找到更合适的GC算法或选项。
表3:推荐的JVM选项
选项类型 | 选项 |
---|---|
运行模式 | -server |
堆大小 | 指定相同的 -Xms 和 -Xmx |
新生代大小 | -XX:NewRatio : 取值在2-4之间 |
-XX:NewSize=? , -XX:MaxNewSize=? 。使用 NewSize 替代 NewRatio 也是不错的选择 | |
永久代大小 | -XX:PermSize = 256m -XX:MaxPermSize=256m 把永久代大小设置为一个运行时不会出错的大小,因为它并不影响系统的性能 |
GC 日志 | -Xloggc:$CATALANA_HOME/logs/gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps 。输出GC日志并不明显影响应用性能,因此推荐保留详细的GC日志信息。 |
GC 算法 | -XX:+UseParNewGC -XX:+CMSParallelRemarkEnabled -XX:+UseConcMarkSweepGC -XX:CMSInitiatingOccupancyFraction=75 。这只是一个推荐的通用配置。根据应用特点不同,其他配置也许更优。 |
OOM发生时输出堆dump | -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=$CATALINA_HOME/logs |
OOM发生后的执行动作 | -XX:OnOutOfMemoryError=$CATALINA_HOME/bin/stop.sh 或者 -XX:OnOutOfMemoryError=$CATALINA_HOME/bin/restart.sh 。OOM之后除了保留堆dump外,根据管理策略选择合适的运行脚本。 |
衡量应用的性能
需要获取能反映应用性能的几个关键信息如下:
- TPS(OPS):这个信息用于从概念上理解应用的性能。
- Request Per Second(RPS):严格来说,RPS并不同于响应速度,但你可以把它理解为响应速度。通过RPS,你可以检查用户获取请求结果所耗费的时间。
- RPS 标准偏差(RPS Standard Deviation):如果有可以,尽量保持RPS的稳定。如果出现偏差,则需要检查是否需要做GC优化或者是否有内部系统问题。
为了获取尽可能精确的性能结果,首先要对应用进行充分的预热,待稳定之后再开始性能测量,因为这时字节码已被HotSpot JIT进行了编译。通常,在使用nGrinder工具做负载测试时,至少要等系统达到某个负载水平10分钟后再测量系统的实际性能。
在关键点上做优化
如果nGrinder的测试结果满足预期,那就不需要对应用进行优化。如果性能逊于预期,则需要开始优化以解决问题。下面通过具体案例来看性能优化的方法。
Stop-the-World耗时过长
长时间的 stop-the-world 通常是由于使用了不恰当的GC选项或者不正确的应用实现所致。通常可以通过分析工具(profiler)或者堆dump的结果判断导致 stop-the-world 的原因。也就是说可以通过检查堆中对象的类型和数量判断问题原因。如果有过多非必须对象存在,则需要修改应用代码优化实现。如果在创建对象过程中没有明显的问题,则需要调整GC选项。
为了把GC选项调整到恰当的设置,你需要有足够长时间的GC日志,并从中找出在哪种状况下出现了stop-the-world。关于选择合适GC选项的具体细节,可参考Java 垃圾回收的监控。
CPU使用率过低
当系统发生阻塞时,TPS和CPU使用率都会降低。问题可能来自于内部交互系统或者高并发。分析这种场景,可以对线程dump的结果进行分析或者使用分析工具(profiler)。线程dump的分析方法可以参考如何分析Java线程Dumps
使用一些商业分析工具(profiler),你可以得到非常具体的锁相关的分析报告。不过,大多数场景只需要使用jvisualvm中的CPU分析器就可以获得满意的结果。
CPU使用率过高
如果TPS很低,但CPU使用率却非常高,就通常由于低效率的代码实现所致。这种场景,也需要通过使用分析器找到瓶颈的位置。可用的分析工具有 jvisuavm ,Eclipse的 TPTP 或者使用 JProbe 。
优化的途径
关于应用优化的一些建议途径如下:
首先,判断是否有必要做性能优化。衡量系统的性能并非易事,任何时候都不能保证你能得到满意的结果。所以如果应用已经达到了期望的目标性能,就没必要投入精力做额外的优化。
问题就在那里,你需要做的是解决它。 Pareto 法则 同样适用于性能优化。这并不是说一个特定的低性能表现只来源于一个问题,相反,在性能优化过程中,更应该把精力投入到对性能影响最大的那一点上。所以,当解决了最严重的问题后,就可以接着处理其他问题。不过建议是每次只着重解决一个问题。
你可能想到了 气球效应 。为了实现一个目标,你需要决定放弃哪些。你可以通过使用缓存来提高响应速度,然而随着缓存的增加,其Full GC所需耗时也将增加。一般来说,如果你想维持少量的内存使用,系统的呑吐量和响应时间将会受到影响。所以,你要清楚哪些是最重要的,哪些微不足道的。
到目前为止,你已经了解了Java应用性能优化的方法。为了介绍衡量性能的具体过程,我忽略了一些细节。尽管如此,我想本文已足够应对Java Web应用的大多数优化场景。
作者:Se Hoon Park,网络平台开发实验室高级软件工程师,NHN公司