Java GC 专家系列5:Java应用性能优化的原则

图片 2

本文是GC专家系列中的第五篇。在第一篇理解Java垃圾回收中我们学习了几种不同的GC算法的处理过程,GC的工作方式,新生代与老年代的区别。所以,你应该已经了解了JDK
7中的5种GC类型,以及每种GC对性能的影响。

最近一个运行了很久的程序出现了好几次OutOfMemory故障,造成大量业务都无法访问数据库的严重事故。事后通过加大堆内存暂时先优化了一下,顺便买了一本《Java性能权威指南》,研究了一下,发现其中的垃圾收集章节基本上能够解释所有的问题了。于是把该章节整理了一下,这些知识对于大多数的Java程序来说基本够用了。

在第二篇Java垃圾回收的监控中介绍了在真实场景中JVM是如何运行GC,如何监控GC数据以及有哪些工具可用来方便进行GC监控。

1 垃圾收集概念

在第三篇GC
调优中基于真实案例介绍了可用于GC调优的最佳选项。同时也描述了如何通过降低移动到老年代中对象的数量来缩短Full
GC耗时,以及如何设置GC类型及内存大小。

1.1 分代垃圾收集器

  1. 所有的GC算法都将堆分成了老年代和新生代。
  2. 所有的GC算法在清理新生代对象时,都使用了“时空停顿”(stop-the-world)方式

在第四篇 Apache的MaxClients设置及其对Tomcat Full
GC的影响
中介绍了Apache对 MaxClients 选项在系统发生GC时对整体性能的影响。

1.2 GC算法

JVM提供了4中不同的垃圾收集算法

  • Serial垃圾收集器
  • Throghput垃圾收集器
  • CMS收集器
  • G1垃圾收集器
  1. 这四种垃圾收集算法分别采用了不同的方法来缓解GC对应用程序的影响。
  2. Serial收集器常用于仅有单CPU可用以及当其他程序会干扰GC的情况(通常是默认值)。
  3. Throughput收集器在其他的虚拟机上是默认值,它能最大化应用程序的总吞吐量,但是有些操作可能遭遇较长的停顿。
  4. CMS收集器能够在应用县城运行的同时并行地对老年代的垃圾进行收集。如果CPU的计算能力足以支撑后台垃圾收集县城的运行,该算法能避免应用程序发生Full
    GC。
  5. G1收集器也能在应用线程运行的同时并发地对老年代进行收集,在某种程度上能够减少发生Full
    GC的风险。G1的设计理念使得它比CMS更不容易遭遇Full GC。

在本文中我将会介绍Java应用性能优化的一般原则。具体来说,我会介绍性能优化的必要条件、判断是否需要优化的步骤,同时也会列出在性能优化过程中经遇到的一些问题。在文章结尾,我会给你一些在性能优化过程中如何做出最优决定的建议。

1.3 选择GC算法

GC算法的选择一方面取决于应用程序的特征,另一方面取决于应用的性能目标

概述

不是每个应用都需要优化。如果系统的运行状况正如你的期望,你就没必要花费更多精力在额外的性能提升上。然而,在调试过程中就期望系统能达到它的目标性能往往会比较困难。这时就需要做系统优化的工作了。不管使用哪种语言,性能优化都要有较高的专业技能和高度专注。另外,因为每个应用都有自己独特的操作和不同的资源使用情况,在优化两个不同系统中可能需要使用不同的具体方法。所以与开发应用相比,性能优化更需要有扎实的基础知识,例如需要具有虚拟机、操作系统甚至计算机体系结构的相关知识。基于这些基础,再面对系统进行优化时,成功的机率就会更高。

一些Java应用的优化只需要调整JVM的选项,例如改变垃圾回收类型,不过有时也是需要去调整源码。不管使用哪种方式,你首先都需要去监控Java应用的执行处理过程。基于此,本文主要涵盖的内容如下:

  • 如何监控Java应用
  • 如何设置JVM选项
  • 如何判断是否有必要修改应用代码

GC算法与批量任务

  • 使用Throughput收集器处理应用程序线程的批量任务能最大程度地利用CPU的处理能力,通常能获得更好的性能。
  • 如果批量任务并没有使用机器上所有可能的CPU资源,那么切换到Concurrent收集器往往能取得更好的性能。

Java性能优化必备的基础知识

Java应用在JVM中运行,因此优化Java应用,你需要理解JVM的运行过程。在前面的文章深入理解JVM你可以找到一些关于JVM重要概念的介绍。

在本文中关于JVM运行过程的讲解着重于垃圾收集(GC)和
Hotspot相关知识。为了构造一个使JVM
运行良好的环境,你需要理解操作如何为进程分配资源。所以即便是优化Java应用,你也需要像熟悉JVM一样去熟悉操作系统甚至硬件知识。

