天天看點

位元組碼層面學習Java異常底層原理

作者:Java架構學習指南

公司最近狠抓代碼品質,各種代碼掃描插件的報告中,異常的問題何其多,回頭審視組内的代碼,發現确實沒有太在意異常這個東西,對異常一深究,發現大有乾坤。

前言

Java中的異常,就是Java程式在運作過程中出現的非正常情況。Java有着完備的異常體系,先通過下圖來看一下。

位元組碼層面學習Java異常底層原理

Throwable是Java中所有異常的頂層父類,Java虛拟機抛出的異常或者Java代碼throw抛出的異常,都需要是Throwable或者Throwable的子類的執行個體。

Throwable的實作有兩個大類:Exception和Error,簡要說明如下。

  1. Exception。Java應用程式能夠處理的異常,Java應用程式可以捕獲的異常;
  2. Error。Java應用程式不能夠處理的異常,Java應用程式不應該捕獲的異常。

關于Exception和Error,還有一個重要的點需要關注,如下所示。

  1. Exception以及Exception的非RuntimeException子類稱為檢查異常;
  2. RuntimeException及其子類,和Error都稱為未檢查異常。

檢查異常就是在編譯時會校驗應用程式對異常的處理:是否通過throws關鍵字進行了抛出,是否通過try-catch進行了捕獲處理。

未檢查異常則是編譯時不會校驗應用程式對異常的處理,因為這些異常是屬于不應該發生的異常,是以應用程式不應該嘗試去抛出或者捕獲這些異常,而是應該避免這些異常的産生。

關于Exception和Error,本文後續會進行詳細的分析,這裡不再展開讨論。

本文關于Java異常體系的分析,腦圖如下所示。

位元組碼層面學習Java異常底層原理

正文

一. Throwable

1. 概念說明

Throwable是Java中所有異常的父類,隻有Throwable或者Throwable子類的執行個體才能夠被throws或者catch,換言之,Java中的異常都是Throwable。

Throwable有三個重要字段,如下所示。

  1. detailMessage。目前Throwable的描述資訊,描述這個異常的具體細節,這個字段不是必須的,可以為空;
  2. stackTrace。異常的堆棧資訊,是一個StackTraceElement數組,在建立Throwable時就會生成目前線程目前時刻的堆棧資訊,以類似于快照的形式儲存在stackTrace中;
  3. cause。導緻目前Throwable發生的Throwable,也就是導緻目前異常發生的原始異常,初始狀态時cause等于目前Throwable自身,此時表明沒有原始異常或者原始異常未知,可以在構造函數中傳入原始異常或者調用initCause() 方法來指定原始異常。

那麼相應的,Throwable提供的四個構造函數就是在對上述三個字段做初始化處理,構造函數如下所示。

public Throwable() {
    // 記錄堆棧資訊
    fillInStackTrace();
}

public Throwable(String message) {
    // 記錄堆棧資訊
    fillInStackTrace();
    // 記錄異常描述資訊
    detailMessage = message;
}

public Throwable(String message, Throwable cause) {
    // 記錄堆棧資訊
    fillInStackTrace();
    // 記錄異常描述資訊
    detailMessage = message;
    // 記錄異常的原始異常
    this.cause = cause;
}

public Throwable(Throwable cause) {
    // 記錄堆棧資訊
    fillInStackTrace();
    // 将原始異常的描述資訊作為目前異常的描述資訊
    detailMessage = (cause == null ? null : cause.toString());
    // 記錄異常的原始異常
    this.cause = cause;
}           

在重載的四個構造方法中,都會調用fillInStackTrace() 方法,該方法用于将目前線程的棧幀的資訊記錄到Throwable中,下面分析一下該方法的使用方式。

首先有如下一個簡單的Java程式。

@Slf4j
public class FillInStackTraceTest {

    private Throwable throwable;

    @Test
    public void 測試棧幀資訊填充() {
        methodA();
    }

    public void methodA() {
        throwable = new Throwable();
        methodB();
    }

    public void methodB() {
        methodC();
    }

    public void methodC() {
        log.info("列印異常", throwable);
    }

}           

運作程式,列印如下所示。

11:55:17.168 [main] INFO com.lee.learn.exception.FillInStackTraceTest - 列印異常
java.lang.Throwable: null
    at com.lee.learn.exception.FillInStackTraceTest.methodA(FillInStackTraceTest.java:17)
    at com.lee.learn.exception.FillInStackTraceTest.測試棧幀資訊填充(FillInStackTraceTest.java:13)
    ......           

在執行new Throwable() 時,就會将這一刻的線程的棧幀的資訊儲存到Throwable的stackTrace字段上,此時如果将methodC() 方法改造如下。

public void methodC() {
    throwable.fillInStackTrace();
    log.info("列印異常", throwable);
}           

再次運作程式,列印如下所示。

11:58:14.632 [main] INFO com.lee.learn.exception.FillInStackTraceTest - 列印異常
java.lang.Throwable: null
    at com.lee.learn.exception.FillInStackTraceTest.methodC(FillInStackTraceTest.java:26)
    at com.lee.learn.exception.FillInStackTraceTest.methodB(FillInStackTraceTest.java:22)
    at com.lee.learn.exception.FillInStackTraceTest.methodA(FillInStackTraceTest.java:18)
    at com.lee.learn.exception.FillInStackTraceTest.測試棧幀資訊填充(FillInStackTraceTest.java:13)
    ......           

異常的堆棧資訊不再是建立異常時的堆棧資訊,而是在最後一次調用fillInStackTrace() 方法時那一刻的線程的堆棧資訊。

2. 異常鍊

如果對于異常的處理有如下需求。

  1. 基于捕獲的異常抛出新的異常;
  2. 新的異常能夠儲存有原始異常的資訊。

這種異常處理場景稱為異常鍊,如下是一個簡單示例。

@Slf4j
public class ExceptionChainTest {

    @Test
    public void 測試異常鍊() {
        try {
            methodA();
        } catch (Throwable t) {
            log.error("執行方法A發生了異常", t);
        }
    }

    private void methodA() throws Throwable {
        try {
            methodB();
        } catch (Throwable t) {
            throw new Throwable("方法A抛出的異常", t);
        }
    }

    private void methodB() throws Throwable {
        try {
            methodC();
        } catch (Throwable t) {
            throw new Throwable("方法B抛出的異常", t);
        }
    }

    private void methodC() throws Throwable {
        throw new Throwable("方法C抛出的異常");
    }

}           

運作上述程式,列印如下。

12:01:47.745 [main] ERROR com.lee.learn.exception.ExceptionChainTest - 執行方法A發生了異常
java.lang.Throwable: 方法A抛出的異常
    at com.lee.learn.exception.ExceptionChainTest.methodA(ExceptionChainTest.java:22)
    at com.lee.learn.exception.ExceptionChainTest.測試異常鍊(ExceptionChainTest.java:12)
    ......
Caused by: java.lang.Throwable: 方法B抛出的異常
    at com.lee.learn.exception.ExceptionChainTest.methodB(ExceptionChainTest.java:30)
    at com.lee.learn.exception.ExceptionChainTest.methodA(ExceptionChainTest.java:20)
    .......
Caused by: java.lang.Throwable: 方法C抛出的異常
    at com.lee.learn.exception.ExceptionChainTest.methodC(ExceptionChainTest.java:35)
    at com.lee.learn.exception.ExceptionChainTest.methodB(ExceptionChainTest.java:28)
    ......           

