Java程序中空指针异常的最佳实践

1、空指针问题

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

  • 参数值是 Integer 等包装类型,使用时因为自动拆箱出现了空指针异常;
  • 字符串比较出现空指针异常;
  • 诸如 ConcurrentHashMap 这样的容器不支持 Key 和 Value 为 null,强行 put null 的 Key 或 Value 会出现空指针异常;
  • A 对象包含了 B,在通过 A 对象的字段获得 B 之后,没有对字段判空就级联调用 B 的方法出现空指针异常;
  • 方法或远程服务返回的 List 不是空而是 null,没有进行判空就直接调用 List 的方法出现空指针异常。

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

  • 如果是来源于入参,还要进一步分析入参是否合理等;
  • 如果是来源于 Bug,那空指针不一定是纯粹的程序 Bug,可能还涉及业务属性和接口调用规范等。

如果要先判空后处理,大多数人会想到使用 if-else 代码块。但,这种方式既增加代码量又会降低易读性,我们可以尝试利用 Java 8 的 Optional 类来消除这样的 if-else 逻辑,使用一行代码进行判空和处理。

修复思路如下:

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

Integer integer = Optional.ofNullable(a).orElse(0);
  • 对于 String 和字面量的比较,可以把字面量放在前面,比如"OK".equals(s),这样即使 s 是 null 也不会出现空指针异常;

    而对于两个可能为 null 的字符串变量的 equals 比较,可以使用 Objects.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 的话一行代码就够了。

Optional.ofNullable(fooService).map(FooService::getBarService)
.filter(barService -> "OK".equals(barService.bar()))
.ifPresent(result -> log.info("OK"));

  • 对于接口返回的 List,由于不能确认其是否为 null,所以在调用 size 方法获得列表大小之前,同样可以使用 Optional.ofNullable 包装一下返回值,然后通过.orElse(Collections.emptyList()) 实现在 List 为 null 的时候获得一个空的 List,最后再调用 size 方法。
@GetMapping("right")
public int right(@RequestParam(value = "test", defaultValue = "1111") String test) {
    return Optional.ofNullable(rightMethod(test.charAt(0) == '1' ? null : new FooService()))
            .orElse(Collections.emptyList()).size();
}

注:关于Optional的详细用法见都2023年还不知道Java8如何优雅简化代码就落后了_出世&入世的博客-CSDN博客

2、DTO 中属性的 null

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

  • DTO 中字段的 null 到底意味着什么?是客户端没有传给我们这个信息吗?
  • 既然空指针问题很讨厌,那么 DTO 中的字段要设置默认值么?
  • 如果数据库实体中的字段有 null,那么通过数据访问框架保存数据是否会覆盖数据库中的既有数据?

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

  • 明确 DTO 中 null 的含义。对于 JSON 到 DTO 的反序列化过程,null 的表达是有歧义的,客户端不传某个属性,或者传 null,这个属性在 DTO 中都是 null。但,对于用户信息更新操作,不传意味着客户端不需要更新这个属性,维持数据库原先的值;传了 null,意味着客户端希望重置这个属性。因为 Java 中的 null 就是没有这个数据,无法区分这两种表达
  • POJO 中的字段有默认值。如果客户端不传值,就会赋值为默认值。
  • 注意字符串格式化时可能会把 null 值格式化为 null 字符串。
  • DTO 和 Entity 共用了一个 POJO。对于一些字段的设置是程序控制的,我们不应该把它们暴露在 DTO 中,否则很容易把客户端随意设置的值更新到数据库中。此外,创建时间最好让数据库设置为当前时间,不用程序控制,可以通过在字段上设置 columnDefinition 来实现。
  • 数据库字段允许保存 null,会进一步增加出错的可能性和复杂度。因为如果数据真正落地的时候也支持 NULL 的话,可能就有 NULL、空字符串和字符串 null 三种状态。如果所有属性都有默认值,问题会简单一点。

3、MySQL 中有关 NULL 的三个坑

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

假如表中只有一条数据,其 id 是自增列自动设置的 1,score 是 NULL:

测试下面三个用例,来看看结合数据库中的 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; 显然,这三条 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<User> right3();

4、捕获和处理异常容易犯的错

4.1、统一异常处理使用不当

不建议在框架层面进行异常的自动、统一处理,尤其是不要随意捕获异常。

