應(yīng)用程序避免不了出異常,捕獲和處理異常是一個精細(xì)活。在開發(fā)業(yè)務(wù)邏輯時不考慮任何異常處理,項目接近完成時再采用“流水線”的方式進(jìn)行異常處理,也就是統(tǒng)一為所有方法打上 try…catch…捕獲所有異常記錄日 志,或者使用 AOP 來進(jìn)行類似的“統(tǒng)一異常處理”。 其實,這種處理異常的方式非常不可取。
下面來說下不可取的原因、與異常處理相關(guān)的坑和異常處理的最佳實踐。
一、捕獲和處理異常容易犯的錯
1. 常見錯誤
1.1 不在業(yè)務(wù)代碼層面考慮異常處理,僅在框架層面粗獷捕獲和處理異常
這個也就是常說的“統(tǒng)一異常處理”,那這樣做有什么問題呢?
先看下大多數(shù)業(yè)務(wù)應(yīng)用都采用的三層架構(gòu):
- Controller 層負(fù)責(zé)信息收集、參數(shù)校驗、轉(zhuǎn)換服務(wù)層處理的數(shù)據(jù)適配前端,輕業(yè)務(wù)邏輯;
- Service 層負(fù)責(zé)核心業(yè)務(wù)邏輯,包括各種外部服務(wù)調(diào)用、訪問數(shù)據(jù)庫、緩存處理、消息處理等;
- Repository 層負(fù)責(zé)數(shù)據(jù)訪問實現(xiàn),一般沒有業(yè)務(wù)邏輯。
由于每層架構(gòu)的工作性質(zhì)不同,且從業(yè)務(wù)性質(zhì)上異常分為業(yè)務(wù)異常和系統(tǒng)異常兩大類,這就決定了很難進(jìn)行統(tǒng)一的異常處理。我們從底向上看一下三層架構(gòu):
- Repository 層出現(xiàn)異?;蛟S可以忽略,或許可以降級,或許需要轉(zhuǎn)化為一個友好的異常。如果一律捕獲異常僅記錄日志,很可能業(yè)務(wù)邏輯已經(jīng)出錯,而用戶和程序本身完全感知不到。(比如 update 一個字段,sql執(zhí)行失敗了,但是異常被捕獲不會影響之后邏輯的執(zhí)行,導(dǎo)致業(yè)務(wù)邏輯出錯)
- Service 層往往涉及數(shù)據(jù)庫事務(wù),出現(xiàn)異常同樣不適合捕獲,否則事務(wù)無法自動回滾。此外 Service 層涉及業(yè)務(wù)邏輯,有些業(yè)務(wù)邏輯執(zhí)行中遇到業(yè)務(wù)異常,可能需要在異常后轉(zhuǎn)入分支業(yè)務(wù)流程。如果業(yè)務(wù)異常都被框架捕獲了,業(yè)務(wù)功能就會不正常。(比如當(dāng)庫存為0時仍然進(jìn)行了減庫存操作)
- 如果下層異常上升到 Controller 層還是無法處理的話,Controller 層往往會給予用戶友好提示,或是根據(jù)每一個 API 的異常表返回指定的異常類型,同樣無法對所有異常一視同仁。
因此,不建議在框架層面進(jìn)行異常的自動、統(tǒng)一處理,尤其不要隨意捕獲異常。但框架可以做兜底工作。如果異常上升到 Controller 還是無法處理的話,可以以統(tǒng)一的方式進(jìn)行異常轉(zhuǎn)換,比如通過 @RestControllerAdvice + @ExceptionHandler,來捕獲這些“未處理”異常:
- 對于自定義的業(yè)務(wù)異常,以 Warn級別的日志記錄異常以及當(dāng)前 URL、執(zhí)行方法等信息后,提取異常中的錯誤碼和消息等信息,轉(zhuǎn)換為合適的 API 包裝體返回給 API 調(diào)用方;
- 對于無法處理的系統(tǒng)異常,以Error 級別的日志記錄異常和上下文信息(比如 URL、參 數(shù)、用戶 ID)后,轉(zhuǎn)換為普適的“服務(wù)器忙,請稍后再試”異常信息,同樣以 API 包裝體返回給調(diào)用方。
示例如下:
/**
* 異常處理測試入口
*/
@RestController
@Slf4j
public class ExceptionTestController {
@GetMapping("/testExceptionHandler")
public APIResponse testExceptionHandler(@RequestParam("business") boolean flag) {
if (flag) {
throw new BusinessException("訂單不存在", 2001);
}
throw new RuntimeException("系統(tǒng)錯誤");
}
}
/**
* 統(tǒng)一異常處理
*/
@Slf4j
@RestControllerAdvice
public class RestControllerExceptionHandler {
private static int GENERIC_SERVER_ERROR_CODE = 2000;
private static String GENERIC_SERVER_ERROR_MESSAGE = "服務(wù)器繁忙,請稍后再試";
@ExceptionHandler
public APIResponse handleServerError(HttpServletRequest request, HandlerMethod method, Exception exception) {
if (exception instanceof BusinessException) {
BusinessException businessException = (BusinessException) exception;
log.warn(String.format("訪問 %s -> %s 出現(xiàn)業(yè)務(wù)異常!", request.getRequestURI(), method.toString()), exception);
return new APIResponse(false, null, businessException.getCode(), businessException.getMessage());
} else {
log.error(String.format("訪問 %s -> %s 出現(xiàn)系統(tǒng)異常!", request.getRequestURI(), method.toString()), exception);
return new APIResponse(false, null, GENERIC_SERVER_ERROR_CODE, GENERIC_SERVER_ERROR_MESSAGE);
}
}
}
其中定義的實體:
/**
* 自定義業(yè)務(wù)異常
*/
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;
}
出現(xiàn)運(yùn)行時系統(tǒng)異常后,異常處理程序會直接把異常轉(zhuǎn)換為 JSON 返回給調(diào)用方:
{"success":false,"data":null,"code":2000,"message":"服務(wù)器繁忙,請稍后再試"}
1.2 捕獲了異常后直接生吞
生吞就是捕獲異常后不記錄,不拋出。這樣處理還不如不捕獲異常,因為被生吞掉的異常一旦導(dǎo)致 Bug,就很難在程序中找到蛛絲馬跡,使得 Bug 排查工作難上加難。
通常情況下,生吞異常的原因,可能是不希望自己的方法拋出受檢異常,只是為了把異?!疤幚淼簟?,也可能是想當(dāng)然地認(rèn)為異常并不重要或不可能產(chǎn)生。但不管是什么原因,都不應(yīng)該生吞,哪怕是一個日志也好。
1.3 丟棄異常的原始信息
有時捕獲系統(tǒng)異常后,會轉(zhuǎn)換為自定義異常拋出,這時如果寫法不當(dāng)會造成原始異常信息丟失。
示例如下:
/**
* 異常處理測試入口
*/
@RestController
@Slf4j
public class ExceptionTestController {
@GetMapping("wrong1")
public void wrong1(){
try {
readFile();
} catch (IOException e) {
//原始異常信息丟失
throw new RuntimeException("系統(tǒng)忙請稍后再試");
}
}
private void readFile() throws IOException {
Files.readAllLines(Paths.get("a_file"));
}
}
像這樣調(diào)用 readFile 方法,捕獲異常后,完全不記錄原始異常,直接拋出一個轉(zhuǎn)換后異常,導(dǎo)致出了問題不知道 IOException 具體是哪里引起的:
java.lang.RuntimeException: 系統(tǒng)忙請稍后再試
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("系統(tǒng)忙請稍后再試");
}
}
留下的日志是這樣的,看完一臉茫然,只知道文件讀取錯誤的文件名,至于為什么讀取錯誤、是不存在還是沒權(quán)限,完全不知道。
[ERROR] [http-nio-8080-exec-5] [c.j.e.ExceptionTestController ] 文件讀取錯誤, src\b_file.txt
這兩種處理方式都不太合理,可以改為如下方式:
catch (IOException e) {
log.error("文件讀取錯誤", e);
throw new RuntimeException("系統(tǒng)忙請稍后再試");
}
// 或者把原始異常作為轉(zhuǎn)換后新異常的 cause,原始異常信息同樣不會丟
catch (IOException e) {
throw new RuntimeException("系統(tǒng)忙請稍后再試", e);
}
1.4 拋出異常時不指定任何消息
throw new RuntimeException();
這樣寫一旦拋異常了,會輸出下面的信息:
java.lang.RuntimeException: null
這里的 null 非常容易引起誤解。按照空指針問題排查半天才發(fā)現(xiàn),其實是異常的 message 為空。
2. 對于異常的三種處理模式
如果捕獲了異常打算處理的話,除了通過日志正確記錄異常原始信息外,通常還有 三種處理模式:
- 轉(zhuǎn)換:即轉(zhuǎn)換新的異常拋出。對于新拋出的異常,最好具有特定的分類和明確的異常消息,而不是隨便拋一個無關(guān)或沒有任何信息的異常,并最好通過 cause 關(guān)聯(lián)老異常。
- 重試:即重試之前的操作。如果是遠(yuǎn)程調(diào)用服務(wù)端超時的情況,盲目重試會讓問題更嚴(yán)重,需要考慮當(dāng)前情況是否適合重試。
- 恢復(fù):即嘗試進(jìn)行降級處理,或使用默認(rèn)值來替代原始數(shù)據(jù)。
二、小心 finally 中的異常
有些時候,我們希望不管是否遇到異常,邏輯完成后都要釋放資源,比如被占用的鎖,這時可以使用 finally 代碼塊而跳過使用 catch 代碼塊。
1. 異常屏蔽
要小心 finally 代碼塊中的異常,因為資源釋放處理等收尾操作同樣也可能出現(xiàn)異常。
比如下面這段代碼,在 finally 中拋出一個異常:
@GetMapping("wrong")
public void wrong() {
try {
log.info("try");
//異常丟失
throw new RuntimeException("try");
} finally {
log.info("finally");
throw new RuntimeException("finally");
}
}
最后在日志中只能看到 finally 中的異常,雖然 try 中的邏輯出現(xiàn)了異常,但卻被 finally 中的異常覆蓋了。這是非常危險的,特別是 finally 中出現(xiàn)的異常是偶發(fā)的,就會在部分時 候覆蓋 try 中的異常,讓問題更不明顯:
java.lang.RuntimeException: finally
異常為什么被覆蓋,因為一個方法無法出現(xiàn)兩個異常。修復(fù)方式是, finally 代碼塊自己負(fù)責(zé)異常捕獲和處理:
@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;
}
運(yùn)行方法可以得到如下異常信息,其中同時包含了主異常和被屏蔽的異常:
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 語句的做法,對于實現(xiàn)了 AutoCloseable 接口的資源,建議使用 try-with-resources 來釋放資源,否則也可能會產(chǎn)生剛才提到的,釋放資源時出現(xiàn)的異常覆蓋主異常的問題。比如如下我們定義一個測試資源,其 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");
}
}
使用傳統(tǒng)的 try-finally 語句,在 try 中調(diào)用 read 方法,在 finally 中調(diào)用 close 方法:
@GetMapping("useresourcewrong")
public void useresourcewrong() throws Exception {
TestResource testResource = new TestResource();
try {
testResource.read();
} finally {
testResource.close();
}
}
可以看到,同樣出現(xiàn)了 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 之前,為了保證每個聲明了的資源在語句結(jié)束的時候都會被關(guān)閉,需要在 finally 中進(jìn)行關(guān)閉操作,這時打開的資源越多,finally 中嵌套的將會越深。
JDK1.7 開始,有了 try-with-resources 語句,保證了每個聲明了的資源在語句結(jié)束的時候都會被關(guān)閉。任何實現(xiàn)了 java.lang.AutoCloseable接口的對象,和實現(xiàn)了 java.io.Closeable接口的對象,都可以當(dāng)做資源使用。
注意:try-with-resources 語句也可以像普通的 try 語句一樣,有 catch 和 finally 代碼塊。在 try-with-resources 語句中,任何的 catch 和 finally 代碼塊都在所有被聲明的資源被關(guān)閉后執(zhí)行。
try-with-resources 是怎么實現(xiàn)的
對比一下 編譯前后的 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 本質(zhì)上不是新東西,它是一個語法糖,在編譯時對代碼進(jìn)行了處理。jdk8 跟 jdk11 在處理上有所不同,但本質(zhì)上還是一樣的。
3. 在 finally 中返回的問題
若 try 代碼塊與 finally 代碼塊中都有 return,以 finally 中的為準(zhǔn),因為編譯時會把 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;
}
}
三、別把異常定義為靜態(tài)變量
我們通常會自定義一個業(yè)務(wù)異常類型,來包含更多的異常信息,比如異常錯誤碼、友好的錯誤提示等,那就需要在業(yè)務(wù)邏輯各處,手動拋出各種業(yè)務(wù)異常來返回指定的錯誤碼描述 (比如對于下單操作,用戶不存在返回 2001,商品缺貨返回 2002 等)。
對于這些異常的錯誤代碼和消息,我們期望能夠統(tǒng)一管理,而不是散落在程序各處定義。這個想法很好,但稍有不慎就可能會出現(xiàn)把異常定義為靜態(tài)變量的坑。
把異常定義為靜態(tài)變量,會導(dǎo)致異常棧信息錯亂
下面來模擬一下這個場景:
定義異常:
public class Exceptions {
// 錯誤的定義法
public static BusinessException ORDEREXISTS = new BusinessException("訂單已存在", 3001);
}
測試接口:在創(chuàng)建訂單、取消訂單時分別拋出異常
/**
* 把異常定義為靜態(tài)變量測試 <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;
}
下面看下被定義為靜態(tài)變量的異常被拋出的情況:
[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異常的其他內(nèi)容
[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異常的其他內(nèi)容
可以看到,兩個不同方法拋出的異常,棧信息卻是一樣的。
cancelOrder got error 的提示對應(yīng)了 createOrderWrong 方 法。cancelOrderWrong 方法在出錯后拋出的異常,打印的其實是 createOrderWrong 方法出錯的異常。
修復(fù)方式很簡單,改一下 Exceptions 類的實現(xiàn),通過不同的方法把每一種異常都 new 出來拋出即可:
public class Exceptions {
// 正確的定義法
public static BusinessException orderExists() {
return new BusinessException("訂單已經(jīng)存在", 3001);
}
}
在拋出異常時 用orderExists方法 new 一個異常拋出:打印了正確的異常信息
[ERROR] [http-nio-8080-exec-1] [.j.e.c.ExceptionTestController] createOrder got error
com.jiangxb.exceptionhandling.BusinessException: 訂單已經(jīng)存在
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: 訂單已經(jīng)存在
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 出來的時候才初始化(調(diào)用fillInStackTrace 方法)是一次性的(除非你手動調(diào)用那個方法),而非getStackTrace 的時候去獲得 stacktrace
四、提交線程池的任務(wù)出了異常會怎么樣?
1. 任務(wù)異常導(dǎo)致線程退出
線程池常用作異步處理或并行處理。那么,把任務(wù)提交到線程池處理,任務(wù)本身出現(xiàn)異常時會怎樣呢?
下面看個例子:提交 10 個任務(wù)到線程池異步處理,第 5 個任務(wù)拋出一個 RuntimeException,每個任務(wù)完成后都會輸出一行日志:
@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個任務(wù)到線程池處理,第5個任務(wù)拋出運(yùn)行時異常
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);
}
觀察日志可以發(fā)現(xiàn)兩點:
[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)
任務(wù) 1 到 4 所在的線程是 test0,任務(wù) 6 開始運(yùn)行在線程 test1。由于我的線程池通過線程工廠為線程使用統(tǒng)一的前綴 test 加上計數(shù)器進(jìn)行命名,因此從線程名的改變可以知道 因為異常的拋出老線程退出了,線程池只能重新創(chuàng)建一個線程**。如果每個異步任務(wù)都以異常結(jié)束,那么線程池可能完全起不到線程重用的作用。
因為沒有手動捕獲異常進(jìn)行處理,所以 ThreadGroup 幫忙進(jìn)行了未捕獲異常的默認(rèn)處理, 向標(biāo)準(zhǔn)錯誤輸出打印了出現(xiàn)異常的線程名稱和異常信息。顯然,這種沒有以統(tǒng)一的錯誤日志格式記錄錯誤信息打印出來的形式,對生產(chǎn)級代碼是不合適的
ThreadGroup 的相關(guān)源碼如下所示:
// JDK1.8 ThreadGroup.java
public void uncaughtException(Thread t, Throwable e) {
if (parent != null) {
// 若有父線程組,則調(diào)用父線程組的 uncaughtException 方法
parent.uncaughtException(t, e);
} else {
// 沒有父線程組,則看線程是否設(shè)置了defaultUncaughtExceptionHandler
Thread.UncaughtExceptionHandler ueh =
Thread.getDefaultUncaughtExceptionHandler();
if (ueh != null) {
// 若設(shè)置了defaultUncaughtExceptionHandler,則調(diào)用它的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方法的注釋:
當(dāng)此線程組中的線程由于未捕獲的異常而停止,并且該線程沒有設(shè)置特定的 Thread.UncaughtExceptionHandler未捕獲異常處理器時,該方法由Java虛擬機(jī)調(diào)用。
ThreadGroup類的 uncaughtException 方法會做如下的事:
- 如果這個線程組有父線程組,則使用相同的兩個參數(shù)調(diào)用該父線程組的 uncaughtException 方法。
- 否則,此方法會檢查是否設(shè)置了 Thread.defaultUncaughtExceptionHandler默認(rèn)的未捕獲異常處理器。如果有,會以相同的兩個參數(shù)調(diào)用它的 uncaughtException 方法。
- 否則,此方法確定 Throwable 參數(shù)是否是 ThreadDeath 的實例。 如果是這樣,則不會執(zhí)行任何特殊操作。 否則,將包含線程名稱的消息(從線程的getName方法返回)和堆?;厮荩ㄊ褂肨hrowable的printStackTrace方法)打印到 System.error 標(biāo)準(zhǔn)錯誤流。
程序可以在ThreadGroup子類中覆蓋此方法,以提供對未捕獲異常的替代處理。
2. 修復(fù)方式
修復(fù)方式有兩步:
- 以 execute 方法提交到線程池的異步任務(wù),最好在任務(wù)內(nèi)部做好異常處理;
- 設(shè)置自定義的異常處理程序作為保底,比如在聲明線程池時自定義線程池的未捕獲異常處理程序:
ExecutorService threadPool = new ThreadPoolExecutor(1, 1, 0L,
TimeUnit.MILLISECONDS,
new LinkedBlockingQueue(),
new ThreadFactoryBuilder().setNameFormat(prefix + "%d")
// 設(shè)置 uncaughtExceptionHandler
.setUncaughtExceptionHandler((thread, throwable) -> log.error("ThreadPool {} got exception", thread, throwable))
.build()
);
或者設(shè)置全局的默認(rèn)未捕獲異常處理程序:
static {
Thread.setDefaultUncaughtExceptionHandler((thread, throwable) -> log.error("Thread {} got exception", thread, throwable));
}
3. submit提交任務(wù)會屏蔽異常
通過線程池 ExecutorService 的 execute 方法提交任務(wù)到線程池處理,如果出現(xiàn)異常會導(dǎo) 致線程退出,控制臺輸出中可以看到異常信息。那么,把 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 源碼可以發(fā)現(xiàn),在執(zhí)行任務(wù)出現(xiàn)異常之后,異常存到了一個 outcome 字 段中,只有在調(diào)用 get 方法獲取 FutureTask 結(jié)果的時候,才會以 ExecutionException 的 形式重新拋出異常。
如果需要捕獲異常,要把 submit 返回的 Future 放到 List 中,分別調(diào)用 Future 的 get 方法,這時才能獲取異常任務(wù)拋出的異常。
3.1 FutureTask部分源碼
先看下 FutureTask 中幾個重要的變量
private volatile int state;
// 表示這是一個新的任務(wù),或者還沒有執(zhí)行完的任務(wù),是初始狀態(tài)。
private static final int NEW = 0;
// 表示任務(wù)執(zhí)行結(jié)束(正常執(zhí)行結(jié)束,或者發(fā)生異常結(jié)束),但是還沒有將結(jié)果保存到outcome中,是一個中間狀態(tài)。
private static final int COMPLETING = 1;
// 表示任務(wù)正常執(zhí)行結(jié)束,并且已經(jīng)把執(zhí)行結(jié)果保存到outcome字段中,是一個最終狀態(tài)。
private static final int NORMAL = 2;
表示任務(wù)發(fā)生異常結(jié)束,異常信息已經(jīng)保存到outcome中,是一個最終狀態(tài)。
private static final int EXCEPTIONAL = 3;
// 任務(wù)在新建之后,執(zhí)行結(jié)束之前被取消了,但是不要求中斷正在執(zhí)行的線程,
// 也就是調(diào)用了cancel(false),任務(wù)就是CANCELLED狀態(tài)。
private static final int CANCELLED = 4;
// 任務(wù)在新建之后,執(zhí)行結(jié)束之前被取消了,并要求中斷線程的執(zhí)行,
// 也就是調(diào)用了cancel(true),這時任務(wù)狀態(tài)就是INTERRUPTING。這是一個中間狀態(tài)。
private static final int INTERRUPTING = 5;
// 調(diào)用cancel(true)取消異步任務(wù),會調(diào)用interrupt()中斷線程的執(zhí)行,然后狀態(tài)會從INTERRUPTING變到INTERRUPTED。
private static final int INTERRUPTED = 6;
狀態(tài)變化有如下4種情況:
NEW -> COMPLETING -> NORMAL:正常執(zhí)行結(jié)束的流程
NEW -> COMPLETING -> EXCEPTIONAL:執(zhí)行過程中出現(xiàn)異常的流程
NEW -> CANCELLED:被取消,即調(diào)用了cancel(false)
NEW -> INTERRUPTING -> INTERRUPTED:被中斷,即調(diào)用了cancel(true)
// 封裝了計算任務(wù),可獲取計算結(jié)果
private Callable<V> callable;
// 保存計算任務(wù)的返回結(jié)果,或者執(zhí)行過程中拋出的異常
private Object outcome; // non-volatile, protected by state reads/writes
// 指向當(dāng)前在運(yùn)行Callable任務(wù)的線程
private volatile Thread runner;
// WaitNode是FutureTask的內(nèi)部類,表示一個阻塞隊列,如果任務(wù)還沒有執(zhí)行結(jié)束,
// 那么調(diào)用get()獲取結(jié)果的線程會阻塞,在這個阻塞隊列中排隊等待
private volatile WaitNode waiters;
任務(wù)被執(zhí)行時調(diào)用 run 方法
public void run() {
// 狀態(tài)不是NEW,返回
// 調(diào)用CAS方法,判斷runnerOffset為null的話,就將當(dāng)前線程保存到runnerOffset中,設(shè)置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 {
// 執(zhí)行Callable任務(wù)
result = c.call();
ran = true;
} catch (Throwable ex) {
result = null;
ran = false;
// 若執(zhí)行任務(wù)時發(fā)生異常,設(shè)置異常到 outcome
setException(ex);
}
if (ran)
// 任務(wù)正常結(jié)束,保存返回結(jié)果
set(result);
}
} finally {
// runner must be non-null until state is settled to
// prevent concurrent calls to run()
// runner置空,表示沒有線程在執(zhí)行這個任務(wù)
runner = null;
// state must be re-read after nulling runner to prevent
// leaked interrupts
// 根據(jù)狀態(tài)判斷當(dāng)前任務(wù)是否被中斷了,若被中斷,處理中斷
int s = state;
if (s >= INTERRUPTING)
handlePossibleCancellationInterrupt(s);
}
}
任務(wù)出現(xiàn)異常時調(diào)用 setException() 方法保存異常,這時 run 方法不會拋出異常
protected void setException(Throwable t) {
if (UNSAFE.compareAndSwapInt(this, stateOffset, NEW, COMPLETING)) {
outcome = t;
UNSAFE.putOrderedInt(this, stateOffset, EXCEPTIONAL); // final state
finishCompletion();
}
}
調(diào)用 get() 方法獲取任務(wù)執(zhí)行結(jié)果或異常
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 完成時的狀態(tài)
*/
private int awaitDone(boolean timed, long nanos)
throws InterruptedException {
final long deadline = timed ? System.nanoTime() + nanos : 0L;
WaitNode q = null;
boolean queued = false;
for (;;) {
// 若調(diào)用get()的線程被中斷了,就從等待的線程棧中移除這個等待節(jié)點,然后拋出中斷異常
if (Thread.interrupted()) {
removeWaiter(q);
throw new InterruptedException();
}
// 若當(dāng)前任務(wù)是已結(jié)束的狀態(tài),將等待節(jié)點線程置空,返回該狀態(tài)。這時不會阻塞
int s = state;
if (s > COMPLETING) {
if (q != null)
q.thread = null;
return s;
}
// 若任務(wù)已經(jīng)執(zhí)行,但還未將結(jié)果保存到outcome中,
// 使當(dāng)前線程讓出執(zhí)行權(quán),以便其它線程執(zhí)行
else if (s == COMPLETING) // cannot time out yet
Thread.yield();
else if (q == null)
q = new WaitNode();
// 如果這個等待節(jié)點還沒有加入等待隊列,就加入隊列頭
else if (!queued)
queued = UNSAFE.compareAndSwapObject(this, waitersOffset,
q.next = waiters, q);
// 若使用了超時時間
else if (timed) {
nanos = deadline - System.nanoTime();
if (nanos <= 0L) {
// 移除等待隊列中的當(dāng)前節(jié)點
removeWaiter(q);
return state;
}
// 阻塞特定的時間
LockSupport.parkNanos(this, nanos);
}
else
// 一直阻塞,等待喚醒
LockSupport.park(this);
}
}
finishCompletion()
/**
* 刪除所有等待線程并發(fā)出信號,調(diào)用 done(),并將callable設(shè)為 null。
*/
private void finishCompletion() {
// assert state > COMPLETING;
for (WaitNode q; (q = waiters) != null;) {
// CAS操作將等待節(jié)點置空
if (UNSAFE.compareAndSwapObject(this, waitersOffset, q, null)) {
for (;;) {
Thread t = q.thread;
if (t != null) {
// 將等待節(jié)點的線程置空
q.thread = null;
// 喚醒等待返回結(jié)果的線程
LockSupport.unpark(t);
}
WaitNode next = q.next;
if (next == null)
break;
q.next = null; // unlink to help gc
q = next;
}
break;
}
}
// 什么都沒有做,但子類可以實現(xiàn)這個方法,做一些額外的操作
done();
callable = null; // to reduce footprint
}
參考:極客時間《Java 業(yè)務(wù)開發(fā)常見錯誤 100 例》