開發(fā)中的異常處理問題

應(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. 對于異常的三種處理模式

如果捕獲了異常打算處理的話,除了通過日志正確記錄異常原始信息外,通常還有 三種處理模式:

  1. 轉(zhuǎn)換:即轉(zhuǎn)換新的異常拋出。對于新拋出的異常,最好具有特定的分類和明確的異常消息,而不是隨便拋一個無關(guān)或沒有任何信息的異常,并最好通過 cause 關(guān)聯(lián)老異常。
  2. 重試:即重試之前的操作。如果是遠(yuǎn)程調(diào)用服務(wù)端超時的情況,盲目重試會讓問題更嚴(yán)重,需要考慮當(dāng)前情況是否適合重試。
  3. 恢復(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ù)方式有兩步:

  1. 以 execute 方法提交到線程池的異步任務(wù),最好在任務(wù)內(nèi)部做好異常處理;
  2. 設(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 例》

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容