Java性能优化的策略和常见方法(一)
Document #: 8893647L12009
Body:
供稿人:肖华飚
概述
随着Java的广泛应用,越来越多的关键企业系统也使用Java构建。作为Java核心运行环境的Java虚拟机JVM 被广泛地部署在各种系统平台上。对Java应用的性能优化也越来越受到关注;谈到Java应用的性能问题就不得不涉及到两个方面:一是Java应用的构造 是否是最优化的;二是对JVM的微调。本文将从一般意义上对Java性能的优化做一些总结。
Java性能优化的策略
一谈到性能优化,往往会被认为是应用开发和部署过程中或之后的事情,其实不然。如果想要构建一个最优化的系统,我们必须 从该系统的需求分析和业务模型设计之初就要考虑到性能的最优化问题;当然对于一个已经构造好的系统来讲,我们能做的只是在不改变系统代码的前提下,尽量地 在该系统的部署方案和运行环境上下功夫。由此,我们得出一个结论就是:所谓最优化是一个相对的概念,一个系统是否是最优化的,必须基于某个大前提来进行评 判。因此,在进行优化分析之前一定要把握好前提条件是什么。
如上图所示,可以看出,对系统性能提高贡献最大、最明显的是从业务层面和架构层面所作的分析和优化;最不明显的是对系统 平台和硬件层面以及网络层面的优化。因此在着手对目标系统进行优化分析之前,我们一定要从优化最明显、贡献最大的方面着手。这样有助于我们在最大程度上去 提高系统性能。
以下我们将针对Java系统的性能优化,从代码编写和JVM两个角度着手,总结一下常见的方法和思路。
编写性能高效的Java代码
根据GC的工作原理,我们可以通过一些技巧和方式,让GC运行更加有效率,更加符合应用程序的要求。以下就是一些程序设计的几点建议:
1)避免对象创建和GC
只要有可能,应该避免创建对象,防止调用构造函数带来的相关性能成本,以及在对象结束其生命周期时进行垃圾收集所带来的成本。考虑以下这些准则:
只要有可能,就使用基本变量类型,而不使用对象类型。例如,使用 int,而不使用 Integer;
缓存那些频繁使用的寿命短的对象,避免一遍又一遍地重复创建相同的对象,并因此加重垃圾收集的负担;
在处理字符串时,使用 StringBuffer 而不使用字符串String进行连接操作,因为字符串对象具有不可变的特性,并且需要创建额外的字符串对象以完成相应的操作,而这些对象最终必须经历 GC;
避免过度地进行 Java 控制台的写操作,降低字符串对象处理、文本格式化和输出带来的成本;
实现数据库连接池,重用连接对象,而不是重复地打开和关闭连接;
使用线程池(thread pooling),避免不停地创建和删除线程对象,特别是在大量使用线程的时候;
避免在代码中调用GC。GC是一个“停止所有处理(stop the world)”的事件,它意味着除了 GC 线程自身外,其他所有执行线程都将处于挂起状态。如果必须调用 GC,那么可以在非紧急阶段或空闲阶段实现它;
避免在循环内分配对象。
尽早释放无用对象的引用。大多数程序员在使用临时变量的时候,都是让引用变量在退出活动域(scope)后,自动设置为 null。我们在使用这种方式时候,必须特别注意一些复杂的对象,例如数组,队列,树,图等,这些对象之间的相互引用关系较为复杂。对于这类对象,GC回 收它们一般效率较低。如果程序允许,尽早将不再使用的引用对象赋为null。这样可以加速GC的工作。
如果有经常使用的图片,可以使用soft引用类型。它可以尽可能将图片保存在内存中,供程序调用,而不引起Out Of Memory。
注意一些全局的变量,以及一些静态变量。这些变量往往容易引起悬挂对象(dangling reference),造成内存浪费。
2)Java Native Interface(JNI)
使用本机代码编写应用程序的一部分,特别是频繁使用的部分,并将之与 Java 链接,这样做通常是为了提高性能。不过,JVM 与本机代码之间的通信通常很慢,因此,太多的 JNI 调用可能会降低性能。只要有可能就应该将本机操作集合在一起,以减少 JNI 调用的数量。使用 JNI 代码本地处理异常,尽管有时不可避免,但会导致性能下降。在这种情况下,应该使用 ExceptionCheck() 函数,因为与 ExceptionOccurred() 相比较,它带来的计算开销更少一些。后者必须创建一个将引用的对象,以及一个本地引用。
3)同步
为了减少 JVM 和操作系统中的争用,应该只在可行的情况下才使用同步方法。不要将同步方法放到循环结构中。
4)数据结构
作为一条通用规则,在更简单的数据结构能满足需要的地方,应该避免使用更复杂的数据结构。例如,在可以使用数组的地方不要使用向量。使用最有效的方法搜索元素,并将元素插入数据结构中,比如说,在向量的结尾处添加和删除元素,以便获得更好的性能。
5)尽可能使用堆栈变量
如果您频繁存取变量,就需要考虑从何处存取这些变量。变量是static变量,还是堆栈变量,或者是类的实例变量? 变量的存储位置对存取它的代码的性能有明显的影响。JVM 是一种基于堆栈的虚拟机,因此优化了对堆栈数据的存取和处理。所有局部变量都存储在一个局部变量表中,在 Java 操作数堆栈中进行处理,并可被高效地存取。存取 static 变量和实例变量成本更高,因为 JVM 必须使用代价更高的操作码,并从常数存储池中存取它们。(常数存储池保存一个类型所使用的所有类型、字段和方法的符号引用。)通常,在第一次从常数存储池 中访问 static 变量或实例变量以后,JVM 将动态更改字节码以使用效率更高的操作码。尽管有这种优化,堆栈变量的存取仍然更快。
考虑到这些事实,在构建代码时就可以考虑通过存取堆栈变量而不是实例变量或 static 变量,使操作更高效。如果必须使用,可以考虑将实例变量或 static 变量复制到局部堆栈变量中。当变量的处理完成以后,其值又被复制回实例变量或 static 变量中。这并不表示您应该避免使用 static 变量或实例变量。您应该使用对您的设计有意义的存储机制。例如,如果您在一个循环中存取 static 变量或实例变量,则您可以临时将它们存储在一个局部堆栈变量中,这样就可以明显地提高代码的性能。这将提供最高效的字节码指令序列供 JVM 执行。
6)finalize函数
finalize是位于Object类的一个方法,该方法的访问修饰符为protected,由于所有类为Object 的子类,因此用户类很容易访问到这个方法。由于,finalize函数没有自动实现链式调用,我们必须手动的实现,因此finalize函数的最后一个语 句通常是super.finalize()。通过这种方式,我们可以从下到上实现finalize的调用,即先释放自己的资源,然后再释放父类的资源。
根据Java语言规范,JVM保证调用finalize函数之前,这个对象是不可达的,但是JVM不保证这个函数一定会被调用。另外,规范还保证finalize函数最多运行一次。
很多Java初学者会认为这个方法类似于C++中的析构函数,将很多对象、资源的释放都放在这一函数里面。其实,这不是 一种很好的方式。原因有三,其一,GC为了能够支持finalize函数,要对覆盖这个函数的对象作很多附加的工作。其二,在finalize运行完成之 后,该对象可能变成可达的,GC还要再检查一次该对象是否是可达的。因此,使用finalize会降低GC的运行性能。其三,由于GC调用 finalize的时间是不确定的,因此通过这种方式释放资源也是不确定的。
通常,finalize用于一些不容易控制、并且非常重要的资源的释放,例如一些I/O的操作,数据的连接。这些资源的 释放对整个应用程序是非常关键的。在这种情况下,程序员应该以通过程序本身管理(包括释放)这些资源为主,以finalize函数释放资源方式为辅,形成 一种双保险的管理机制,而不应该仅仅依靠finalize来释放资源。
7)异常的开销很大
是的,异常开销很大。那么,这是不是就意味着您不该使用异常?当然不是。但是,何时应该使用异常,何时又不应该使用异常 呢?不幸的是,答案不是一下子就说得清的。我们要说的是,您不必放弃已经学到的好的 try-catch 编程习惯,但是使用异常时可能会遇到麻烦,创建异常就是一个例子。当创建一个异常时,需要收集一个栈跟踪(stack track),这个栈跟踪用于描述异常是在何处创建的。构建这些栈跟踪时需要为运行时栈做一份快照,正是这一部分开销很大。运行时栈不是为有效的异常创建 而设计的,而是设计用来让运行时尽可能快且没有任何不必要的延迟。但是,当需要创建一个 Exception 时,JVM 不得不说:“先别动,我想就您现在的样子存一份快照,所以暂时停止入栈和出栈操作。”栈跟踪不只包含运行时栈中的一两个元素,而是包含这个栈中的每一个元 素,从栈顶到栈底,还有行号和一切应有的东西。如果在一个深度为20的栈中创建了异常,那么就别指望只记录顶部的几个栈元素了——得完完整整地记录下所有 20个元素。从 main 或 Thread.run (在栈底)到栈顶,记录整个栈。
因此,创建异常这一部分开销很大。从技术上讲,栈跟踪快照是在本地方法 Throwable.fillInStackTrace() 中发生的,这个方法又是从 Throwable constructor 那里调用的。但是这并没有什么影响——如果您创建一个 Exception ,就得付出代价。好在捕获异常开销不大,因此可以使用 try-catch 将核心内容包起来。从技术上讲,您甚至可以随意地抛出异常,而不用花费很大的代价。招致性能损失的并不是 throw 操作——尽管在没有预先创建异常的情况下就抛出异常是有点不寻常。真正要花代价的是创建异常。幸运的是,好的编程习惯已教会我们,不应该不管三七二十一就 抛出异常。异常是为异常的情况而设计的,使用时也应该牢记这一原则。
8)避免非常大的分配
有时候问题不是由当时的堆状态造成的,而是因为分配失败造成的。分配的内存块都必须是连续的,而随着堆越来越满,找到较 大的连续块越来越困难。这不仅仅是 Java 的问题,使用 C 中的 malloc 也会遇到这个问题。JVM 在压缩阶段通过重新分配引用来减少碎片,但其代价是要冻结应用程序较长的时间。
![]() Java性能优化的策略和常见方法(二)![]() |
Document #: 8893647L12010
Body:
优化JVM垃圾收集的性能
1)JVM对堆空间的管理
JVM 在初始化的过程中分配堆。堆的大小取决于指定或者默认的最小和最大值以及堆的使用情况。如果用Heapbase表示堆底,heaptop表示堆能够增长到 的最大绝对值,用heaplimit表示实际的堆顶;则两者的差值(heaptop - heapbase)由命令行参数 -Xmx 决定。heaplimit指针可以随着堆的扩展而上升,随着堆的收缩而下降。heaplimit永远不能超过heaptop,也不能低于使用 -Xms 指定的初始堆大小。任何时候堆的大小都是 heaplimit - heapbase。如果整个堆的自由空间比例低于 -Xminf 指定的值(minf 是最小自由空间),堆就会扩展。如果整个堆的自由空间比例高于 -Xmaxf 指定的值(maxf 是最大自由空间),堆就会收缩。-Xminf 和 -Xmaxf 的默认值分别是 0.3 和 0.6,因此 JVM 总是尝试将堆的自由空间比例维持在 30% 到 60% 之间。参数 -Xmine(mine 是最小扩展大小)和 -Xmaxe(maxe 是最大扩展大小)控制扩展的增量。这 4 个参数对固定大小的堆不起作用(用相等的 -Xms 和 -Xmx 值启动 JVM,这意味着 HeapLimit = HeapTop),因为固定大小的堆不能扩展或收缩。
2)基本收集算法
复制:将堆内分成两个相同空间,从根(ThreadLocal的对象,静态对象)开始访问每一个关联的活跃对象,将空间 A的活跃对象全部复制到空间B,然后一次性回收整个空间A。因为只访问活跃对象,将所有活动对象复制走之后就清空整个空间,不用去访问死对象,所以遍历空 间的成本较小,但需要巨大的复制成本和较多的内存。
标记清除(mark-sweep):收集器先从根开始访问所有活跃对象,标记为活跃对象。然后再遍历一次整个内存区域,把所有没有标记活跃的对象进行回收处理。该算法遍历整个空间的成本较大暂停时间随空间大小线性增大,而且整理后堆里的碎片很多。
标记整理(mark-sweep-compact):综合了上述两者的做法和优点,先标记活跃对象,然后将其合并成较大的内存块。
3)分代
分代是Java垃圾收集的一大亮点,根据对象的生命周期长短,把堆分为年轻代和年老代,根据不同代的特点采用不同的收集算法。
年轻代(New Area)
实际上大部分对象都是朝生暮死,随生随灭的,因此所有收集器都为年轻代选择了复制算法。复制算法优点是只访问活跃对象, 缺点是复制成本高。因为年轻代只有少量的对象能熬到垃圾收集,因此只需少量的复制成本。而且复制收集器只访问活跃对象,对那些占了最大比率的死对象视而不 见,充分发挥了它遍历空间成本低的优点。年轻代随堆内存增大而增大,JVM会根据情况动态管理其大小变化。-Xmns<value>, -Xmnx<value>, -Xmos<value>, -Xmox<value> 等JVM选项可以设置年轻代与年老代的初始尺寸和最大尺寸。
年轻代里面又分为2个区域,一个是Allocate区,所有新建对象都会存在于该区,另一个是Survivor区;并实 施复制算法。每次复制就是将Allocate中的活对象复制到Survivor或者年老代中(如果符合一定的年老化条件),然后将Allocate区与 Survivor区的角色互换。
年老代(Tenured Area)
年轻代的对象如果能够经历过数次收集,就会进入年老区。年老区使用标记整理算法。因为年老区的对象通常有较长的生命周期,采用复制算法就要反复地复制对象,很不合算,所以采用标记清理算法。
年老期限(Tenure age)
年老期限是用于衡量一个年轻代的对象在什么情况下被升级为年老代的对象。这个参数会被JVM动态调整,并达到一个最大值 14。每次垃圾收集之后存活下来的对象的年老期限会递增一。一个年老期限为x的对象意味着,当该对象经历了Allocate区和Survivor区的x次 反转后仍然存活,则该对象会被升级为年老代对象。该阈值的调整是基于年轻代空间所占堆空间的比例。
倾斜比率(Tilt ratio)
Allocate区在年轻代区域中占用的空间是使用一种称为Tilting的技术进行最大化的。Tilting控制 Allocate区和Survivor区的相对大小。基于每次反转之后存活下来的对象所占空间的总数,该倾斜比率(Tilt ratio)会被调整以使Survivor区变得更小。比如,如果初始年轻代的大小为500MB,那么Allocate区和Survivor区将各占一 半,即250MB。随着应用程序的运行,一次垃圾收集事件被触发,而且只有50MB的对象存活下来。在这种情况下,Survivor区的空间将被减少,从 而为Allocate区提供更多的空间。较大的Allocate区意味着将经历更长的时间才会发生下一次垃圾收集。如下图所示,Survivor区的空间 会被逐步调整到最合适的比例。
垃圾收集前后的Allocate区和Survivor区的分布举例
4)verbosegc日志输出
verbosegc日志由 JVM 在指定 -verbosegc 命令行参数时生成,是一种非常可靠的独立于平台的调试工具。启用 verbosegc 可能对应用程序的性能有一定影响。如果这种影响是无法接受的,则应该使用测试系统来收集 verbosegc 日志。这是监控整个 JVM 是否运转良好的一种好办法,在出现 OutOfMemory 错误的情况下,这种方法尤其重要。
5)正确设置堆的大小
计算正确的堆大小参数很容易,但它可能对应用程序启动时间和运行时性能有很大的影响。初始大小和最大值分别由参数 -Xms 和 -Xmx 控制,这些值通常是根据理想情况和重负荷情况下堆的使用情况的估计来设置的,但 verbosegc 可以帮助确定这些值,而避免胡乱猜测。下面是从启动到完成程序的初始化(或者进入“就绪”状态)这段时间里,一个应用程序的 verbosegc 输出,如下所示。
<GC[0]: Expanded System Heap by 65536 bytes
<GC[0]: Expanded System Heap by 65536 bytes
<AF[1]: Allocation Failure. need 64 bytes, 0 ms since last AF>
<AF[1]: managing allocation failure, action=1 (0/3983128) (209640/209640)>
<GC(1): GC cycle started Tue Oct 29 11:05:04 2002
<GC(1): freed 1244912 bytes, 34% free (1454552/4192768), in 10 ms>
<GC(1): mark: 9 ms, sweep: 1 ms, compact: 0 ms>
<GC(1): refs: soft 0 (age >= 32), weak 5, final 237, phantom 0>
<AF[1]: completed in 12 ms>
上述记录表明,第一次发生 AF 时,堆中的自由空间为 0%(3983128 中有 0 字节可用)。此外,第一次垃圾收集之后,自由空间比例上升到 34%,略高于 -Xminf 标记(默认为 30%)。根据应用程序的使用,使用 -Xms 分配更大的初始堆可能会更好一些。几乎可以肯定的是,上例中的应用程序在下一次 AF 时会导致堆扩展。分配更大的初始堆可以避免这种情况。一旦应用程序进入 Ready 状态,通常不会再遇到 AF,因此也就确定了比较好的初始堆大小。类似地,通过增加应用程序负载也可以探测到避免出现 OutOfMemory 错误的 -Xmx 值。
如果堆太小,即使应用程序不会长期使用很多对象,也会频繁地进行垃圾收集。因此,自然会出现使用很大的堆的倾向。但是由 于平台和其他方面的因素,堆的最大大小还受物理因素的限制。如果堆被分页,性能就会急剧恶化,因此堆的大小一定不能超出安装在系统上的物理内存总量。比 如,如果 AIX 机器上有 1 GB 的内存,就不应该为 Java 应用程序分配 2 GB 的堆。
垃圾收集周期所花费的时间直接与堆的大小成正比。一条好的原则是根据需要设置堆的大小,而不是将它配置得太大或太小。
常见的一种性能优化技术是将初始堆大小(-Xms)设成与最大堆大小(-Xmx)相同。因为不会出现堆扩展和堆收缩,所 以在某些情况下,这样做可以显著地改善性能。通常,只有需要处理大量分配请求的应用程序时,才在初始和最大堆大小之间设置较大的差值。但是要记住,如果指 定 -Xms100m -Xmx100m,那么 JVM 将在整个生命期中消耗 100 MB 的内存,即使利用率不超过 10%。
6)避免堆失效
如果使用大小可变的堆(比如,-Xms 和 -Xmx 不同),应用程序可能遇到这样的情况,不断出现分配失败而堆没有扩展。这就是堆失效,是由于堆的大小刚刚能够避免扩展但又不足以解决以后的分配失败而造成 的。通常,垃圾收集周期释放的空间不仅可以满足当前的分配失败,而且还有很多可供以后的分配请求使用的空间。但是,如果堆处于失效状态,那么每个垃圾收集 周期释放的空间刚刚能够满足当前的分配失败。结果,下一次分配请求时,又会进入垃圾收集周期,依此类推。大量生存时间很短的对象也可能造成这种现象。避免 这种循环的一种办法是增加 -Xminf 和 -Xmaxf 的值。比方说,如果使用 -Xminf.5,堆将增长到至少有 50% 的自由空间。同样,增加 -Xmaxf 也是很合理。如果 -Xminf等于 5,-Xmaxf 为默认值 0.6,因为 JVM 要把自由空间比例保持在 50% 和 60% 之间,所以就会出现太多的扩展和收缩。两者相差 0.3 是一个不错的选择,这样 -Xmaxf.8 可以很好地匹配 -Xminf.5。
如果记录表明,需要多次扩展才能达到稳定的堆大小,但可以更改 -Xmine,根据应用程序的行为来设置扩展大小的最小值。目标是获得足够的可用空间,不仅能满足当前的请求,而且能满足以后的很多请求,从而避免过多的 垃圾收集周期。-Xmine、-Xmaxf 和 -Xminf 为控制应用程序的内存使用特性提供了很大的灵活性。
7)应该避免的开关
下列命令行开关应避免使用:
-Xnocompactgc 该参数完全关闭压缩。虽然在性能方面有短期的好处,最终应用程序堆将变得支离破碎,即使堆中有足够的自由空间也会导致 OutOfMemory 错误
-Xcompactgc 使用该参数将导致每个垃圾收集周期都执行压缩,无论是否有必要。JVM 在压缩时要做大量的决策,在普通模式下会推迟压缩
-Xgcthreads 该参数控制 JVM 在启动过程中创建的垃圾收集帮助器线程个数。对于 N-处理器机器,默认的线程数为 N-1。这些线程提供并行标记和并行清理模式中的并行机制