Java性能权威指南-总结26
- 数据库性能的最佳实践
- 异常
- 日志
数据库性能的最佳实践
异常
Java的异常处理一直有代价高昂的坏名声。其代价确实比处理正常的控制流高一些,不过在大多数情况下,这种代价并不值得浪费精力去绕过。另一方面,因为异常处理是有成本的,所以不应将其用作一种通用机制。这里的指导方针是,根据良好程序设计的一般原则来使用异常:基本上,代码仅应该通过抛出异常来说明发生了意料之外的情况。遵循良好的代码设计原则,意味着Java代码不会因异常处理而变慢。
有两个因素会影响异常处理的一般性能。一个是代码块本身:创建一个try-catch
块代价高吗?尽管很久以前可能是这样,但是近几年来,情况已非如此。不过在互联网上有些信息会留存很久,所以偶尔还会看到有人建议避免使用异常,因为try-catch
块代价较高。这些建议都是老黄历了,因为现代JVM生成的代码可以非常高效地处理异常。
第二个方面是,(大部分)异常会涉及获取该异常发生时的栈轨迹信息。这一操作代价可能会很高,特别是在栈的轨迹很深时。
下面看一个例子。假如现在有一个特定方法的3种实现:
public ArrayList<String> testSystemException() {
ArrayList<String> al = new ArrayList<>();
for (int i = 0; i < numTestLoops; i++) {
Object o = null;
if((i % exceptionFactor) != θ) {
o = new Object();
}
try {
al.add(o.tostring());
} catch(NullPointerException npe) {
//继续获取下一个字符串
}
}
return al;
}
public ArrayList<String> testCodeException() {
ArrayList<String> al = new ArrayList>();
for (int i = 0; i < numTestLoops; i++) {
try {
if ((i% exceptionFactor) == θ) {
throw new NullPointerException("Force Exception");
}
Object o = new Object();
al.add(o.tostring());
} catch(NullPointerException npe){
//继续获取下一个字符串
}
}
return al;
}
public ArrayList<String> testDefensiveProgramming() {
ArrayList<String> al = new ArrayList<>();
for (int i = θ;i < numTestLoops; i++) {
Object o = null;
if((i% exceptionFactor)!=θ) {
o = new Object();
}
if (o != null) {
al.add(o.tostring());
}
}
return al;
}
每个方法都返回一个字符串数组,其元素是从新创建的对象得到的。数组的大小会变化,跟抛出异常的次数有关。
下表列出了在最坏情况下(即exceptionFactor
为1,每次迭代都会生成异常,得到的结果是一个空列表)为100000次迭代执行每个方法的时间。示例代码中,有的方法栈轨迹很浅(当调用这个方法时,栈上只有3个类),有的栈轨迹很深(当调用这个方法时,栈上有100个类)。
100%产生异常时的处理时间
这里有3点差别。首先,在每次迭代显式地构建异常的代码中,栈较浅和栈较深两种情况下时间差别很大。构建栈轨迹需要时间,这个时间和栈的深度有关。
第二个差别在这两种情况之间:代码显式地创建异常,或者是当JVM
解析到空指针时创建异常(见表中的前两行)。目前的情况是,在某一个时刻,编译器会优化掉系统生成的异常;JVM
开始重用同一个异常对象,而不是每次需要时创建一个新的。不管调用栈是什么样的,相关的代码每次执行时都会重用这个对象;而且这个异常实际上没有包含调用栈(也就是说,printStackTrace()
没有输出)。这种优化在完整的栈异常信息抛出很长一段时间之后才会出现,所以如果测试用例中没有包含足够长的热身周期,是不会看到这种效果的。
最后,在访问对象之前先判断一下是否为null
,这种防御性编程性能最好。在这个例子中,这一点并不意外,因为整个循环变成了空操作。所以对这个数字要持保留态度。尽管这些实现存在一些差别,但是请注意,大部分情况下,所用的时间都很少,是毫秒级的。平均到100000次调用,每次调用的执行时间几乎看不到什么差别。
如果异常使用得当,这些循环中的异常数目就会非常小。下列出了执行100000次循环时,产生1000次异常(1%的几率)需要的时间。
现在toString()
方法的处理时间成了计算的大头。在栈较深的情况下,创建异常仍然有性能损失,不过提前测试null
值的收益都被抵消了。
所以异常使用不当所带来的性能损失并没有想象的那么大。有些情况下,仍然会遇到创建太多异常的代码。因为性能损失主要来自填充栈轨迹信息,因此可以使用-XX:-StackTraceInThrowable
标志(默认为false)来禁止生成栈轨迹信息。
这并不是个好主意:栈轨迹的存在就是为帮我们分析哪里出问题的。如果使用了-xx:-StackTraceInThrowable
标志,也就丢失了这种能力。而且有些代码实际上会检查栈轨迹,并以此确定如何从异常恢复。(CORBA的参考实现就是这么工作的。)这种方式本身就有问题,但关键还在于禁止栈跟踪信息会使代码出现莫名其妙的问题。
JDK
中有些API
的异常处理会导致性能问题。当集合中并不存在要检索的元素时,很多集合类就会抛出异常。比如Stack
类,如果栈是空的,当调用pop()
时,就会抛出EmptyStackException
。这种情况下,先通过防御性编程方式检查一下栈的长度会好一些。(另一方面,和很多集合类不同的是,Stack
类支持保存为null
的对象,所以不能用pop()
方法返回null来说明栈是空的。)
关于异常的不当使用,JDK中最臭名昭著的例子是类加载:当使用ClassLoader
类的loadClass()
方法加载某个找不到的类时,就会抛出ClassNotFoundException
。这实际并不是一个异常条件。不要期望一个类加载器能知道如何加载应用中的每个类,这也是之所以会有类加载器的层次结构的原因了。
在一个存在大量类加载器的环境中,这意味着,在层次化的类加载器中搜索知道如何加载给定类的那个类加载器时,会有大量的异常。比如前面类加载的例子中,如果关闭栈轨迹信息,运行速度会提升3%。
不过,类加载只是个例外。那个例子是使用很长的classpath
做的微基准测试,而且即便是在这样的条件下,每次调用的差别也是毫秒级的。
快速小结
- 处理异常的代价未必会很高,不过还是应该在适合的时候才用。
- 栈越深,处理异常的代价越高。
- 对于会频繁创建的系统异常,JVM会将栈上的性能损失优化掉。
- 关闭异常中的栈轨迹信息,有时可以提高性能,不过这个过程往往会丢失一些关键信息
日志
日志有很多种。GC
会生成自己的日志语句。日志可以定向到一个单独的文件中,其大小可以由JVM
管理。即便在生产代码中,GC日志(使用-XX:+PrintGCDetails
标志开启)的开销也是非常低的,而当出现问题时,它们的好处非常大,所以GC日志应该一直打开。
Java EE应用服务器会生成一个访问日志,每当有请求时都会更新。这类日志的影响通常比较明显:不管在应用服务器上运行的是何种测试,关闭这类日志可以明显改进性能。根据我的经验,从诊断角度看,当出现问题时这些日志的帮助不是很大。不过在业务需求方面,这类日志往往非常关键,此时必须开启。
很多应用服务器都支持Apache
的mod_log_config
标准,尽管它并非一个Java EE
标准。它可以针对每个请求精确地指定想要记录的信息(不支持mod_log_config
语法的服务器通常也会支持某种形式的定制)。这里的关键是,记录的信息应该尽可能少,同时仍要满足业务需求。日志的性能会受所写数据量的影响。
特别是在HTTP访问日志中(或者笼统地说,在任何种类的日志中),记录下所有的数字信息是个不错的主意:记录IP地址而不是主机名,记录时间戳(比如从Unix纪元到现在所经过的秒数)而不是字符串数据(比如“Monday, June 3,201317:23:00-0600”
),诸如此类。尽量减少需要花时间和内存去计算的任何数据转换,以便使日志对系统的影响将至最低。转换后的数据总是可以通过对日志做后续处理来获得。
对于应用日志,需要记住3个基本原则。
第一,协调好要打日志的数据和所选级别(Level
)之间的关系。JDK
中有7个标准的日志级别,而且Logger
实例一般默认配置为输出其中的3个级别(INFO
及更高级别)。在项目中,这往往会导致混淆:INFO
级别听上去好像应该非常常见,而且应该提供与应用流程相关的描述(“现在正在处理A任务”,“现在正在做B任务"
,等等)。特别是对于存在大量线程的可扩展应用(包括Java EE应用服务器)而言,这类日志多了会给性能带来不利影响(更不用说太多没什么用的日志信息带来的风险了)。要学会使用更低级别的日志语句。类似地,当把代码签入到组库中时,应该考虑的是项目使用者的需求,而不是作为开发者的需求。如果消息对最终用户或系统管理员没什么意义,那默认开启这些日志就没什么帮助。它们的“作用”不过是拖慢了系统(还会让最终用户迷惑不解)。
第二个原则是使用细粒度的Logger
实例。对每个类的Logger
实例进行配置可能会很繁琐,但这么做是值得的,因为能够更好地控制日志输出。在一个较小的模块中,让一组类共享一个Logger
实例,是个不错的折中办法。要记住的关键一点是,如果生产环境变化很大,有些问题(特别是那些在高负载情况下出现的问题,或者是其他与性能有关的问题)很难重现。打开太多日志往往会改变环境,导致原来的问题不再复现。
因此,必须能够做到仅打开一小组代码的日志(至少最初能控制一小组FINE
级别的日志语句,然后是控制更多FINER
和FINEST
级别的),这样就不会影响代码的性能了。在这两个原则之间,应该能够支持在生产环境中生成信息的小子集,前提是不影响系统性能。无论如何这都是应该考虑的,原因在于:如果日志会让生产系统变慢,其管理员很可能不会开启日志;在这种情况下,如果系统确实变慢了,重现问题的可能性也小了。
第三个原则是,在向代码引入日志时,应该注意,很容易编写出带来意想不到的副作用的日志代码,即使这个日志并没有开启。这是可以说明“过早的优化”很不错的又一种情况:每当要打日志的信息包含方法调用、字符串连接或者其他任何形式的资源分配(比如为MessageFormat
参数分配一个Object
数组)时,记得使用isLoggable()
方法。
快速小结
- 为帮助用户找出问题,代码应该包含大量日志,但是这些日志默认都应该是关闭的。
- 如果
Logger
实例的参数需要调用方法或者分配对象,那么在调用该实例之前,不要忘了测试日志级别。