博客 / 詳情

返回

Java異常到底是個啥——一次異常引發的思考

一、前言

最近在一次寫代碼的時候,出現了一個低級錯誤,但凡對異常有些瞭解,也不至於寫出這樣的代碼:

try {
    //不應該直接在try語句塊中拋異常,catch直接獲取後,相當於異常沒拋出去
    throw new ThirdPlatformException("第三方平台異常");
} catch {
    
}

説明自己對異常的處理機制和異常處理的規範都不太瞭解,趁着這次出現的問題,來好好學習Java異常的體系機制和原理。這篇文章主要通過三個部分闡釋Java異常

  • Java中異常的分類,異常的處理機制
  • 異常的處理規範和實戰,如何利用Springboot框架處理異常
  • 從JVM的角度分析異常機制,包括try-catch, try-finally, try-with-resource的字節碼分析

二、什麼是異常-異常處理機制

在Java中,異常就是指Java程序運行中出現的問題,比如網絡資源讀取失敗,空指針,使用非法數組索引等等。而我們日常看到的各種xxxException類就是對這些異常的描述,他們都是派生於Throwable類(表示可以拋出這個異常)的一個類的實例,下面就來詳細介紹一下Java中的異常分類:

2.1 異常分類

image.png

  • Throwable類:是所有錯誤和異常的超類,其中包括該線程執行堆棧的快照,提供printStackTrace()等接口用用獲取堆棧異常等等信息。
  • Error類及其子類:表示運行應用程序中出現了嚴重錯誤,一般表示代碼運行中的非代碼錯誤。當此類異常發生時,應用程序不應該去處理此類錯誤。因此我們也不應該實現任何新的Error子類的。
  • Exception類及其子類是程序本身可以捕獲並且可以處理的異常。也是在日常寫代碼中接觸的最多的一類異常,主要有兩類:運行時異常(RuntimeException)編譯異常(非運行時異常)

    • 運行時異常(RuntimeException):主要是RuntimeException類及其子類,比如NullPointerExceptionIndexOutOfBoundsException等。這類異常的特點是Java編譯器不會檢查,即便沒有使用異常處理,代碼也會編譯通過
    • 非運行時異常:除RuntimeException類及其子類外的Exception子類,比如IOExceptionSQLException等。這類異常Java編譯器肯定會檢查,如果不作異常處理,代碼就不能編譯通過。

上面提到Exception時,有些異常不會被編譯通過。所以對於整個異常體系來説,在是否能被Java編譯器檢查的角度,又分為可查異常不可查異常

  • 可查異常(Checked Exception):在編譯器就會被檢查,如果這類異常不處理,則代碼編譯不通過。也就是Exception中的非運行時異常——除RuntimeException之外的Exception子類。這類異常很好發現,在IDE軟件中如果有問題,就會報紅:
  • 不可查異常(Unchecked Exception):這類異常編譯器不會檢查,在運行時才可能拋出該異常,主要有Exception中的運行時異常和Error及其子類,在運行過程中出現問題才會報錯,一般在日誌中才能見到:

除了這些JDK自帶的異常外,我們同樣可以自定義異常,通過繼承相關的異常類

  • 自定義檢查異常

    class CheckedException extends Exception{}
  • 自定義非檢查異常

    class UnCheckedException extends RuntimeException{}

    這些自定義檢查異常的效果和JDK中自帶的異常相同,自定義的檢查異常不處理的話,同樣會編譯不通過。

2.2 異常關鍵字

  • try(監聽異常):主要用於監聽try語句塊的代碼,當其中的代碼出現異常,就會被拋出,需要和catchfinally等其他關鍵字一起使用
  • catch(捕獲異常):用來捕獲異常,在捕獲try語句塊的異常後會執行該語句塊中的代碼
  • finally(總是會被執行):無論是否有異常,都會執行該語句塊中的代碼。
  • throw(拋出異常):拋出相關異常,如前言中的:

    throw new ThirdPlatformException("第三方平台異常");

    然而在大多數情況中,都不需要手動拋出異常,一方面在調用JDK資源類時,已經處理過異常;另一方面在業務代碼中,都會統一自定義異常類,所以儘量做捕獲異常或者向上拋。

  • throws(聲明異常):在方法上聲明可能會拋出的異常,作用是將異常傳遞給合適的處理程序,比如:

    public interface LargeModelSession throws RuntimeException{}
  • trycatchfinally都不能單獨使用,只能是try-catchtry-finally或者try-catch-finally

2.3 異常的處理

image.png
把大象放入冰箱需要幾步?類似的,對於程序中的異常處理,可以分為發現異常,傳遞異常和處理異常這三步:

  • 第一步 發現異常(捕獲)

    • 通過try塊來監聽異常
    • 方法頭中使用throws顯示聲明可能會拋出的異常
  • 第二步 傳遞異常

    • try塊的異常拋給catch,或者不處理直接到達finally
    • throws拋給該方法的調用者
    • throw直接new一個異常實例拋出
  • 第三步 處理異常

    • try-catch類型則直接在catch塊中處理異常
    • throws則需要方法調用者進行處理

常見的異常捕獲主要有以下幾種:

  • try-catch
  • try-catch-finally
  • try-finally
  • try-with-resource

