learn-tech/专栏/Java业务开发常见错误100例/11空值处理:分不清楚的null和恼人的空指针.md
2024-10-16 00:20:59 +08:00

25 KiB
Raw Blame History

                        因收到Google相关通知网站将会择期关闭。相关通知内容
                        
                        
                        11 空值处理分不清楚的null和恼人的空指针
                        今天,我要和你分享的主题是,空值处理:分不清楚的 null 和恼人的空指针。

有一天我收到一条短信,内容是“尊敬的 null 你好XXX”。当时我就笑了这是程序员都能 Get 的笑点,程序没有获取到我的姓名,然后把空格式化为了 null。很明显这是没处理好 null。哪怕把 null 替换为贵宾、顾客,也不会引发这样的笑话。

程序中的变量是 null就意味着它没有引用指向或者说没有指针。这时我们对这个变量进行任何操作都必然会引发空指针异常在 Java 中就是 NullPointerException。那么空指针异常容易在哪些情况下出现又应该如何修复呢

空指针异常虽然恼人但好在容易定位,更麻烦的是要弄清楚 null 的含义。比如,客户端给服务端的一个数据是 null那么其意图到底是给一个空值还是没提供值呢再比如数据库中字段的 NULL 值,是否有特殊的含义呢,针对数据库中的 NULL 值,写 SQL 需要特别注意什么呢?

今天,就让我们带着这些问题开始 null 的踩坑之旅吧。

修复和定位恼人的空指针问题

NullPointerException 是 Java 代码中最常见的异常,我将其最可能出现的场景归为以下 5 种:

参数值是 Integer 等包装类型,使用时因为自动拆箱出现了空指针异常;

字符串比较出现空指针异常;

诸如 ConcurrentHashMap 这样的容器不支持 Key 和 Value 为 null强行 put null 的 Key 或 Value 会出现空指针异常;

A 对象包含了 B在通过 A 对象的字段获得 B 之后,没有对字段判空就级联调用 B 的方法出现空指针异常;

方法或远程服务返回的 List 不是空而是 null没有进行判空就直接调用 List 的方法出现空指针异常。

为模拟说明这 5 种场景,我写了一个 wrongMethod 方法,并用一个 wrong 方法来调用它。wrong 方法的入参 test 是一个由 0 和 1 构成的、长度为 4 的字符串,第几位设置为 1 就代表第几个参数为 null用来控制 wrongMethod 方法的 4 个入参,以模拟各种空指针情况:

private List wrongMethod(FooService fooService, Integer i, String s, String t) { log.info("result {} {} {} {}", i + 1, s.equals("OK"), s.equals(t), new ConcurrentHashMap<String, String>().put(null, null)); if (fooService.getBarService().bar().equals("OK")) log.info("OK"); return null; }

@GetMapping("wrong") public int wrong(@RequestParam(value = "test", defaultValue = "1111") String test) { return wrongMethod(test.charAt(0) == '1' ? null : new FooService(), test.charAt(1) == '1' ? null : 1, test.charAt(2) == '1' ? null : "OK", test.charAt(3) == '1' ? null : "OK").size(); }

class FooService { @Getter private BarService barService; }

class BarService { String bar() { return "OK"; } }

很明显,这个案例出现空指针异常是因为变量是一个空指针,尝试获得变量的值或访问变量的成员会获得空指针异常。但,这个异常的定位比较麻烦。

在测试方法 wrongMethod 中,我们通过一行日志记录的操作,在一行代码中模拟了 4 处空指针异常:

对入参 Integer i 进行 +1 操作;

对入参 String s 进行比较操作判断内容是否等于”OK”

对入参 String s 和入参 String t 进行比较操作,判断两者是否相等;

对 new 出来的 ConcurrentHashMap 进行 put 操作Key 和 Value 都设置为 null。

输出的异常信息如下:

java.lang.NullPointerException: null at org.geekbang.time.commonmistakes.nullvalue.demo2.AvoidNullPointerExceptionController.wrongMethod(AvoidNullPointerExceptionController.java:37) at org.geekbang.time.commonmistakes.nullvalue.demo2.AvoidNullPointerExceptionController.wrong(AvoidNullPointerExceptionController.java:20)