最終在列印方法A抛出的異常時,列印出了其原始異常也就是方法B抛出的異常,進而又列印出了其原始異常也就是方法C抛出的異常。

已知Throwable有一個重要字段叫做cause,表示目前異常的原始異常,其簽名如下。

private Throwable cause = this;           

Java中的異常鍊就是依靠cause字段完成。由字段簽名可知,在異常初始狀态下,cause字段就是異常本身,此時表示沒有原始異常或者原始異常未知,然後Throwable提供了如下三個方法來設定cause,實作如下。

// 構造方法中設定cause
public Throwable(String message, Throwable cause) {
    fillInStackTrace();
    detailMessage = message;
    this.cause = cause;
}

// 構造方法中設定cause
public Throwable(Throwable cause) {
    fillInStackTrace();
    detailMessage = (cause==null ? null : cause.toString());
    this.cause = cause;
}

// 異常建立出來後也可以通過initCause()方法設定cause
public synchronized Throwable initCause(Throwable cause) {
    // 初始化原始異常隻能調用一次
    if (this.cause != this) {
        throw new IllegalStateException("Can't overwrite cause with " +
                                        Objects.toString(cause, "a null"), this);
    }
    // 不能讓異常的原始異常就是異常本身
    if (cause == this) {
        throw new IllegalArgumentException("Self-causation not permitted", this);
    }
    this.cause = cause;
    return this;
}           

當原始異常設定給cause字段後,可以通過Throwable提供的如下方法進行擷取。

public synchronized Throwable getCause() {
    return (cause == this ? null : cause);
}           

如果cause為異常本身,那麼getCause() 方法傳回空,表示沒有原始異常或者原始異常未知,如果cause不是異常本身,那麼getCause() 方法傳回原始異常。

3. 異常堆棧

每一個Throwable在建立時,都會調用fillInStackTrace() 方法來将目前線程的棧幀資訊儲存一份到stackTrace字段中作為異常堆棧資訊,然後Throwable提供了若幹方法來操作其儲存的堆棧資訊,下面分别進行說明。

Throwable提供了如下方法來擷取堆棧,實作如下。

public StackTraceElement[] getStackTrace() {
    return getOurStackTrace().clone();
}           

實際是調用到getOurStackTrace() 方法拿堆棧資訊,如下所示。

private synchronized StackTraceElement[] getOurStackTrace() {
    // 如果stackTrace未設定或者stackTrace設定為null且backtrace不為null
    // 此時基于backtrace來拿到堆棧資訊
    if (stackTrace == UNASSIGNED_STACK ||
        (stackTrace == null && backtrace != null)) {
        int depth = getStackTraceDepth();
        stackTrace = new StackTraceElement[depth];
        for (int i=0; i < depth; i++) {
            stackTrace[i] = getStackTraceElement(i);
        }
    } else if (stackTrace == null) {
        // stackTrace和backtrace同為null
        // 此時傳回空數組
        return UNASSIGNED_STACK;
    }
    return stackTrace;
}           

此外,Throwable還提供了如下三個方法來列印堆棧資訊,實作如下。

public void printStackTrace() {
    printStackTrace(System.err);
}

public void printStackTrace(PrintStream s) {
    printStackTrace(new WrappedPrintStream(s));
}

public void printStackTrace(PrintWriter s) {
    printStackTrace(new WrappedPrintWriter(s));
}           

三個方法最終都會調用到printStackTrace(PrintStreamOrWriter s) 進行列印,如下所示。

private void printStackTrace(PrintStreamOrWriter s) {
    Set<Throwable> dejaVu =
        Collections.newSetFromMap(new IdentityHashMap<Throwable, Boolean>());
    dejaVu.add(this);

    synchronized (s.lock()) {
        // 列印目前異常的堆棧資訊
        s.println(this);
        StackTraceElement[] trace = getOurStackTrace();
        for (StackTraceElement traceElement : trace) {
            s.println("\tat " + traceElement);
        }

        // 列印抑制的異常的堆棧資訊
        for (Throwable se : getSuppressed()) {
            se.printEnclosedStackTrace(s, trace, SUPPRESSED_CAPTION, "\t", dejaVu);
        }

        // 如果有原始異常則列印原始異常的堆棧資訊
        Throwable ourCause = getCause();
        if (ourCause != null) {
            ourCause.printEnclosedStackTrace(s, trace, CAUSE_CAPTION, "", dejaVu);
        }
    }
}           

最後再觀察一下stackTrace字段,簽名如下。

private StackTraceElement[] stackTrace           

即堆棧資訊是以StackTraceElement數組的形式儲存在stackTrace中,每一個StackTraceElement都表示一個棧幀元素,數組下标為0的StackTraceElement表示棧頂的棧幀元素,并且除了棧頂的棧幀元素以外,其餘棧幀元素都表示方法調用的執行點,而棧頂的棧幀元素則表示生成堆棧資訊的執行點。

StackTraceElement有如下四個字段。

// 棧幀元素的執行點所屬方法的所屬類的全限定名
private String declaringClass;
// 棧幀元素的執行點所屬方法的方法名
private String methodName;
// 棧幀元素的執行點所屬的Java檔案名
private String fileName;
// 棧幀元素的執行點的代碼行号
private int lineNumber;           

在StackTraceElement的toString() 方法中會将上述四個字段拼接成我們常見的異常堆棧資訊,如下所示。

public String toString() {
    return getClassName() + "." + methodName +
        (isNativeMethod() ? "(Native Method)" :
         (fileName != null && lineNumber >= 0 ?
          "(" + fileName + ":" + lineNumber + ")" :
          (fileName != null ?  "("+fileName+")" : "(Unknown Source)")));
}           

那麼最終會有如下四種列印形式。

// 正常情況
com.lee.MyTest.method(MyTest.java:25)
// 行号不可用
com.lee.MyTest.method(MyTest.java)
// 檔案名和行号均不可用
com.lee.MyTest.method(Unknown Source)
// 檔案名和行号均不可用
// 并且執行點所屬方法是本地方法
com.lee.MyTest.method(Native Method)           

4. 異常性能研究

現在已經知道,如果建立一個Throwable,就會調用到fillInStackTrace() 方法來擷取目前線程的棧幀資訊,這一爬棧操作是異常整個生命周期中十分耗時的一個環節。

下面做一個測試,循環一百萬次建立Throwable,并統計執行耗時,測試代碼如下所示。

private static final int BATCH = 100 * 10000;

@Test
public void 循環一百萬次建立Throwable() {
    long beginTime = System.currentTimeMillis();
    for (int i = 0; i < BATCH; i++) {
        Throwable throwable = new Throwable();
    }
    long endTime = System.currentTimeMillis();
    System.out.println("執行耗時:" + (endTime - beginTime));
}           

運作測試程式,列印如下。

執行耗時:1573           

現在實作一個Throwable的子類,也就是自定義一個異常類,同時重寫Throwable的fillInStackTrace() 方法,如下所示。

public class PerformanceThrowable extends Throwable {

    public PerformanceThrowable() {
        super();
    }

    public PerformanceThrowable(String message) {
        super(message);
    }

    @Override
    public synchronized Throwable fillInStackTrace() {
        return this;
    }

}           

在重寫的fillInStackTrace() 方法中直接傳回異常自己,而不再去擷取堆棧資訊,此時再添加如下測試程式。

@Test
public void 循環一百萬次建立PerformanceThrowable() {
    long beginTime = System.currentTimeMillis();
    for (int i = 0; i < BATCH; i++) {
        Throwable throwable = new PerformanceThrowable();
    }
    long endTime = System.currentTimeMillis();
    System.out.println("執行耗時:" + (endTime - beginTime));
}           