2.3.1try-catch

try-catch語句中可以通過多個catch捕獲多個異常類型,並做不同的處理,並且也可以在一個catch中捕獲不同的異常:

/**1. 多個catch捕獲多個異常類型*/
try{
    //監視的可能會出現異常的代碼
} catch(Exception1 e1){
    //Exception1類型的異常處理
} catch(Exception2 e2){
    //Exception1類型的異常處理
}
/**2. 一個catch捕獲多個異常類型*/
try{
    //監視的可能會出現異常的代碼
} catch(Exception1 |Exception2 e2){
    //Exception1和Exception2類型的異常處理
} 

當程序發生異常後,會按照異常從上到下(多個catch)或者從左到右(一個catch)的順序依次匹配,匹配成功後就直接在該catch中進行處理。

2.3.2try-catch-finally

try-catch-finally類型的特點是不管try塊中是否監聽到異常,finally塊中的語句都會被執行:

  • 有異常:try塊出現異常->拋給catch塊執行->執行finally塊中代碼
  • 無異常:try塊沒有異常->執行finally塊中代碼

    try {                        
      //監視可能會出現異常的代碼                
    } catch(Exception e) {   
      //捕獲異常並處理   
    } finally {
      //一定會執行的代碼
    }

    finally塊主要用在IO讀取、數據庫連接、運行清理等需要關閉的場景

2.3.3try-finally

try-finally比try-catch-finally更加直接,try塊的代碼出現異常不予處理,立即執行finally塊中代碼,一般用於不需要捕獲異常的代碼:

//截取DefaultMBeanServerInterceptor中的部分代碼
final ResourceContext context = unregisterFromRepository(resource, instance, name);
try {
    if (instance instanceof MBeanRegistration)
        postDeregisterInvoke(name,(MBeanRegistration) instance);
} finally {
    context.done();//無論是否出現問題直接執行
}

2.3.4try-with-resource

try-with-resource是JDK1.7後引入的,如果一個類實現了AutoCloseable接口,那麼這個類就可以寫在try後的括號中,並且能再try-catch塊執行後自動執行close方法,也就不用再寫finally塊
它實際上將try-catch-finally簡化成try-catch,在編譯時會轉化成try-catch-finally語句,主要包含三個部分:

  • try(聲明需要關閉的資源)
  • try塊和catch塊

    try(Connection conn = newConnection()) {
      conn.sendData()
    }catch(Exception e) {
     e.printStackTrace();
    }

三、異常該怎麼處理-異常的實踐

異常到底該如何處理,首先可以借鑑一下國內優秀開發團隊的異常處理經驗,也就是異常處理規範:

3.1 異常處理規範

對於異常處理實踐規範,最著名的就是阿里Java異常處理規約,在此基礎上也請教了公司經驗豐富的同事,總結列出如下異常處理規範:

3.1.1 空指針、數組越界等能通過預檢查的異常就不要用異常處理

異常類其實也是一種資源消耗,如果我們能夠通過預先邏輯判斷,檢查出來可能會發生的問題,就可以避免使用異常:image.png
類似的,在空指針問題的處理上,應該對遠程調用的對象要做好預先檢查和處理:
image.png

3.1.2 捕獲異常時要注意區分異常類型,儘量捕獲具體的異常

//比如知道會出現ArithmeticException,卻捕獲RuntimeException異常
try{
    int a = 3/0;
} catch(RuntimeException e){ //
    ...
}

3.1.3 捕獲異常後應該用語言描述具體錯誤信息,包括相關的參數,而不是什麼都不做

如果在catch後什麼都不做,相當於把異常給吞了,這個異常什麼也沒有幹,還消耗創建異常類的資源,因此捕獲了異常後一定要描述清楚錯誤信息

//可以使用e.printStackTrace(),但是在日誌中無法查看具體的信息
//因此可以嘗試使用日誌框架來打印錯誤信息
logger.error("説明信息,異常信息:{}", e.getMessage(), e)

3.1.4 拋出異常信息時,注意要正常傳遞異常信息

  • 不要拋出和捕獲異常完全不同的異常

    try{
      ...
    }catch(ArithmeticException e) {
      throw new NullPointerException(e); //拋出和捕獲異常完全不同的異常,會導致異常轉譯錯誤
    }
  • 不要拋出比捕獲異常更抽象的異常

    try{
      ...
    }catch(ArithmeticException e) {
      throw new RuntimeException(e); //RuntimeException是ArithmeticException父類,傳遞過程會丟失異常信息
    }
  • 不要拋出和捕獲異常完全相同的異常

    try{
      ...
    }catch(ArithmeticException e) { //與其拋出完全相同的異常,還不如直接處理打印異常信息
      throw new ArithmeticException(e); 
    }

3.1.5 拋出包裝異常時,要注意不要拋棄原始異常信息

在拋出異常時,可以自定義異常信息然後拋出,但這個時候儘量傳入完整捕獲異常的異常信息

try{
    ...
}catch(ArithmeticException e) { //自定義包裝異常應該傳入完整的異常信息
    throw new MyException(e.getMessage()); //錯誤
    throw new MyException(e);              //正確
}