统一异常处理是为了做兜底工作。如果异常上升到最上层逻辑还是无法处理的话,可以以统一的方式进行异常转换,比如通过 @RestControllerAdvice + @ExceptionHandler,来捕获这些“未处理”异常:

  • 对于自定义的业务异常,以 Warn 级别的日志记录异常以及当前 URL、执行方法等信息后,提取异常中的错误码和消息等信息,转换为合适的 API 包装体返回给 API 调用方;

  • 对于无法处理的系统异常,以 Error 级别的日志记录异常和上下文信息(比如 URL、参数、用户 ID)后,转换为普适的“服务器忙,请稍后再试”异常信息,同样以 API 包装体返回给调用方。

    demo:

@RestControllerAdvice
@Slf4j
public class RestControllerExceptionHandler {
    private static int GENERIC_SERVER_ERROR_CODE = 2000;
    private static String GENERIC_SERVER_ERROR_MESSAGE = "服务器忙,请稍后再试";

    @ExceptionHandler
    public APIResponse handle(HttpServletRequest req, HandlerMethod method, Exception ex) {
        if (ex instanceof BusinessException) {
            BusinessException exception = (BusinessException) ex;
            log.warn(String.format("访问 %s -> %s 出现业务异常!", req.getRequestURI(), method.toString()), ex);
            return new APIResponse(false, null, exception.getCode(), exception.getMessage());
        } else {
            log.error(String.format("访问 %s -> %s 出现系统异常!", req.getRequestURI(), method.toString()), ex);
            return new APIResponse(false, null, GENERIC_SERVER_ERROR_CODE, GENERIC_SERVER_ERROR_MESSAGE);
        }
    }
}

出现运行时系统异常后,异常处理程序会直接把异常转换为 JSON 返回给调用方:

4.2、捕获了异常后直接生吞

在任何时候,我们捕获了异常都不应该生吞,也就是直接丢弃异常不记录、不抛出。这样的处理方式还不如不捕获异常,因为被生吞掉的异常一旦导致 Bug,就很难在程序中找到蛛丝马迹,使得 Bug 排查工作难上加难。

通常情况下,生吞异常的原因,可能是不希望自己的方法抛出受检异常,只是为了把异常“处理掉”而捕获并生吞异常,也可能是想当然地认为异常并不重要或不可能产生。但不管是什么原因,不管是你认为多么不重要的异常,都不应该生吞,哪怕是一个日志也好。

4.3、丢弃异常的原始信息

下面两个例子中的,虽然没有丢弃异常,但是只记录了异常消息,却丢失了异常的类型、栈等重要信息:

@GetMapping("wrong1")
public void wrong1(){
    try {
        readFile();
    } catch (IOException e) {
        //原始异常信息丢失  
        throw new RuntimeException("系统忙请稍后再试");
    }
}


catch (IOException e) {
    //只保留了异常消息,栈没有记录
    log.error("文件读取错误, {}", e.getMessage());
    throw new RuntimeException("系统忙请稍后再试");
}

可以改为下面这两种方式:

catch (IOException e) {
    log.error("文件读取错误", e);
    throw new RuntimeException("系统忙请稍后再试");
}


catch (IOException e) {
    throw new RuntimeException("系统忙请稍后再试", e);
}

4.4、抛出异常时不指定任何消息

见过一些代码中的偷懒做法,直接抛出没有 message 的异常:

throw new RuntimeException();

这么写的同学可能觉得永远不会走到这个逻辑,永远不会出现这样的异常。但,这样的异常却出现了,被 ExceptionHandler 拦截到后输出了下面的日志信息:

[13:25:18.031] [http-nio-45678-exec-3] [ERROR] [c.e.d.RestControllerExceptionHandler:24  ] - 访问 /handleexception/wrong3 -> org.geekbang.time.commonmistakes.exception.demo1.HandleExceptionController#wrong3(String) 出现系统异常!
java.lang.RuntimeException: null
...

这里的 null 非常容易引起误解。按照空指针问题排查半天才发现,其实是异常的 message 为空。

总之,如果你捕获了异常打算处理的话,除了通过日志正确记录异常原始信息外,通常还有三种处理模式:

  • 转换,即转换新的异常抛出。对于新抛出的异常,最好具有特定的分类和明确的异常消息,而不是随便抛一个无关或没有任何信息的异常,并最好通过 cause 关联老异常。

  • 重试,即重试之前的操作。比如远程调用服务端过载超时的情况,盲目重试会让问题更严重,需要考虑当前情况是否适合重试。

  • 恢复,即尝试进行降级处理,或使用默认值来替代原始数据。

