您的当前位置:首页正文

开发中的异常处理问题

2024-11-30 来源:个人技术集锦

应用程序避免不了出异常,捕获和处理异常是一个精细活。在开发业务逻辑时不考虑任何异常处理,项目接近完成时再采用“流水线”的方式进行异常处理,也就是统一为所有方法打上 try…catch…捕获所有异常记录日 志,或者使用 AOP 来进行类似的“统一异常处理”。 其实,这种处理异常的方式非常不可取。

下面来说下不可取的原因、与异常处理相关的坑和异常处理的最佳实践。

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

1. 常见错误

1.1 不在业务代码层面考虑异常处理,仅在框架层面粗犷捕获和处理异常

这个也就是常说的“统一异常处理”,那这样做有什么问题呢?

先看下大多数业务应用都采用的三层架构:

  • Controller 层负责信息收集、参数校验、转换服务层处理的数据适配前端,轻业务逻辑;
  • Service 层负责核心业务逻辑,包括各种外部服务调用、访问数据库、缓存处理、消息处理等;
  • Repository 层负责数据访问实现,一般没有业务逻辑。

由于每层架构的工作性质不同,且从业务性质上异常分为业务异常和系统异常两大类,这就决定了很难进行统一的异常处理。我们从底向上看一下三层架构:

  • Repository 层出现异常或许可以忽略,或许可以降级,或许需要转化为一个友好的异常。如果一律捕获异常仅记录日志,很可能业务逻辑已经出错,而用户和程序本身完全感知不到。(比如 update 一个字段,sql执行失败了,但是异常被捕获不会影响之后逻辑的执行,导致业务逻辑出错)
  • Service 层往往涉及数据库事务,出现异常同样不适合捕获,否则事务无法自动回滚。此外 Service 层涉及业务逻辑,有些业务逻辑执行中遇到业务异常,可能需要在异常后转入分支业务流程。如果业务异常都被框架捕获了,业务功能就会不正常。(比如当库存为0时仍然进行了减库存操作)
  • 如果下层异常上升到 Controller 层还是无法处理的话,Controller 层往往会给予用户友好提示,或是根据每一个 API 的异常表返回指定的异常类型,同样无法对所有异常一视同仁。

因此,不建议在框架层面进行异常的自动、统一处理,尤其不要随意捕获异常。但框架可以做兜底工作。如果异常上升到 Controller 还是无法处理的话,可以以统一的方式进行异常转换,比如通过 @RestControllerAdvice + @ExceptionHandler,来捕获这些“未处理”异常:

  • 对于 自定义的业务异常,以 Warn 级别的日志记录异常以及当前 URL、执行方法等信息后,提取异常中的错误码和消息等信息,转换为合适的 API 包装体返回给 API 调用方;
  • 对于无法处理的 系统异常,以 Error 级别的日志记录异常和上下文信息(比如 URL、参数、用户ID)后,转换为普适的“服务器忙,请稍后再试”异常信息,同样以 API 包装体返回给调用方。

示例如下:

/**
 * 异常处理测试入口
 */
@RestController
@Slf4j
public class ExceptionTestController {
    
    @GetMapping("/testExceptionHandler")
    public APIResponse testExceptionHandler(@RequestParam("business") boolean flag) {
        if (flag) {
            throw new BusinessException("订单不存在", 2001);
        }
        throw new RuntimeException("系统错误");
    }

}
/**
 * 统一异常处理
 */
@Slf4j
@RestControllerAdvice
public class RestControllerExceptionHandler {

    private static int GENERIC_SERVER_ERROR_CODE = 2000;
    private static String GENERIC_SERVER_ERROR_MESSAGE = "服务器繁忙,请稍后再试";

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

}

其中定义的实体:

/**
 * 自定义业务异常
 */
public class BusinessException extends RuntimeException {

    private int code;

    public BusinessException(String message, int code) {
        super(message);
        this.code = code;
    }

    public int getCode() {
        return code;
    }

}
------------------------------------------------------------------------
/**
 * 返回体
 */
@Data
@AllArgsConstructor
public class APIResponse<T> {

    private Boolean success;

    private T data;

    private Integer code;