3.1.6 不要在記錄異常信息同時拋出異常

try{
    ...
}catch(Exception e) {
    logger.error("有異常,小心",e);
    throw new NullPointerException(e); //會產生多條日誌信息,兩者選一條即可
}

在記錄異常信息的同時又拋出異常,會產生多條的日誌信息,而且在後期如果出現異常,日誌也不太好分析

3.1.7 捕獲多個異常時,應該從具體到抽象,優先捕獲具體異常

try {
  ...
} catch (NumberFormatException e) { //NumberFormatException是IllegalArgumentException的子類
    logger.error(e);
} catch (IllegalArgumentException e) {
    logger.error(e)
}

3.1.8 不要在finally塊中使用return語句

finally語句相當於嵌入try塊和catch塊中
image.png

3.1.9 在 finally 塊中或者使用 try-with-resource 語句關閉資源

利用finally塊關閉資源

FileInputStream inputStream = null;
try {
    File file = new File("./test.txt");
    inputStream = new FileInputStream(file);
} catch (FileNotFoundException e) {
    logger.error(e);
} finally {
    if (inputStream != null) { //如果finally中發現異常,可以繼續用
        try {
            inputStream.close();
        } catch (IOException e) {
            logger.error(e);
        }
    }
}

利用try-with-resource關閉資源,注意該資源類必須實現AutoCloseable接口

File file = new File("./test.txt");
try (FileInputStream inputStream = new FileInputStream(file);) {
} catch (FileNotFoundException e) {
    logger.error(e);
} catch (IOException e) {
    logger.error(e);
}

3.2 項目異常處理

3.2.1 SpringBoot項目中的異常處理

下面我創建一個項目來詳細講解SpringBoot中的異常,項目詳細地址[Link]()為:

1.BasicExceptionController轉發到異常頁面

比如每當我們訪問其他網頁出現問題時,總會跳轉到404頁面,這就是一種處理異常的方式。一旦系統全局中出現異常,SpringBoot就會請求異常錯誤,然後通過BasicExceptionController來處理這個請求,並讓當前頁面跳轉至對應的異常頁面。例如我在項目中沒有創建任何接收網絡請求的controller,這個時候在瀏覽器發起請求,那麼springboot框架會轉發請求並跳轉至默認異常頁面:
image.png
image.png
用的最多的場景是在網絡請求中出現問題,比如喜聞樂見的404頁面,就是對這種異常的一種處理與反饋。

2.@ExceptionHandler處理局部異常

該註解用在某個控制器類中的方法上,可以集中處理不同類型的異常。如果該控制器類中的其他方法拋出對應異常,該註解方法都能攔截並處理:

@Controller
@RequestMapping("/exception")
public class ExceptionController {

    private static final Logger logger = LoggerFactory.getLogger(ExceptionController.class);

    /**
     * 在一個方法中統一處理異常,在該類下的其他方法出現異常,都會在該方法中處理
     * @return
     */
    @ExceptionHandler({ArithmeticException.class, NullPointerException.class})
    public ModelAndView testExceptionHandler(Exception ex) {
        ModelAndView mv = new ModelAndView();
        mv.addObject("ex", ex);
        if (ex instanceof ArithmeticException) {
            mv.setViewName("ArithmeticException");
            logger.info("當前是ArithmeticException異常");
        } else if (ex instanceof NullPointerException){
            mv.setViewName("NullPointerException");
            logger.info("當前是NullPointerException異常");
        } else{
            mv.setViewName("error");
            logger.info("當前是error異常");
        }
        return mv;
    }

    /**
     * 運算式異常
     * @return
     */
    @GetMapping("/arthmetic")
    @ResponseBody
    public String testExceptionHandler2() throws ArithmeticException{
        logger.error(String.valueOf(1/0));
        return "testExceptionHandler";
    }

    /**
     * 空指針異常
     * @return
     * @throws NullPointerException
     */
    @GetMapping("/nullPointer")
    @ResponseBody
    public String testExceptionHandler3() throws NullPointerException{
        String string = new String();
        string = null;
        java.lang.String s = string.toString();
        logger.info(s);
        return "testNullPointer";
    }
}

對該ExceptionController中的方法進行請求測試,得到如下結果:

GET http://localhost:8080/exception/arthmetic
GET http://localhost:8080/exception/nullPointer
----------------------------------------------
INFO 31092 --- [nio-8080-exec-2] c.e.s.controller.ExceptionController     : 當前是ArithmeticException異常
INFO 31092 --- [nio-8080-exec-7] c.e.s.controller.ExceptionController     : 當前是NullPointerException異常

此外,其他的controller類可以通過繼承ExceptionController來獲取到異常處理的方法

@Controller
@RequestMapping("/first")
public class FirstController extends ExceptionController{

    private static final Logger logger = LoggerFactory.getLogger(ExceptionController.class);

    @RequestMapping("/testException1")
    public void testFirst() throws ArithmeticException{
        logger.error(String.valueOf(1/0));
    }
}

請求該接口後,同樣會調用繼承類中定義好的異常處理方法:

GET http://localhost:8080/first/testException1
----------------------------------------------
INFO 13860 --- [nio-8080-exec-2] c.e.s.controller.ExceptionController     : 當前是ArithmeticException異常

