问题表现
在运行java项目时,出现了调用栈为空的NullPointerException:
1 | { |
原因分析
所使用的JVM中的HotSpot为了提升性能对代码进行了优化,导致抛出了无调用栈的异常。
由于代码在运行中频繁触发某处代码,抛出空指针异常,触发了HotSpot的优化机制,将该方法进行了重新编译。重新编译时编译器使用不提供堆栈跟踪的预分配异常来提升性能。因而导致了无调用栈的NullPointerException的出现。
关于这个机制的相关内容如下:
The compiler in the server VM now provides correct stack backtraces for all “cold” built-in exceptions. For performance purposes, when such an exception is thrown a few times, the method may be recompiled. After recompilation, the compiler may choose a faster tactic using preallocated exceptions that do not provide a stack trace. To disable completely the use of preallocated exceptions, use this new flag: -XX:-OmitStackTraceInFastThrow.
这是对JVM中hotspot的功能的一段描述,大致意思是:
服务器VM中的编译器现在为所有“冷”内置异常提供正确的调用栈信息。 但出于性能目的,当多次抛出某个此类异常时,会重新编译该方法。重新编译之后,编译器可以使用不提供调用栈信息的预分配异常(preallocated exception)来得到更快的实现。运行时使用:-XX:-OmitStackTraceInFastThrow
进行配置,可禁用预分配的异常。
也就是说,出于性能方面的考虑,JVM在运行我们的java程序时,将频繁触发的某些内置异常替换为了不含调用栈信息的预分配异常(preallocated exception)。那么这套机制到底是如何运作的?这里所说的重新编译指的是什么?
这就涉及到Java的编译与执行方式,通常来说,要让Java代码运行起来,首先需要将其编译为JVM字节码,再由JVM对字节码进行执行。
在 Java 中提到“编译”,自然很容易想到 javac 编译器将.java文件编译成为.class文件的过程,这里的 javac 编译器称为前端编译器,其他的前端编译器还有诸如 Eclipse JDT 中的增量式编译器 ECJ 等。相对应的还有后端编译器,它在程序运行期间将字节码转变成机器码(现在的 Java 程序在运行时基本都是解释执行加编译执行),如 HotSpot 虚拟机自带的 JIT(Just In Time Compiler)编译器(分 Client 端和 Server 端)。另外,有时候还有可能会碰到静态提前编译器(AOT,Ahead Of Time Compiler)直接把*.java文件编译成本地机器代码,如 GCJ、Excelsior JET 等。
前面所提到的重新编译优化的过程就是发生在后端编译阶段。Java 程序最初是仅仅通过解释器解释执行的,即对字节码逐条解释执行,这种方式的执行速度相对会比较慢,尤其当某个方法或代码块运行的特别频繁时,这种方式的执行效率就显得很低。于是后来在虚拟机中引入了 JIT 编译器(即时编译器):当虚拟机发现某个方法或代码块运行特别频繁时,就会把这些代码认定为“Hot Spot Code”(热点代码),为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各层次的优化,完成这项任务的正是 JIT 编译器。现在主流的商用虚拟机(如Sun HotSpot、IBM J9)中几乎都同时包含解释器和编译器(三大商用虚拟机之一的 JRockit 是个例外,它内部没有解释器,因此会有启动相应时间长之类的缺点,但它主要是面向服务端的应用,这类应用一般不会重点关注启动时间)。二者各有优势:当程序需要迅速启动和执行时,解释器可以首先发挥作用,省去编译的时间,立即执行;当程序运行后,随着时间的推移,编译器逐渐会返回作用,把越来越多的代码编译成本地代码后,可以获取更高的执行效率。解释执行可以节约内存,而编译执行可以提升效率。
运行过程中会被即时编译器编译的“热点代码”有两类:
- 被多次调用的方法。
- 被多次调用的循环体。
对这两种情况,编译器都是以整个方法作为编译对象,这种编译也是虚拟机中标准的编译方式。要知道一段代码或方法是不是热点代码,是不是需要触发即时编译,需要进行 Hot Spot Detection(热点探测)。
因此当我们代码中的某处异常被频繁触发时,被热点探测判定为热点代码就会被即时编译器编译。而抛出异常最昂贵的部分不是实际的抛出和堆栈展开,而是在异常中创建堆栈跟踪(这是对VM的相对较慢的调用,并通过Throwable#fillInStackTrace在异常构造函数中发生)。所以就有了这个出于性能考虑的优化策略:对频繁抛出的内置异常,由原本的每次重新分配异常进行抛出的方式替换为直接抛出没有调用栈信息的预分配异常(preallocated exception)的方式。
那么那些异常会可能触发这种机制呢?
这类异常应该是隐式触发的,不是由 throw new...
方式抛出的。该机制依赖于具体的实现,在hotspot中,根据在graphKit.cpp中的内容:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36...
// If this throw happens frequently, an uncommon trap might cause
// a performance pothole. If there is a local exception handler,
// and if this particular bytecode appears to be deoptimizing often,
// let us handle the throw inline, with a preconstructed instance.
// Note: If the deopt count has blown up, the uncommon trap
// runtime is going to flush this nmethod, not matter what.
if (treat_throw_as_hot
&& (!StackTraceInThrowable || OmitStackTraceInFastThrow)) {
// If the throw is local, we use a pre-existing instance and
// punt on the backtrace. This would lead to a missing backtrace
// (a repeat of 4292742) if the backtrace object is ever asked
// for its backtrace.
// Fixing this remaining case of 4292742 requires some flavor of
// escape analysis. Leave that for the future.
ciInstance* ex_obj = NULL;
switch (reason) {
case Deoptimization::Reason_null_check:
ex_obj = env()->NullPointerException_instance();
break;
case Deoptimization::Reason_div0_check:
ex_obj = env()->ArithmeticException_instance();
break;
case Deoptimization::Reason_range_check:
ex_obj = env()->ArrayIndexOutOfBoundsException_instance();
break;
case Deoptimization::Reason_class_check:
if (java_bc() == Bytecodes::_aastore) {
ex_obj = env()->ArrayStoreException_instance();
} else {
ex_obj = env()->ClassCastException_instance();
}
break;
}
...
可知这类异常包括有:
1 | NullPointerException |