这段信息确实提示了这行代码出现了空指针异常,但我们很难定位出到底是哪里出现了空指针,可能是把入参 Integer 拆箱为 int 的时候出现的,也可能是入参的两个字符串任意一个为 null也可能是因为把 null 加入了 ConcurrentHashMap。

你可能会想到,要排查这样的问题,只要设置一个断点看一下入参即可。但,在真实的业务场景中,空指针问题往往是在特定的入参和代码分支下才会出现,本地难以重现。如果要排查生产上出现的空指针问题,设置代码断点不现实,通常是要么把代码进行拆分,要么增加更多的日志,但都比较麻烦。

在这里,我推荐使用阿里开源的 Java 故障诊断神器Arthas。Arthas 简单易用功能强大,可以定位出大多数的 Java 生产问题。

接下来,我就和你演示下如何在 30 秒内知道 wrongMethod 方法的入参,从而定位到空指针到底是哪个入参引起的。如下截图中有三个红框,我先和你分析第二和第三个红框:

第二个红框表示Arthas 启动后被附加到了 JVM 进程;

第三个红框表示,通过 watch 命令监控 wrongMethod 方法的入参。

watch 命令的参数包括类名表达式、方法表达式和观察表达式。这里,我们设置观察类为 AvoidNullPointerExceptionController观察方法为 wrongMethod观察表达式为 params 表示观察入参:

watch org.geekbang.time.commonmistakes.nullvalue.demo2.AvoidNullPointerExceptionController wrongMethod params

开启 watch 后,执行 2 次 wrong 方法分别设置 test 入参为 1111 和 1101也就是第一次传入 wrongMethod 的 4 个参数都为 null第二次传入的第 1、2 和 4 个参数为 null。

配合图中第一和第四个红框可以看到,第二次调用时,第三个参数是字符串 OK 其他参数是 nullArchas 正确输出了方法的所有入参,这样我们很容易就能定位到空指针的问题了。

到这里,如果是简单的业务逻辑的话,你就可以定位到空指针异常了;如果是分支复杂的业务逻辑,你需要再借助 stack 命令来查看 wrongMethod 方法的调用栈,并配合 watch 命令查看各方法的入参,就可以很方便地定位到空指针的根源了。

下图演示了通过 stack 命令观察 wrongMethod 的调用路径:

如果你想了解 Arthas 各种命令的详细使用方法,可以点击这里查看。

接下来,我们看看如何修复上面出现的 5 种空指针异常。

其实,对于任何空指针异常的处理,最直白的方式是先判空后操作。不过,这只能让异常不再出现,我们还是要找到程序逻辑中出现的空指针究竟是来源于入参还是 Bug

如果是来源于入参,还要进一步分析入参是否合理等;

如果是来源于 Bug那空指针不一定是纯粹的程序 Bug可能还涉及业务属性和接口调用规范等。

在这里,因为是 Demo所以我们只考虑纯粹的空指针判空这种修复方式。如果要先判空后处理大多数人会想到使用 if-else 代码块。但,这种方式既增加代码量又会降低易读性,我们可以尝试利用 Java 8 的 Optional 类来消除这样的 if-else 逻辑,使用一行代码进行判空和处理。

修复思路如下:

对于 Integer 的判空,可以使用 Optional.ofNullable 来构造一个 Optional然后使用 orElse(0) 把 null 替换为默认值再进行 +1 操作。

对于 String 和字面量的比较可以把字面量放在前面比如”OK”.equals(s),这样即使 s 是 null 也不会出现空指针异常;而对于两个可能为 null 的字符串变量的 equals 比较,可以使用 Objects.equals它会做判空处理。

对于 ConcurrentHashMap既然其 Key 和 Value 都不支持 null修复方式就是不要把 null 存进去。HashMap 的 Key 和 Value 可以存入 null而 ConcurrentHashMap 看似是 HashMap 的线程安全版本,却不支持 null 值的 Key 和 Value这是容易产生误区的一个地方。

对于类似 fooService.getBarService().bar().equals(“OK”) 的级联调用,需要判空的地方有很多,包括 fooService、getBarService() 方法的返回值,以及 bar 方法返回的字符串。如果使用 if-else 来判空的话可能需要好几行代码,但使用 Optional 的话一行代码就够了。