這樣如果其他的controller需要處理同樣的異常,就必須繼承該異常controller,會顯得比較麻煩。能夠使用更加優雅的方式,讓全局所有的controller應用該異常類的處理方法嘛?有的,可以通過@ControllerAdvice+@ExceptionHandler:

3.@ControllerAdvice + @ExceptionHandler處理全局異常

我們可以通過定義一個全局的異常處理類,在這個類中加上@ControllerAdvice註解,並在方法中加上@ExceptionHandler註解,來處理所有Controller的異常:

public class GlobalExceptionHandler {

    private static final Logger logger = LoggerFactory.getLogger(ExceptionController.class);

    @ExceptionHandler({ArithmeticException.class, NullPointerException.class})
    public ModelAndView testExceptionHandler(Exception ex) {
        ModelAndView mv = new ModelAndView();
        mv.addObject("ex", ex);
        if (ex instanceof ArithmeticException) {
            mv.setViewName("ArithmeticException");
            logger.info("當前是全局ArithmeticException異常");
        } else if (ex instanceof NullPointerException){
            mv.setViewName("NullPointerException");
            logger.info("當前是全局NullPointerException異常");
        } else{
            mv.setViewName("error");
            logger.info("當前是全局error異常");
        }
        return mv;
    }
}

單獨定義一個controller,如果發生異常,也能通過全局異常處理類進行處理:

public class SecondController{

    private static final Logger logger = LoggerFactory.getLogger(ExceptionController.class);

    @RequestMapping("/testException2")
    public void testFirst() throws ArithmeticException{
        logger.error(String.valueOf(1/0));
    }
}

請求測試結果:

INFO 33660 --- [nio-8080-exec-1] c.e.s.controller.ExceptionController     : 當前是全局ArithmeticException異常
4. 配置SimpleMappingExceptionResolver類處理全局異常

同樣也可以定義一個全局異常類,來處理全局異常,和@ControllerAdvice+ @ExceptionHandler不同點是在全局異常類上加一個@Configuration註解,並將SimpleMappingExceptionResolver注入Spring容器中:

@Configuration
public class GlobalException {

    @Bean
    public SimpleMappingExceptionResolver getSimpleMappingExceptionResolver() {
        SimpleMappingExceptionResolver resolver = new SimpleMappingExceptionResolver();
        Properties properties = new Properties();
        //設置異常類型並映射到不同的jsp頁面
        properties.put("java.lang.ArithmeticException", "ArithmeticException");
        properties.put("java.lang.NullPointerException", "NullPointerException");
        //將配置文件映射到resolver中
        resolver.setExceptionMappings(properties);
        return resolver;
    }
}

在項目中添加ArithmeticException.jspNullPointerException.jsp文件,同樣發送請求測試:

<%@ page language="java" contentType="text/html; charset=Utf-8"
         pageEncoding="Utf-8"%>
<!DOCTYPE html>
<html>
<head>
    <meta charset="Utf-8">
    <title>ArithmeticException</title>
</head>
<body>
    發生ArithmeticException異常
</body>
</html>
GET http://localhost:8080/first/testException1
@Controller
@RequestMapping("/first")
public class FirstController extends ExceptionController{

    private static final Logger logger = LoggerFactory.getLogger(ExceptionController.class);

    @RequestMapping("/testException1")
    public void testFirst() throws ArithmeticException{
        logger.error(String.valueOf(1/0));
    }
}

image.png

5. 實現HandlerExceptionResolver接口處理全局異常

需要實現HandlerExceptionResolver接口,並全局配置:

@Configuration
public class HandlerExceptionResolverImpl implements HandlerExceptionResolver {
    @Override
    public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        ModelAndView modelAndView = new ModelAndView();
        if (ex instanceof NullPointerException) {
            modelAndView.setViewName("NullPointerException");
            return modelAndView;
        } else if (ex instanceof ArithmeticException) {
            modelAndView.setViewName("ArithmeticException");
            return modelAndView;
        }
        return modelAndView;
    }
}

配置接口和jsp文件,進行測試驗證:

<%@ page language="java" contentType="text/html; charset=Utf-8"
         pageEncoding="Utf-8"%>
<!DOCTYPE html>
<html>
<head>
    <meta charset="Utf-8">
    <title>ArithmeticException</title>
</head>
<body>
    實現接口:發生ArithmeticException異常
</body>
</html>
@Controller
@RequestMapping("/first")
public class FirstController{

    private static final Logger logger = LoggerFactory.getLogger(ExceptionController.class);

    @RequestMapping("/testException1")
    public void testFirst() throws ArithmeticException{
        logger.error(String.valueOf(1/0));
    }
}
GET http://localhost:8080/first/testException1

image.png

6. 利用Spring AOP進行異常處理

同樣,作為spring框架的核心,我們可以使用切面來對異常進行處理:

@Aspect
@Component
public class WebRequestExceptionAspect {

    private static final Logger logger = LoggerFactory.getLogger(WebRequestExceptionAspect.class);
    //攔截帶有@RequestMapping的註解方法
    @Pointcut("@annotation(org.springframework.web.bind.annotation.RequestMapping)")
    private void webRequestPointcut() {

    }

