JEP 358: 更加友好的 NullPointerExceptions
概述
通过准确描述哪个变量为 null,提高 JVM 生成的 NullPointerException 的可用性。
目标
-
向开发人员和支持人员提供有关程序提前终止的有用信息。
-
通过将动态异常与静态程序代码更清晰地关联起来,提高对程序的理解。
-
减少新开发人员对
NullPointerException的困惑和担忧。
非目标
-
目标并不是追踪
null引用的最终生产者,而是找出不幸的消费者。 -
目标并不是抛出更多的
NullPointerException,或在不同的时间点抛出它们。
动机
每位 Java 开发者都遇到过 NullPointerException(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 必须为 null。然而,对于更复杂的代码,如果不使用调试器,就无法确定哪个变量为 null。假设这段代码中发生了 NPE(空指针异常):
a.b.c.i = 99;
文件名和行号并不能准确指出哪个变量为 null。是 a 还是 b 或者 c?
数组的访问和赋值也会发生类似的问题。假设在此代码中发生 NPE:
a[i][j][k] = 99;
文件名和行号并不能准确指出哪个数组元素为 null。是 a 还是 a[i] 或者 a[i][j]?
单行代码可能包含多个访问路径,每个访问路径都有可能是 NPE 的来源。假设此代码中发生了 NPE:
a.i = b.j;
文件名和行号并不能准确指出有问题的访问路径。是 a 为 null,还是 b 为 null?
最后,NPE 可能源自方法调用。假设此代码中发生 NPE:
x().y().i = 99;
文件名和行号并不能准确指出是哪个方法调用返回了 null。是 x() 还是 y()?
如果 JVM 能够提供必要的信息来准确定位 NPE 的来源,然后找出其根本原因,而无需使用额外的工具或代码调整,那么整个 Java 生态系统都将受益。自 2006 年以来,SAP 的商用 JVM 就已经做到了这一点,受到了开发者和技术支持工程师的高度赞扬。
描述
当程序中的代码试图解引用一个 null 引用时,JVM 会在该点抛出一个 NullPointerException(NPE)。通过分析程序的字节码指令,JVM 将准确地确定哪个变量是 null,并在 NPE 中使用 null-detail message 按源代码描述该变量。然后,null-detail message 将与方法、文件名和行号一起显示在 JVM 的消息中。
注意:JVM 会在异常类型所在的同一行显示异常消息,这可能会导致行变得很长。为了在网页浏览器中提高可读性,此 JEP 将 null 详细信息消息显示在异常类型之后的第二行。
例如,赋值语句 a.i = 99; 中的 NPE 会生成以下消息:
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 详细信息消息与行号相结合,足以定位源代码中为 null 的表达式。理想情况下,null 详细信息消息会显示实际的源代码,但鉴于源代码与字节码指令之间的对应关系,这很难做到(见下文)。此外,当表达式涉及数组访问时,null 详细信息消息无法显示导致 null 元素的实际数组索引,例如当 a[i][j] 为 null 时,运行时 i 和 j 的值。这是因为数组索引存储在方法的操作数栈上,在抛出 NPE 时该栈丢失了。
只有由 JVM 直接创建并抛出的 NPE(空指针异常)才会包含 null 详细信息消息。在 JVM 上运行的程序显式创建和/或显式抛出的 NPE 不适用于下文所述的字节码分析和 null 详细信息消息生成。此外,对于由 隐藏方法 中的代码引发的 NPE,不会报告 null 详细信息消息。隐藏方法是由 JVM 生成并调用的特殊用途底层方法,例如用于优化字符串连接。隐藏方法没有文件名或行号来帮助定位 NPE 的来源,因此打印 null 详细信息消息将是无意义的。
计算空详细信息消息
像 a.b.c.i = 99; 这样的源代码会被编译为多条字节码指令。当抛出 NPE 时,JVM 能准确知道是哪个方法中的哪条字节码指令导致了问题,并利用这些信息计算出空指针异常的详细信息。该信息包含两部分:
-
第一部分 --
Cannot read field "c"-- 是 NPE(空指针异常)的 结果。它表示由于字节码指令从操作数栈中弹出了一个null引用,导致哪个操作无法执行。 -
第二部分 --
because "a.b" is null-- 是 NPE 的 原因。它重现了将null引用推入操作数栈的那部分源代码。
空值详情消息的第一部分是根据弹出 null 的字节码指令计算得出的,具体细节如表 1 所述:
| bytecode | 1st part |
|---|---|
aload | "无法从 <element type> 数组加载" |
arraylength | "无法读取数组长度" |
astore | "无法存储到 <element type> 数组" |
athrow | "无法抛出异常" |
getfield | "无法读取字段 <field name>" |
invokeinterface, invokespecial, invokevirtual | "无法调用 <method>" |
monitorenter | "无法进入同步块" |
monitorexit | "无法退出同步块" |
putfield | "无法赋值字段 <field name>" |
| Any other bytecode | 不可能发生空指针异常,无消息 |
<method> 分解为 <class name>.<method name>(<parameter types>)
空详细信息消息的第二部分更为复杂。它标识了导致操作数栈上出现 null 引用的访问路径,但复杂的访问路径涉及多个字节码指令。给定一个方法中的指令序列,目前尚不清楚是哪条先前的指令推送了 null 引用。因此,对方法的所有指令执行简单的数据流分析。该分析计算哪条指令推送到哪个操作数栈槽,并将此信息传播到弹出该槽的指令。(分析与指令数量呈线性关系。)通过分析,可以逐步回溯组成源代码中访问路径的指令。消息的第二部分根据每个步骤中的字节码指令逐步组装,详见表 2:
| 字节码 | 第二部分 |
|---|---|
aconst_null | "null" |
aaload | 计算推送数组引用的指令的第二部分,然后追加 "[",接着计算推送索引的指令的第二部分,然后追加 "]" |
iconst_*, bipush, sipush | 常量值 |
getfield | 计算推送由该 getfield 访问的引用的指令的第二部分,然后追加 .<字段名> |
getstatic | <类名>.<字段名> |
invokeinterface, invokevirtual, invokespecial, invokestatic | 如果在第一步,“方法 <方法> 的返回值”,否则为 <方法> |
iload*, aload* | 对于局部变量 0,“this”。对于其他局部变量和参数,如果有局部变量表,则为变量名称,否则为 <参数 i > 或 <局部变量 i >。 |
| 任何其他字节码 | 不适用于第二部分。 |
访问路径可以由任意数量的字节码指令组成。空详细信息消息不一定涵盖所有这些指令。该算法只会通过指令回溯有限的步骤,以限制输出的复杂性。如果达到最大步数,将发出“...”等占位符。在极少数情况下,无法回溯指令,那么空详细信息消息将仅包含第一部分(“无法...”,没有“因为...”的解释)。
空指针异常的详细信息 -- Cannot read field "c" because "a.b" is null -- 是在 JVM 调用 Throwable::getMessage 作为其消息的一部分时按需计算的。通常,异常所携带的消息必须在创建异常对象时提供,但该计算代价较高,并且并非总是需要,因为许多 NPE(空指针异常)会被程序捕获并丢弃。该计算需要导致 NPE 的方法的字节码指令,以及弹出 null 的指令索引;幸运的是,Throwable 的实现中包含了有关异常来源的这些信息。
该特性可以通过新的布尔命令行选项 -XX:{+|-}ShowCodeDetailsInExceptionMessages 进行切换。该选项的默认值最初为 false,因此不会打印消息。在后续的版本中,默认情况下将会在异常消息中启用代码详细信息。
计算 null 详细信息消息的示例
以下是一个基于以下源代码片段的示例:
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。这将在存储到 b[i][j] 时导致抛出一个空指针异常(NPE)。JVM 将执行字节码 16: iastore 并抛出 NPE,因为字节码 12: aaload 将 null 推送到了操作数栈上。空指针详细信息消息将按以下方式计算:
Cannot store to int array because "Test.a().b[i]" is null
计算从包含字节码指令的方法开始,字节码索引为 16。由于索引 16 处的指令是 iastore,根据表 1,消息的第一部分是 “Cannot store to int array”。
对于消息的第二部分,算法回退到推送 null 的指令,而该 null 恰好被 iastore 弹出。数据流分析显示这是 12: aaload,即数组加载操作。根据表 2,当数组加载导致 null 数组引用时,我们回退到将数组引用(而不是数组索引)推送到操作数栈的指令,即 8: getfield。再次根据表 2,当 getfield 是访问路径的一部分时,我们回退到推送供 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 附带了包含许多 null-detail 消息示例的文件:output_with_debug_info.txt 列出了当类文件包含局部变量表时的消息,而 output_no_debug_info.txt 则列出了当类文件不包含局部变量表时的消息。
替代方案
空详细信息消息的存在
JVM 可以使用其他方式来提供 null 详细信息,比如写入标准输出或使用跟踪或日志记录工具。然而,异常是 JVM 上报告问题的标准方式,NPE(空指针异常)已经通过包含带有行号信息的堆栈跟踪提供了异常发生位置的信息。由于这些信息不足以定位原因,因此很自然地可以通过添加缺失的信息来增强 NPE。
默认情况下,空详细信息消息是关闭的,可以通过命令行选项 -XX:+ShowCodeDetailsInExceptionMessages 启用。无法指定仅对某些引发 NPE 的字节码感兴趣。由于以下原因,在所有情况下可能都不需要空详细信息消息:
-
性能。该算法在生成堆栈跟踪时增加了一些开销。然而,这与抛出异常时进行的堆栈遍历相当。如果一个应用程序频繁抛出并打印消息,以至于打印影响了性能,那么抛出异常本身所带来的开销绝对应该避免。
-
安全性。空详细信息消息提供了对源代码的洞察,而这些源代码通常不容易获得。可以关闭此消息以避免这种情况,但异常消息应该包含有关异常原因的信息,以便能够解决问题。如果暴露这些信息是不可接受的,那么应用程序不应该打印该消息,而是捕获并丢弃它。这不应该通过配置 JVM 来处理。
-
兼容性。JVM 传统上没有为 NPE(空指针异常)包含消息,现在包含消息可能会对那些以过于敏感的方式解析堆栈跟踪的工具造成问题。然而,Java 程序一直能够抛出带有消息的 NPE,因此预计工具将适应来自 JVM 的 NPE 消息。一个相关风险是,工具可能依赖于空详细信息消息的确切格式。
我们打算在未来的版本中默认启用 null 详细信息消息。
空详细信息消息的计算
按需计算 null-detail 消息会对消息在高级场景中的可用性产生影响:
-
通过 RMI 执行远程代码时,远程代码抛出的任何异常都会通过序列化传递给调用者。序列化异常对象不会保留其内部数据结构,因此如果远程代码抛出并序列化了 NPE(空指针异常),最终的反序列化将产生一个无法按需计算空详细信息消息的 NPE。
-
如果在程序运行期间方法的字节码指令发生变化,例如由于使用 JVMTI 的 Java 代理重新定义了该方法,则原始指令会保留一段时间,但在垃圾回收周期中可能会被丢弃。由于计算空详细信息消息需要原始指令,如果发生这种情况,将不会按需计算空详细信息消息。
为了尽量减少 NullPointerException 类本身的更改,决定不支持序列化。如果需要持久化序列化中的 null 详细信息消息,则可以在该类中实现 writeReplace 方法。或者,可以在创建异常对象时计算 null 详细信息消息,这样无论是序列化还是方法重定义,null 详细信息消息都会得以保留。
空详细信息消息的格式
空指针异常的详细信息消息由两部分组成:第一部分描述了无法执行的操作(NPE 的 后果),而第二部分描述了之前将 null 引用推入操作数栈的表达式(NPE 的 原因)。在某些情况下,这会导致消息文本过于冗长,而实际上只需要一小部分信息就可以在源代码中准确定位导致 null 的表达式。例如,在以下两种场景中,缩短消息可能会有所帮助:
-
在一个失败的数组访问中 --
无法从对象数组加载,因为 "a[i][j]" 为 null-- 第二部分"a[i][j]" 为 null足以在源代码a[i][j][k] = 99;中准确定位到null表达式。 -
在一个失败的方法调用中 --
无法调用 "NullPointerExceptionTest.callWithTypes(String[][], int[][][], float, long, short, boolean, byte, double, char)",因为...-- 方法的声明类型和参数类型通常很冗长,省略它们不会严重影响开发者定位null表达式的能力。
然而,空详细信息消息并未遗漏此信息。计算消息的算法处理的是任意字节码指令序列,因此并不总能成功组装出有用的消息。例如,对于失败的数组访问,它可能完全无法计算出第二部分,这样如果省略第一部分,就不会打印任何消息;在这种情况下,单独的第一部分可能足以在源代码中定位到 null 表达式。通常,由于消息是从每个访问指令的各个构建块组装而成的,因此无法通过算法判断是否在某个时刻已经收集了足够的信息,以至可以省略后续部分而不损害消息的实用性。因此,选择打印所有信息,以便在尽可能多的情况下使消息都有帮助。
风险与假设
在有用的 NPE(NullPointerException)中,null 详细信息消息可能包含源代码中的变量名。具体来说,如果 class 文件中包含调试信息(通过 javac -g 编译),那么局部变量名会被打印出来。这些名称之前并不能通过反射 API 直接暴露;程序必须通过间接方式,例如使用 ClassLoader::getResourceAsStream() 检查 class 文件来获取它们。在 NPE 中暴露这些名称可能会被认为存在安全风险,但如果不包含它们,则会限制 null 详细信息消息的作用。
假定如果向 JVM 规范中添加新的字节码,将会扩展 null-detail 消息的计算。
测试
此功能的原型由 JDK-8218628 实现。该原型包含一个单元测试,用于演练每个消息部分。自 2006 年以来,SAP 的商用 JVM 中已经有了一个前身实现,并且已经证明是稳定的。
为避免回归,应运行较大量的代码。应运行 jtreg 测试以检测其他处理消息并需要调整的测试。