对于 rightMethod 返回的 List由于不能确认其是否为 null所以在调用 size 方法获得列表大小之前,同样可以使用 Optional.ofNullable 包装一下返回值,然后通过.orElse(Collections.emptyList()) 实现在 List 为 null 的时候获得一个空的 List最后再调用 size 方法。

private List rightMethod(FooService fooService, Integer i, String s, String t) { log.info("result {} {} {} {}", Optional.ofNullable(i).orElse(0) + 1, "OK".equals(s), Objects.equals(s, t), new HashMap<String, String>().put(null, null)); Optional.ofNullable(fooService) .map(FooService::getBarService) .filter(barService -> "OK".equals(barService.bar())) .ifPresent(result -> log.info("OK")); return new ArrayList<>(); }

@GetMapping("right") public int right(@RequestParam(value = "test", defaultValue = "1111") String test) { return Optional.ofNullable(rightMethod(test.charAt(0) == '1' ? null : new FooService(), test.charAt(1) == '1' ? null : 1, test.charAt(2) == '1' ? null : "OK", test.charAt(3) == '1' ? null : "OK")) .orElse(Collections.emptyList()).size(); }

经过修复后,调用 right 方法传入 1111也就是给 rightMethod 的 4 个参数都设置为 null日志中也看不到任何空指针异常了

[21:43:40.619] [http-nio-45678-exec-2] [INFO ] [.AvoidNullPointerExceptionController:45 ] - result 1 false true null

但是,如果我们修改 right 方法入参为 0000即传给 rightMethod 方法的 4 个参数都不可能是 null最后日志中也无法出现 OK 字样。这又是为什么呢BarService 的 bar 方法不是返回了 OK 字符串吗?

我们还是用 Arthas 来定位问题,使用 watch 命令来观察方法 rightMethod 的入参,-x 参数设置为 2 代表参数打印的深度为 2 层:

可以看到FooService 中的 barService 字段为 null这样也就可以理解为什么最终出现这个 Bug 了。

这又引申出一个问题,使用判空方式或 Optional 方式来避免出现空指针异常,不一定是解决问题的最好方式,空指针没出现可能隐藏了更深的 Bug。因此解决空指针异常还是要真正 case by case 地定位分析案例,然后再去做判空处理,而处理时也并不只是判断非空然后进行正常业务流程这么简单,同样需要考虑为空的时候是应该出异常、设默认值还是记录日志等。

POJO 中属性的 null 到底代表了什么?

在我看来,相比判空避免空指针异常,更容易出错的是 null 的定位问题。对程序来说null 就是指针没有任何指向,而结合业务逻辑情况就复杂得多,我们需要考虑:

DTO 中字段的 null 到底意味着什么?是客户端没有传给我们这个信息吗?

既然空指针问题很讨厌,那么 DTO 中的字段要设置默认值么?

如果数据库实体中的字段有 null那么通过数据访问框架保存数据是否会覆盖数据库中的既有数据

如果不能明确地回答这些问题,那么写出的程序逻辑很可能会混乱不堪。接下来,我们看一个实际案例吧。

有一个 User 的 POJO同时扮演 DTO 和数据库 Entity 角色,包含用户 ID、姓名、昵称、年龄、注册时间等属性

@Data @Entity public class User {

@Id
@GeneratedValue(strategy = IDENTITY)
private Long id;

private String name;

private String nickname;

private Integer age;

private Date createDate = new Date();

}

有一个 Post 接口用于更新用户数据,更新逻辑非常简单,根据用户姓名自动设置一个昵称,昵称的规则是“用户类型 + 姓名”,然后直接把客户端在 RequestBody 中使用 JSON 传过来的 User 对象通过 JPA 更新到数据库中,最后返回保存到数据库的数据。

@Autowired private UserRepository userRepository;

@PostMapping("wrong") public User wrong(@RequestBody User user) { user.setNickname(String.format("guest%s", user.getName())); return userRepository.save(user); }

@Repository public interface UserRepository extends JpaRepository<User, Long> {

}

首先在数据库中初始化一个用户age=36、name=zhuye、create_date=2020 年 1 月 4 日、nickname 是 NULL