    private String message;

}

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

{"success":false,"data":null,"code":2000,"message":"服务器繁忙,请稍后再试"}

1.2 捕获了异常后直接生吞

生吞就是捕获异常后不记录,不抛出。这样处理还不如不捕获异常,因为被生吞掉的异常一旦导致 Bug,就很难在程序中找到蛛丝马迹,使得 Bug 排查工作难上加难。

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

1.3 丢弃异常的原始信息

有时捕获系统异常后,会转换为自定义异常抛出,这时如果写法不当会造成原始异常信息丢失。

示例如下:

/**
 * 异常处理测试入口
 */
@RestController
@Slf4j
public class ExceptionTestController {

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

    private void readFile() throws IOException {
        Files.readAllLines(Paths.get("a_file"));
    }

}

像这样调用 readFile 方法,捕获异常后,完全不记录原始异常,直接抛出一个转换后异常,导致出了问题不知道 IOException 具体是哪里引起的:

java.lang.RuntimeException: 系统忙请稍后再试
	at com.jiangxb.exceptionhandling.ExceptionTestController.wrong1(ExceptionTestController.java:38)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
......

或者是这样,只记录了异常消息,却丢失了异常的类型、栈等重要信息:

@GetMapping("/wrong2")
public void wrong2(){
    try {
        readFile();
    } catch (IOException e) {
        // 只记录了异常消息,却丢失了异常的类型、栈等重要信息
        log.error("文件读取错误, {}", e.getMessage());
        throw new RuntimeException("系统忙请稍后再试");
    }
}

留下的日志是这样的,看完一脸茫然,只知道文件读取错误的文件名,至于为什么读取错误、是不存在还是没权限,完全不知道。

[ERROR] [http-nio-8080-exec-5] [c.j.e.ExceptionTestController ] 文件读取错误, src\b_file.txt

这两种处理方式都不太合理,可以改为如下方式:

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

// 或者把原始异常作为转换后新异常的 cause,原始异常信息同样不会丢

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

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

throw new RuntimeException();

这样写一旦抛异常了,会输出下面的信息:

java.lang.RuntimeException: null

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

2. 对于异常的三种处理模式

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

二、小心 finally 中的异常

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

1. 异常屏蔽

要小心 finally 代码块中的异常,因为资源释放处理等收尾操作同样也可能出现异常。
比如下面这段代码,在 finally 中抛出一个异常:

@GetMapping("wrong")
public void wrong() {
    try {
        log.info("try");
        //异常丢失
        throw new RuntimeException("try");
    } finally {
        log.info("finally");
        throw new RuntimeException("finally");
    }
}

最后在日志中只能看到 finally 中的异常,虽然 try 中的逻辑出现了异常,但却被 finally 中的异常覆盖了。这是非常危险的,特别是 finally 中出现的异常是偶发的,就会在部分时 候覆盖 try 中的异常,让问题更不明显:

java.lang.RuntimeException: finally

异常为什么被覆盖,因为一个方法无法出现两个异常。修复方式是, 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 com.jiangxb.exceptionhandling.controller.FinallyTestController.right2(FinallyTestController.java:45)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
......
    Suppressed: java.lang.RuntimeException: finally
		at com.jiangxb.exceptionhandling.controller.FinallyTestController.right2(FinallyTestController.java:51)
		... 50 common frames omitted

2. try-with-resources

上面这正是 try-with-resources 语句的做法,对于实现了 AutoCloseable 接口的资源,建议使用 try-with-resources 来释放资源,否则也可能会产生刚才提到的,释放资源时出现的异常覆盖主异常的问题。比如如下我们定义一个测试资源,其 read 和 close 方法都会抛 出异常:

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 中异常的问题:只有 finally 中的异常被抛出

java.lang.Exception: close error

而改为 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 com.jiangxb.exceptionhandling.TestResource.read(TestResource.java:6)
	at com.jiangxb.exceptionhandling.controller.FinallyTestController.useresourceright(FinallyTestController.java:77)
    ......
    Suppressed: java.lang.Exception: close error
		at com.jiangxb.exceptionhandling.TestResource.close(TestResource.java:11)
		at com.jiangxb.exceptionhandling.controller.FinallyTestController.useresourceright(FinallyTestController.java:76)
		... 50 common frames omitted

