应用程序避免不了出异常,捕获和处理异常是一个精细活。在开发业务逻辑时不考虑任何异常处理,项目接近完成时再采用“流水线”的方式进行异常处理,也就是统一为所有方法打上 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 {
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. 对于异常的三种处理模式
如果捕获了异常打算处理的话,除了通过日志正确记录异常原始信息外,通常还有 三种处理模式:
- 转换:即转换新的异常抛出。对于新抛出的异常,最好具有特定的分类和明确的异常消息,而不是随便抛一个无关或没有任何信息的异常,并最好通过 cause 关联老异常。
- 重试:即重试之前的操作。如果是远程调用服务端超时的情况,盲目重试会让问题更严重,需要考虑当前情况是否适合重试。
- 恢复:即尝试进行降级处理,或使用默认值来替代原始数据。
二、小心 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);
}
测试接口:在创建订单、取消订单时分别抛出异常
/**
* 把异常定义为静态变量测试
* 对比两处的异常日志
*/
@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.(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.(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);
}
}
}
翻译自 JDK1.8 uncaughtException方法的注释:
当此线程组中的线程由于未捕获的异常而停止,并且该线程没有设置特定的 Thread.UncaughtExceptionHandler未捕获异常处理器时,该方法由Java虚拟机调用。
ThreadGroup类的 uncaughtException 方法会做如下的事:
- 如果这个线程组有父线程组,则使用相同的两个参数调用该父线程组的 uncaughtException 方法。
- 否则,此方法会检查是否设置了 Thread.defaultUncaughtExceptionHandler默认的未捕获异常处理器。如果有,会以相同的两个参数调用它的 uncaughtException 方法。
- 否则,此方法确定 Throwable 参数是否是 ThreadDeath 的实例。 如果是这样,则不会执行任何特殊操作。 否则,将包含线程名称的消息(从线程的getName方法返回)和堆栈回溯(使用Throwable的printStackTrace方法)打印到 System.error 标准错误流。
程序可以在ThreadGroup子类中覆盖此方法,以提供对未捕获异常的替代处理。
2. 修复方式
修复方式有两步:
- 以 execute 方法提交到线程池的异步任务,最好在任务内部做好异常处理;
- 设置自定义的异常处理程序作为保底,比如在声明线程池时自定义线程池的未捕获异常处理程序:
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 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 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 例》