02 | 自动拆箱里的 NPE,藏得比你想象深
摘要:Integer == 1 居然抛空指针?自动拆箱是语法糖也是暗坑,本文把 NPE 的所有触发场景一次讲清。
一、问题现象
来看几段"看起来完全没问题"的代码:
public class UnboxingNpe {
public static void main(String[] args) {
Integer a = null;
int b = a; // ❌ NullPointerException
System.out.println(b);
}
}
「等等,我没有调用任何方法,为什么会 NPE?」
再看一个更隐蔽的场景:
public class HiddenNpe {
public static void main(String[] args) {
Integer a = null;
Integer b = 1;
System.out.println(a == b); // ❌ NPE
System.out.println(a == 1); // ❌ NPE
System.out.println(a.equals(b)); // ✅ 正常(但逻辑错误)
}
}
还有实际开发中最常见的踩坑场景:
// 从数据库查出的年龄字段,数据库里是 NULL
Integer age = user.getAge();
// 业务判断:未成年?
if (age < 18) { // ❌ age 为 null 时 NPE
System.out.println("未成年");
}
二、踩坑现场
场景 1:数据库 NULL 值直接参与运算
// MyBatis 查询,age 字段在数据库为 NULL
User user = userMapper.selectById(1L);
int userAge = user.getAge(); // ❌ 自动拆箱,NULL → NPE
为什么这么容易踩:数据库里大量字段允许 NULL,对应的 Java 实体用 Integer 接收,但开发者习惯性当作 int 来用。
场景 2:集合里的包装类型取出来直接运算
List<Integer> scores = new ArrayList<>();
scores.add(null); // 允许添加 null
int first = scores.get(0); // ❌ NPE
场景 3:三目运算符的暗坑
Integer a = null;
int b = true ? a : 0; // ❌ NPE
这段代码的真实执行逻辑是:
三、原理解析
3.1 自动拆箱的编译真相
自动拆箱(Unboxing)是编译器在编译期帮你加的语法糖,运行时根本没有"自动"这回事。
// 你写的代码
Integer a = null;
int b = a;
// 编译后的字节码(等价于)
Integer a = null;
int b = a.intValue(); // null.intValue() → NPE
所有包装类型的拆箱方法:
| Integer | intValue() | ✅ |
| Long | longValue() | ✅ |
| Boolean | booleanValue() | ✅ |
| Double | doubleValue() | ✅ |
| Character | charValue() | ✅ |
3.2 比较运算时的隐式拆箱
Integer a = null;
Integer b = 1;
// 以下所有比较都会触发 a 的自动拆箱
System.out.println(a == b); // a.intValue() == b → NPE
System.out.println(a > b); // a.intValue() > b.intValue() → NPE
System.out.println(a + b); // a.intValue() + b.intValue() → NPE
关键规则:只要涉及算术运算或大小比较,包装类型会先拆箱再运算。
3.3 三目运算符的类型提升陷阱
这是 Java 语言规范里最令人意外的细节之一。
// 情况 1:两个操作数类型相同
int x = true ? 1 : 2; // ✅ 正常,都是 int 字面量
// 情况 2:一个包装类型,一个基本类型
Integer a = null;
int x = true ? a : 0; // ❌ NPE!
// 编译后逻辑:
// int x = (true ? a.intValue() : 0); // a 先拆箱
JLS 规范规定:当三目运算符的两个操作数类型不同时,会触发数值类型上下文转换,包装类型会被拆箱。
3.4 方法参数传递时的拆箱
public void print(int value) {
System.out.println(value);
}
print(null); // 编译错误,但如果是:
Integer a = null;
print(a); // ❌ 运行时 NPE,a 在传入时拆箱
四、正确写法
4.1 防御式编程:用前先判空
// ✅ 写法一:先判空
Integer age = user.getAge();
if (age != null && age < 18) {
System.out.println("未成年");
}
// ✅ 写法二:提供默认值
int age = Optional.ofNullable(user.getAge()).orElse(0);
if (age < 18) { ... }
4.2 数据库字段:在实体层做保护
// ✅ 在实体类里做默认保护
public class User {
private Integer age;
public int getAgeOrDefault() {
return age != null ? age : 0;
}
}
4.3 三目运算符:手动保证类型一致
// ❌ 危险
int x = true ? a : 0;
// ✅ 安全:两个分支都用包装类型,或都拆箱
Integer x = true ? a : Integer.valueOf(0); // 注意 a 仍可能为 null
int x = true ? (a != null ? a : 0) : 0;
4.4 使用 Optional(Java 8+)
// ✅ 推荐写法
Optional.ofNullable(user.getAge())
.map(age -> age < 18)
.orElse(false);
五、最佳实践
✅ 5 条铁律
🔍 快速自查清单
- 所有 Integer、Long 参与 <、>、==、+、- 运算前是否有判空?
- 三目运算符的两个分支是否类型一致?
- 从 Map/List 取出来的包装类型是否直接拆箱?
- 数据库 NULL 字段的实体类字段是否用了基本类型?
🛠️ Lombok 方案
// 用 @Builder 时,默认值是这样处理的
@Builder.Default
private Integer age = 0; // 构建时如果没填 age,默认为 0 而非 null
六、小结
- 自动拆箱是编译期语法糖,本质是调用 xxxValue() 方法
- null 的包装类型一旦拆箱,必然 NPE,且编译期无提示
- 三目运算符类型不一致时,包装类型会被强制拆箱(最隐蔽的坑)
- 生产规范:所有包装类型运算前必须判空,或使用 Optional 包装
- 数据库实体类用包装类型接收 NULL 字段,使用前提供默认值
下一篇预告:BigDecimal 用 double 构造?精度就这样丢了 —— 0.1 + 0.2 不等于 0.3 的背后,藏着计算机最基础的精度问题。