2.1 try-with-resources原理

在 JDK1.7 之前,为了保证每个声明了的资源在语句结束的时候都会被关闭,需要在 finally 中进行关闭操作,这时打开的资源越多,finally 中嵌套的将会越深。

JDK1.7 开始,有了 try-with-resources 语句,保证了每个声明了的资源在语句结束的时候都会被关闭。任何实现了 java.lang.AutoCloseable接口的对象,和实现了 java.io.Closeable接口的对象,都可以当做资源使用。

注意:try-with-resources 语句也可以像普通的 try 语句一样,有 catch 和 finally 代码块。在 try-with-resources 语句中,任何的 catch 和 finally 代码块都在所有被声明的资源被关闭后执行。

try-with-resources 是怎么实现的
对比一下 编译前后的 useresourceright 方法:

// 编译前
public void useresourceright() throws Exception {
    try (TestResource testResource = new TestResource()){
        testResource.read();
    }
}

// jdk8 编译后(class反编译)
public void useresourceright() throws Exception {
    TestResource testResource = new TestResource();
    Throwable var2 = null;
    try {
        testResource.read();
    } catch (Throwable var11) {
        var2 = var11;
        throw var11;
    } finally {
        if (testResource != null) {
            if (var2 != null) {
                try {
                    testResource.close();
                } catch (Throwable var10) {
                    var2.addSuppressed(var10);
                }
            } else {
                testResource.close();
            }
        }
    }
}

// jdk11 编译后(class反编译)
public void useresourceright() throws Exception {
    TestResource testResource = new TestResource();
    try {
        testResource.read();
    } catch (Throwable var5) {
        try {
            testResource.close();
        } catch (Throwable var4) {
            var5.addSuppressed(var4);
        }
        throw var5;
    }
    testResource.close();
}

可以看到 try-with-resources 本质上不是新东西,它是一个语法糖,在编译时对代码进行了处理。jdk8 跟 jdk11 在处理上有所不同,但本质上还是一样的。

3. 在 finally 中返回的问题

若 try 代码块与 finally 代码块中都有 return,以 finally 中的为准,因为编译时会把 try 代码块中的 return 语句去掉

测试如下:

public static int m() {
    int i = 10;
    try {
        i++;
        System.out.println("try i = " + i);
        return i;
    } catch (Exception e) {
        e.printStackTrace();
    } finally {
        i++;
        System.out.println("finally i = " + i);
        return i;
    }
}

都知道方法会返回12,看一下编译后的代码:

public static int m() {
    int i = 10;
    try {
        ++i;
        System.out.println("try i = " + i);
    } catch (Exception var5) {
        var5.printStackTrace();
    } finally {
        ++i;
        System.out.println("finally i = " + i);
        return i;
    }
}

三、别把异常定义为静态变量

我们通常会自定义一个业务异常类型,来包含更多的异常信息,比如异常错误码、友好的错误提示等,那就需要在业务逻辑各处,手动抛出各种业务异常来返回指定的错误码描述 (比如对于下单操作,用户不存在返回 2001,商品缺货返回 2002 等)。

对于这些异常的错误代码和消息,我们期望能够统一管理,而不是散落在程序各处定义。这个想法很好,但稍有不慎就可能会出现把异常定义为静态变量的坑。

把异常定义为静态变量,会导致异常栈信息错乱

下面来模拟一下这个场景:

定义异常:

public class Exceptions {
    // 错误的定义法
    public static BusinessException ORDEREXISTS = new BusinessException("订单已存在", 3001);
}

测试接口:在创建订单、取消订单时分别抛出异常

/**
 * 把异常定义为静态变量测试 <br/>
 * 对比两处的异常日志
 */
@GetMapping("wrong")
public void wrong() {
    try {
        createOrderWrong();
    } catch (Exception ex) {
        log.error("createOrder got error", ex);
    }
    try {
        cancelOrderWrong();
    } catch (Exception ex) {
        log.error("cancelOrder got error", ex);
    }
}