運作測試程式,列印如下。

執行耗時:41           

可見性能提升了幾十倍,但是缺點就是沒有堆棧資訊,現在再看如下一個測試程式。

@Test
public void 列印PerformanceThrowable() {
    Throwable throwable = new PerformanceThrowable("我是沒有堆棧的異常");
    log.error("列印沒有堆棧的異常", throwable);
}           

運作測試程式,列印如下。

13:29:30.430 [main] ERROR com.lee.learn.exception.PerformanceTest - 列印沒有堆棧的異常
com.lee.learn.exception.PerformanceThrowable: 我是沒有堆棧的異常           

可見異常列印時,沒有堆棧資訊。

PerformanceThrowable是通過繼承Throwable并重寫了fillInStackTrace() 方法來放棄儲存堆棧以提升性能,在JDK1.7的Throwable中提供了如下一個protected構造方法來更優雅的設定是否儲存堆棧,如下所示。

protected Throwable(String message, Throwable cause,
                    boolean enableSuppression,
                    boolean writableStackTrace) {
    if (writableStackTrace) {
        fillInStackTrace();
    } else {
	// 設定stackTrace為null則不會再去擷取堆棧資訊
        stackTrace = null;
    }
    detailMessage = message;
    this.cause = cause;
    if (!enableSuppression) {
        suppressedExceptions = null;
    }
}           

現在基于上述構造方法實作一個不要堆棧資訊的異常類,如下所示。

public class ElegantPerformanceThrowable extends Throwable {

    public ElegantPerformanceThrowable(String message, Throwable cause,
                                       boolean enableSuppression,
                                       boolean writableStackTrace) {
        super(message, cause, enableSuppression, writableStackTrace);
    }

}           

測試程式如下所示。

@Test
public void 循環一百萬次建立ElegantPerformanceThrowable() {
    long beginTime = System.currentTimeMillis();
    for (int i = 0; i < BATCH; i++) {
        Throwable throwable = new ElegantPerformanceThrowable(
            null, null, false, false);
    }
    long endTime = System.currentTimeMillis();
    System.out.println("執行耗時:" + (endTime - beginTime));
}           

運作測試程式,列印如下。

執行耗時:27           

那麼現在知道了,建立異常時去擷取堆棧資訊是有比較重的性能開銷的,同時也有一些手段可以避免這個性能開銷,但是代價就是會丢失異常發生時的堆棧資訊,那麼這其實就是一個取舍問題,對于這個問題,可以依據是否需要異常堆棧資訊進行判斷,例如基于異常做流程控制,那麼堆棧資訊肯定是不需要的,但是通常,堆棧資訊對于異常來說是十分重要的,不到萬不得已不要輕易丢棄,如果真的因為異常影響到了程式性能,考慮一下是否是在代碼某個地方建立了大量的異常。

最後,正是由于異常建立時去擷取堆棧資訊帶來的巨大性能開銷,Hotspot對部分運作時異常提供了Fast Throw機制,讓這部分運作時異常被抛出時十分絲滑,這點在本文後續會詳細進行說明。

二. 檢查異常

1. 概念說明

檢查異常,也就是Checked Exception,在編譯時編譯器會檢查程式對檢查異常的處理,如果沒有正确處理檢查異常,那麼是無法通過編譯的。

  • 哪些異常是校驗異常
Exception以及Exception的非RuntimeException子類稱為檢查異常
  • 如何正确處理校驗異常才能通過編譯器檢查
捕獲校驗異常 抛出校驗異常

2. 使用示例

現在定義一個Exception的子類,如下所示。

public class CheckedException extends Exception {

    public CheckedException() {
        super();
    }

    public CheckedException(String message) {
        super(message);
    }

    public CheckedException(String message, Throwable cause) {
        super(message, cause);
    }

    public CheckedException(Throwable cause) {
        super(cause);
    }

    protected CheckedException(String message, Throwable cause, 
                               boolean enableSuppression, boolean writableStackTrace) {
        super(message, cause, enableSuppression, writableStackTrace);
    }
    
}           

上述異常滿足是Exception的子類且不是RuntimeException的子類,是以上述異常是一個校驗異常。

現在有如下一個方法,實作如下。

public class CheckedExceptionTest {

    private void methodB() {
        throw new CheckedException();
    }

}           

在methodB() 方法中發生了異常,此時執行如下指令。

javac CheckedExceptionTest.java CheckedException.java           

編譯時會列印如下錯誤資訊。

CheckedExceptionTest.java:6: 錯誤: 未報告的異常錯誤CheckedException; 必須對其進行捕獲或聲明以便抛出           

現在更改CheckedExceptionTest的實作,如下所示。

@Slf4j
public class CheckedExceptionTest {

    @Test
    public void 測試校驗異常的捕獲和抛出() {
        try {
            methodA();
        } catch (CheckedException e) {
            log.error("捕獲校驗異常", e);
        } finally {
            log.info("Finally代碼塊執行");
        }
    }

    private void methodA() throws CheckedException {
        methodB();
    }

    private void methodB() throws CheckedException {
        throw new CheckedException("我是校驗異常");
    }

}           

運作測試程式,列印如下。

13:37:45.428 [main] ERROR com.lee.learn.exception.CheckedExceptionTest - 捕獲校驗異常
com.lee.learn.exception.CheckedException: 我是校驗異常
    at com.lee.learn.exception.CheckedExceptionTest.methodB(CheckedExceptionTest.java:25)
    at com.lee.learn.exception.CheckedExceptionTest.methodA(CheckedExceptionTest.java:21)
    at com.lee.learn.exception.CheckedExceptionTest.測試校驗異常的捕獲和抛出(CheckedExceptionTest.java:12)
    ......
13:37:45.430 [main] INFO com.lee.learn.exception.CheckedExceptionTest - Finally代碼塊執行           

也就是某個方法中産生了校驗異常時,假如要抛出這個校驗異常,則需要在方法簽名上通過關鍵字throws來聲明抛出這個校驗異常,假如目前能夠處理這個校驗異常,則通過try-catch語句來捕獲這個校驗異常。

3. throw和throws

throw和throws相信大家是常用,并且也知道throw用于在方法中往外抛出異常,throws用于聲明一個方法往外抛出的異常,但是再仔細想一下,這兩個指令的運作機制是什麼呢,為什麼可以将一個異常從方法中往外抛出,這裡就得看一下throw和throws相關的位元組碼了。

首先編譯上一小節中的CheckedExceptionTest.java檔案,得到CheckedExceptionTest.class檔案,然後執行javap -v -p CheckedExceptionTest,在反編譯class檔案輸出的方法表資訊中,找到methodB() 方法的資訊,如下所示。

private void methodB() throws com.lee.learn.exception.CheckedException;
    descriptor: ()V
    flags: ACC_PRIVATE
    Code:
        stack=3, locals=1, args_size=1
            0: new           #6		// #6表示CheckedException
            3: dup
            4: ldc           #10	// #10表示"我是校驗異常"這個字元串
            6: invokespecial #11	// #11表示CheckedException的<init>方法
            9: athrow
        LineNumberTable:
            line 25: 0
        LocalVariableTable:
            Start  Length  Slot  Name   Signature
                0      10     0  this   Lcom/lee/learn/exception/CheckedExceptionTest;
    Exceptions:
        throws com.lee.learn.exception.CheckedException           

簡單的分析一下methodB() 方法的位元組碼指令。

  1. new #6。生成CheckedException的未初始化的對象,在堆上配置設定記憶體,并将該對象的引用壓入操作數棧;
