Skip to content

JEP 358: Helpful NullPointerExceptions | 有用的 NullPointerExceptions

摘要

通过精确描述哪个变量为 null,改进由 JVM 生成的 NullPointerException 的可用性。

目标

  • 为开发人员和支持人员提供有关程序提前终止的有用信息。

  • 通过将动态异常与静态程序代码更清晰地关联起来,提高程序的理解性。

  • 减少新开发人员通常对 NullPointerException困惑和担忧

非目标

  • 目标不是追踪 null 引用的最终生成者,而只是不幸的消费者。

  • 目标不是抛出更多的 NullPointerException,或在不同的时间点抛出它们。

动机

每位 Java 开发者都遇到过 NullPointerException(NPE)。由于 NPE 几乎可以在程序的任何地方发生,因此尝试捕获和恢复它们通常是不切实际的。因此,当 NPE 实际发生时,开发人员依赖于 JVM 来准确定位 NPE 的源头。例如,假设以下代码中出现了 NPE:

java
a.i = 99;

JVM 将打印出导致 NPE 的方法、文件名和行号:

java
线程 "main" 中的异常 java.lang.NullPointerException
    在 Prog.main(Prog.java:5)

通过通常包含在错误报告中的消息,开发人员可以定位到 a.i = 99; 并推断出 a 必须是 null。然而,对于更复杂的代码,如果不使用调试器,则无法确定哪个变量是 null。假设以下代码中出现了 NPE:

java
a.b.c.i = 99;

文件名和行号并不能确切地指出哪个变量是 null。是 ab 还是 c

数组访问和赋值时也会出现类似的问题。假设以下代码中出现了 NPE:

java
a[i][j][k] = 99;

文件名和行号并不能确切地指出哪个数组组件是 null。是 aa[i] 还是 a[i][j]

单行代码中可能包含多个访问路径,每个路径都可能是 NPE 的源头。假设以下代码中出现了 NPE:

java
a.i = b.j;

文件名和行号并不能确定有问题的访问路径。是 a 为 null,还是 b 为 null?

最后,NPE 可能源自方法调用。假设以下代码中出现了 NPE:

java
x().y().i = 99;

文件名和行号并不能指出哪个方法调用返回了 null。是 x() 还是 y()

各种策略 可以缓解 JVM 无法准确定位的问题。例如,面对 NPE 的开发者可以通过将访问路径拆分为中间局部变量来定位问题。(var 关键字可能在此处 有所帮助。)这将使 JVM 的消息中报告的 null 变量更加准确,但重新格式化代码以追踪异常是不合心意的。无论如何,大多数 NPE 都发生在生产环境中,观察 NPE 的支持工程师与编写引起问题的代码的开发者之间相隔甚远。

如果 JVM 能够给出定位 NPE 源头并确定其根本原因所需的信息,而无需使用额外的工具或重新组织代码,那么整个 Java 生态系统都将受益。SAP 的商业 JVM 自 2006 年以来一直在做这项工作,赢得了开发人员和支持工程师的广泛赞誉。

描述

当程序中的代码试图对 null 引用进行解引用时,JVM 会抛出一个 NullPointerException(NPE)。通过分析程序的字节码指令,JVM 将精确确定哪个变量是 null,并在 NPE 中使用 null-detail 消息(以源代码的术语)描述该变量。然后,null-detail 消息将与方法、文件名和行号一起显示在 JVM 的消息中。

注意:JVM 在与异常类型相同的行上显示异常消息,这可能导致长行。为了在 Web 浏览器中提高可读性,本 JEP 在异常类型之后的第二行显示 null-detail 消息。

例如,从赋值语句 a.i = 99; 产生的 NPE 将生成以下消息:

java
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 的完整访问路径来指出原因:

java
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:

java
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:

java
Exception in thread "main" java.lang.NullPointerException:
        Cannot read field "j" because "b" is null
    at Prog.main(Prog.java:5)

在每个例子中,null-detail 消息结合行号就足以在源代码中定位哪个表达式是 null。理想情况下,null-detail 消息会显示实际的源代码,但由于源代码和字节码指令之间的对应关系性质(见下文),这很难实现。此外,当表达式涉及数组访问时,null-detail 消息无法显示导致 null 元素的实际数组索引,例如当 a[i][j]nullij 的运行时值。这是因为数组索引存储在方法的操作数栈上,当 NPE 被抛出时,这些信息就丢失了。