private void createOrderWrong() {
    //这里有问题
    throw Exceptions.ORDEREXISTS;
}

private void cancelOrderWrong() {
    //这里有问题
    throw Exceptions.ORDEREXISTS;
}

下面看下被定义为静态变量的异常被抛出的情况:

[ERROR] [http-nio-8080-exec-1] [.j.e.c.ExceptionTestController] createOrder got error
com.jiangxb.exceptionhandling.BusinessException: 订单已存在
	at com.jiangxb.exceptionhandling.Exceptions.<clinit>(Exceptions.java:10)
	at com.jiangxb.exceptionhandling.controller.ExceptionTestController.createOrderWrong(ExceptionTestController.java:110)
	at com.jiangxb.exceptionhandling.controller.ExceptionTestController.wrong(ExceptionTestController.java:97)
  ...... 省略第一个createOrderWrong异常的其他内容

[ERROR] [http-nio-8080-exec-1] [.j.e.c.ExceptionTestController] cancelOrder got error
com.jiangxb.exceptionhandling.BusinessException: 订单已存在
	at com.jiangxb.exceptionhandling.Exceptions.<clinit>(Exceptions.java:10)
	at com.jiangxb.exceptionhandling.controller.ExceptionTestController.createOrderWrong(ExceptionTestController.java:110)
	at com.jiangxb.exceptionhandling.controller.ExceptionTestController.wrong(ExceptionTestController.java:97)
  ...... 省略第二个cancelOrderWrong异常的其他内容

可以看到,两个不同方法抛出的异常,栈信息却是一样的。
cancelOrder got error 的提示对应了 createOrderWrong 方 法。cancelOrderWrong 方法在出错后抛出的异常,打印的其实是 createOrderWrong 方法出错的异常。

修复方式很简单,改一下 Exceptions 类的实现,通过不同的方法把每一种异常都 new 出来抛出即可:

public class Exceptions {
    // 正确的定义法
    public static BusinessException orderExists() {
        return new BusinessException("订单已经存在", 3001);
    }
}

在抛出异常时 用orderExists方法 new 一个异常抛出:打印了正确的异常信息

[ERROR] [http-nio-8080-exec-1] [.j.e.c.ExceptionTestController] createOrder got error
com.jiangxb.exceptionhandling.BusinessException: 订单已经存在
	at com.jiangxb.exceptionhandling.Exceptions.orderExists(Exceptions.java:14)
	at com.jiangxb.exceptionhandling.controller.ExceptionTestController.createOrderWrong(ExceptionTestController.java:111)
	at com.jiangxb.exceptionhandling.controller.ExceptionTestController.wrong(ExceptionTestController.java:97)
  ......
  
[ERROR] [http-nio-8080-exec-1] [.j.e.c.ExceptionTestController] cancelOrder got error
com.jiangxb.exceptionhandling.BusinessException: 订单已经存在
	at com.jiangxb.exceptionhandling.Exceptions.orderExists(Exceptions.java:14)
	at com.jiangxb.exceptionhandling.controller.ExceptionTestController.cancelOrderWrong(ExceptionTestController.java:117)
	at com.jiangxb.exceptionhandling.controller.ExceptionTestController.wrong(ExceptionTestController.java:102)
  ......

这是因为 Throwable 的 stacktrace 只是在其 new 出来的时候才初始化(调用fillInStackTrace 方法)是一次性的(除非你手动调用那个方法),而非getStackTrace 的时候去获得 stacktrace

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

1. 任务异常导致线程退出

线程池常用作异步处理或并行处理。那么,把任务提交到线程池处理,任务本身出现异常时会怎样呢?

下面看个例子:提交 10 个任务到线程池异步处理,第 5 个任务抛出一个 RuntimeException,每个任务完成后都会输出一行日志:

@GetMapping("execute")
public void execute() throws InterruptedException {
    String prefix = "test";
    ExecutorService threadPool = new ThreadPoolExecutor(1, 1, 0L, 
            TimeUnit.MILLISECONDS, 
            new LinkedBlockingQueue(), 
            new ThreadFactoryBuilder().setNameFormat(prefix + "%d")
                .setUncaughtExceptionHandler((thread, throwable) -> log.error("ThreadPool {} got exception", thread, throwable))
                .build()
    );
    // 提交10个任务到线程池处理,第5个任务抛出运行时异常
    IntStream.rangeClosed(1, 10).forEach(i -> threadPool.execute(() -> {
        if (i == 5) {
            throw new RuntimeException("error");
        }
        log.info("I'm done : {}", i);
    }));

    threadPool.shutdown();
    threadPool.awaitTermination(1, TimeUnit.HOURS);
}

观察日志可以发现两点:

[INFO ] [test0] [readPoolAndExceptionController] I'm done : 1
[INFO ] [test0] [readPoolAndExceptionController] I'm done : 2
[INFO ] [test0] [readPoolAndExceptionController] I'm done : 3
[INFO ] [test0] [readPoolAndExceptionController] I'm done : 4
[INFO ] [test1] [readPoolAndExceptionController] I'm done : 6
[INFO ] [test1] [readPoolAndExceptionController] I'm done : 7
[INFO ] [test1] [readPoolAndExceptionController] I'm done : 8
[INFO ] [test1] [readPoolAndExceptionController] I'm done : 9
[INFO ] [test1] [readPoolAndExceptionController] I'm done : 10
Exception in thread "test0" java.lang.RuntimeException: error
	at com.jiangxb.exceptionhandling.controller.ThreadPoolAndExceptionController.lambda$null$0(ThreadPoolAndExceptionController.java:41)
	at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
	at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
	at java.lang.Thread.run(Thread.java:748)

任务 1 到 4 所在的线程是 test0,任务 6 开始运行在线程 test1。由于我的线程池通过线程工厂为线程使用统一的前缀 test 加上计数器进行命名,因此从线程名的改变可以知道 因为异常的抛出老线程退出了,线程池只能重新创建一个线程**。如果每个异步任务都以异常结束,那么线程池可能完全起不到线程重用的作用。

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

ThreadGroup 的相关源码如下所示:

// JDK1.8 ThreadGroup.java
public void uncaughtException(Thread t, Throwable e) {
    if (parent != null) {
        // 若有父线程组,则调用父线程组的 uncaughtException 方法
        parent.uncaughtException(t, e);
    } else {
        // 没有父线程组,则看线程是否设置了defaultUncaughtExceptionHandler
        Thread.UncaughtExceptionHandler ueh =
            Thread.getDefaultUncaughtExceptionHandler();
        if (ueh != null) {
            // 若设置了defaultUncaughtExceptionHandler,则调用它的uncaughtException
            ueh.uncaughtException(t, e);
        } else if (!(e instanceof ThreadDeath)) {
            System.err.print("Exception in thread \""
                             + t.getName() + "\" ");
            e.printStackTrace(System.err);
        }
    }
}

当此线程组中的线程由于未捕获的异常而停止,并且该线程没有设置特定的 Thread.UncaughtExceptionHandler未捕获异常处理器时,该方法由Java虚拟机调用。
ThreadGroup类的 uncaughtException 方法会做如下的事:

  • 如果这个线程组有父线程组,则使用相同的两个参数调用该父线程组的 uncaughtException 方法。
  • 否则,此方法会检查是否设置了 Thread.defaultUncaughtExceptionHandler默认的未捕获异常处理器。如果有,会以相同的两个参数调用它的 uncaughtException 方法。
  • 否则,此方法确定 Throwable 参数是否是 ThreadDeath 的实例。 如果是这样,则不会执行任何特殊操作。 否则,将包含线程名称的消息(从线程的getName方法返回)和堆栈回溯(使用Throwable的printStackTrace方法)打印到 System.error 标准错误流。

程序可以在ThreadGroup子类中覆盖此方法,以提供对未捕获异常的替代处理。

2. 修复方式

修复方式有两步:

  1. 以 execute 方法提交到线程池的异步任务,最好在任务内部做好异常处理;
  2. 设置自定义的异常处理程序作为保底,比如在声明线程池时自定义线程池的未捕获异常处理程序:
ExecutorService threadPool = new ThreadPoolExecutor(1, 1, 0L, 
                TimeUnit.MILLISECONDS, 
                new LinkedBlockingQueue(), 
                new ThreadFactoryBuilder().setNameFormat(prefix + "%d")
                // 设置 uncaughtExceptionHandler                                    
                .setUncaughtExceptionHandler((thread, throwable) -> log.error("ThreadPool {} got exception", thread, throwable))
                .build()
        );

或者设置全局的默认未捕获异常处理程序:

static {
    Thread.setDefaultUncaughtExceptionHandler((thread, throwable) -> log.error("Thread {} got exception", thread, throwable));
}

3. submit提交任务会屏蔽异常

通过线程池 ExecutorService 的 execute 方法提交任务到线程池处理,如果出现异常会导 致线程退出,控制台输出中可以看到异常信息。那么,把 execute 方法改为 submit,线程 还会退出吗,异常还能被处理程序捕获到吗?

由 execute 改为 submit 后,日志输出如下 :

[INFO ] [test0] [readPoolAndExceptionController] I'm done : 1
[INFO ] [test0] [readPoolAndExceptionController] I'm done : 2
[INFO ] [test0] [readPoolAndExceptionController] I'm done : 3
[INFO ] [test0] [readPoolAndExceptionController] I'm done : 4
[INFO ] [test0] [readPoolAndExceptionController] I'm done : 6
[INFO ] [test0] [readPoolAndExceptionController] I'm done : 7
[INFO ] [test0] [readPoolAndExceptionController] I'm done : 8
[INFO ] [test0] [readPoolAndExceptionController] I'm done : 9
[INFO ] [test0] [readPoolAndExceptionController] I'm done : 10

可以看到线程没退出,一直只有一个线程test0

异常则被屏蔽了,为什么会这样呢?
查看 FutureTask 源码可以发现,在执行任务出现异常之后,异常存到了一个 outcome 字 段中,只有在调用 get 方法获取 FutureTask 结果的时候,才会以 ExecutionException 的 形式重新抛出异常。

如果需要捕获异常,要把 submit 返回的 Future 放到 List 中,分别调用 Future 的 get 方法,这时才能获取异常任务抛出的异常。

3.1 FutureTask部分源码

先看下 FutureTask 中几个重要的变量

private volatile int state;

// 表示这是一个新的任务,或者还没有执行完的任务,是初始状态。
private static final int NEW          = 0;

// 表示任务执行结束(正常执行结束,或者发生异常结束),但是还没有将结果保存到outcome中,是一个中间状态。
private static final int COMPLETING   = 1;

// 表示任务正常执行结束,并且已经把执行结果保存到outcome字段中,是一个最终状态。
private static final int NORMAL       = 2;

表示任务发生异常结束,异常信息已经保存到outcome中,是一个最终状态。
private static final int EXCEPTIONAL  = 3;

// 任务在新建之后,执行结束之前被取消了,但是不要求中断正在执行的线程,
// 也就是调用了cancel(false),任务就是CANCELLED状态。
private static final int CANCELLED    = 4;

// 任务在新建之后,执行结束之前被取消了,并要求中断线程的执行,
// 也就是调用了cancel(true),这时任务状态就是INTERRUPTING。这是一个中间状态。
private static final int INTERRUPTING = 5;

// 调用cancel(true)取消异步任务,会调用interrupt()中断线程的执行,然后状态会从INTERRUPTING变到INTERRUPTED。
private static final int INTERRUPTED  = 6;

状态变化有如下4种情况:
NEW -> COMPLETING -> NORMAL:正常执行结束的流程
NEW -> COMPLETING -> EXCEPTIONAL:执行过程中出现异常的流程
NEW -> CANCELLED:被取消,即调用了cancel(false)
NEW -> INTERRUPTING -> INTERRUPTED:被中断,即调用了cancel(true)

// 封装了计算任务,可获取计算结果
private Callable<V> callable;

// 保存计算任务的返回结果,或者执行过程中抛出的异常
private Object outcome; // non-volatile, protected by state reads/writes

// 指向当前在运行Callable任务的线程
private volatile Thread runner;

// WaitNode是FutureTask的内部类,表示一个阻塞队列,如果任务还没有执行结束,
// 那么调用get()获取结果的线程会阻塞,在这个阻塞队列中排队等待
private volatile WaitNode waiters;