位元組碼層面學習Java異常底層原理
  1. dup。将操作數棧棧頂的CheckedException對象的引用複制一份并壓入操作數棧,此時操作數棧的棧頂和次棧頂都是CheckedException對象的引用;
位元組碼層面學習Java異常底層原理
  1. ldc #10。将我是校驗異常這個字元串從常量池中擷取出來并壓入操作數棧,此時操作數棧的棧頂和次棧頂分别是我是校驗異常字元串和CheckedException對象的引用;
位元組碼層面學習Java異常底層原理
  1. invokespecial #11。執行CheckedException的<init>方法進行CheckedException對象的初始化,<init>方法有兩個參數,第一個參數是一個字元串,這裡傳入操作數棧棧頂的我是校驗異常,第二個參數是this,這裡傳入操作數棧棧頂的CheckedException對象的引用;
位元組碼層面學習Java異常底層原理
  1. athrow。将操作數棧棧頂的内容作為異常抛出,這裡會将CheckedException對象的引用抛出。

也就是最終建立出來的CheckedException異常對象是通過athrow指令由操作數棧棧頂抛出,然後,再看methodB() 反編譯位元組碼指令中的Exceptions的内容,表明該方法通過throws關鍵字聲明抛出了com.lee.learn.exception.CheckedException異常。

4. try-catch-finally

try-catch-finally相信大家也是常用,用于捕獲代碼塊中抛出的異常并執行相應的操作。現在繼續結合位元組碼指令,探究一下try-catch-finally的作用機制。緊接第3小節,在反編譯class檔案輸出的方法表資訊中,找到測試校驗異常的捕獲和抛出() 方法的資訊,部分内容如下所示。

public void 測試校驗異常的捕獲和抛出();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
        stack=3, locals=3, args_size=1
            0: aload_0
            1: invokespecial #2
            4: getstatic     #3
            7: ldc           #4
            9: invokeinterface #5,  2
            14: goto          55
            17: astore_1
            18: getstatic     #3
            21: ldc           #7
            23: aload_1
            24: invokeinterface #8,  3
            29: getstatic     #3
            32: ldc           #4
            34: invokeinterface #5,  2
            39: goto          55
            42: astore_2
            43: getstatic     #3
            46: ldc           #4
            48: invokeinterface #5,  2
            53: aload_2
            54: athrow
            55: return
        Exception table:
            from    to  target type
               0     4    17   Class com/lee/learn/exception/CheckedException
               0     4    42   any
              17    29    42   any
        LineNumberTable:
            line 12: 0
            line 16: 4
            line 17: 14
            line 13: 17
            line 14: 18
            line 16: 29
            line 17: 39
            line 16: 42
            line 17: 53
            line 18: 55           

關注到相較于methodB() 方法,測試校驗異常的捕獲和抛出() 方法多了一個叫做Exception table的東西,這裡就稱之為異常表。異常表的作用其實就是定義一段位元組碼(由from和to表示)執行時如果發生了指定類型的異常(由type指定)則跳轉到目标行位元組碼繼續執行,以上面異常表的第一行進行舉例說明,如下所示。

  1. from為0,to為4表示第0到3行位元組碼(這裡的to是Exclusive的),實際就是第0和第1行位元組碼,也就是對應代碼的第12行調用methodA() 方法;
  2. type為Class com/lee/learn/exception/CheckedException,target為17表示調用methodA() 的過程中如果發生了CheckedException異常,則跳轉到第17行位元組碼繼續執行,而對照行号表(LineNumberTable)可知17行位元組碼對應代碼中的第13行,也就是通過catch關鍵字捕獲CheckedException這一行代碼。

在異常表的第二行,表示如果調用methodA() 方法時抛出了任何CheckedException之外的異常,則跳轉到第42行位元組碼繼續執行,也就是跳轉到代碼中的finally代碼塊繼續執行。

在異常表的第三行,表示如果在執行catch代碼塊時抛出了任何異常,則跳轉到第42行位元組碼繼續執行,也就是跳轉到代碼中的finally代碼塊繼續執行。

那麼try-catch-finally作用機制主要是展現在位元組碼層面,具體是展現在位元組碼的異常表中,異常表聲明了一段位元組碼執行時如果發生特定異常則跳轉到某行位元組碼繼續執行,并且無論是哪種異常,也無論異常是否被聲明,最終都會跳轉到finally代碼塊對應的位元組碼。

5. 異常屏蔽

對于try-catch-finally來說,無論try代碼塊和catch代碼塊是正常結束還是非正常結束,finally代碼塊都會執行,那麼現在考慮這樣一種場景,那就是在try代碼塊,catch代碼塊和finally代碼塊中都抛出異常,那麼最終抛出的異常會是哪個代碼塊抛出的異常呢,下面以一個執行個體進行說明。

測試代碼如下所示。

public class ExceptionShieldTest {

    @Test
    public void 測試異常屏蔽() {
        try {
            exceptionTest();
        } catch (Exception e) {
            System.out.println(e.getMessage());
        }
    }

    private void exceptionTest() throws ExceptionB, ExceptionC {
        try {
            throwExceptionA();
        } catch (ExceptionA e) {
            throwExceptionB();
        } finally {
            throwExceptionC();
        }
    }

    private void throwExceptionA() throws ExceptionA {
        throw new ExceptionA();
    }

    private void throwExceptionB() throws ExceptionB {
        throw new ExceptionB();
    }

    private void throwExceptionC() throws ExceptionC {
        throw new ExceptionC();
    }

    private static class ExceptionA extends Exception {
        public ExceptionA() {
            super("我是異常A");
        }
    }

    private static class ExceptionB extends Exception {
        public ExceptionB() {
            super("我是異常B");
        }
    }

    private static class ExceptionC extends Exception {
        public ExceptionC() {
            super("我是異常C");
        }
    }

}           

運作測試程式,列印如下。

我是異常C           

測試程式中,exceptionTest() 方法的catch代碼塊會抛出ExceptionB,finally代碼塊會抛出ExceptionC,但是其實最終隻會抛出ExceptionC,而ExceptionB會被屏蔽,這稱作異常屏蔽,同樣,從位元組碼入手觀察一下異常屏蔽的發生,exceptionTest() 對應的位元組碼指令如下所示。

private void exceptionTest() throws com.lee.learn.exception.ExceptionShieldTest$ExceptionB, com.lee.learn.exception.ExceptionShieldTest$ExceptionC;
    descriptor: ()V
    flags: ACC_PRIVATE
    Code:
        stack=1, locals=3, args_size=1
            0: aload_0
            1: invokespecial #7		// 執行throwExceptionA()
            4: aload_0
            5: invokespecial #8		// 執行throwExceptionC()
            8: goto          30
            11: astore_1
            12: aload_0
            13: invokespecial #10	// 執行throwExceptionB()
            16: aload_0
            17: invokespecial #8	// 執行throwExceptionC()
            20: goto          30
            23: astore_2
            24: aload_0
            25: invokespecial #8	// 執行throwExceptionC()
            28: aload_2
            29: athrow
            30: return
        Exception table:
            from    to  target type
               0     4    11   Class com/lee/learn/exception/ExceptionShieldTest$ExceptionA
               0     4    23   any
              11    16    23   any
        LineNumberTable:
            line 18: 0
            line 22: 4
            line 23: 8
            line 19: 11
            line 20: 12
            line 22: 16
            line 23: 20
            line 22: 23
            line 23: 28
            line 24: 30
        LocalVariableTable:
            Start  Length  Slot  Name   Signature
               12       4     1     e   Lcom/lee/learn/exception/ExceptionShieldTest$ExceptionA;
                0      31     0  this   Lcom/lee/learn/exception/ExceptionShieldTest;           