只有 JVM 直接创建和抛出的 NPE 才会包含 null-detail 消息。在 JVM 上运行的程序显式创建和 / 或显式抛出的 NPE 不受下文描述的字节码分析和 null-detail 消息创建的约束。此外,对于由 隐藏方法 中的代码引起的 NPE,不会报告 null-detail 消息。隐藏方法是 JVM 生成和调用的特殊用途的低级方法,用于优化字符串拼接等操作。隐藏方法没有可以帮助定位 NPE 来源的文件名或行号,因此打印 null-detail 消息将是徒劳的。

计算 null-detail 消息

源代码如 a.b.c.i = 99; 会被编译成多个字节码指令。当抛出 NPE 时,JVM 确切地知道是哪个方法中的哪个字节码指令导致了问题,并使用这些信息来计算 null-detail 消息。该消息有两个部分:

  • 第一部分 -- Cannot read field "c" -- 是 NPE 的 结果。它说明由于一个字节码指令从操作数栈中弹出了一个 null 引用,哪个操作无法执行。

  • 第二部分 -- because "a.b" is null -- 是 NPE 的 原因。它重新创建了将 null 引用推送到操作数栈上的源代码部分。

null-detail 消息的第一部分是根据弹出 null 的字节码指令计算的,如表 1 所示:

字节码第一部分
aload"Cannot load from <element type> array"
arraylength"Cannot read the array length"
astore"Cannot store to <element type> array"
athrow"Cannot throw exception"
getfield"Cannot read field "<field name>""
invokeinterface, invokespecial, invokevirtual"Cannot invoke "<method>""
monitorenter"Cannot enter synchronized block"
monitorexit"Cannot exit synchronized block"
putfield"Cannot assign field "<field name>""
其他任何字节码不可能抛出 NPE,无消息

<method> 分解为 <class name>.<method name>(<parameter types>)

null-detail 消息的第二部分更为复杂。它识别了导致操作数栈上出现 null 引用的访问路径,但复杂的访问路径涉及多个字节码指令。给定方法中的指令序列,很难明确是哪个先前的指令推送了 null 引用。因此,对所有方法的指令执行一个简单的数据流分析。它计算哪个指令将哪个值推送到哪个操作数栈槽,并将此信息传播到弹出该槽的指令。(该分析与指令的数量呈线性关系。)根据分析,可以回溯源代码中构成访问路径的指令。消息的第二部分是根据每一步的字节码指令逐步组装的,如表 2 所示:

字节码第二部分
aconst_null"null"
aaload计算推送数组引用的指令的第二部分,然后添加 "[",接着计算推送索引的指令的第二部分,然后添加 "]"
iconst_*, bipush, sipush常量值
getfield计算通过此 getfield 访问的引用所推送的指令的第二部分,然后添加 ".<字段名>"
getstatic"<类名>.<字段名>"
invokeinterface, invokevirtual, invokespecial, invokestatic如果在第一步,则为 "<方法>" 的返回值,否则为 "<方法>"
iload*, aload*对于局部变量 0,为 "this"。对于其他局部变量和参数,如果可用局部变量表,则为变量名,否则为 "<参数i>" 或 "<局部变量i>"。
其他任何字节码不适用于第二部分。

访问路径可以由任意数量的字节码指令组成。但 null-detail 消息不一定涵盖所有这些指令。为了限制输出的复杂性,该算法只回溯有限数量的指令步骤。如果达到最大步骤数,将发出如 "..." 之类的占位符。在极少数情况下,无法回溯指令,此时 null-detail 消息将仅包含第一部分("Cannot ...",没有 "because ..." 的解释)。

null-detail 消息——Cannot read field "c" because "a.b" is null——是在 JVM 调用 Throwable::getMessage 作为其消息的一部分时按需计算的。通常,异常所携带的消息必须在 创建异常对象时 提供,但计算成本较高,并且可能并非总是需要,因为许多 NPE(空指针异常)都会被程序捕获并丢弃。计算需要导致 NPE 的方法的字节码指令以及弹出 null 的指令的索引;幸运的是,Throwable 的实现包含了关于异常来源的这些信息。