    @AfterThrowing(pointcut = "webRequestPointcut()", throwing = "ex")
    public void handleException(Exception ex) {
        //攔截異常並設置對應的異常信息
        String exceptionMsg = StringUtils.isEmpty(ex.getMessage()) ? "出現異常" : ex.getMessage();
        logger.error("發生了異常:{}",exceptionMsg);
    }
}

測試:

GET http://localhost:8881/first/testException1
-------------------------------------------------------------------------------------------------
ERROR 29824 --- [nio-8881-exec-2] c.e.s.e.WebRequestExceptionAspect        : 發生了異常:/ by zero
java.lang.ArithmeticException: / by zero
    at com.ethan.springbootexception.controller.FirstController.testFirst(FirstController.java:21) ~[classes/:na]
    at com.ethan.springbootexception.controller.FirstController$$FastClassBySpringCGLIB$$cfba05ad.invoke(<generated>) ~[classes/:na]
 ...

3.2.2 實際項目中的異常處理

上面提到了在Springboot框架中的異常處理註解,但在實際項目中不僅要在框架層面考慮異常,而且還要在業務代碼層面捕獲和處理異常,此外需要根據不同業務邏輯、異常類型來分別處理:

  • 業務異常: 用户操作業務時,提示出來的異常信息,這些信息能直接讓用户可以繼續下一步操作,或者換一個正確操作方式去使用,換句話就是用户可以自己能解決的。比如:“用户沒有登錄”,“沒有權限操作”。
  • 系統異常: 用户操作業務時,提示系統程序的異常信息,這類的異常信息時用户看不懂的,需要告警通知程序員排查對應的問題,如 NullPointerException,IndexOfException。另一個情況就是接口對接時,參數的校驗時提示出來的信息,如:缺少ID,缺少必須的參數等,這類的信息對於客户來説也是看不懂的,也是解決不了的,所以將這兩類的錯誤應當統一歸類於系統異常。

也就是從用户的角度來看,如果用户能處理則拋出業務異常,不能處理就拋出系統異常。
借用12 | 異常處理:別讓自己在出問題的時候變為瞎子-極客時間中的例子來説明:

對於自定義的業務異常,以 Warn 級別的日誌記錄異常以及當前 URL、執行方法等信息後,提取異常中的錯誤碼和消息等信息,轉換為合適的 API 包裝體返回給 API 調用方;
對於無法處理的系統異常,以 Error 級別的日誌記錄異常和上下文信息(比如 URL、參數、用户 ID)後,轉換為普適的“服務器忙,請稍後再試”異常信息,同樣以 API 包裝體返回給調用方。
@RestControllerAdvice
@Slf4j
public class RestControllerExceptionHandler {
    private static int GENERIC_SERVER_ERROR_CODE = 2000;
    private static String GENERIC_SERVER_ERROR_MESSAGE = "服務器忙,請稍後再試";

    @ExceptionHandler
    public APIResponse handle(HttpServletRequest req, HandlerMethod method, Exception ex) {
        if (ex instanceof BusinessException) {
            BusinessException exception = (BusinessException) ex;
            log.warn(String.format("訪問 %s -> %s 出現業務異常!", req.getRequestURI(), method.toString()), ex);
            return new APIResponse(false, null, exception.getCode(), exception.getMessage());
        } else {
            log.error(String.format("訪問 %s -> %s 出現系統異常!", req.getRequestURI(), method.toString()), ex);
            return new APIResponse(false, null, GENERIC_SERVER_ERROR_CODE, GENERIC_SERVER_ERROR_MESSAGE);
        }
    }
}

這樣,能夠在異常出現時,方便維護人員根據日誌上下文快速解決問題。

四、異常到底是個啥-從JVM角度看異常處理

4.1 創建異常有多慢

説用異常慢,首先來看看異常慢在哪裏?有多慢?下面的測試用例簡單的測試了建立對象、建立異常對象、拋出並接住異常對象三者的耗時對比:
public class ExceptionTest {

    private int testTimes;

    public ExceptionTest(int testTimes) {
        this.testTimes = testTimes;
    }

    /**
     * 創建Object對象
     */
    public void newPureObject() {
        long startTime = System.nanoTime();
        for (int i = 0; i < testTimes; i++) {
            new Object();
        }
        System.out.println("創建普通對象時間:" + (System.nanoTime() - startTime));
    }

    /**
     * 創建Exception對象
     */
    public void newException() {
        long startTime = System.nanoTime();
        for (int i = 0; i < testTimes; i++) {
            new RuntimeException();
        }
        System.out.println("創建異常對象時間:" + (System.nanoTime() - startTime));
    }

    /**
     * 創建異常並捕獲Exception
     */
    public void catchException() {
        long startTime = System.nanoTime();
        for (int i = 0; i < testTimes; i++) {
            try {
                throw new RuntimeException();
            } catch (RuntimeException e) {
            }
        }
        System.out.println("創建異常並捕獲Exception對象時間:" + (System.nanoTime() - startTime));
    }

