JEP 358:有用的 NullPointerExceptions
概括
NullPointerException
通过精确描述哪个变量是 ,提高 JVM 生成的可用性null
。
目标
-
向开发人员和支持人员提供有关程序提前终止的有用信息。
-
通过更清晰地将动态异常与静态程序代码关联起来,提高程序理解。
-
减少新开发人员经常对s产生的困惑和担忧。
NullPointerException
非目标
-
我们的目标不是追查
null
参考文献的最终生产者,而是追查不幸的消费者。 -
NullPointerException
抛出更多s 或在不同时间点抛出它们并不是目标。
动机
每个 Java 开发人员都遇到过NullPointerException
S(NPE)。由于 NPE 几乎可能出现在程序中的任何位置,因此尝试捕获它们并从中恢复通常是不切实际的。因此,当 NPE 实际发生时,开发人员依靠 JVM 来查明 NPE 的来源。例如,假设以下代码中出现 NPE:
a.i = 99;
JVM 将打印出导致 NPE 的方法、文件名和行号:
Exception in thread "main" java.lang.NullPointerException
at Prog.main(Prog.java:5)
使用通常包含在错误报告中的消息,开发人员可以找到a.i = 99;
并推断该消息a
必须为空。然而,对于更复杂的代码,如果不使用调试器就不可能确定哪个变量为空。假设此代码中出现 NPE:
a.b.c.i = 99;
文件名和行号不能准确指出哪个变量为空。是a
还是b
还是c
?
数组访问和赋值也会出现类似的问题。假设此代码中出现 NPE:
a[i][j][k] = 99;
文件名和行号无法准确指出哪个数组组件为空。是a
还是a[i]
还是a[i][j]
?
一行代码可能包含多个访问路径,每个访问路径都可能是 NPE 的源。假设此代码中出现 NPE:
a.i = b.j;
文件名和行号无法查明有问题的访问路径。为a
空,或b
?
最后,NPE 可能源于方法调用。假设此代码中出现 NPE:
x().y().i = 99;
文件名和行号无法确定哪个方法调用返回了 null。是x()
还是y()
?
有多种策略可以缓解 JVM 缺乏准确定位的问题。例如,面对 NPE 的开发人员可以通过分配中间局部变量来中断访问路径。 (var
关键字在这里可能会有所帮助。)结果将是null
JVM 消息中变量的更准确报告,但重新格式化代码以跟踪异常是不可取的。无论如何,大多数 NPE 都发生在生产环境中,其中观察 NPE 的支持工程师与导致 NPE 的代码的开发人员相距许多步骤。
如果 JVM 能够提供查明 NPE 来源所需的信息,然后确定其根本原因,而无需使用额外的工具或改组代码,那么整个 Java 生态系统都会受益。 SAP 的商业 JVM 自 2006 年以来就做到了这一点,并获得了开发人员和支持工程师的好评。
描述
NullPointerException
JVM在程序中尝试取消引用引用的地方抛出(NPE) null
。通过分析程序的字节码指令,JVM 将准确确定哪个变量是,并在 NPE 中用_null-detail 消息_null
描述该变量(就源代码而言) 。然后,空详细信息消息将与方法、文件名和行号一起显示在 JVM 的消息中。
注意:JVM 在与异常类型相同的行上显示异常消息,这可能会导致长行。为了提高 Web 浏览器的可读性,此 JEP 在第二行(异常类型之后)显示空详细信息。
例如,赋值语句中的 NPEa.i = 99;
将生成以下消息:
Exception in thread "main" java.lang.NullPointerException:
Cannot assign field "i" because "a" is null
at Prog.main(Prog.java:5)
如果更复杂的语句a.b.c.i = 99;
抛出 NPE,则消息将剖析该语句并通过显示导致以下情况的完整访问路径来查明原因null
:
Exception in thread "main" java.lang.NullPointerException:
Cannot read field "c" because "a.b" is null
at Prog.main(Prog.java:5)
提供完整的访问路径比仅提供字段名称更有帮助,null
因为它可以帮助开发人员导航一行复杂的源代码,特别是在该行代码多次使用相同名称的情况下。
类似地,如果数组访问和赋值语句a[i][j][k] = 99;
抛出 NPE:
Exception in thread "main" java.lang.NullPointerException:
Cannot load from object array because "a[i][j]" is null
at Prog.main(Prog.java:5)
类似地,如果a.i = b.j;
抛出 NPE:
Exception in thread "main" java.lang.NullPointerException:
Cannot read field "j" because "b" is null
at Prog.main(Prog.java:5)
在每个示例中,空详细信息与行号结合起来足以识别null
源代码中的表达式。理想情况下,空细节消息将显示实际的源代码,但考虑到源代码和字节码指令之间对应关系的性质,这是很难做到的(见下文)。此外,当表达式涉及数组访问时,空详细消息无法显示导致元素的实际数组索引,例如和当is时null
的运行时值。这是因为数组索引存储在方法的操作数堆栈上,当抛出 NPE 时该操作数堆栈就会丢失。i``j``a[i][j]``null
只有由 JVM 直接创建和抛出的 NPE 才会包含 null-detail 消息。由 JVM 上运行的程序显式创建和/或显式抛出的 NPE 不受下面描述的字节码分析和空详细消息创建的影响。此外,对于由_隐藏方法_中的代码引起的NPE,不会报告空详细消息,隐藏方法是由JVM 生成和调用的特殊用途的低级方法,例如优化字符串连接。隐藏方法没有可以帮助查明 NPE 来源的文件名或行号,因此打印空详细信息将是徒劳的。
计算空详细消息
诸如此类的源代码a.b.c.i = 99;
被编译为多个字节码指令。当抛出 NPE 时,JVM 确切地知道哪个方法中的哪个字节码指令负责,并使用此信息来计算 null-detail 消息。该消息有两部分:
-
第一部分————
Cannot read field "c"
是NPE的_结果_null
。它表示由于字节码指令从操作数堆栈中弹出引用而无法执行哪个操作。 -
第二部分————
because "a.b" is null
是NPE的_缘由_null
。它重新创建将引用推送到操作数堆栈的源代码部分。
空详细消息的第一部分是根据弹出的字节码指令计算的null
,如表 1 所示:
字节码
第 1 部分
aload
“无法从 <元素类型> 数组加载”
arraylength
“无法读取数组长度”
astore
“无法存储到 <元素类型> 数组”
athrow
“不能抛出异常”
getfield
“无法读取字段“<字段名称>””
invokeinterface
, invokespecial
,invokevirtual
“无法调用“<方法>””
monitorenter
“无法进入同步块”
monitorexit
“无法退出同步块”
putfield
“无法分配字段“<字段名称>””
任何其他字节码
不可能有 NPE,没有消息
<method> 分解为 <class name>.<method name>(<parameter types>)
空详细信息消息的第二部分更加复杂。它标识导致null
操作数堆栈上的引用的访问路径,但复杂的访问路径涉及多个字节码指令。给定方法中的指令序列,之前的指令推送引用并不明显null
。因此,对所有方法的指令执行简单的数据流分析。它计算哪条指令推送到哪个操作数堆栈槽,并将此信息传播到弹出该槽的指令。 (分析与指令数量呈线性关系。)根据分析,可以回溯构成源代码中访问路径的指令。消息的第二部分是逐步组装的,每个步骤都有字节码指令,如表 2 所示:
字节码
第二部分
aconst
_无效的
“无效的”
aaload
计算推送数组引用的指令的第二部分,然后附加“[”,然后计算推送索引的指令的第二部分,然后附加“]”
iconst_*
, bipush
,sipush
常数值
getfield
计算推送此 getfield 访问的引用的指令的第二部分,然后附加“.<field name>”
getstatic
“<类名>.<字段名>”
invokeinterface
, invokevirtual
, invokespecial
,invokestatic
如果第一步是“<method>的返回值”,则“<method>”
iload*
,aload*
对于局部变量 0,“this”。对于其他局部变量和参数,如果局部变量表可用,则为变量名称,否则为“<parameter i >”或“<local i >”。
任何其他字节码
不适用于第二部分。
访问路径可以由任意数量的字节码指令组成。空细节消息不一定涵盖所有这些。该算法仅在指令中执行有限数量的步骤,以限制输出的复杂性。如果达到最大步数,则会发出“...”等占位符。在极少数情况下,不可能向后退一步执行指令,然后空详细信息消息将仅包含第一部分(“不能...”,没有“因为...”解释)。
Cannot read field "c" because "a.b" is null
当 JVMThrowable::getMessage
作为其消息的一部分进行调用时,空详细消息 -- -- 会按需计算。通常,在创建异常对象时必须提供异常携带的消息,但计算成本很高,而且可能并不总是需要,因为许多 NPE 会被程序捕获并丢弃。计算需要引起NPE的方法的字节码指令,以及弹出 的指令的索引null
;幸运的是, 的实现Throwable
包含有关异常起源的信息。
该功能可以使用新的布尔命令行选项进行切换-XX:{+|-}ShowCodeDetailsInExceptionMessages
。该选项首先具有默认值“false”,以便不打印消息。其目的是在以后的版本中默认启用异常消息中的代码详细信息。
计算空详细消息的示例
这是基于以下源代码片段的示例:
a().b[i][j] = 99;
源代码在字节码中的表示形式如下:
5: invokestatic #7 // Method a:()LA;
8: getfield #13 // Field A.b, an array
11: iload_1 // Load local variable i, an array index
12: aaload // Load b[i], another array
13: iload_2 // Load local variable j, another array index
14: bipush 99
16: iastore // Store to b[i][j]
假设a().b[i]
是null
.这将导致在存储到 时抛出 NPE b[i][j]
。 JVM 将执行字节码16: iastore
并抛出 NPE,因为字节码12: aaload
被推null
送到操作数堆栈。空详细消息将按如下方式计算:
Cannot store to int array because "Test.a().b[i]" is null
计算从包含字节码指令和字节码索引 16 的方法开始。由于索引 16 处的指令是iastore
,因此消息的第一部分是“无法存储到 int 数组”,如表 1 所示。
对于消息的第二部分,算法返回到推送不幸弹出的null
指令。iastore
数据流分析表明这是12: aaload
一个数组负载。根据表 2,当数组加载负责null
数组引用时,我们退回到将数组引用(而不是数组索引)推送到操作数堆栈的指令8: getfield
。然后再次根据表 2,当 agetfield
是访问路径的一部分时,我们退回到推送 , 所使用的引用的指令getfield
。5: invokestatic
我们现在可以组装消息的第二部分:
- 对于
5: invokestatic
,发出“Test.a()” - 对于
8: getfield
,发出“.b” - 对于
12: aaload
,发出“[”并后退到推送索引 的指令11: iload_1
。发出“i”(局部变量#1 的名称),然后发出“]”。
该算法永远不会执行到13: iload_2
“哪个推送索引”j
或“14: bipush
哪个推送” 99
,因为它们与 NPE 的原因无关。
包含许多空详细消息示例的文件附加到此 JEP:output_with_debug_info.txt 列出了类文件包含局部变量表时的消息。当类文件不包含局部变量表时,会出现output_no_debug_info.txt 消息。
备择方案
存在空详细信息消息
JVM 可以使用其他方式来提供空详细信息,例如写入 stdout 或使用跟踪或日志记录工具。然而,异常是报告 JVM 问题的标准方式,并且 NPE 已经通过包含带有行号信息的堆栈跟踪来提供有关引发异常的位置的信息。由于这些信息不足以定位原因,因此很自然地通过添加缺失的信息来增强 NPE。
空详细信息默认情况下处于关闭状态,可以通过命令行选项启用-XX:+ShowCodeDetailsInExceptionMessages
。无法指定仅对某些引发 NPE 的字节码感兴趣。由于以下原因,可能并不在所有情况下都需要空详细信息:
-
表现。该算法为堆栈跟踪的生成增加了一些开销。但是,这与引发异常时执行的堆栈遍历相当。如果应用程序频繁抛出和打印消息,从而导致打印影响性能,则抛出异常会带来绝对应该避免的开销。
-
安全。空详细信息消息可以让您深入了解原本不容易获得的源代码。可以关闭该消息以避免这种情况,但异常消息应该携带有关异常原因的信息,以便可以解决问题。如果公开此信息是不可接受的,则应用程序不应打印该消息,而应捕获并丢弃该消息。这不应该通过 JVM 的配置来处理。
-
兼容性。 JVM 传统上不会包含 NPE 的消息,现在包含消息可能会给以过于敏感的方式解析堆栈跟踪的工具带来问题。然而,Java 程序始终能够通过消息抛出 NPE,因此工具应该能够适应来自 JVM 的 NPE 消息。一个相关的风险是工具可能依赖于空详细信息消息的精确格式。
我们打算在未来版本中默认启用空详细信息消息。
空细节消息的计算
按需计算空详细消息会对高级场景中消息的可用性产生影响:
-
当通过 RMI 执行远程代码时,远程代码抛出的任何异常都会通过序列化传递给调用者。序列化异常对象不会保留其内部数据结构,因此如果远程代码抛出异常并因此序列化 NPE,最终的反序列化将产生一个无法按需计算空详细信息的 NPE。
-
如果方法的字节码指令在程序运行时发生更改,例如由于 Java 代理使用 JVMTI 重新定义方法,则原始指令会保留一段时间,但可能会在 GC 周期期间被丢弃。由于需要原始指令来计算空细节消息,因此如果发生这种情况,将不会按需计算空细节消息。
选择不支持序列化是为了最大限度地减少NullPointerException
类本身 的更改。如果需要保留空详细信息以进行序列化,则writeReplace
可以在该类中实现。或者,可以在创建异常对象时计算空详细信息消息,并且这将在序列化和方法重新定义中持久保留空详细信息。
空详细消息的格式
空详细消息由两部分构成:第一部分描述了无法执行的操作(NPE 的_结果_null
),而第二部分描述了之前将引用推入操作数堆栈的表达式(原因) NPE)。在某些情况下,这会导致冗长的文本,实际上只需要消息的一小部分来查明null
源代码中的表达式。例如,在以下两种情况下缩短消息可能会有所帮助:
-
在失败的数组访问中——
Cannot load from object array because "a[i][j]" is null.
第二部分"a[i][j]" is null
足以查明null
源代码中的表达式a[i][j][k] = 99;
。 -
在失败的方法调用中——
Cannot invoke "NullPointerExceptionTest.callWithTypes(String[][], int[][][], float, long, short, boolean, byte, double, char)" because...
方法的声明类型和参数类型通常很庞大,可以省略,而不会严重损害开发人员精确定位表达式的能力null
。
然而,空细节消息并没有遗漏该信息。计算消息的算法处理任意字节码指令序列,因此它并不总是能够成功地组装有用的消息。例如,对于失败的数组访问,它可能无法完全计算第二部分,因此如果省略第一部分,则根本不会打印任何消息;在这种情况下,仅第一部分就足以查明null
源代码中的表达式。一般来说,由于从访问的每条指令的单独构建块组装消息,因此通过算法来决定是否在某个点已经收集了足够的信息以在不损害消息的有用性的情况下省略更多部分是不可行的。因此,我们选择打印所有信息,以使该消息在尽可能多的情况下有所帮助。
风险和假设
在有用的 NPE 中,空详细信息消息可能包含源代码中的变量名称。具体来说,如果文件中包含调试信息class
(通过javac -g
),则打印局部变量名称。这些名称以前并未由反射 API 直接公开;程序必须通过检查class
文件的间接途径来获取它们ClassLoader::getResourceAsStream()
。在 NPE 中公开这些名称可能会被视为安全风险,但将它们排除在外会限制空详细信息消息的好处。
假定如果将新字节码添加到 JVM 规范,则空详细信息消息的计算将得到扩展。
测试
此功能的原型由JDK-8218628实现。该原型包含一个测试每个消息部分的单元测试。自 2006 年以来,之前的实现已在 SAP 的商业 JVM 中使用,并且已被证明是稳定的。
为了避免回归,应该运行一些更大量的代码。应运行 jtreg 测试来检测处理消息并需要调整的其他测试。