下面結合上述位元組碼指令以及圖示,說明一下異常屏蔽的發生。

剛開始時,執行的位元組碼指令如下所示。

0: aload_0			// 複制局部變量表第0個槽位的資料this到操作數棧棧頂
1: invokespecial #7		// 将操作數棧棧頂資料彈出,作為參數執行throwExceptionA()方法           

上述指令執行完畢後,由于執行throwExceptionA() 方法會抛出ExceptionA,是以此時局部變量表和操作數棧如下所示。

位元組碼層面學習Java異常底層原理

根據異常表的定義,發生ExceptionA時需要跳轉到第11行位元組碼指令繼續執行,執行的位元組碼指令如下所示。

11: astore_1			// 将操作數棧棧頂的資料彈出并存放到局部變量表的第1個槽位                   

此時局部變量表和操作數棧如下所示。

位元組碼層面學習Java異常底層原理

繼續執行,執行的位元組碼指令如下所示。

12: aload_0			// 複制局部變量表第0個槽位的資料this到操作數棧棧頂
13: invokespecial #10		// 将操作數棧棧頂資料彈出,作為參數執行throwExceptionB()方法           

上述指令執行完畢後,由于執行throwExceptionB() 方法會抛出ExceptionB,是以此時局部變量表和操作數棧如下所示。

位元組碼層面學習Java異常底層原理

根據異常表的定義,發生ExceptionB時需要跳轉到第23行位元組碼指令繼續執行,執行的位元組碼指令如下所示。

23: astore_2	// 将操作數棧棧頂的資料彈出并存放到局部變量表的第2個槽位           

此時局部變量表和操作數棧如下所示。

位元組碼層面學習Java異常底層原理

繼續執行,執行的位元組碼指令如下所示。

24: aload_0			// 複制局部變量表第0個槽位的資料this到操作數棧棧頂
25: invokespecial #8	        // 将操作數棧棧頂資料彈出,作為參數執行throwExceptionC()方法           

由于第25行位元組碼指令執行時抛出了ExceptionC,但是異常表沒有定義跳轉位置,這種情況下會導緻目前方法也就是exceptionTest() 方法的棧幀強制彈出,操作數棧棧頂的異常往上層抛出,也就是往上層抛出ExceptionC。

現在假設第25行位元組碼指令執行時不發生異常,那麼會繼續執行如下位元組碼指令。

28: aload_2		// 将局部變量表第2個槽位的變量(也就是ExceptionB)複制到棧頂
29: athrow		// 将棧頂的異常抛出,也就是抛出ExceptionB
30: return		// 方法結束并傳回           

也就是說,exceptionTest() 方法正常應該抛出ExceptionB,但是由于finally代碼塊中抛出了ExceptionC,最終exceptionTest() 方法抛出的異常是ExceptionC,而ExceptionB就發生了異常屏蔽。

6. 異常抑制

發生異常屏蔽時,會導緻被屏蔽的異常資訊丢失,要避免異常屏蔽的發生,可以将第5小節中的測試代碼進行如下修改。

public class ExceptionSuppressionTest {

