tommwq的博客

如何避免NPE

· [tommwq@126.com]

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的建议:

  1. 所有变量都使用nonnull对象初始化。
  2. 禁止将引用赋值为null。
  3. 所有方法都返回nonnull对象。
  4. 不要用null表达业务概念。
  5. 如果按照业务规则所查询对象可能不存在,则使用Optional作为返回值类型。
  6. 如果按照业务规则所查询对象必须存在,则在查询失败时抛出异常。
  7. 利用不变性保证类中成员是nonnull的。
  8. 尽量避免使用反射为域赋值。
  9. 在使用反射时,禁止将域赋值为null。
  10. 对线程间共享的对象,使用锁进行保护。
  11. 对非线程安全类,定义并添加注解@NonThreadSafe,以避免类被错误使用。

修订记录

  1. 2020年11月11日 新建文档。
  2. 2023年08月20日 将标题改为《如何避免NPE》;修改部分文字。
  3. 2024年02月10日 增加段落标题。
  4. 2025.03.14 修改部分文字。