    public static void main(String[] args) {
        //在1000次循環中三種創建方式的耗時對比
        ExceptionTest exceptionTest = new ExceptionTest(1000);
        exceptionTest.newPureObject();
        exceptionTest.newException();
        exceptionTest.catchException();
    }
}

測試在1000次循環中三種創建方式的耗時對比,測試結果:

--------------------------------------
創建普通對象時間:91200
創建異常對象時間:1131900
創建異常並捕獲Exception對象時間:1196900

説明創建一個對象時間是創建普通Object對象12倍,所以在流程業務中,最好不要用異常來進行處理。下面就從字節碼角度看看異常的處理:

4.2 從JVM角度看各個異常的處理

下面就來看一看字節碼層面的異常處理過程,查看類的字節碼信息需要使用jclasslib Bytecode Viewer,我是IDEA環境,直接在plugin搜索安裝即可:
image.png

4.2.1 try-catch

首先寫一個包含try-catch的方法,並查看其字節碼:
image.png
先説説代碼對應的字節碼含義:

  • 0到5之間表示 System.out.println("查看try-catch的字節碼")這段代碼
  • 8到16之間表示try{} catch()中的代碼塊
  • 17到22之間表示catch{}的代碼塊

重點看try塊中的代碼:

  • new指令: new 指令用於創建一個新的對象。這裏的操作是創建一個RuntimeException的新實例
  • dup指令:dup 指令用於複製棧頂的值。這裏的操作是複製棧頂的異常對象引用,以備後續使用
  • invokespecial指令: invokespecial 指令用於調用對象的構造方法。這裏的操作是調用RuntimeException的默認構造方法,初始化新創建的異常對象。
  • athrow指令: throw的底層實現指令,其拋出的objectref必須是引用類型,而且是Throwable或其子類。拋出對應的異常後,會在異常表中查找第一個與該異常相匹配的異常類型(也就是捕獲異常處),這裏拋出的是RuntimeException

具體的解釋可以查看此處的JDK文檔

The objectref must be of type reference and must refer to an object that is an instance of class Throwable or of a subclass of Throwable. It is popped from the operand stack. The objectref is then thrown by searching the current method (§2.6) for the first exception handler that matches the class of _objectref_, as given by the algorithm in §2.10.
If an exception handler that matches objectref is found, it contains the location of the code intended to handle this exception. The pc register is reset to that location, the operand stack of the current frame is cleared, objectref is pushed back onto the operand stack, and execution continues.
If no matching exception handler is found in the current frame, that frame is popped. If the current frame represents an invocation of a synchronized method, the monitor entered or reentered on invocation of the method is exited as if by execution of a monitorexit instruction (§monitorexit). Finally, the frame of its invoker is reinstated, if such a frame exists, and the objectref is rethrown. If no such frame exists, the current thread exits.
  • astore_1指令:將棧頂的引用類型變量放入局部變量表中索引為1的位置,這裏的操作表示將捕獲到的RuntimeException存儲到局部變量表為1的位置。

那我們再來看看異常表:
Start PC: 開始計數器位置
End PC: 結束計數器位置
Handler PC:發生異常後程序跳轉的位置
Catch Type: 捕獲異常類型
image.png
具體在字節碼層面怎麼操作的呢?首先new一個RuntimeException異常類型實例,然後去異常表中查找是否存在這個類型,如果有則跳轉到Handler PC的位置,繼續執行代碼(捕獲異常後的處理)

4.2.2 try-catch-finally

在try-catch基礎上增加finally代碼塊,再來查看一下其字節碼:
image.png
我們知道無論是否捕獲異常,程序都會執行finally中的代碼塊,那麼字節碼中如何實現的呢?首先加上finally後,在字節碼中出現了兩次fianlly代碼塊中的內容,再來看看異常表:
image.png
發現比try-catch多了一條any類型的記錄,這條記錄説明在8~25行無論是否拋出異常,都會跳轉到36行執行。
下面我們來從字節碼的角度,無論是拋出異常還是捕獲異常,是否都會執行finally中的代碼:

  1. 沒有異常

假設執行過程中沒有異常,程序會一直沿着字節碼往下執行:

  • 0~5行:執行System.out.println("查看try-catch-finally的字節碼");
  • 8~25行: 這段沒有異常,就無法在異常表中匹配到第一條記錄,那麼會匹配到第二條any類型,直接跳轉到36行
  • 36~47行:執行finally代碼塊中的代碼,如果有異常,就會繼續執行athrow指令,最後結束並return
  • 捕獲異常

假設執行過程中發生了異常,程序會按照如下順序執行:

  • 0~5行:執行System.out.println("查看try-catch-finally的字節碼");
  • 8~16行:拋出了RuntimeException異常,在異常表中查找到第一條記錄,按照異常表顯示的16行繼續執行
  • 17~33行: 這一段執行了和finally語句相同的語句,最後到goto指令,跳轉到47行
  • 47行:執行完成並return

4.2.3 try-finally

再來看看try-finally語句的字節碼:
image.png
以及異常表:
image.png
從異常表的記錄我們知道,無論是否拋出異常,都會執行finally中的語句。

4.2.4 finally 塊和 return 的執行順序

1. finally 中沒有 return, try 或 catch 塊中有 return,finally 語句塊是在 return 語句執行完,返回之前執行