与Java语言相关的知识也十分重要。同样理解锁和并发、熟悉类的加载与对象创建都是应该具备的技能。

一旦将Java应用优化付诸行动,你就需要综合利用上面提到的相关知识进行全面分析。

GC算法与吞吐量测试

  • 衡量标准是响应时间或吞吐量,在Throughput收集器和Concurrent收集器之间选择的依据主要是多少空闲CPU资源能用于运行后台的并发县城。
  • 通常情况下,Throughput收集器的平均响应时间比Concurrent收集器要差,但是在90%响应时间或者99%响应时间这几项指标上,Throughput收集器比Concurrent收集器要好一些。
  • 使用Throughput收集器会超负荷地进行大量Full
    GC时,切换到Concurrent收集器通常能获得更低的响应时间。

Java性能优化的流程

图1摘取自Charlie Hunt和Binu
John合著的《Java性能》,描述了Java应用性能优化的处理流程。

图片 1

图1: Java应用性能优化流程

上图并不是一个一次性流程,在性能优化完成之前你可能需要重复其中的过程。此过程同样适用于如何选取一个期望的性能指标。在优化过程中,有时需要降低性能指标的预期值,有时则需要提高性能指标的预期值。

CMS收集器和G1收集器之间的抉择

  • 选择Concurrent收集器时,如果堆较小,推荐使用CMS收集器。
  • G1的设计使得它能够在不同的分区(Region)处理堆,因此它的扩展性更好,比CMS更易于处理超大堆的情况。

JVM部署模型

JVM部署模型关系到如何决定是否把应用部署到单个或多个JVM上运行。这可以从系统的可用性、响应速度和可维护性上来做取舍。即便是决定了使用多个JVM,你也还需要确定在单台服务器上运行多个JVM或者是每台服务器上运行一个JVM。例如,对每台服务器,你面临着为单个JVM分配8GB堆内存和运行4个JVM并为每个JVM分配2GB堆内存的选择。当然单台服务器运行的JVM的数量也取决于CPU的核数以及应用本身的特点。在对比以上两个配置的响应速度时,具有2GB堆空间的方案可能更有优势,因为使用2GB的堆空间比使用8GB堆空间在Full
GC时耗时更短。不过话说回来,使用8GB堆空间却可以减少Full
GC的频率。另外也可以通过提高应用内部缓存命中率的方式来提高系统响应速度。所以,最终选择部署模型需要综合考虑应用的特点和所选方案对应用带来的优劣对比。

2 GC调优基础

JVM体系结构

选择JVM时还需要面临 32位JVM64位JVM
。同样条件下,应该优化选择32位JVM,因为32位JVM比64位的表现更优。不过32位JVM能使用堆内存最大理论值只有4GB。(事实上,32位操作系统和64位操作系统能分配的空间大小都只有2-3GB)。当堆空间需求更大时,使用64位JVM会是更好的选择。

2.1 调整堆的大小

最大堆: -Xmx
最小堆: -Xms

  1. JVM会根据其运行的机器,尝试估算合适的最大、最小堆的大小。
  2. 除非应用程序需要比默认值更大的堆,否则在进行调优时,尽量考虑通过调整GC算法的性能目标,而非微调堆的大小来改善程序性能。

表 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

图2:NHN公司的推荐的Java应用优化过程

上图中的 选择JVM(Select JVM)
是说通常32位JVM就足够了,除非你需要使用JVM维护几个GB的缓存数据。

好了,基于 图 2 中的流程,你将开始学到处理每一步中所需应对的事情。

2.2 代空间的调整

-XX:NewRatio=N 设置新生代与老年代的空间占用比率 , 默认值为2
-XX:NewSize=N 设置新生代的初始大小
-XX:MaxNewSize=N 设置新生代的最大大小
-XmnN 将NewSize和MaxNewSize设定为同一个值得快捷方法

Initial Young Gen Size = Initial Heap Size / ( 1 + NewRatio )

  1. 整个堆范围内,不同代的大小划分是由新生代所占用的空间控制的。
  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.3 永久代和元空间的调整

  1. 永久代或元空间保存着类的元数据(并非类本体的数据)。它以分离的堆的形式存在。
  2. 典型应用程序在启动后不需要载入新的类,这个区域的初始值可以依据所有类都加载后的情况设置。使得优化的初始值能够加速启动的过程。
  3. 开发中的应用服务器(或者任何需要繁重重新载入类的环境)上经常能碰到由于永久代或元空间耗尽触发的Full
    GC,这时老的元数据会被丢弃回收。

表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算法或选项。

2.4 控制并发

-XX:ParallelGCThreads=N 控制启动的线程数
默认情况下JVM会在机器的每个CPU上运行一个线程,最多同时运行8个。一旦达到这个上限,JVM会调整算法,每超出5/8个CPU启动一个新的线程,所以总的线程数就是(N:CPU数)

