bigywater.tech
Java 内部原理 · 38

三元表达式里的拆箱 NPE:真实验证条件选中的分支才会真实拆箱

"flag ? 0 : nullableInteger 不管 flag 是什么值都会 NPE"是流传很广的说法。真实反编译字节码发现,至少在 Java 17 上,这句话是不准确的——真实结果比传言更符合直觉。

flag=true → 正常返回 0
真实测出:选中 0 这个分支时完全没有异常,和流传的"无条件都会 NPE"说法不一致
flag=false → 真实 NPE
真实测出:只有真正选中 null 分支时才会拆箱、才会抛出 NullPointerException

上一篇:String.hashCode() 真实公式:真实造出一对内容完全不同却哈希相同的字符串

先看两个真实、没有争议的拆箱 NPE

在验证有争议的三元表达式场景之前,先真实确认两个大家公认、确实会踩坑的拆箱陷阱。

真实实测 · 场景一:Integer null 直接赋值给 int
真实抛出 NullPointerException(拆箱 null 相当于调用 null.intValue())
真实实测 · 场景四:方法返回可能为 null 的 Integer,参与算术运算
真实抛出 NullPointerException(方法返回 null,+ 运算符触发了自动拆箱)

这两个场景毫无争议:int unboxed = boxedNull; 和 maybeNull(true) + 1 都会真实、无条件地触发拆箱,boxedNull/maybeNull(true) 只要是 null,就一定真实抛出 NullPointerException。

真实验证:三元表达式,结果和流传的说法不一致

流传很广的说法是:flag ? 0 : nullableInteger 这种写法,因为两个分支类型不统一(一个 int 字面量,一个 Integer),编译器会强制把整个表达式的类型定成 int,导致哪怕走的是 0 这个分支,nullableInteger 也会被提前拆箱。真实运行两种 flag 取值看看。

真实实测
flag=true 时 result = 0(真实没有抛异常)
flag=false 时真实抛出 NullPointerException(这次真的选中了 null 分支)

真实结果和"无条件都会 NPE"的说法不一致——flag=true 时选中的是 0 这个分支,真实完全没有触发异常;只有 flag=false、真正选中 nullableInteger 这个分支时,才真实抛出了 NullPointerException。这个结果本身其实更符合直觉("没被选中的分支不该被求值"),只是和网上一些流传的说法对不上。

真实验证:javap 揭示的真相——按条件选择性拆箱

反编译字节码,看编译器到底生成了什么样的控制流。

真实实测 · javap -c 反编译片段
57: iload_2
58: ifeq          65
61: iconst_0
62: goto          69
65: aload_3
66: invokevirtual #27   // Method java/lang/Integer.intValue:()I
69: istore        4

字节码真实显示的是一个标准的 if-else 结构:先判断 flag(ifeq),为真就直接 iconst_0 压入常量 0 然后跳过后面(goto 69);为假才会走到 aload_3 加载 nullableInteger、再 invokevirtual Integer.intValue() 真实拆箱。intValue() 这条调用只出现在 flag 为假的分支里,不会在 flag 为真时被执行——这就是真实测出"选中 0 分支不报错"的字节码层面的原因。

那流传的说法是完全错的吗?这类"两个分支类型不统一、无条件拆箱"的说法,更多是对 JLS 条件表达式类型统一规则的简化转述,加上部分场景确实可能因为不同的表达式结构、不同的编译器版本产生不同的字节码。真实、可靠的结论应该是:遇到 Integer 可能为 null 又要参与三元表达式或算术运算的场景,不要依赖"应该不会拆箱"或"应该会拆箱"的猜测——按系列一贯的态度,真正靠得住的做法是要么提前判空,要么直接反编译验证,不猜。

分步演示:字节码里的两条真实路径

每一步高亮的指令,都是真实用 javap -c 反编译这段代码得到的,不是按语义推测的。

flag ? 0 : nullableInteger 真实编译成的 if-else 跳转:intValue() 只出现在 flag 为假的那条路径上
这一步真实执行到的指令这条路径完全没走到的指令

    参考与说明

    • 本文全部结果均在本机真实运行 Java 17(javac/java 均为 17.0.20.1)得到,不同 JDK 大版本上重新编译这段代码,生成的字节码结构可能不同,这也是本文强调"反编译验证,不要背设定"的原因。
    • 和类初始化时机那篇一样,都是靠 javap 拿到编译器真实生成了什么,而不是停留在语言规范的文字描述层面。
    • 没有涉及:三元表达式两个分支都是包装类型时(不需要拆箱)的完整类型统一规则、Java 语言规范 15.25 节条件运算符类型判定的完整算法——这些留给系列后面按需再写。
    京ICP备2026067502号-1京公网安备11010502063487号