我們可以用一個例子來説明:

public int testFinally() {
    int i = 0;
    try {
        i = 1;
        return i;
    } finally {
        System.out.println("finally語句執行");
    }
}
/**
 * 執行結果:
 *  finally語句執行
 *  1
 */

但是如果在 finally 中對變量進行修改,情況就有些不同:

  • 若 return 的變量類型是基本數據類型,則 finally 中對變量的修改不起作用
  • 若 return 的變量類型是引用數據類型,則 finally 中對變量的修改會成功

我們同樣舉例來説明:

public int testFinally() {
    int i = 0;
    try {
        i = 1;
        return i;
    } finally {
        i = 2;
    }
}
public StringBuilder testFinallyReturn() {
    StringBuilder exception = new StringBuilder("Exception");
    try {
        exception.append("java");
        return exception;
    } finally {
        exception.append("last");
    }
}
public static void main(String[] args) {
    AnalysisExceptionCode analysisExceptionCode = new AnalysisExceptionCode();
    int I = analysisExceptionCode.testFinally();
    System.out.println(I);
    StringBuilder stringBuilder = analysisExceptionCode.testFinallyReturn();
    System.out.println(stringBuilder.toString());
}

執行結果:

1 
Exceptionjavacode

説明finally語句對引用數據類型中的值進行了修改,那麼看看兩個方法的字節碼:
首先是對基本類型(int)的修改:
image.png
最後返回的結果是1,因此finally語句修改值後,並沒有對其產生影響,我們再來看看字節碼指令

 0 iconst_0 #將0推送到操作數棧上
 1 istore_1 #將操作數棧頂的0出棧,存儲到局部變量表1的位置
 2 iconst_1 #將1推送到操作數棧上
 3 istore_1 #將操作數棧頂的1出棧,存儲到局部變量表1的位置(1覆蓋了之前存儲的0)
 4 iload_1  #將局部變量表1位置的1,加載到操作數棧上
 5 istore_2 #將操作數棧頂的1出棧,存儲到局部變量表2的位置
 6 iconst_2 #將2推送到操作數棧上
 7 istore_1 #將操作數棧頂的2出棧,存儲到局部變量表1的位置(2覆蓋了之前存儲的1)
 8 iload_2  #將局部變量表2位置的1,加載到操作數棧上
 9 ireturn  #返回操作數棧的值1
 #如果發生異常,則將異常對象存儲到局部變量表3的位置
10 astore_3 
11 iconst_2
12 istore_1
13 aload_3
14 athrow

接着再來看看引用類型:
image.png

#這段創建StringBuilder,將對象引用放入操作數棧中,類似於基本類型中的int i = 0
0 new #11 <java/lang/StringBuilder>  //創建一個新的StringBuilder對象
3 dup                                //複製棧頂的引用,以備後續使用
4 ldc #12 <Exception>                //將字符串常量"Exception"加載到操作數棧中
6 invokespecial #13 <java/lang/StringBuilder.<init> : (Ljava/lang/String;)V> //調用StringBuilder的構造方法,將之前加載的字符串常量Exception作為參數傳遞進去,初始化新創建的StringBuilder對象。
#######################################################################
9 astore_1   //將操作數棧中的對象引用出棧,放入局部變量表的1位置                 
10 aload_1   //將局部變量表1中的對象引用加載到操作數棧中
#######################################################################
#這段類似於try語句塊中的 i = 1
11 ldc #14 <java> //將字符串常量"java"加載到操作數棧中
13 invokevirtual #15 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;> //調用StringBuilder對象的append方法,將之前加載的字符串常量作為參數傳遞進去,實現字符串的拼接
16 pop            //從操作數棧中彈出append方法的返回值
#######################################################################
17 aload_1       //將局部變量表1中的對象引用加載到操作數棧中
18 astore_2      //將棧頂的引用存儲到局部變量表中的索引為2的位置。這裏是將StringBuilder對象的引用存儲到局部變量2的位置
19 aload_1       //將局部變量表中索引為1的對象引用加載到操作數棧中
#######################################################################
#這段類似於finally語句塊中的 i = 2
20 ldc #16 <code> //將字符串常量"code"加載到操作數棧中
22 invokevirtual #15 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;> //調用StringBuilder對象的append方法,將之前加載的字符串常量作為參數傳遞進去,實現字符串的拼接
25 pop           //從操作數棧頂彈出一個值,這裏是丟棄append方法的返回值
#######################################################################
26 aload_2      //將局部變量表中索引為2的引用加載到操作數棧中
27 areturn      //將棧頂的對象引用作為返回值返回
#如果有異常,則執行該段代碼
28 astore_3
29 aload_1
30 ldc #16 <code>
32 invokevirtual #15 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
35 pop
36 aload_3
37 athrow

其實無論是基本類型和引用類型,其字節碼執行流程都大致相同,但是最後finally語句塊的修改還是影響到了引用類型,這是因為操作數棧,局部變量表中存儲的變量是引用地址,而不是對象本身,因此每次局部變量表的覆蓋操作,都影響了對象本身。因此引用類型內部的值才被修改。

2. 如果在 try 和 finally 語句塊中都使用 return 則 try 或 catch 中的 return 將會失效

