揭曉Java異常體系中的祕密

大閒人柴毛毛發表於2018-03-12

相信大家每天都在使用Java異常機制,也相信大家對try-catch-finally執行流程爛熟於胸。本文將介紹Java異常機制的一些細節問題,這些問題雖然很小,但對程式碼效能、可讀性有著較為重要的作用。

揭曉Java異常體系中的祕密

1. Java異常體系介紹

在學習一項技術前,一定要先站在制高點俯瞰技術全域性,從巨集觀上把控某項技術的整個脈絡結構。這樣你就可以有針對性地學習該體系結構中最重要的知識點,並且在學習細節的時候不至於鑽入牛角尖。所以,在介紹Java異常你所不知道的一些祕密之前,先讓大家複習一下Java異常體系。

揭曉Java異常體系中的祕密

Throwable是整個Java異常體系的頂層父類,它有兩個子類,分別是:Error和Exception。

Error表示系統致命錯誤,程式無法處理的錯誤,比如OutOfMemoryError、ThreadDeath等。這些錯誤發生時,Java虛擬機器只能終止執行緒。

Exception是程式本身可以處理的異常,這種異常分兩大類執行時異常和非執行時異常。

執行時異常都是RuntimeException類及其子類異常,如NullPointerException、IndexOutOfBoundsException等,這些異常屬於unchecked異常,開發人員可以選擇捕獲處理,也可以不處理。這些異常一般是由程式邏輯錯誤引起的,程式應該從邏輯角度儘可能避免這類異常的發生。

在Exception異常體系中,除了RuntimeException類及其子類的異常,均屬於checked異常。當你呼叫了丟擲這些異常的方法後,必須要處理這些異常。如果不處理,程式就不能編譯通過。如:IOException、SQLException、使用者自定義的Exception異常等。

2. try-with-resources

在JDK 1.7之前,處理IO操作非常麻煩。由於IOException屬於checked異常,呼叫者必須通過try-catch處理他們;又因為IO操作完成後需要關閉資源,然而關閉資源的close()方法也會丟擲checked異常,因此也需要使用try-catch處理該異常。因此,原本小小的一段IO操作程式碼會被複雜的try-catch巢狀包裹,從而極大地降低了程式的可讀性。

一個標準的IO操作程式碼如下:

public class Demo {
    public static void main(String[] args) {
        BufferedInputStream bin = null;
        BufferedOutputStream bout = null;
        try {
            bin = new BufferedInputStream(new FileInputStream(new File("test.txt")));
            bout = new BufferedOutputStream(new FileOutputStream(new File("out.txt")));
            int b;
            while ((b = bin.read()) != -1) {
                bout.write(b);
            }
        }
        catch (IOException e) {
            e.printStackTrace();
        }
        finally {
            if (bin != null) {
                try {
                    bin.close();
                }
                catch (IOException e) {
                    e.printStackTrace();
                }
                finally {
                    if (bout != null) {
                        try {
                            bout.close();
                        }
                        catch (IOException e) {
                            e.printStackTrace();
                        }
                    }
                }
            }
        }
    }
}
複製程式碼

上述程式碼使用一個輸出流bin和一個輸入六bout,將一個檔案中的資料寫入另一個檔案。由於IO資源非常寶貴,因此在完成操作後,必須在finally中分別釋放這兩個資源。並且為了能夠正確釋放這兩個IO資源,需要用兩個finally程式碼塊巢狀的方式完成資源的釋放。

在上述40行程式碼中,真正處理IO操作的程式碼不到10行,而其餘30行程式碼都是用於保證資源合理釋放的。這顯然導致程式碼可讀性較差。不過好在JDK 1.7提供了try-with-resources解決這一問題。修改後的程式碼如下:

public class TryWithResource {
    public static void main(String[] args) {
        try (BufferedInputStream bin = new BufferedInputStream(new FileInputStream(new File("test.txt")));
             BufferedOutputStream bout = new BufferedOutputStream(new FileOutputStream(new File("out.txt")))) {
            int b;
            while ((b = bin.read()) != -1) {
                bout.write(b);
            }
        }
        catch (IOException e) {
            e.printStackTrace();
        }
    }
}
複製程式碼

我們需要將資源宣告程式碼放入try後的括號中,然後將資源處理程式碼放入try後的{}中,catch程式碼塊中仍然進行異常處理,並且無需寫finally程式碼塊。

那麼,try-with-resources為什麼能夠避免大量資源釋放程式碼呢?答案是,由Java編譯器來幫我們新增finally程式碼塊。注意,編譯器只會新增finally程式碼塊,而資源釋放的過程需要資源提供者提供。

在JDK 1.7中,所有的IO類都實現了AutoCloseable介面,並且需要實現其中的close()函式,資源釋放過程需要在該函式中完成。

那麼,編譯器在編譯時,會自動新增finally程式碼塊,並將close()函式中的資源釋放程式碼加入finally程式碼塊中。從而提高程式碼可讀性。

異常遮蔽問題