可以使用新的布尔命令行选项 -XX:{+|-}ShowCodeDetailsInExceptionMessages 来切换该功能。该选项的默认值为 'false',因此不会打印消息。计划在后续版本中默认在异常消息中启用代码详细信息。

计算 null-detail 消息的示例

以下是一个基于以下源代码片段的示例:

java
a().b[i][j] = 99;

源代码在字节码中的表示如下:

java
5: invokestatic  #7    // 调用静态方法 a:()LA;
   8: getfield      #13   // 获取 A 类的 b 字段,它是一个数组
  11: iload_1             // 加载局部变量 i,它是一个数组索引
  12: aaload              // 加载 b[i],它是另一个数组
  13: iload_2             // 加载局部变量 j,它是另一个数组索引
  14: bipush        99
  16: iastore             // 将值存储到 b[i][j]

假设 a().b[i]null。当尝试将值存储到 b[i][j] 时,这将导致抛出 NPE(空指针异常)。JVM 将执行字节码 16: iastore 并因为字节码 12: aaloadnull 推送到操作数栈上而抛出 NPE。null-detail 消息将按以下方式计算:

java
Cannot store to int array because "Test.a().b[i]" is null

计算从包含字节码指令的方法开始,并以字节码索引 16 为起点。由于索引 16 处的指令是 iastore,根据表 1,消息的第一部分是 "Cannot store to int array"。

对于消息的第二部分,算法回溯到将 null 压入操作数栈的指令,而 iastore 不幸地弹出了这个 null。数据流分析表明这是 12: aaload,即数组加载指令。根据表 2,当数组加载指令导致 null 数组引用时,我们回溯到将数组引用(而不是数组索引)压入操作数栈的指令,即 8: getfield。再次根据表 2,当 getfield 是访问路径的一部分时,我们回溯到由 getfield 使用的引用压入操作数栈的指令,即 5: invokestatic。现在我们可以组装消息的第二部分:

  • 对于 5: invokestatic,输出 "Test.a()"
  • 对于 8: getfield,输出 ".b"
  • 对于 12: aaload,输出 "[" 并回溯到将索引压入操作数栈的指令,即 11: iload_1。输出局部变量 #1 的名称 "i",然后输出 "]"。

算法永远不会回溯到将索引 j 压入操作数栈的 13: iload_2 指令,也不会回溯到将 99 压入操作数栈的 14: bipush 指令,因为它们与 NPE 的原因无关。

包含许多 null-detail 消息示例的文件已附加到此 JEP:output_with_debug_info.txt 列出了当类文件包含局部变量表时的消息,而 output_no_debug_info.txt 列出了当类文件不包含局部变量表时的消息。

备选方案

null-detail 消息的存在

JVM 可以通过其他方式提供 null-detail 信息,例如写入标准输出或使用跟踪或日志记录工具。但是,异常是 JVM 上报告问题的标准方式,NPE 本身就通过包含行号信息的堆栈跟踪信息给出了异常发生位置的信息。由于这些信息不足以定位原因,因此通过添加缺失的信息来增强 NPE 是很自然的。

null-detail 消息默认是关闭的,可以通过命令行选项 -XX:+ShowCodeDetailsInExceptionMessages 来启用。没有办法指定只对某些引发 NPE 的字节码感兴趣。由于以下原因,可能并非在所有情况下都需要 null-detail 消息:

  1. 性能。该算法在生成堆栈跟踪时增加了一些开销。但是,这与引发异常时进行的堆栈遍历相当。如果应用程序频繁地抛出并打印消息以至于打印操作影响性能,那么已经抛出异常就会带来开销,这绝对应该避免。

  2. 安全性。null-detail 消息提供了对源代码的深入了解,而这些源代码通常不容易获取。可以通过关闭消息来避免这种情况,但异常消息应该包含有关异常原因的信息,以便可以修复问题。如果暴露这些信息是不可接受的,那么应用程序不应打印该消息,而应捕获并丢弃它。这不应通过 JVM 的配置来处理。

  3. 兼容性。JVM 传统上并未为 NPE 提供消息,现在加入消息可能会导致以过于敏感的方式解析堆栈跟踪的工具出现问题。但是,Java 程序始终可以抛出带有消息的 NPE,因此预计工具将适应 JVM 抛出的 NPE 上的消息。一个相关的风险是,工具可能依赖于 null-detail 消息的精确格式。

