http://tommwq.tech/blog/2020/11/12/203
如果对null进行解引用操作,就会引发NPE异常。NPE是让人感到非常讨厌的一个异常,一不小心就会掉到NPE的陷阱里面。
Listing 1: switch引发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。
实际上,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执行 if (ref.test()) 线程调度 线程2执行 对ref解绑定,ref变为null 线程调度 线程1执行 ref.get(),引发NPE
因此在多线程环境下,要保证null安全,必须将ref.test()和ref.get()放在临界区中:要么由语言(和运行时)提供testAndGet()原子操作,要么使用锁进行保护。Java没有提供testAndGet原子操作,而对所有变量使用锁进行保护,从工作量和性能角度看,是不现实的。难道null安全问题无法解决吗?如果我们跳出语法层面,从语义层面取考虑,就可以从很大程度上避免NPE的发生。
引用存在nonnull和null两种状态。一些人用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,以避免类被错误使用。