如何避免NPE
NPE
对null进行解引用操作会引发NPE异常。NPE是非常讨厌的一个异常,一不小心就会掉到NPE陷阱里面。
public int foo(String s) {
switch (s) {
case "abc":
return 1;
case "xyz":
return 2;
default:
return 0;
}
}
String s = "abc";
// ...
s = null;
// ...
foo(s); // NPE
也许有人会说,加上null检查就可以避免NPE问题。但是Java语言中的变量(除了少数基础类型外)全部都是引用,如果在使用每个变量前都进行null检查,无疑会增加非常大的工作量。而且后面我们也会看到,null检查也无法完全避免NPE。
根源是引用缺少testAndGet
实际上null安全问题的根源在于引用没有testAndGet原子操作。这个问题不仅存在于Java中,任何允许null引用存在的语言都有同样的问题。null安全的解决方案不在于语法层面,在于维护语义清晰。
“引用”是与具体对象相关联的一个句柄。对句柄进行解引用可以得到具体对象自身。据此我们可以为引用建立一个模型。
public interface Reference<T> {
T get(); // 解引用
}
一些语言允许尚未和具体对象建立关联的引用(即null)存在。对于这类语言,引用存在一个测试方法,判断引用是否和具体对象建立关联。
public interface Reference<T> {
T get() throws NPE; // 解引用
boolean test(); // 判断引用是否有效,即nonnull
}
对于这种情况,由于null引用的存在,在解引用前必须进行null检查。
Reference ref;
if (ref.test()) {
ref.get();
}
kotlin中所所谓的null安全(?.)就是这段代码的语法糖。这段代码在单线程环境下可以避免NPE。但是在多线程环境中,由于随时可能发生线程调度,如果出现如下指令序列,NPE仍然会发生。
// 线程1 // 线程2
if (ref.test())
// 线程调度
ref = null;
// 线程调度
// 线程1
ref.get();
// NPE
因此在多线程环境下,要保证null安全,必须将ref.test()和ref.get()放在临界区中:要么由语言(和运行时)提供testAndGet()原子操作,要么使用锁进行保护。Java没有提供testAndGet原子操作,而对所有变量使用锁进行保护,从工作量和性能角度看是不现实的。那么难道null安全问题无法解决吗?如果我们跳出语法层面,从语义层面取考虑,就可以从很大程度上避免NPE的发生。
利用语义解决NPE
引用存在nonnull和null两种状态,一个表示引用已绑定,一个表示引用未绑定。它们是技术层面的概念,不是业务层面概念。如果用null表示(符合查询条件的)业务对象不存在,混用两个层面的概念,就容易导致NPE。如果把这两个概念分开,保证所有引用都是nonnull,就不需要担心NPE问题。对于(符合查询条件的)业务对象不存在的情况,则要按照业务规则分别处理。如果业务规则要求这种对象一定要存在,那么查询不到就是一种异常情况,需要通过异常流程处理。
// always return nonnull object
Foo getFooById(String id) throws FooNotFoundException;
如果方法返回,返回值一定是nonnull对象,可以放心使用。对于找不到对象的情况,则通过异常流程处理。如果业务规则允许找不到对象的情况,应该使用Optional表达业务意图。
Optional<Foo> findFooById(String id);
做到这一点,我们就可以放心的使用方法返回的对象,不再需要检查null了。
对于类成员的使用,可以通过不变性保证成员一直是nonnull的。
public class Bar {
private String x;
public Bar(String x) {
if (x == null) {
throw new IllegalArgumentException("invalid constructor parameter");
}
this.x = x;
}
public String getX() {
return x;
}
public void setX(String x) {
if (x == null) {
throw new IllegalArgumentException("invalid constructor parameter");
}
this.x = x;
}
}
做到了这一点还不够。由于Java支持反射,还必须保证通过反射设置域时不能传入null。这一点要通过代码评审和单元测试保证。有人可能觉得这里同样使用了异常,和NPE差不多。这种方案有两点优势:第一,保证任何时候域都是nonnull的,可以安全使用。第二,符合fast-fail原则,可以快速找到尝试赋值null的代码。
此外,还要保证每个引用都通过nonnull对象初始化,并且禁止出现类似 foo = null;
的语句。
做到了上面这几点,可以保证在单线程环境下不会发生NPE。在多线程环境下,对于线程之间共享的对象(包括类内部的对象),还必须使用锁进行保护。对于没有使用锁保护的类,可以加上(自定义的)@ThreadSafe/@NonThreadSafe注解,表明类不能在多线程环境下直接使用。
一些建议
总的来说,解决null安全问题不能依赖于语法层面的“银弹”,而是要通过清晰的语义,规范的编码以及仔细的代码评审来解决。下面是一些避免NPE的建议:
- 所有变量都使用nonnull对象初始化。
- 禁止将引用赋值为null。
- 所有方法都返回nonnull对象。
- 不要用null表达业务概念。
- 如果按照业务规则所查询对象可能不存在,则使用Optional作为返回值类型。
- 如果按照业务规则所查询对象必须存在,则在查询失败时抛出异常。
- 利用不变性保证类中成员是nonnull的。
- 尽量避免使用反射为域赋值。
- 在使用反射时,禁止将域赋值为null。
- 对线程间共享的对象,使用锁进行保护。
- 对非线程安全类,定义并添加注解@NonThreadSafe,以避免类被错误使用。
修订记录
- 2020年11月11日 新建文档。
- 2023年08月20日 将标题改为《如何避免NPE》;修改部分文字。
- 2024年02月10日 增加段落标题。
- 2025.03.14 修改部分文字。