你真的會閱讀Java的異常資訊嗎?

monkeysayhi發表於2017-11-14

給出如下異常資訊:

java.lang.RuntimeException: level 2 exception
    at com.msh.demo.exceptionStack.Test.fun2(Test.java:17)
    at com.msh.demo.exceptionStack.Test.main(Test.java:24)
    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
    at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
    at java.lang.reflect.Method.invoke(Method.java:498)
    at com.intellij.rt.execution.application.AppMain.main(AppMain.java:147)
Caused by: java.io.IOException: level 1 exception
    at com.msh.demo.exceptionStack.Test.fun1(Test.java:10)
    at com.msh.demo.exceptionStack.Test.fun2(Test.java:15)
    ... 6 more複製程式碼

學這麼多年Java,你真的會閱讀Java的異常資訊嗎?你能說清楚異常丟擲過程中的事件順序嗎?

需要內化的內容

寫一個demo測試

上述異常資訊在由一個demo產生:

package com.msh.demo.exceptionStack;

import java.io.IOException;

/**
 * Created by monkeysayhi on 2017/10/1.
 */
public class Test {
  private void fun1() throws IOException {
    throw new IOException("level 1 exception");
  }

  private void fun2() {
    try {
      fun1();
    } catch (IOException e) {
        throw new RuntimeException("level 2 exception", e);
    }
  }


  public static void main(String[] args) {
    try {
      new Test().fun2();
    } catch (Exception e) {
      e.printStackTrace();
    }
  }
}複製程式碼

這次我複製了完整的檔案內容,使文章中的程式碼行號和實際行號一一對應。

根據上述異常資訊,異常丟擲過程中的事件順序是:

  1. 在Test.java的第10行,丟擲了一個IOExceotion("level 1 exception") e1
  2. 異常e1被逐層向外丟擲,直到在Test.java的第15行被捕獲
  3. 在Test.java的第17行,根據捕獲的異常e1,丟擲了一個RuntimeException("level 2 exception", e1) e2
  4. 異常e2被逐層向外丟擲,直到在Test.java的第24行被捕獲
  5. 後續沒有其他異常資訊,經過必要的框架後,由程式自動或使用者主動呼叫了e2.printStackTrace()方法

如何閱讀異常資訊

那麼,如何閱讀異常資訊呢?有幾點你需要認識清楚:

  • 異常棧以FILO的順序列印,位於列印內容最下方的異常最早被丟擲,逐漸導致上方異常被丟擲。位於列印內容最上方的異常最晚被丟擲,且沒有再被捕獲。從上到下數,第i+1個異常是第i個異常被丟擲的原因cause,以“Caused by”開頭。
  • 異常棧中每個異常都由異常名+細節資訊+路徑組成。異常名從行首開始(或緊隨"Caused by"),緊接著是細節資訊(為增強可讀性,需要提供恰當的細節資訊),從下一行開始,跳過一個製表符,就是路徑中的一個位置,一行一個位置。
  • 路徑以FIFO的順序列印,位於列印內容最上方的位置最早被該異常經過,逐層向外丟擲。最早經過的位置即是異常被丟擲的位置,逆向debug時可從此處開始;後續位置一般是方法呼叫的入口,JVM捕獲異常時可以從方法棧中得到。對於cause,其可列印的路徑截止到被包裝進下一個異常之前,之後列印“... 6 more”,表示cause作為被包裝異常,在這之後還逐層向外經過了6個位置,但這些位置與包裝異常的路徑重複,所以在此處省略,而在包裝異常的路徑中列印。“... 6 more”的資訊不重要,可以忽略。

現在,回過頭再去閱讀示例的異常資訊,是不是相當簡單?

為了幫助理解,我儘可能通俗易懂的描述了異常資訊的結構和組成元素,可能會引入一些紕漏。閱讀異常資訊是Java程式猿的基本技能,希望你能內化它,忘掉這些冗長的描述。

如果還不理解,建議你親自追蹤一次異常的建立和列印過程,使用示例程式碼即可,它很簡單但足夠。難點在於異常是JVM提供的機制,你需要了解JVM的實現;且底層呼叫了很多native方法,而追蹤native程式碼沒有那麼方便。

擴充套件

為什麼有時我在日誌中只看到異常名"java.lang.NullPointerException",卻沒有異常棧

示例的異常資訊中,異常名、細節資訊、路徑三個元素都有,但是,由於JVM的優化,細節資訊和路徑可能會被省略

這經常發生於伺服器應用的日誌中,由於相同異常已被列印多次,如果繼續列印相同異常,JVM會省略掉細節資訊和路徑佇列,向前翻閱即可找到完整的異常資訊