一句话总结:捕获和处理异常的最佳实践。

  • 首先,不应该用 AOP 对所有方法进行统一异常处理,异常要么不捕获不处理,要么根据不同的业务逻辑、不同的异常类型进行精细化、针对性处理;
  • 其次,处理异常应该杜绝生吞,并确保异常栈信息得到保留;
  • 最后,如果需要重新抛出异常的话,请使用具有意义的异常类型和异常消息。

5、finally 中的异常

有些时候,我们希望不管是否遇到异常,逻辑完成后都要释放资源,这时可以使用 finally 代码块而跳过使用 catch 代码块。

但要千万小心 finally 代码块中的异常,因为资源释放处理等收尾操作同样也可能出现异常

因为一个方法无法出现两个异常。所以如果finally中抛出异常,就会覆盖try 中的异常。所以finally 代码块自己负责异常捕获和处理:

@GetMapping("right")
public void right() {
    try {
        log.info("try");
        throw new RuntimeException("try");
    } finally {
        log.info("finally");
        try {
            throw new RuntimeException("finally");
        } catch (Exception ex) {
            log.error("finally", ex);
        }
    }
}

或者可以把 try 中的异常作为主异常抛出,使用 addSuppressed 方法把 finally 中的异常附加到主异常上: 

@GetMapping("right2")
public void right2() throws Exception {
    Exception e = null;
    try {
        log.info("try");
        throw new RuntimeException("try");
    } catch (Exception ex) {
        e = ex;
    } finally {
        log.info("finally");
        try {
            throw new RuntimeException("finally");
        } catch (Exception ex) {
            if (e!= null) {
                e.addSuppressed(ex);
            } else {
                e = ex;
            }
        }
    }
    throw e;
}

运行方法可以得到如下异常信息,其中同时包含了主异常和被屏蔽的异常:

java.lang.RuntimeException: try
  at org.geekbang.time.commonmistakes.exception.finallyissue.FinallyIssueController.right2(FinallyIssueController.java:69)
  at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
  ...
  Suppressed: java.lang.RuntimeException: finally
    at org.geekbang.time.commonmistakes.exception.finallyissue.FinallyIssueController.right2(FinallyIssueController.java:75)
    ... 54 common frames omitted

try-with-resources 释放资源

对于实现了 AutoCloseable 接口的资源,建议使用 try-with-resources 来释放资源,否则也可能会产生刚才提到的,释放资源时出现的异常覆盖主异常的问题。

作用:将创建资源的操作写在try()的括号中,那就不需要在finally中编写关闭资源的操作了,减少代码开发量,让代码变得更加美观

示例:

原理分析:采用try-with-resources写法,当try中代码执行结束(正常结束/异常结束)之后就会调用try()括号中对象的close()方法来关闭资源,虽然表面上来看try-with-resources写法更加优雅,如果我们对try-with-resources语法代码进行反编译,可以看到里面仍然是try-catch-finally写法,所以说try-with-resources写法是一种语法糖写法

demo:

//定义资源
public class TestResource implements AutoCloseable {
    public void read() throws Exception{
        throw new Exception("read error");
    }
    @Override
    public void close() throws Exception {
        throw new Exception("close error");
    }
}

//使用传统的 try-finally 语句,在 try 中调用 read 方法,在 finally 中调用 close 方法:
@GetMapping("useresourcewrong")
public void useresourcewrong() throws Exception {
    TestResource testResource = new TestResource();
    try {
        testResource.read();
    } finally {
        testResource.close();
    }
}

//可以看到,同样出现了 finally 中的异常覆盖了 try 中异常的问题:
=========================================================================================
java.lang.Exception: close error
  at org.geekbang.time.commonmistakes.exception.finallyissue.TestResource.close(TestResource.java:10)
  at org.geekbang.time.commonmistakes.exception.finallyissue.FinallyIssueController.useresourcewrong(FinallyIssueController.java:27)
=======================================================================================


//使用try-with-resources 模式
@GetMapping("useresourceright")
public void useresourceright() throws Exception {
    try (TestResource testResource = new TestResource()){
        testResource.read();
    }
}