    @Test
    public void 測試異常抑制() {
        try {
            exceptionTest();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    private void exceptionTest() throws ExceptionB, ExceptionC {
        ExceptionB finalException = null;
        try {
            throwExceptionA();
        } catch (ExceptionA exa) {
            try {
                throwExceptionB();
            } catch (ExceptionB exb) {
                finalException = exb;
                throw exb;
            }
        } finally {
            try {
                throwExceptionC();
            } catch (ExceptionC exc) {
                if (finalException != null) {
                    finalException.addSuppressed(exc);
                } else {
                    throw exc;
                }
            }
        }
    }

    private void throwExceptionA() throws ExceptionA {
        throw new ExceptionA();
    }

    private void throwExceptionB() throws ExceptionB {
        throw new ExceptionB();
    }

    private void throwExceptionC() throws ExceptionC {
        throw new ExceptionC();
    }

    private static class ExceptionA extends Exception {
        public ExceptionA() {
            super("我是異常A");
        }
    }

    private static class ExceptionB extends Exception {
        public ExceptionB() {
            super("我是異常B");
        }
    }

    private static class ExceptionC extends Exception {
        public ExceptionC() {
            super("我是異常C");
        }
    }

}           

運作測試程式,列印如下。

com.lee.learn.exception.ExceptionSuppressionTest$ExceptionB: 我是異常B
    at com.lee.learn.exception.ExceptionSuppressionTest.throwExceptionB(ExceptionSuppressionTest.java:45)
    at com.lee.learn.exception.ExceptionSuppressionTest.exceptionTest(ExceptionSuppressionTest.java:22)
    at com.lee.learn.exception.ExceptionSuppressionTest.測試異常抑制(ExceptionSuppressionTest.java:10)
    ......
    Suppressed: com.lee.learn.exception.ExceptionSuppressionTest$ExceptionC: 我是異常C
            at com.lee.learn.exception.ExceptionSuppressionTest.throwExceptionC(ExceptionSuppressionTest.java:49)
            at com.lee.learn.exception.ExceptionSuppressionTest.exceptionTest(ExceptionSuppressionTest.java:29)
            ......           

上述代碼以及運作結果表明,ExceptionC作為抑制異常添加到了ExceptionB中,可以了解為ExceptionB抑制了ExceptionC,但是與異常屏蔽不同的時,異常抑制不會丢失被抑制的異常的資訊,例如上述例子中列印ExceptionB的堆棧資訊時,會一并将ExceptionC的資訊列印出來。

要實作異常抑制,需要使用到Throwable在1.7版本提供的addSuppressed(Throwable t) 方法,該方法的入參會作為被抑制異常添加到原異常的suppressedExceptions字段中,而suppressedExceptions實際就是一個Throwable的集合,如下所示。

private List<Throwable> suppressedExceptions = SUPPRESSED_SENTINEL;           

是以一個異常可以添加多個抑制異常。

7. try-with-resources

第6小節中的測試代碼基于異常抑制解決了異常屏蔽帶來的丢失異常資訊的問題,但是代碼書寫起來較為繁瑣,是以JDK1.7版本提供了try-with-resources文法糖來解決這個問題。

先看如下例子,是沒有使用try-with-resources的示例。

public class TryWithResourcesTest {

    @Test
    public void 不使用文法糖的寫法() {
        try {
            exceptionNonSugarTest();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    private void exceptionNonSugarTest() throws UpdateException, CloseException {
        Connection connection = null;
        UpdateException finalException = null;
        try {
            connection = new Connection();
            connection.update();
        } catch (UpdateException e) {
            finalException = e;
            throw e;
        } finally {
            if (connection != null) {
                try {
                    connection.close();
                } catch (CloseException e) {
                    if (finalException != null) {
                        finalException.addSuppressed(e);
                    } else {
                        throw e;
                    }
                }
            }
        }
    }

    private static class Connection implements AutoCloseable {
        public void update() throws UpdateException {
            throw new UpdateException();
        }

        @Override
        public void close() throws CloseException {
            throw new CloseException();
        }
    }

    private static class UpdateException extends Exception {
        public UpdateException() {
            super("我是更新異常");
        }
    }

    private static class CloseException extends Exception {
        public CloseException() {
            super("我是關閉異常");
        }
    }

}           

運作測試程式,列印如下。

com.lee.learn.exception.TryWithResourcesTest$UpdateException: 我是更新異常
at com.lee.learn.exception.TryWithResourcesTest$Connection.update(TryWithResourcesTest.java:42)
at com.lee.learn.exception.TryWithResourcesTest.exceptionNonSugarTest(TryWithResourcesTest.java:21)
at com.lee.learn.exception.TryWithResourcesTest.不使用文法糖的寫法(TryWithResourcesTest.java:10)
......
Suppressed: com.lee.learn.exception.TryWithResourcesTest$CloseException: 我是關閉異常
at com.lee.learn.exception.TryWithResourcesTest$Connection.close(TryWithResourcesTest.java:47)
at com.lee.learn.exception.TryWithResourcesTest.exceptionNonSugarTest(TryWithResourcesTest.java:28)
......           

現在基于try-with-resources對上述測試程式進行改進,如下所示。

@Test
public void 使用文法糖的寫法() {
    try {
        exceptionSugarTest();
    } catch (Exception e) {
        e.printStackTrace();
    }
}

private void exceptionSugarTest() throws UpdateException, CloseException {
    try (Connection connection = new Connection()) {
        connection.update();
    }
}           

重新運作測試程式,列印如下。

com.lee.learn.exception.TryWithResourcesTest$UpdateException: 我是更新異常
    at com.lee.learn.exception.TryWithResourcesTest$Connection.update(TryWithResourcesTest.java:57)
    at com.lee.learn.exception.TryWithResourcesTest.exceptionSugarTest(TryWithResourcesTest.java:51)
    at com.lee.learn.exception.TryWithResourcesTest.使用文法糖的寫法(TryWithResourcesTest.java:43)
    ......
    Suppressed: com.lee.learn.exception.TryWithResourcesTest$CloseException: 我是關閉異常
            at com.lee.learn.exception.TryWithResourcesTest$Connection.close(TryWithResourcesTest.java:62)
            at com.lee.learn.exception.TryWithResourcesTest.exceptionSugarTest(TryWithResourcesTest.java:52)
            ......           

代碼得到了極大的精簡,同時執行結果也是符合預期,現在反編譯TryWithResourcesTest的class檔案,看一下exceptionSugarTest() 方法反編譯後的實作,如下所示。

private void exceptionSugarTest() throws TryWithResourcesTest.UpdateException, TryWithResourcesTest.CloseException {
    TryWithResourcesTest.Connection connection = new TryWithResourcesTest.Connection();
    Throwable var2 = null;
    try {
        connection.update();
    } catch (Throwable var11) {
        var2 = var11;
        throw var11;
    } finally {
        if (connection != null) {
            if (var2 != null) {
                try {
                    connection.close();
                } catch (Throwable var10) {
                    var2.addSuppressed(var10);
                }
            } else {
                connection.close();
            }
        }
    }
}           

是以try-with-resources本質上就是幫我們做了如下兩件事情。

  1. 自動調用AutoCloseable接口的close() 方法來關閉資源;
  2. 基于Throwable#addSuppressed方法實作異常抑制以防止丢失異常資訊。

三. RuntimeException

1. 概念說明

RuntimeException,即運作時異常,是JVM正常運作時可以抛出的異常。所有RuntimeException以及RuntimeException的子類都屬于未檢查異常,但是未檢查異常不全是RuntimeException,因為Error及其子類也屬于未檢查異常。

運作時異常具有未檢查異常的所有屬性。

  1. 編譯器不會對運作時異常的處理做校驗;
  2. 運作時異常不需要在方法上通過throws關鍵字聲明抛出;
  3. 運作時異常通常認為是通過優化代碼邏輯可以避免的;
  4. 運作時異常通常不需要被try-catch。

2. NullPointerException

NullPointerException,即空指針異常。如下情況會抛出空指針異常。

  1. 調用null對象的方法;
  2. 使用null對象的字段;
  3. 擷取null數組的長度;
  4. 擷取null數組的元素;
  5. 通過throw關鍵字抛出null異常。

示範一下第5點,測試程式如下所示。

public class NullPointerExceptionTest {

    @Test
    public void 測試抛出空() {
        execute();
    }

    private void execute() {
        throw null;
    }
    
}           

運作測試程式,列印如下。

java.lang.NullPointerException
    at com.lee.learn.exception.NullPointerExceptionTest.execute(NullPointerExceptionTest.java:13)
    at com.lee.learn.exception.NullPointerExceptionTest.測試抛出空(NullPointerExceptionTest.java:9)           

那麼有些時候在判空時,會看到如下兩種寫法。

if (obj == null) {
    ......
}

if (null == obj) {
    ......
}           

上述兩種寫法,對于防止空指針,是沒有說法的。真正的說法見如下示例。

// 由于粗心,少寫了一個等于号,竟然編譯通過了
Boolean booleanObj1 = false;
if (booleanObj1 = null) {
    ......
}

// 由于粗心,少寫了一個等于号,是編譯不過的
Boolean booleanObj2 = true;
if (null = booleanObj1) {
    ......
}           

3. IndexOutOfBoundsException

IndexOutOfBoundsException,即索引越界異常,相信經常刷題的小夥伴不會陌生。

在java.lang包下定義了兩個IndexOutOfBoundsException的子類:ArrayIndexOutOfBoundsException和StringIndexOutOfBoundsException,分别表示數組索引越界異常和字元串索引越界,下面分别示範這兩種異常。

public class IndexOutOfBoundsExceptionTest {

    @Test
    public void 數組索引越界異常() {
        int[] nums = new int[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
        System.out.println(nums[10]);
    }

    @Test
    public void 字元串索引越界異常() {
        String str = new String("AAAAA");
        System.out.println(str.charAt(5));
    }

}           

運作測試程式,分别列印如下。

java.lang.ArrayIndexOutOfBoundsException: 10
    at com.lee.learn.exception.IndexOutOfBoundsExceptionTest.數組索引越界異常(IndexOutOfBoundsExceptionTest.java:10)           
java.lang.StringIndexOutOfBoundsException: String index out of range: 5
    at java.lang.String.charAt(String.java:658)
    at com.lee.learn.exception.IndexOutOfBoundsExceptionTest.字元串索引越界異常(IndexOutOfBoundsExceptionTest.java:16)           

4. ClassCastException

ClassCastException,即類型強轉異常。該異常均發生于試圖将一個對象類型強轉為非目前類型的超類,如下是一個示例。

public class ClassCastExceptionTest {

    @Test
    public void 父類強轉為子類() {
        People people = new People();
        System.out.println((Chinese) people);
    }

    @Test
    public void 子類強轉為父類() {
        Chinese chinese = new Chinese();
        System.out.println((People) chinese);
    }

    @Test
    public void 子類之間強轉() {
        People chinese = new Chinese();
        System.out.println((American) chinese);
    }

    private static class People {}
    private static class Chinese extends People {}
    private static class American extends People {}

}           

上述中,父類強轉為子類() 和子類之間強轉() 兩個測試方法運作時,均會報強轉錯誤,報錯資訊如下所示。

java.lang.ClassCastException: com.lee.learn.exception.ClassCastExceptionTest$People cannot be cast to com.lee.learn.exception.ClassCastExceptionTest$Chinese

java.lang.ClassCastException: com.lee.learn.exception.ClassCastExceptionTest$Chinese cannot be cast to com.lee.learn.exception.ClassCastExceptionTest$American           

5. ArithmeticException

ArithmeticException,即算術異常。最常見的抛出場景就是”整數除以整數零“,而如果除數和被除數中隻要有一個是浮點數,那麼都不會抛出ArithmeticException,但計算結果會是Infinity,表示無窮,示例如下。

public class ArithmeticExceptionTest {

    @Test
    public void 整數除以整零() {
        System.out.println(10 / 0);
    }

    @Test
    public void 浮點數除以零() {
        System.out.println(10.0f / 0);
    }

}           

運作測試程式,分别列印如下。

java.lang.ArithmeticException: / by zero
    at com.lee.learn.exception.ArithmeticExceptionTest.整數除以整零(ArithmeticExceptionTest.java:9)           
Infinity           

6. ArrayStoreException

ArrayStoreException,即數組存儲異常。發生于試圖将錯誤類型的對象存儲到數組中,示例如下所示。

public class ArrayStoreExceptionTest {

    @Test
    public void 測試數組存儲異常() {
        People woman = new Woman();

        People[] men = new Man[10];
        men[0] = woman;
    }

    private static class People {}
    private static class Man extends People {}
    private static class Woman extends People {}

}           

運作測試程式,列印如下。

java.lang.ArrayStoreException: com.lee.learn.exception.ArrayStoreExceptionTest$Woman
    at com.lee.learn.exception.ArrayStoreExceptionTest.測試數組存儲異常(ArrayStoreExceptionTest.java:12)           

7. Fast Thrown機制

那麼多運作時異常,為什麼唯獨介紹上述的五種呢,是因為接下來要分析的Fast Thrown機制。

通常,運作時異常不需要被顯示聲明抛出,也不建議捕獲運作時異常,那麼一個運作時異常如果發生了且沒有被捕獲,那麼危害是很大的,是以運作時異常就不應該讓其産生,會産生運作時異常的地方,就應該優化代碼邏輯。

上述介紹的五種運作時異常,通常均由JVM抛出,而一旦發生由JVM抛出運作時異常的情況,表明代碼存在缺陷,存在缺陷的代碼線上上運作時,可以假定缺陷代碼恒會抛出運作時異常,或者抛出運作時異常的機率很高,而又知道,建立異常會去擷取目前線程的堆棧資訊,擷取堆棧資訊需要爬棧,而這一操作十分消耗性能,是以針對這種情況,JVM提供了一種叫做Fast Thrown的機制來避免因為生成大量運作時異常而造成的性能損耗。

Fast Thrown機制,簡單來說,就是一旦當JVM識别到某一類特定運作時異常發生多次,此時就會直接抛出預先建立好的沒有堆棧的異常對象并抛出。

Fast Thrown機制的好處有兩點。

  1. 提升性能。因為不需要擷取堆棧資訊,是以異常的抛出很快,幾乎不影響性能;
  2. 節約記憶體。因為抛出的異常對象都是預先建立好的同一個異常對象,幾乎不消耗堆記憶體空間。

Fast Thrown機制的缺點也很明顯,如下所示。

  1. 拿不到異常的堆棧資訊。之是以抛出異常快,正是由于沒有去擷取堆棧資訊,是以列印這些Fast Thrown的異常時,隻有異常類型,沒有堆棧。

上面還提及,隻會有特定類型的運作時異常才會進行Fast Thrown,其實這幾種特定的運作時異常就是本節介紹的五種運作時異常,如下所示。

  1. NullPointerException;
  2. ArrayIndexOutOfBoundsException;
  3. ClassCastException;
  4. ArithmeticException;
  5. ArrayStoreException。

如果不需要Fast Thrown機制,可以通過如下JVM參數進行關閉。

-XX:-OmitStackTraceInFastThrow           

上述是關于Fast Thrown的基礎,下面稍微進階一點了解其機制原理。

我們的Java代碼經過編譯後,得到class檔案,然後以二進制流的方式被加載到JVM中,像我們編寫的方法,最終呈現在class檔案中的形态其實就是位元組碼指令,當執行方法時,是由JVM的執行引擎來完成,而執行引擎包含解釋器(Interpreter)和即時編譯器(JIT Compiler),簡單說明如下。

  • 解釋器:根據Java虛拟機規範對位元組碼指令進行逐行解釋的方式來執行,即将每條位元組碼指令轉換為對應平台的機器碼(機器指令碼)運作,通過解釋器,JVM也是能夠認識位元組碼指令的;
  • 即時編譯器:将方法直接編譯成對應平台的機器碼,通常是針對熱點方法進行即時編譯,又稱為後端運作期編譯,即位元組碼到機器碼的編譯(前端編譯就是源碼到位元組碼的編譯)。

在JVM實際執行時,執行引擎是解釋器和即時編譯器配合工作的,這麼做的原因有如下兩點。

  1. 即時編譯器需要預熱。因為即時編譯器工作的前提是被識别為熱點代碼,是以這個識别過程也就是預熱過程,是需要一定時間的,是以當程式剛啟動的這一段時間,通常是解釋器在工作,這樣可以節約等待即時編譯器預熱的時間;
  2. 罕見陷阱(Uncommon Trap)發生時的後備方案。

當即時編譯器識别某一個方法為熱點代碼時,就會進行即時編譯優化,這種即時編譯優化是以性能提升為主要目标的,也就是根據統計資訊和先驗規則來得到方法的最有可能的執行邏輯。但是如果即時編譯優化後的方法中出現了罕見陷阱(為什麼會出現下面會分析),那麼此時會進行去優化操作,也就是回退為解釋執行的狀态(解釋執行的方法棧幀替換即時編譯的方法棧幀),然後再根據統計資訊和先驗規則來得到方法的最有可能的執行邏輯并重新進行即時編譯優化。

假如我們在程式中有如下一行代碼。

param.id           

其中param是一個對象,那麼JVM在實際執行上述的代碼時,為我們做了大量的防禦性程式設計,上面一行代碼,對應JVM執行的僞碼如下所示。

if (param != null) {
    return param.id;
} else {
    throw new NullPointException();
}           

那麼當多次執行并且JVM識别到param幾乎不為null時,會将上述僞碼優化如下。

try {
    return param.id;
} catch (segment_fault) {
    uncommon_trap()
}           

優化掉了對param的非空判斷,但是如果一旦param為null,就會導緻罕見陷阱的發生,此時會進行去優化,其實去優化也沒什麼問題,這是一個很正常的操作,但是如果有大量的param為空的情況,就可能會出現頻繁的去優化,這對性能是巨大的損耗。

是以當因為param為空導緻罕見陷阱的發生進而導緻去優化的次數達到一定統計量後,此時JVM會使用預構造的空指針異常來優化異常抛出的邏輯,優化後的僞碼如下所示。

try {
    return param.id;
} catch (segment_fault) {
    throw NullPointerException_instance;
}           

如此一來,當param不為空時,直接傳回param的id字段,當param為空時,直接抛出預構造的空指針異常。這個過程,就是Fast Thrown機制。

下面最後結合部分源碼看一下Fast Thrown機制的生效過程。

// 辨別目前抛出的異常是否是熱點異常
// 被識别為熱點異常是Fast Thrown機制的大前提
bool treat_throw_as_hot = false;
ciMethodData* md = method()->method_data();

if (ProfileTraps) {
    // 這裡的reason就是去優化的原因
    // 比如空值校驗優化導緻的去優化
    // 在這裡判斷目前reason導緻的去優化是否達到統計數量
	if (too_many_traps(reason)) {
        // 達到統計數量就設定熱點異常辨別為true
      	treat_throw_as_hot = true;
    }
    
    ......
    
}           

上面是判斷異常是否是熱點異常的邏輯,下面再看一下判斷熱點異常類型的邏輯。

if (treat_throw_as_hot && (!StackTraceInThrowable || OmitStackTraceInFastThrow)) {
    // 待抛出的預構造的異常對象
    ciInstance* ex_obj = NULL;
    switch (reason) {
    	// 如果是空值校驗優化導緻的去優化
        case Deoptimization::Reason_null_check:
            // 抛出預構造的空指針異常
            ex_obj = env()->NullPointerException_instance();
            break;
        // 如果是除零校驗優化導緻的去優化
        case Deoptimization::Reason_div0_check:
            // 抛出預構造的算術異常
            ex_obj = env()->ArithmeticException_instance();
            break;
        // 如果是數組邊界校驗優化導緻的去優化
        case Deoptimization::Reason_range_check:
            // 抛出預構造的數組索引越界異常
            ex_obj = env()->ArrayIndexOutOfBoundsException_instance();
            break;
        // 如果是類型校驗優化導緻的去優化
        case Deoptimization::Reason_class_check:
            if (java_bc() == Bytecodes::_aastore) {
            	// 抛出數組存儲異常
                ex_obj = env()->ArrayStoreException_instance();
            } else {
            	// 抛出類型強轉異常
                ex_obj = env()->ClassCastException_instance();
            }
            break;
        default:
            break;
    }
}           

四. Error

1. 概念說明

Error表示應用程式運作過程中發生的緻命錯誤,這個錯誤通常由JVM抛出,且應用程式無法處理。但是Error及其子類的執行個體本質上還是對象,同時均繼承于Throwable,是以我們可以自己在程式中建立出來Error執行個體然後再通過throw關鍵字抛出,同時也可以通過catch關鍵字捕獲,但這麼做是沒有意義的。

2. OutOfMemoryError

官方對OutOfMemoryError的定義是:當JVM由于記憶體不足而無法配置設定對象,并且垃圾收集器無法提供更多記憶體時抛出。

OutOfMemoryError有如下幾種分類,也就分别對應發生OutOfMemoryError的幾種原因。

  1. 堆記憶體溢出。堆上剩餘記憶體不足以容納新對象時抛出,異常資訊如下所示。
java.lang.OutOfMemoryError: Java heap space           
  1. 元空間記憶體溢出。元空間剩餘記憶體不足以加載Class時抛出,異常資訊如下所示。
java.lang.OutOfMemoryError: Metaspace           
  1. 直接記憶體溢出。剩餘直接記憶體不足時抛出,通常是使用-XX:MaxDirectMemorySize指定了直接記憶體後并且應用程式中有使用到NIO架構或者不合理的直接記憶體配置設定,異常資訊如下所示。
java.lang.OutOfMemoryError: Direct buffer memory           
  1. GC開銷超限。連續多次GC的記憶體回收率低于2%時抛出,異常資訊如下所示。
java.lang.OutOfMemoryError: GC overhead limit exceeded           
  1. 無法建立本機線程。無法再繼續建立線程時抛出,通常是在容器中通過cgroup限制了最大線程數量同時應用程式中建立的線程又達到了限制的最大值,異常資訊如下所示。
java.lang.OutOfMemoryError: unable to create new native thread           

3. StackOverflowError

Java中每個線程有棧記憶體大小限制,棧記憶體大小決定了方法調用的最大深度,如果超出了方法調用的最大深度,就會抛出StackOverflowError,可以通過-Xss來設定線程棧記憶體大小。如下是一個簡單示例。

public class StackOverflowErrorTest {

    @Test
    public void 測試棧記憶體溢出() {
        unterminatedRecursion();
    }

    private void unterminatedRecursion() {
        unterminatedRecursion();
    }

}           

執行測試程式,運作一小會兒,就會抛出StackOverflowError,如下所示。

java.lang.StackOverflowError
    at com.lee.learn.exception.StackOverflowErrorTest.unterminatedRecursion(StackOverflowErrorTest.java:13)
    at com.lee.learn.exception.StackOverflowErrorTest.unterminatedRecursion(StackOverflowErrorTest.java:13)
    at com.lee.learn.exception.StackOverflowErrorTest.unterminatedRecursion(StackOverflowErrorTest.java:13)
    at com.lee.learn.exception.StackOverflowErrorTest.unterminatedRecursion(StackOverflowErrorTest.java:13)
    at com.lee.learn.exception.StackOverflowErrorTest.unterminatedRecursion(StackOverflowErrorTest.java:13)
    at com.lee.learn.exception.StackOverflowErrorTest.unterminatedRecursion(StackOverflowErrorTest.java:13)
    at com.lee.learn.exception.StackOverflowErrorTest.unterminatedRecursion(StackOverflowErrorTest.java:13)
    ......           

4. NoClassDefFoundError

如果在編譯時類能夠成功完成編譯,但是在運作并加載類時無法完成加載,此時會抛出該異常,示例如下所示。

public class NoClassDefFoundErrorTest {

    @Test
    public void 測試沒有類定義異常() {
        Demo.say();
    }

    private static class Demo {
        static {
            int i = 1 / 0;
        }

        public static void say() {
            System.out.println("你好世界");
        }
    }

}           

運作測試程式,列印如下。

java.lang.ExceptionInInitializerError
    at com.lee.learn.exception.NoClassDefFoundErrorTest.測試沒有類定義異常(NoClassDefFoundErrorTest.java:9)
    ......
Caused by: java.lang.ArithmeticException: / by zero
    at com.lee.learn.exception.NoClassDefFoundErrorTest$Demo.<clinit>(NoClassDefFoundErrorTest.java:14)
    ......           

因為加載類時,會執行靜态代碼塊,但是上述示例在靜态代碼塊中抛出了異常,導緻類加載失敗,是以抛出了NoClassDefFoundError。

和NoClassDefFoundError比較像的一個異常叫做ClassNotFoundException,官方對ClassNotFoundException的解釋是應用程式試圖基于全限定名來加載類但在類路徑下找不到對應類時抛出,通常對應如下三種調用。

  1. Class的forName() 方法調用;
  2. ClassLoader的findSystemClass() 方法調用;
  3. ClassLoader的loadClass() 調用。

下面是一個簡單的示例。

public class ClassNotFoundExceptionTest {

    @Test
    public void 測試找不到加載類異常() throws ClassNotFoundException {
        Class.forName("com.lee.learn.exception.NotExistClass");
    }

}           

運作測試程式,列印如下。

java.lang.ClassNotFoundException: com.lee.learn.exception.NotExistClass
    at java.net.URLClassLoader.findClass(URLClassLoader.java:381)
    at java.lang.ClassLoader.loadClass(ClassLoader.java:424)
    at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:338)
    at java.lang.ClassLoader.loadClass(ClassLoader.java:357)
    at java.lang.Class.forName0(Native Method)
    at java.lang.Class.forName(Class.java:264)
    at com.lee.learn.exception.ClassNotFoundExceptionTest.測試找不到加載類異常(ClassNotFoundExceptionTest.java:9)           

總結

原本是想在本文的最後,再總結一下Java異常使用的最佳實踐的,但是後面發現,好像談異常的最佳實踐就是一個很沒有意義的事情,因為在Java的異常體系中,存在着Checked Exception和Unchecked Exception,而有人覺得Checked Exception是優秀的異常設計,有人又覺得其完全是一個敗筆,網上的争論也很多,哪方都無法說服對方,最終都落回那一句話:具體情況具體分析。

繼續閱讀