先用例子來看看是否會存在這種現象:

public int testFinally() {
    int i = 0;
    try {
        i = 1;
        return i;
    } finally {
        i = 2;
        return i;
    }
}
/**
 * 執行結果:
 *  2
 */

果然出現了 try 塊中 return 的失效現象,再來看看這個方法和不加 return 方法對比的字節碼:
image.png
問題在於第8行:

  • finally加上return後,會從局部變量表中加載索引為1的值,這個值被finally中修改的值覆蓋了,所以try 中的return指令變相失效
  • 而不加return時,是從局部變量表中加載索引為2的值,這個值是之前沒有被finally修改的值,因此try中的return指令有效。

4.2.5 try-with-resource

這裏借用Java異常處理和最佳實踐(含案例分析)中的例子:通過打包文件來看一下其本質:

public static void zipFile(List<File> fileList) {
    // 文件的壓縮包路徑
    String zipPath = OUT + "/打包附件.zip";
    // 獲取文件壓縮包輸出流
    try (OutputStream outputStream = new FileOutputStream(zipPath);
         CheckedOutputStream checkedOutputStream = new CheckedOutputStream(outputStream, new Adler32());
         ZipOutputStream zipOut = new ZipOutputStream(checkedOutputStream)) {
        for (File file : fileList) {
            // 獲取文件輸入流
            InputStream fileIn = new FileInputStream(file);
            // 使用 common.io中的IOUtils獲取文件字節數組
            byte[] bytes = IOUtils.toByteArray(fileIn);
            // 寫入數據並刷新
            zipOut.putNextEntry(new ZipEntry(file.getName()));
            zipOut.write(bytes, 0, bytes.length);
            zipOut.flush();
        }
    } catch (FileNotFoundException e) {
        System.out.println("文件未找到");
    } catch (IOException e) {
        System.out.println("讀取文件異常");
    }
}
實際上這是Java的一種語法糖,查看編譯後的代碼就知道編譯器為我們做了什麼,下面是反編譯後的代碼:
public static void zipFile(List<File> fileList) {
        String zipPath = "./打包附件.zip";

        try {
            OutputStream outputStream = new FileOutputStream(zipPath);
            Throwable var3 = null;

            try {
                CheckedOutputStream checkedOutputStream = new CheckedOutputStream(outputStream, new Adler32());
                Throwable var5 = null;

                try {
                    ZipOutputStream zipOut = new ZipOutputStream(checkedOutputStream);
                    Throwable var7 = null;

                    try {
                        Iterator var8 = fileList.iterator();

                        while(var8.hasNext()) {
                            File file = (File)var8.next();
                            InputStream fileIn = new FileInputStream(file);
                            byte[] bytes = IOUtils.toByteArray(fileIn);
                            zipOut.putNextEntry(new ZipEntry(file.getName()));
                            zipOut.write(bytes, 0, bytes.length);
                            zipOut.flush();
                        }
                    } catch (Throwable var60) {
                        var7 = var60;
                        throw var60;
                    } finally {
                        if (zipOut != null) {
                            if (var7 != null) {
                                try {
                                    zipOut.close();
                                } catch (Throwable var59) {
                                    var7.addSuppressed(var59);
                                }
                            } else {
                                zipOut.close();
                            }
                        }

                    }
                } catch (Throwable var62) {
                    var5 = var62;
                    throw var62;
                } finally {
                    if (checkedOutputStream != null) {
                        if (var5 != null) {
                            try {
                                checkedOutputStream.close();
                            } catch (Throwable var58) {
                                var5.addSuppressed(var58);
                            }
                        } else {
                            checkedOutputStream.close();
                        }
                    }

                }
            } catch (Throwable var64) {
                var3 = var64;
                throw var64;
            } finally {
                if (outputStream != null) {
                    if (var3 != null) {
                        try {
                            outputStream.close();
                        } catch (Throwable var57) {
                            var3.addSuppressed(var57);
                        }
                    } else {
                        outputStream.close();
                    }
                }

            }
        } catch (FileNotFoundException var66) {
            System.out.println("文件未找到");
        } catch (IOException var67) {
            System.out.println("讀取文件異常");
        }

    }

在使用try-with-resource時,try(聲明需要關閉的資源),並且需要其聲明的變量實現AutoCloseable接口,從編譯代碼可以看到,編譯器能幫我們自動關閉資源,這樣就可以不用寫finally語句塊,編譯器具體的異常處理過程如下:

  • try 塊沒有發生異常時,自動調用 close 方法,
  • try 塊發生異常,然後自動調用 close 方法,如果 close 也發生異常,catch 塊只會捕捉 try 塊拋出的異常,close 方法的異常會在catch 中通過調用 Throwable.addSuppressed 來壓制異常,但是你可以在catch塊中,用 Throwable.getSuppressed 方法來獲取到壓制異常的數組。

參考資料

  1. Java異常處理和最佳實踐(含案例分析)
  2. 12 | 異常處理:別讓自己在出問題的時候變為瞎子-極客時間
  3. 透過JVM看Exception本質 - FenixSoft 3.0 - ITeye博客
  4. return 和 finally究竟誰先被執行?
user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.