//try 和 finally 中的异常信息都可以得到保留:
=======================================================================================================
java.lang.Exception: read error
  at org.geekbang.time.commonmistakes.exception.finallyissue.TestResource.read(TestResource.java:6)
  ...
  Suppressed: java.lang.Exception: close error
    at org.geekbang.time.commonmistakes.exception.finallyissue.TestResource.close(TestResource.java:10)
    at org.geekbang.time.commonmistakes.exception.finallyissue.FinallyIssueController.useresourceright(FinallyIssueController.java:35)
    ... 54 common frames omitted
=======================================================================================================

一句话总结:务必小心 finally 代码块中资源回收逻辑,确保 finally 代码块不出现异常,内部把异常处理完毕,避免 finally 中的异常覆盖 try 中的异常;或者考虑使用 addSuppressed 方法把 finally 中的异常附加到 try 中的异常上,确保主异常信息不丢失。此外,使用实现了 AutoCloseable 接口的资源,务必使用 try-with-resources 模式来使用资源,确保资源可以正确释放,也同时确保异常可以正确处理。

6、提交线程池的任务出了异常会怎么样?

6.1、以execute 方法提交任务

线程池中的线程在抛出异常后,直接退出,线程池只能重新创建一个线程,之前的老线程不能复用,所以如果每个异步任务都以异常结束,那么线程池可能完全起不到线程重用的作用。

同时如果我们没有手动捕获异常进行处理,ThreadGroup 帮我们进行了未捕获异常的默认处理,向标准错误输出打印了出现异常的线程名称和异常信息。但是,这种没有以统一的错误日志格式记录错误信息打印出来的形式,对生产级代码是不合适的。

解决办法:

  • 以 execute 方法提交到线程池的异步任务,最好在任务内部做好异常处理;

  • 设置自定义的异常处理程序作为保底,比如在声明线程池时自定义线程池的未捕获异常处理程序;

new ThreadFactoryBuilder()
  .setNameFormat(prefix+"%d")
  .setUncaughtExceptionHandler((thread, throwable)-> log.error("ThreadPool {} got exception", thread, throwable))
  .get()
  • 设置全局的默认未捕获异常处理程序
static {
    Thread.setDefaultUncaughtExceptionHandler((thread, throwable)-> log.error("Thread {} got exception", thread, throwable));
}

6.2、以submit方法提交任务

线程池以submit方法提交任务,在线程在抛出异常后,线程并不会退出,但是异常不会直接打印。

原因:查看 FutureTask 源码可以发现,在执行任务出现异常之后,异常存到了一个 outcome 字段中,只有在调用 get 方法获取 FutureTask 结果的时候,才会以 ExecutionException 的形式重新抛出异常:

public void run() {
...
    try {
        Callable<V> c = callable;
        if (c != null && state == NEW) {
            V result;
            boolean ran;
            try {
                result = c.call();
                ran = true;
            } catch (Throwable ex) {
                result = null;
                ran = false;
                setException(ex);
            }
...
}

protected void setException(Throwable t) {
    if (UNSAFE.compareAndSwapInt(this, stateOffset, NEW, COMPLETING)) {
        outcome = t;
        UNSAFE.putOrderedInt(this, stateOffset, EXCEPTIONAL); // final state
        finishCompletion();
    }
}

public V get() throws InterruptedException, ExecutionException {
    int s = state;
    if (s <= COMPLETING)
        s = awaitDone(false, 0L);
    return report(s);
}

private V report(int s) throws ExecutionException {
    Object x = outcome;
    if (s == NORMAL)
        return (V)x;
    if (s >= CANCELLED)
        throw new CancellationException();
    throw new ExecutionException((Throwable)x);
}

那么怎么捕获submit提交的异常呢?demo如下:

List<Future> tasks = IntStream.rangeClosed(1, 10).mapToObj(i -> threadPool.submit(() -> {
    if (i == 5) throw new RuntimeException("error");
    log.info("I'm done : {}", i);
})).collect(Collectors.toList());

tasks.forEach(task-> {
    try {
        task.get();
    } catch (Exception e) {
        log.error("Got exception", e);
    }
});

一句话总结:确保正确处理了线程池中任务的异常,如果任务通过 execute 提交,那么出现异常会导致线程退出,大量的异常会导致线程重复创建引起性能问题,我们应该尽可能确保任务不出异常,同时设置默认的未捕获异常处理程序来兜底;如果任务通过 submit 提交意味着我们关心任务的执行结果,应该通过拿到的 Future 调用其 get 方法来获得任务运行结果和可能出现的异常,否则异常可能就被生吞了。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

出世&入世

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值