在try-catch-finally程式碼塊中,如果try塊、catch塊和finally塊均有異常丟擲,那麼最終只能丟擲finally塊中的異常,而try塊和catch塊中的異常將會被遮蔽。這就是異常遮蔽問題。如下面程式碼所示:

public class Connection implements AutoCloseable {
    public void sendData() throws Exception {
        throw new Exception("send data");
    }
    @Override
    public void close() throws Exception {
        throw new MyException("close");
    }
}
複製程式碼

首先定義一個Connection類,該類提供了sendData()close()方法,為了實驗需要,這兩個方法沒有任何業務邏輯,都直接丟擲一個異常。下面我們使用這個類。

public class TryWithResource {
    public static void main(String[] args) {
        try {
            test();
        }
        catch (Exception e) {
            e.printStackTrace();
        }
    }
    private static void test() throws Exception {
        Connection conn = null;
        try {
            conn = new Connection();
            conn.sendData();
        }
        finally {
            if (conn != null) {
                conn.close();
            }
        }
    }
}
複製程式碼

當執行conn.sendData()時,該方法會將異常拋給呼叫者main(),但在拋之前會先執行finally塊。當執行finally塊中的conn.close()方法時,也會向呼叫者拋一個異常。此時,由try塊丟擲的異常將會被覆蓋,main方法中僅列印finally塊中的異常。其結果如下所示:

basic.exception.MyException: close
	at basic.exception.Connection.close(Connection.java:10)
	at basic.exception.TryWithResource.test(TryWithResource.java:82)
	at basic.exception.TryWithResource.main(TryWithResource.java:7)
	......
複製程式碼

這就是try-catch-finally的異常遮蔽問題,而try-with-resources能很好地解決這一問題。那麼,它是如何解決這一問題的呢?

我們首先將這段程式碼用try-with-resources改寫:

public class TryWithResource {
    public static void main(String[] args) {
        try {
            test();
        }
        catch (Exception e) {
            e.printStackTrace();
        }
    }
    private static void test() throws Exception {
        Connection conn = null;
        try (conn = new Connection();) {
            conn.sendData();
        }
    }
}
複製程式碼

為了能清楚地瞭解Java編譯器在try-with-resources上所做的事情,我們反編譯這段程式碼,得到如下程式碼:

public class TryWithResource {
    public TryWithResource() {
    }
    public static void main(String[] args) {
        try {
            // 資源宣告程式碼
            Connection e = new Connection();
            Throwable var2 = null;
            try {
                // 資源使用程式碼
                e.sendData();
            } catch (Throwable var12) {
                var2 = var12;
                throw var12;
            } finally {
                // 資源釋放程式碼
                if(e != null) {
                    if(var2 != null) {
                        try {
                            e.close();
                        } catch (Throwable var11) {
                            var2.addSuppressed(var11);
                        }
                    } else {
                        e.close();
                    }
                }
            }
        } catch (Exception var14) {
            var14.printStackTrace();
        }
    }
}
複製程式碼

最核心的操作是22行var2.addSuppressed(var11);。編譯器將try塊和catch塊中的異常先存入一個區域性變數,當finally塊中再次丟擲異常時,通過之前異常的addSuppressed()方法將當前異常新增至其異常棧中,從而保證了try塊和catch塊中的異常不丟失。當使用了try-with-resources後,輸出結果如下所示:

java.lang.Exception: send data
	at basic.exception.Connection.sendData(Connection.java:5)
	at basic.exception.TryWithResource.main(TryWithResource.java:14)
	......
	Suppressed: basic.exception.MyException: close
		at basic.exception.Connection.close(Connection.java:10)
		at basic.exception.TryWithResource.main(TryWithResource.java:15)
		... 5 more
複製程式碼

3. try-catch-finally執行流程

眾所周知,首先執行try中程式碼,若未發生異常,則直接執行finally中程式碼;若發生異常,則先執行catch中程式碼後,再執行finally中程式碼。

相信上述流程大家都爛熟於胸,但如果try塊和catch塊中出現了return呢?出現了throw呢?此時執行順序就會發生變化。

但是,萬變不離其中,大家只要記住一點:fianlly中的return、throw會覆蓋try、catch中的return、throw。此話怎講?請繼續往下閱讀。

要解釋這個問題,先來看一個例子,請問下面程式碼中的test()函式會返回什麼結果?

public int test() {
    try {
        int a = 1;
        a = a / 0;
        return a;
    } catch (Exception e) {
        return -1;
    } finally{
        return -2;
    }
}
複製程式碼

答案是-2。

當執行程式碼a = a / 0;時發生異常,try塊中它之後的程式碼便不再執行,而是直接執行catch中程式碼; 在catch塊中,當在執行return -1前,先會執行finally塊; 由於finally塊中有return語句,因此catch中的return將會被覆蓋,直接執行fianlly中的return -2後程式結束。因此輸出結果是-2。

同樣地,將return換成throw也是一樣的結果,finally會覆蓋try、catch塊中的return、throw。

