之前一直没有去研究try catch的内部机制,只是一直停留在了感觉上,正好这周五开会交流学习的时候,有人提出了相关的问题。借着周末,正好研究一番。
当时讨论的是这样的问题:
比较下面两种try catch写法,哪一种性能更好。
for (int i = 0; i < 1000000; i++) { try { Math.sin(j); } catch (Exception e) { e.printStackTrace(); }
}
try {
for (int i = 0; i < 1000000; i++) {
Math.sin(j);
}
} catch (Exception e) {
e.printStackTrace();
}
在没有发生异常时,两者性能上没有差异。如果发生异常,两者的处理逻辑不一样,已经不具有比较的意义了。
要知道这两者的区别,最好的办法就是查看编译后生成的Java字节码。看一下try catch到底做了什么。
下面是我的测试代码
package com.kevin.java.performancetTest;
import org.openjdk.jmh.annotations.Benchmark;
/** * Created by kevin on 16-7-10. */
public class ForTryAndTryFor {
public static void main(String[] args) {
tryFor();
forTry();
}
public static void tryFor() {
int j = 3;
try {
for (int i = 0; i < 1000; i++) {
Math.sin(j);
}
} catch (Exception e) {
e.printStackTrace();
}
}
public static void forTry() {
int j = 3;
for (int i = 0; i < 1000; i++) {
try {
Math.sin(j);
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
使用javap -c fileName.class
输出对应的字节码
$ javap -c ForTryAndTryFor.class
Compiled from "ForTryAndTryFor.java"
public class com.kevin.java.performancetTest.ForTryAndTryFor {
public com.kevin.java.performancetTest.ForTryAndTryFor();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: invokestatic #2 // Method tryFor:()V
3: invokestatic #3 // Method forTry:()V
6: return
public static void tryFor();
Code:
0: iconst_3
1: istore_0
2: iconst_0
3: istore_1
4: iload_1
5: sipush 1000
8: if_icmpge 23
11: iload_0
12: i2d
13: invokestatic #4 // Method java/lang/Math.sin:(D)D
16: pop2
17: iinc 1, 1
20: goto 4
23: goto 31
26: astore_1
27: aload_1
28: invokevirtual #6 // Method java/lang/Exception.printStackTrace:()V
31: return
Exception table:
from to target type
2 23 26 Class java/lang/Exception
public static void forTry();
Code:
0: iconst_3
1: istore_0
2: iconst_0
3: istore_1
4: iload_1
5: sipush 1000
8: if_icmpge 31
11: iload_0
12: i2d
13: invokestatic #4 // Method java/lang/Math.sin:(D)D
16: pop2
17: goto 25
20: astore_2
21: aload_2
22: invokevirtual #6 // Method java/lang/Exception.printStackTrace:()V
25: iinc 1, 1
28: goto 4
31: return
Exception table:
from to target type
11 17 20 Class java/lang/Exception
}
指令含义不是本文的重点,所以这里就不介绍具体的含义,感兴趣可以到Oracle官网查看相应指令的含义The Java Virtual Machine Instruction Set
好了让我们来关注一下try catch 到底做了什么。我们就拿forTry方法来说吧,从输出看,字节码分两部分,code(指令)和exception table(异常表)两部分。当将java源码编译成相应的字节码的时候,如果方法内有try catch异常处理,就会产生与该方法相关联的异常表,也就是Exception table:
部分。异常表记录的是try 起点和终点,catch方法体所在的位置,以及声明捕获的异常种类。通过这些信息,当程序出现异常时,java虚拟机就会查找方法对应的异常表,如果发现有声明的异常与抛出的异常类型匹配就会跳转到catch处执行相应的逻辑,如果没有匹配成功,就会回到上层调用方法中继续查找,如此反复,一直到异常被处理为止,或者停止进程。具体介绍可以看这篇文章How the Java virtual machine handles exceptions
所以,try 在反映到字节码上的就是产生一张异常表,只有发生异常时才会被使用。由此得到出开始的结论。
这里再对结论扩充:
try catch与未使用try catch代码区别在于,前者禁止try语句块中的代码进行优化,例如重排序,try catch里面的代码是不会被编译器优化重排的。对于上面两个函数而言,只是异常表中try起点和终点位置不一样。至于刚刚说到的指令重排的问题,由于for循环条件部分符合happens- before原则,因此两者的for循环都不会发生重排。当然只是针对这里而言,在实际编程中,还是提倡try catch范围尽量小,这样才可以充分发挥java编译器的优化能力。
既然通过字节码已经分析出来了,两者性能没有差异。那我们就来检测一下吧,看看到底是不是如前面分析的那样。
在正式开始测试时,首先我们要明白,一个好的测试方法,就是尽量保证我们的测试不被其他因素所歪曲污染而影响测试的结果。那应该使用什么方法来测试我们的代码呢?
这里首先说一下常见的几种错误的测量方法,测量一个方法的执行时间,最容易想到的应该是下面这种了:
long startTime = System.currentTimeMillis();
doReallyLongThing();
long endTime = System.currentTimeMillis();
System.out.println("That took " + (endTime - startTime) + " milliseconds");
但是我会跟你说,这个方式不准确,我这里给大家展示一下我的使用上面的方式来进行测试的结果
package com.kevin.java.performancetTest;
/** * Created by kevin on 16-7-10. */
public class ForTryAndTryFor {
public static void main(String[] args) {
forTry();
tryFor();
}
public static void tryFor() {
long startTime = System.currentTimeMillis();
int j = 3;
try {
for (int i = 0; i < 1000000; i++) {
Math.sin(j);
}
} catch (Exception e) {
e.printStackTrace();
}
long endTime = System.currentTimeMillis();
System.out.println("tryFor " + (endTime - startTime) + " milliseconds");
}
public static void forTry() {
long startTime = System.currentTimeMillis();
int j = 3;
for (int i = 0; i < 1000000; i++) {
try {
Math.sin(j);
} catch (Exception e) {
e.printStackTrace();
}
}
long endTime = System.currentTimeMillis();
System.out.println("forTry " + (endTime - startTime) + " milliseconds");
}
}
测试结果
如果你这样就认为forTry比tryFor快,那就大错特错了。如果你再测试多几次就会发现问题了,我再列出一下结果
从结果看来,绝大多数时候,tryFor比forTry快。那是不是可以说tryFor比forTry快了呢?如果没有前面分析,你会不会就这样下定论了呢?问题就出现在这个绝大多数
,当你运行的次数越多,就越发的体会到结果的不可靠。
JIT优化导致结果出现偏差。
像这种多次循环非常容易触发JIT的优化机制,关于JIT,这里简短的介绍一下
在Java编程语言和环境中,即时编译器(JIT compiler,just-in-time compiler)是一个把Java的字节码(包括需要被解释的指令的程序)转换成可以直接发送给处理器的指令的程序。当你写好一个Java程序后,源语言的语句将由Java编译器编译成字节码,而不是编译成与某个特定的处理器硬件平台对应的指令代码(比如,Intel的Pentium微处理器或IBM的System/390处理器)。字节码是可以发送给任何平台并且能在那个平台上运行的独立于平台的代码。
简单来说,JIT会将某些符合条件(比如,频繁的循环)的字节码被编译成目标的机器指令直接执行,从而加快执行速度。可以通过配置-XX:+PrintCompilation
参数,观察JIT。当JIT执行优化时,会在终端输出相应的优化信息。
由上面的分析不难看出为什么绝大多数时候tryFor会比forTry快了,JIT编译耗时和类加载时间会被统计到第一个执行的函数forTry里面。要验证这个也非常简单,把两个函数的调用顺序互换,然后再进行测试。
当然,还有一点不能忽略的是System.currentTimeMillis()并不是统计cpu真正执行时间,所以可能测试的结果会有出入。可以使用JProfiler配合Intellij IDEA进行测试,下面会提到。由于这些因素影响着测试结果,从而使得测试结果扑朔迷离。
对于后两者,需要加入Warmup(预热)阶段。预热阶段就是不断运行你的测试代码,从而使得代码完成初始化工作(类加载),并足以触发JIT编译机制。一般来说,循环几万次就可以预热完毕。
是不是这样就可以得到完美的结果了。很不幸,并没有那么简单,JIT机制并没有想象的这么简单,要做到以下这些点你才能得到比较真实的结果。下面摘录至how-do-i-write-a-correct-micro-benchmark-in-java排名第一的答案
Tips about writing micro benchmarks from the creators of Java
HotSpot:Rule 0: Read a reputable paper on JVMs and micro-benchmarking. A good one is Brian Goetz, 2005. Do not expect too much from
micro-benchmarks; they measure only a limited range of JVM performance
characteristics.Rule 1: Always include a warmup phase which runs your test kernel all the way through, enough to trigger all initializations and
compilations before timing phase(s). (Fewer iterations is OK on the
warmup phase. The rule of thumb is several tens of thousands of inner
loop iterations.)Rule 2: Always run with
-XX:+PrintCompilation
,-verbose:gc
, etc., so you can verify that the compiler and other parts of the JVM
are not doing unexpected work during your timing phase.Rule 2.1: Print messages at the beginning and end of timing and warmup phases, so you can verify that there is no output from Rule 2
during the timing phase.Rule 3: Be aware of the difference between -client and -server, and OSR and regular compilations. The
-XX:+PrintCompilation
flag
reports OSR compilations with an at-sign to denote the non-initial
entry point, for example:Trouble$1::run @ 2 (41 bytes)
. Prefer
server to client, and regular to OSR, if you are after best
performance.Rule 4: Be aware of initialization effects. Do not print for the first time during your timing phase, since printing loads and
initializes classes. Do not load new classes outside of the warmup
phase (or final reporting phase), unless you are testing class loading
specifically (and in that case load only the test classes). Rule 2 is
your first line of defense against such effects.Rule 5: Be aware of deoptimization and recompilation effects. Do not take any code path for the first time in the timing phase, because
the compiler may junk and recompile the code, based on an earlier
optimistic assumption that the path was not going to be used at all.
Rule 2 is your first line of defense against such effects.Rule 6: Use appropriate tools to read the compiler’s mind, and expect to be surprised by the code it produces. Inspect the code
yourself before forming theories about what makes something faster or
slower.Rule 7: Reduce noise in your measurements. Run your benchmark on a quiet machine, and run it several times, discarding outliers. Use
-Xbatch
to serialize the compiler with the application, and consider
setting-XX:CICompilerCount=1
to prevent the compiler from running
in parallel with itself.Rule 8: Use a library for your benchmark as it is probably more efficient and was already debugged for this sole purpose. Such as
JMH, Caliper or Bill and Paul’s Excellent UCSD Benchmarks
for Java.
还可以参考Java theory and practice: Anatomy of a flawed microbenchmark
认真看完这些,你就会发现,要保证microbenchmark结果的可靠,真不是一般的难!!!
那就没有简单可靠的测试方法了吗?如果你认真看完上面提到的点,你应该会注意到Rule 8,没错,我就是使用Rule8提到的JMH来。这里摘录一段网上的介绍
JMH是新的microbenchmark(微基准测试)框架(2013年首次发布)。与其他众多框架相比它的特色优势在于,它是由Oracle实现JIT的相同人员开发的。特别是我想提一下AlekseyShipilev和他优秀的博客文章。JMH可能与最新的Oracle JRE同步,其结果可信度很高。
JMH官方主页:http://openjdk.java.net/projects/code-tools/jmh/
JVM版本:
java version "1.8.0_91"
Java(TM) SE Runtime Environment (build 1.8.0_91-b14)
Java HotSpot(TM) 64-Bit Server VM (build 25.91-b14, mixed mode)
系统:
Linux Mint 17.3 Rosa 64bit
配置:i7-4710hq+16g
工具:Intellij IDEA 2016+JMH的jar包+JMH intellij plugin
插件具体使用可以看JMH插件Github项目地址,上面有介绍使用细节
package com.kevin.java.performancetTest;
import org.openjdk.jmh.annotations.Benchmark;
/** * Created by kevin on 16-7-10. */
public class ForTryAndTryFor {
public static void main(String[] args) {
tryFor();
forTry();
}
@Benchmark
public static void tryFor() {
int j = 3;
try {
for (int i = 0; i < 1000; i++) {
Math.sin(j);
}
} catch (Exception e) {
e.printStackTrace();
}
}
@Benchmark
public static void forTry() {
int j = 3;
for (int i = 0; i < 1000; i++) {
try {
Math.sin(j);
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
JMH会做执行一段时间的WarmUp,之后才开始进行测试。这里只是截取结果部分,运行过程输出就不放出来了
# Run complete. Total time: 00:02:41
Benchmark Mode Cnt Score Error Units
performancetTest.ForTryAndTryFor.forTry thrpt 40 26.122 ± 0.035 ops/ms
performancetTest.ForTryAndTryFor.tryFor thrpt 40 25.535 ± 0.087 ops/ms
# Run complete. Total time: 00:02:41
Benchmark Mode Cnt Score Error Units
performancetTest.ForTryAndTryFor.forTry sample 514957 0.039 ± 0.001 ms/op
performancetTest.ForTryAndTryFor.tryFor sample 521559 0.038 ± 0.001 ms/op
每个函数都测试了两编,总时长都是2分41秒
主要关注Score和Error两列,±
表示偏差。
第一个结果的意思是,每毫秒调用了 26.122 ± 0.035次forTry函数,每毫秒调用了 25.535 ± 0.087次tryFor函数,第二个结果表示的是调用一次函数的时间。
从结果中,可以看到两个函数性能并没有差异,与之前的分析吻合。
http://stackoverflow.com/questions/16451777/is-it-expensive-to-use-try-catch-blocks-even-if-an-exception-is-never-thrown
http://stackoverflow.com/questions/504103/how-do-i-write-a-correct-micro-benchmark-in-java