然后,使用 cURL 测试一下用户信息更新接口 Post传入一个 id=1、name=null 的 JSON 字符串,期望把 ID 为 1 的用户姓名设置为空:

curl -H "Content-Type:application/json" -X POST -d '{ "id":1, "name":null}' http://localhost:45678/pojonull/wrong

{"id":1,"name":null,"nickname":"guestnull","age":null,"createDate":"2020-01-05T02:01:03.784+0000"}%

接口返回的结果和数据库中记录一致:

可以看到,这里存在如下三个问题:

调用方只希望重置用户名,但 age 也被设置为了 null

nickname 是用户类型加姓名name 重置为 null 的话,访客用户的昵称应该是 guest而不是 guestnull重现了文首提到的那个笑点

用户的创建时间原来是 1 月 4 日,更新了用户信息后变为了 1 月 5 日。

归根结底,这是如下 5 个方面的问题:

明确 DTO 中 null 的含义。对于 JSON 到 DTO 的反序列化过程null 的表达是有歧义的,客户端不传某个属性,或者传 null这个属性在 DTO 中都是 null。但对于用户信息更新操作不传意味着客户端不需要更新这个属性维持数据库原先的值传了 null意味着客户端希望重置这个属性。因为 Java 中的 null 就是没有这个数据,无法区分这两种表达,所以本例中的 age 属性也被设置为了 null或许我们可以借助 Optional 来解决这个问题。

POJO 中的字段有默认值。如果客户端不传值,就会赋值为默认值,导致创建时间也被更新到了数据库中。

注意字符串格式化时可能会把 null 值格式化为 null 字符串。比如昵称的设置,我们只是进行了简单的字符串格式化,存入数据库变为了 guestnull。显然这是不合理的也是开头我们说的笑话的来源还需要进行判断。

DTO 和 Entity 共用了一个 POJO。对于用户昵称的设置是程序控制的我们不应该把它们暴露在 DTO 中,否则很容易把客户端随意设置的值更新到数据库中。此外,创建时间最好让数据库设置为当前时间,不用程序控制,可以通过在字段上设置 columnDefinition 来实现。

数据库字段允许保存 null会进一步增加出错的可能性和复杂度。因为如果数据真正落地的时候也支持 NULL 的话,可能就有 NULL、空字符串和字符串 null 三种状态。这一点我会在下一小节展开。如果所有属性都有默认值,问题会简单一点。

按照这个思路,我们对 DTO 和 Entity 进行拆分,修改后代码如下所示:

UserDto 中只保留 id、name 和 age 三个属性,且 name 和 age 使用 Optional 来包装,以区分客户端不传数据还是故意传 null。

在 UserEntity 的字段上使用 @Column 注解,把数据库字段 name、nickname、age 和 createDate 都设置为 NOT NULL并设置 createDate 的默认值为 CURRENT_TIMESTAMP由数据库来生成创建时间。

使用 Hibernate 的 @DynamicUpdate 注解实现更新 SQL 的动态生成,实现只更新修改后的字段,不过需要先查询一次实体,让 Hibernate 可以“跟踪”实体属性的当前状态,以确保有效。