特別提醒:禁止在finally塊中使用return語句!這裡舉例子只是告訴你Java的這一特性,在實際開發中禁止使用!

4. Optional優雅解決NPE問題

空指標異常是一個執行時異常,對於這一類異常,如果沒有明確的處理策略,那麼最佳實踐在於讓程式早點掛掉,但是很多場景下,不是開發人員沒有具體的處理策略,而是根本沒有意識到空指標異常的存在。當異常真的發生的時候,處理策略也很簡單,在存在異常的地方新增一個if語句判定即可,但是這樣的應對策略會讓我們的程式出現越來越多的null判定,我們知道一個良好的程式設計,應該讓程式碼中儘量少出現null關鍵字,而java8所提供的Optional類則在減少NullPointException的同時,也提升了程式碼的美觀度。但首先我們需要明確的是,它並 不是對null關鍵字的一種替代,而是對於null判定提供了一種更加優雅的實現,從而避免NullPointException。

假設存在如下Person類:

class Person{
    private long id;
    private String name;
    private int age;
    
    // 省略setter、getter
}
複製程式碼

當我們呼叫某一個介面,獲取到一個Person物件,此時可以通過如下方法對它進行處理:

  • ofNullable(person)
    • 將Person物件轉化成Optional物件
    • 允許person為空
Optional<Person> personOpt = Optional.ofNullable(person);
複製程式碼
  • T orElse(T other)
    • 若為空,則賦予預設值
personOpt.orElse(new Person("柴毛毛"));
複製程式碼
  • T orElseGet(Supplier<? extends T> other)
    • 若為空,則執行相應程式碼,並返回預設值
personOpt.orElseGet(()->{
    Person person = new Person();
    person.setName("柴毛毛");
    person.setAge(20);
    return person;
});
複製程式碼
  • <X extends Throwable> T orElseThrow(Supplier<? extends X> exceptionSupplier)
    • 若為空,則拋異常
personOpt.orElseThrow(CommonBizException::new);
複製程式碼
  • <U>Optional<U> map(Function<? super T,? extends U> mapper)
    • 對映(獲取person中的姓名)
String name = personOpt
                .map(Person::getName)
                .orElseThrow(CommonBizException::new)
                .map(Optional::get);
複製程式碼

5. 異常處理規約

  • Java 類庫中定義的一類 RuntimeException 可以通過預先檢查進行規避,而不應該通過 catch 來處理,比如: IndexOutOfBoundsException,NullPointerException等等。
    • 正例: if (obj != null) {...}
    • 反例: try { obj.method() } catch (NullPointerException e) {...}
  • 異常不要用來做流程控制,條件控制,因為異常的處理效率比條件分支低。
  • 對大段程式碼進行 try-catch,這是不負責任的表現。catch 時請分清穩定程式碼和非穩定程式碼,穩定程式碼指的是無論如何不會出錯的程式碼。對於非穩定程式碼的catch儘可能進行區分異常型別,再做對應的異常處理。
  • 捕獲異常是為了處理它,不要捕獲了卻什麼都不處理而拋棄之,如果不想處理它,請 將該異常拋給它的呼叫者。最外層的業務使用者,必須處理異常,將其轉化為使用者可以理解的內容。
  • 有 try 塊放到了事務程式碼中,catch 異常後,如果需要回滾事務,一定要注意手動回滾事務。
  • 不能在 finally 塊中使用 return,finally 塊中的 return 返回後方法結束執行,不會再執行 try 塊中的 return 語句。
  • finally 塊必須對資源物件、流物件進行關閉,有異常也要做 try-catch。 說明:如果 JDK7 及以上,可以使用 try-with-resources 方式。
  • 有 try 塊放到了事務程式碼中,catch 異常後,如果需要回滾事務,一定要注意手動回滾事務。
  • 捕獲異常與拋異常,必須是完全匹配,或者捕獲異常是拋異常的父類。也就是丟擲的異常必須是所捕獲異常或其子類。這樣才能讓異常大而化小小而化了。
  • 本規約明確防止 NPE 是呼叫者的責任。即使被呼叫方法返回空集合或者空物件,對呼叫 者來說,也並非高枕無憂,必須考慮到遠端呼叫失敗,執行時異常等場景返回 null 的情況。
  • 定義時區分unchecked/checked 異常,避免直接使用RuntimeException丟擲, 更不允許丟擲 Exception 或者 Throwable,應使用有業務含義的自定義異常。推薦業界已定義 過的自定義異常,如:DAOException / ServiceException 等。
  • 在程式碼中使用“拋異常”還是“返回錯誤碼”:
    • 對於公司外的 http/api 開放介面必須 使用“錯誤碼”;
    • 而應用內部推薦異常丟擲;
    • 跨應用間 RPC 呼叫優先考慮使用 Result 方式,封裝 isSuccess、“錯誤碼”、“錯誤簡簡訊息”。

揭曉Java異常體系中的祕密

相關文章