ParallelGCThreads = 8 + ((N – 8) * 5/ 8)

  1. 几乎所有的垃圾收集算法中基本的垃圾回收线程数都依据机器上的CPU数目计算得出。
  2. 多个JVM运行在同一台物理机上时,依据公司计算出的县城数可能过高,必须进行优化(减少)。

表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外,根据管理策略选择合适的运行脚本。

2.5 自适应调整

自适应调整就是JVM会根据调优的策略不断的尝试,寻找优化性能的机会,它进行性能调优的依据是以往的性能历史:这其中隐含了一个假设,即将来GC周期的状态跟最近历史GC周期的状况可能很类似。事实也证明,在多种负荷下这一假设都是合理的,即使某个时刻内存的分配发生突变的情况,JVM也能够依据最新的情况重新调整它的大小。

自适应调整作用主要在两个方面:

  • 小型应用程序不需要为指定过大的堆而担心。
  • 很多应用程序根本不需要担心它的堆的大小,如果需要使用的堆的大小超过了平台的默认值,可以放心的分配更大的堆,不用关心其他细节,JVM会自动调整堆和代的打小,依据垃圾回收算法的性能目标,使用优化的内存量。

-XX:-UseAdaptiveSizePolicy
关闭自适应调整功能(如果堆得最大最小值相同,新生代的初始值和最大值相同时也会被关闭)

总结:

  1. JVM在堆得内部如何调整新生代及老生代的百分比是由自适应调整机制控制的。
  2. 通常情况下,我们应该开启自适应调整,因为垃圾回收算法依赖于调整后的代的大小来达到它停顿时间的性能目标。
  3. 对于已经精细调优过的堆,关闭自适应调整能获得一定的性能提升。

衡量应用的性能

需要获取能反映应用性能的几个关键信息如下:

  • TPS(OPS):这个信息用于从概念上理解应用的性能。
  • Request Per
    Second(RPS):严格来说,RPS并不同于响应速度,但你可以把它理解为响应速度。通过RPS,你可以检查用户获取请求结果所耗费的时间。
  • RPS 标准偏差(RPS Standard
    Deviation):如果有可以,尽量保持RPS的稳定。如果出现偏差,则需要检查是否需要做GC优化或者是否有内部系统问题。

为了获取尽可能精确的性能结果,首先要对应用进行充分的预热,待稳定之后再开始性能测量,因为这时字节码已被HotSpot
JIT进行了编译。通常,在使用nGrinder工具做负载测试时,至少要等系统达到某个负载水平10分钟后再测量系统的实际性能。

3 垃圾回收工具

开启GC的日志功能
-verbose:gc
-XX:+PrintGC

-XX:+PrintGCDetails 会创建更详细的GC日志(推荐使用)
-XX:+PrintGCTimeStamps或者-XX:+PrintGCDateStamps(推荐使用)
便于我们更精确地判断几次GC操作之间的时间。
两者的差距在于前者相对于0(JVM启动时间)的值,而后者是实际的日期字符串(效率稍低)。

-Xloggc:filename 指定输出到文件
-XX:+UseGCLogfileRotation -XX:NumberOfGCLogfiles=N
-XX:GCLogfilesSize=N可以控制日志文件的循环。

分析日志文件的工具:GC Histogram
(http://java.net/projects/gchisto)

其他工具:
jconsole: 可以实时监控堆的使用情况。

jstat: 可以实时收集数据

jstat -gcutil process_id 1000

小结:

  1. GC日志是分析GC相关问题的重要线索;我们应该开启GC日志标志(即使在生产服务器上)。
  2. 使用PrintGCDetails标志能获得更详尽的GC日志信息。
  3. 使用工具能有效地帮助我们解析和理解GC日志的内容,尤其是在堆GC日志中的数据进行归纳汇总时,它们非常有帮助。
  4. 使用jstat能动态地观察运行程序的垃圾回收操作。

在关键点上做优化

如果nGrinder的测试结果满足预期,那就不需要对应用进行优化。如果性能逊于预期,则需要开始优化以解决问题。下面通过具体案例来看性能优化的方法。

4 总结

对任何一个Java应用程序而言,垃圾收集的性能都是其构成整体性能的关键一环。虽然对大多数的应用程序来说,调优的工作仅仅是选择合适的垃圾收集算法,或者在需要的时候,增大应用程序堆空间。

自使用调整让JVM能够自动地调整它的行为,使用给定的堆,提供尽可能好的性能。

更复杂的应用往往需要额外的调优,尤其是针对特定GC算法的调优。

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公司

You can leave a response, or trackback from your own site.

Leave a Reply

网站地图xml地图