猴哥之前使用Yarn的Timeline Server時遇到過該問題。你能體會那種感覺嗎?臥槽,為什麼只有異常名沒有異常棧?沒有異常棧怎麼老子怎麼知道哪裡丟擲的異常?線上服務老子又不能停,全靠日誌了啊喂!

網上有不少相同的case,比如NullPointerException丟失異常堆疊資訊,讀者可以參照這個連結實驗一下。

如何在異常類中新增成員變數

為了恰當的表達一個異常,我們有時候需要自定義異常,並新增一些成員變數,列印異常棧時,自動補充列印必要的資訊。

追蹤列印異常棧的程式碼:

...
    public void printStackTrace() {
        printStackTrace(System.err);
    }
...
    public void printStackTrace(PrintStream s) {
        printStackTrace(new WrappedPrintStream(s));
    }
...
    private void printStackTrace(PrintStreamOrWriter s) {
        // Guard against malicious overrides of Throwable.equals by
        // using a Set with identity equality semantics.
        Set<Throwable> dejaVu =
            Collections.newSetFromMap(new IdentityHashMap<Throwable, Boolean>());
        dejaVu.add(this);

        synchronized (s.lock()) {
            // Print our stack trace
            s.println(this);
            StackTraceElement[] trace = getOurStackTrace();
            for (StackTraceElement traceElement : trace)
                s.println("\tat " + traceElement);

            // Print suppressed exceptions, if any
            for (Throwable se : getSuppressed())
                se.printEnclosedStackTrace(s, trace, SUPPRESSED_CAPTION, "\t", dejaVu);

            // Print cause, if any
            Throwable ourCause = getCause();
            if (ourCause != null)
                ourCause.printEnclosedStackTrace(s, trace, CAUSE_CAPTION, "", dejaVu);
        }
    }
...複製程式碼

暫不關心同步問題,可知,列印異常名和細節資訊的程式碼為:

            s.println(this);複製程式碼

JVM在執行期通過動態繫結實現this引用上的多型呼叫。繼續追蹤的話,最終會呼叫this例項的toString()方法。所有異常的最低公共祖先類是Throwable類,它提供了預設的toString()實現,大部分常見的異常類都沒有覆寫這個實現,我們自定義的異常也可以直接繼承這個實現:

...
    public String toString() {
        String s = getClass().getName();
        String message = getLocalizedMessage();
        return (message != null) ? (s + ": " + message) : s;
    }
...
    public String getLocalizedMessage() {
        return getMessage();
    }
...
    public String getMessage() {
        return detailMessage;
    }
...複製程式碼

顯然,預設實現的列印格式就是示例的異常資訊格式:異常名(全限定名)+細節資訊。detailMessage由使用者建立異常時設定,因此,如果有自定義的成員變數,我們通常在toString()方法中插入這個變數。參考com.sun.javaws.exceptions包中的BadFieldException,看看它如何插入自定義的成員變數field和value:

  public String toString() {
    return this.getValue().equals("https")?"BadFieldException[ " + this.getRealMessage() + "]":"BadFieldException[ " + this.getField() + "," + this.getValue() + "]";
  }複製程式碼

嚴格的說,BadFieldException的toString中並沒有直接插入field成員變數。不過這不影響我們理解,感興趣的讀者可自行翻閱原始碼。

總結

根據異常資訊debug是程式設計師的基本技能,這裡圍繞異常資訊的閱讀和列印過程作了初步探索,後續還會整理一下常用的異常類,結合程式猿應該記住的幾條基本規則,更好的理解如何用異常幫助我們寫出clean code。

Java相當完備的異常處理機制是一把雙刃劍,用好它能增強程式碼的可讀性和魯棒性,用不好則會讓程式碼變的更加不可控。例如,在空指標上呼叫成員方法,執行期會丟擲異常,這是很自然的——但是,是不可控的等待它在某個時刻某個位置丟擲異常(實際上還是“確定”的,但對於debug來說是“不確定”的),還是可控的在進入方法伊始就檢查並主動丟擲異常呢?進一步的,哪些異常應該被即刻處理,哪些應該繼續拋到外層呢?拋往外層時,何時需要封裝異常呢?看看String#toLowerCase(),看看ProcessBuilder#start(),體會一下。


本文連結:你真的會閱讀Java的異常資訊嗎?
作者:猴子007
出處:monkeysayhi.github.io
本文基於 知識共享署名-相同方式共享 4.0 國際許可協議釋出,歡迎轉載,演繹或用於商業目的,但是必須保留本文的署名及連結。

相關文章