我们打算在未来的版本中默认启用 null-detail 消息。

null-detail 消息的计算

按需计算 null-detail 消息会影响在高级场景中消息的可用性:

  1. 通过 RMI 执行远程代码时,远程代码抛出的任何异常都会通过序列化传递给调用者。序列化异常对象不会保留其内部数据结构,因此如果远程代码抛出并序列化 NPE,最终的反序列化将生成一个 NPE,该 NPE 无法按需计算 null-detail 消息。

  2. 如果程序运行时方法的字节码指令发生更改,例如由于使用 JVMTI 的 Java 代理重新定义方法,则会保留原始指令一段时间,但在 GC 周期期间可能会丢弃它们。由于计算 null-detail 消息需要原始指令,如果发生这种情况,则不会按需计算 null-detail 消息。

不支持序列化的选择是为了尽量减少 NullPointerException 类本身的更改。如果希望保留 null-detail 消息以进行序列化,可以在该类中实现 writeReplace。或者,可以在创建异常对象时计算 null-detail 消息,这将使 null-detail 消息在序列化和方法重新定义中保持不变。

不支持序列化的选择是为了最小化对 NullPointerException 类本身的更改。如果保留 null 详细信息消息以便进行序列化变得可取,那么可以在该类中实现 writeReplace 方法。另外,null 详细信息消息可以在创建异常对象时进行计算,这将在序列化和方法重新定义中都保留 null 详细信息消息。

null 详细信息消息的格式

null 详细信息消息由两部分组成:第一部分描述无法执行的操作(NPE 的 后果),而第二部分描述之前将 null 引用推送到操作数栈上的表达式(NPE 的 原因)。在某些情况下,这会导致冗长的文本,而实际上只需要消息的一部分来精确定位源代码中的 null 表达式。例如,在以下两种情况下缩短消息可能会有所帮助:

  1. 在失败的数组访问中——Cannot load from object array because "a[i][j]" is null.——第二部分 "a[i][j]" is null 足以在源代码 a[i][j][k] = 99; 中精确定位 null 表达式。

  2. 在失败的方法调用中——Cannot invoke "NullPointerExceptionTest.callWithTypes(String[][], int[][][], float, long, short, boolean, byte, double, char)" because...——方法的声明类型和参数类型通常很庞大,可以在不严重影响开发人员精确定位 null 表达式能力的情况下省略它们。

尽管如此,null 详细信息消息并没有省略这些信息。计算消息的算法处理任意字节码指令序列,因此并不总是能够组装出有用的消息。例如,对于失败的数组访问,它可能根本无法计算第二部分,因此如果省略了第一部分,则根本不会打印任何消息;在这种情况下,仅第一部分可能就足以在源代码中精确定位 null 表达式。一般来说,由于消息是由每个访问指令的单独构建块组装而成的,因此在算法上无法确定是否在某个时间点已经收集了足够的信息,以便在不损害消息有用性的情况下省略更多部分。因此,选择了打印所有信息以使消息在尽可能多的情况下都有帮助。

风险与假设

在有用的 NPE 中,null 详细信息消息可能包含源代码中的变量名。具体来说,如果 class 文件中包含了调试信息(通过 javac -g),则会打印局部变量名。这些名称以前并未通过反射 API 直接公开;程序必须通过间接方式,即通过 ClassLoader::getResourceAsStream() 检查 class 文件来获得它们。在 NPE 中公开这些名称可能被视为安全风险,但省略它们会限制 null 详细信息消息的好处。

假设如果 JVM 规范中添加了新的字节码,null 详细信息消息的计算将被扩展。

测试

此功能的原型已通过 JDK-8218628 实现。原型中包含了一个单元测试,该测试对每个消息部分都进行了演练。一个先前的实现在 SAP 的商业 JVM 中自 2006 年起就已存在,并已证明是稳定的。

为了避免回归,应该运行一些较大的代码量。应该运行 jtreg 测试以检测其他处理该消息并需要进行适配的测试。