任务被执行时调用 run 方法

public void run() {
    // 状态不是NEW,返回
    // 调用CAS方法,判断runnerOffset为null的话,就将当前线程保存到runnerOffset中,设置runnerOffset失败,就直接返回
    if (state != NEW ||
        !UNSAFE.compareAndSwapObject(this, runnerOffset,
                                     null, Thread.currentThread()))
        return;
    try {
        Callable<V> c = callable;
        if (c != null && state == NEW) {
            V result;
            boolean ran;
            try {
                // 执行Callable任务
                result = c.call();
                ran = true;
            } catch (Throwable ex) {
                result = null;
                ran = false;
                // 若执行任务时发生异常,设置异常到 outcome
                setException(ex);
            }
            if (ran)
                // 任务正常结束,保存返回结果
                set(result);
        }
    } finally {
        // runner must be non-null until state is settled to
        // prevent concurrent calls to run()
        // runner置空,表示没有线程在执行这个任务
        runner = null;
        // state must be re-read after nulling runner to prevent
        // leaked interrupts
        // 根据状态判断当前任务是否被中断了,若被中断,处理中断
        int s = state;
        if (s >= INTERRUPTING)
            handlePossibleCancellationInterrupt(s);
    }
}

任务出现异常时调用 setException() 方法保存异常,这时 run 方法不会抛出异常

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

调用 get() 方法获取任务执行结果或异常

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);
}

awaitDone()

/**
 * 在中断或超时时等待完成或中止。 
 *
 * @param 如果使用超时时间则为 true
 * @param 等待时间
 * @return 完成时的状态
 */
private int awaitDone(boolean timed, long nanos)
    throws InterruptedException {
    final long deadline = timed ? System.nanoTime() + nanos : 0L;
    WaitNode q = null;
    boolean queued = false;
    for (;;) {
        // 若调用get()的线程被中断了,就从等待的线程栈中移除这个等待节点,然后抛出中断异常
        if (Thread.interrupted()) {
            removeWaiter(q);
            throw new InterruptedException();
        }
		
        // 若当前任务是已结束的状态,将等待节点线程置空,返回该状态。这时不会阻塞
        int s = state;
        if (s > COMPLETING) {
            if (q != null)
                q.thread = null;
            return s;
        }
        // 若任务已经执行,但还未将结果保存到outcome中,
        // 使当前线程让出执行权,以便其它线程执行
        else if (s == COMPLETING) // cannot time out yet
            Thread.yield();
        else if (q == null)
            q = new WaitNode();
        // 如果这个等待节点还没有加入等待队列,就加入队列头
        else if (!queued)
            queued = UNSAFE.compareAndSwapObject(this, waitersOffset,
                                                 q.next = waiters, q);
        // 若使用了超时时间
        else if (timed) {
            nanos = deadline - System.nanoTime();
            if (nanos <= 0L) {
                // 移除等待队列中的当前节点
                removeWaiter(q);
                return state;
            }
            // 阻塞特定的时间
            LockSupport.parkNanos(this, nanos);
        }
        else
            // 一直阻塞,等待唤醒
            LockSupport.park(this);
    }
}

finishCompletion()

/**
 * 删除所有等待线程并发出信号,调用 done(),并将callable设为 null。
 */
private void finishCompletion() {
    // assert state > COMPLETING;
    for (WaitNode q; (q = waiters) != null;) {
        // CAS操作将等待节点置空
        if (UNSAFE.compareAndSwapObject(this, waitersOffset, q, null)) {
            for (;;) {
                Thread t = q.thread;
                if (t != null) {
                    // 将等待节点的线程置空
                    q.thread = null;
                    // 唤醒等待返回结果的线程
                    LockSupport.unpark(t);
                }
                WaitNode next = q.next;
                if (next == null)
                    break;
                q.next = null; // unlink to help gc
                q = next;
            }
            break;
        }
    }

    // 什么都没有做,但子类可以实现这个方法,做一些额外的操作
    done();

    callable = null;        // to reduce footprint
}

参考:极客时间《Java 业务开发常见错误 100 例》

显示全文