@Data public class UserDto {

private Long id;

private Optional<String> name;

private Optional<Integer> age;

;

@Data @Entity @DynamicUpdate public class UserEntity {

@Id
@GeneratedValue(strategy = IDENTITY)
private Long id;

@Column(nullable = false)
private String name;

@Column(nullable = false)
private String nickname;

@Column(nullable = false)
private Integer age;

@Column(nullable = false, columnDefinition = "TIMESTAMP DEFAULT CURRENT_TIMESTAMP")
private Date createDate;

}

在重构了 DTO 和 Entity 后,我们重新定义一个 right 接口,以便对更新操作进行更精细化的处理。首先是参数校验:

对传入的 UserDto 和 ID 属性先判空,如果为空直接抛出 IllegalArgumentException。

根据 id 从数据库中查询出实体后进行判空,如果为空直接抛出 IllegalArgumentException。

然后,由于 DTO 中已经巧妙使用了 Optional 来区分客户端不传值和传 null 值,那么业务逻辑实现上就可以按照客户端的意图来分别实现逻辑。如果不传值,那么 Optional 本身为 null直接跳过 Entity 字段的更新即可,这样动态生成的 SQL 就不会包含这个列;如果传了值,那么进一步判断传的是不是 null。

下面,我们根据业务需要分别对姓名、年龄和昵称进行更新:

对于姓名,我们认为客户端传 null 是希望把姓名重置为空,允许这样的操作,使用 Optional 的 orElse 方法一键把空转换为空字符串即可。

对于年龄,我们认为如果客户端希望更新年龄就必须传一个有效的年龄,年龄不存在重置操作,可以使用 Optional 的 orElseThrow 方法在值为空的时候抛出 IllegalArgumentException。

对于昵称,因为数据库中姓名不可能为 null所以可以放心地把昵称设置为 guest 加上数据库取出来的姓名。

@PostMapping("right") public UserEntity right(@RequestBody UserDto user) {

if (user == null || user.getId() == null)
    throw new IllegalArgumentException("用户Id不能为空");
UserEntity userEntity = userEntityRepository.findById(user.getId())
        .orElseThrow(() -> new IllegalArgumentException("用户不存在"));
if (user.getName() != null) {
    userEntity.setName(user.getName().orElse(""));
}

userEntity.setNickname("guest" + userEntity.getName());
if (user.getAge() != null) {
    userEntity.setAge(user.getAge().orElseThrow(() -> new IllegalArgumentException("年龄不能为空")));
}
return userEntityRepository.save(userEntity);

}

假设数据库中已经有这么一条记录id=1、age=36、create_date=2020 年 1 月 4 日、name=zhuye、nickname=guestzhuye

使用相同的参数调用 right 接口,再来试试是否解决了所有问题。传入一个 id=1、name=null 的 JSON 字符串,期望把 id 为 1 的用户姓名设置为空:

curl -H "Content-Type:application/json" -X POST -d '{ "id":1, "name":null}' http://localhost:45678/pojonull/right

{"id":1,"name":"","nickname":"guest","age":36,"createDate":"2020-01-04T11:09:20.000+0000"}%

结果如下:

可以看到right 接口完美实现了仅重置 name 属性的操作,昵称也不再有 null 字符串,年龄和创建时间字段也没被修改。

通过日志可以看到Hibernate 生成的 SQL 语句只更新了 name 和 nickname 两个字段:

Hibernate: update user_entity set name=?, nickname=? where id=?

接下来,为了测试使用 Optional 是否可以有效区分 JSON 中没传属性还是传了 null我们在 JSON 中设置了一个 null 的 age结果是正确得到了年龄不能为空的错误提示

curl -H "Content-Type:application/json" -X POST -d '{ "id":1, "age":null}' http://localhost:45678/pojonull/right

{"timestamp":"2020-01-05T03:14:40.324+0000","status":500,"error":"Internal Server Error","message":"年龄不能为空","path":"/pojonull/right"}%

小心 MySQL 中有关 NULL 的三个坑

前面提到,数据库表字段允许存 NULL 除了会让我们困惑外,还容易有坑。这里我会结合 NULL 字段,和你着重说明 sum 函数、count 函数,以及 NULL 值条件可能踩的坑。

为方便演示,首先定义一个只有 id 和 score 两个字段的实体:

@Entity @Data public class User {

@Id
@GeneratedValue(strategy = IDENTITY)
private Long id;

private Long score;

}

程序启动的时候,往实体初始化一条数据,其 id 是自增列自动设置的 1score 是 NULL

@Autowired private UserRepository userRepository; @PostConstruct public void init() {

userRepository.save(new User());

}

然后,测试下面三个用例,来看看结合数据库中的 null 值可能会出现的坑:

通过 sum 函数统计一个只有 NULL 值的列的总和,比如 SUM(score)

select 记录数量count 使用一个允许 NULL 的字段,比如 COUNT(score)

使用 =NULL 条件查询字段值为 NULL 的记录,比如 score=null 条件。

@Repository public interface UserRepository extends JpaRepository<User, Long> {

@Query(nativeQuery=true,value = "SELECT SUM(score) FROM `user`")
Long wrong1();

@Query(nativeQuery = true, value = "SELECT COUNT(score) FROM `user`")
Long wrong2();

@Query(nativeQuery = true, value = "SELECT * FROM `user` WHERE score=null")
List<User> wrong3();

}

得到的结果,分别是 null、0 和空 List

[11:38:50.137] [http-nio-45678-exec-1] [INFO ] [t.c.nullvalue.demo3.DbNullController:26 ] - result: null 0 []

显然,这三条 SQL 语句的执行结果和我们的期望不同:

虽然记录的 score 都是 NULL但 sum 的结果应该是 0 才对;

虽然这条记录的 score 是 NULL但记录总数应该是 1 才对;

使用 =NULL 并没有查询到 id=1 的记录,查询条件失效。

原因是:

MySQL 中 sum 函数没统计到任何记录时,会返回 null 而不是 0可以使用 IFNULL 函数把 null 转换为 0

MySQL 中 count 字段不统计 null 值COUNT(*) 才是统计所有记录数量的正确方式。

MySQL 中使用诸如 =、<、> 这样的算数比较操作符比较 NULL 的结果总是 NULL这种比较就显得没有任何意义需要使用 IS NULL、IS NOT NULL 或 ISNULL() 函数来比较。

修改一下 SQL

@Query(nativeQuery = true, value = "SELECT IFNULL(SUM(score),0) FROM user") Long right1();

@Query(nativeQuery = true, value = "SELECT COUNT(*) FROM user") Long right2();

@Query(nativeQuery = true, value = "SELECT * FROM user WHERE score IS NULL") List right3();

可以得到三个正确结果,分别为 0、1、[User(id=1, score=null)]

[14:50:35.768] [http-nio-45678-exec-1] [INFO ] [t.c.nullvalue.demo3.DbNullController:31 ] - result: 0 1 [User(id=1, score=null)]

重点回顾

今天,我和你讨论了做好空值处理需要注意的几个问题。

我首先总结了业务代码中 5 种最容易出现空指针异常的写法,以及相应的修复方式。针对判空,通过 Optional 配合 Stream 可以避免大多数冗长的 if-else 判空逻辑,实现一行代码优雅判空。另外,要定位和修复空指针异常,除了可以通过增加日志进行排查外,在生产上使用 Arthas 来查看方法的调用栈和入参会更快捷。

在我看来,业务系统最基本的标准是不能出现未处理的空指针异常,因为它往往代表了业务逻辑的中断,所以我建议每天查询一次生产日志来排查空指针异常,有条件的话建议订阅空指针异常报警,以便及时发现及时处理。

POJO 中字段的 null 定位,从服务端的角度往往很难分清楚,到底是客户端希望忽略这个字段还是有意传了 null因此我们尝试用 Optional类来区分 null 的定位。同时,为避免把空值更新到数据库中,可以实现动态 SQL只更新必要的字段。

最后,我分享了数据库字段使用 NULL 可能会带来的三个坑(包括 sum 函数、count 函数,以及 NULL 值条件),以及解决方式。

总结来讲null 的正确处理以及避免空指针异常,绝不是判空这么简单,还要根据业务属性从前到后仔细考虑,客户端传入的 null 代表了什么,出现了 null 是否允许使用默认值替代,入库的时候应该传入 null 还是空值,并确保整个逻辑处理的一致性,才能尽量避免 Bug。

为处理好 null作为客户端的开发者需要和服务端对齐字段 null 的含义以及降级逻辑;而作为服务端的开发者,需要对入参进行前置判断,提前挡掉服务端不可接受的空值,同时在整个业务逻辑过程中进行完善的空值处理。

今天用到的代码,我都放在了 GitHub 上,你可以点击这个链接查看。

思考与讨论

ConcurrentHashMap 的 Key 和 Value 都不能为 null而 HashMap 却可以你知道这么设计的原因是什么吗TreeMap、Hashtable 等 Map 的 Key 和 Value 是否支持 null 呢?

对于 Hibernate 框架可以使用 @DynamicUpdate 注解实现字段的动态更新,对于 MyBatis 框架如何实现类似的动态 SQL 功能,实现插入和修改 SQL 只包含 POJO 中的非空字段?

关于程序和数据库中的 null、空指针问题你还遇到过什么坑吗我是朱晔欢迎在评论区与我留言分享也欢迎你把这篇文章分享给你的朋友或同事一起交流。