JVM優化過頭了,直接把異常資訊優化沒了?

why技術發表於2021-07-20

你好呀,我是why。

你猜這次我又要寫個啥沒有卵用的知識點呢?

不好意思,問的稍微有點早了,啥提示都沒給,咋猜呢,對吧?

先給你上個程式碼:

public class ExceptionTest {

    public static void main(String[] args) {
        String msg = null;
        for (int i = 0; i < 500000; i++) {
            try {
                msg.toString();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }
}

來,就這程式碼,你猜猜寫出個什麼花兒來?

當然了,有猜到的朋友,也有沒猜到的朋友。

很好,那麼請猜出來了的同學迅速拉到文末,完成一鍵三連的任務後,就可以出去了。

沒有猜出來的同學,我把程式碼一跑起來,你就知道我要說啥了:

一瞬間的事兒,瞅見了嗎?神奇嗎?產生疑問了嗎?

沒關係,你要沒看清楚,我還能給你截個圖:

在丟擲一定次數的空指標異常後,異常堆疊沒了。

這就是我標題說的:太扯了吧?異常資訊突然就沒了。

你說為啥?

為啥?

這事就得從 2004 年講起了。

那一年,SUN 公司於 9 月 30 日 18 點發布了 JDK 5。

在其 release-notes 中有這樣一段話:

https://www.oracle.com/java/technologies/javase/release-notes-introduction.html

主要是框起來的這句話,看不明白沒關係,我用我八級半的英語給你翻譯一下。

我們一句句的來:

The compiler in the server VM now provides correct stack backtraces for all "cold" built-in exceptions.

對於所有的內建異常,編譯器都可以提供正確的異常堆疊的回溯。

For performance purposes, when such an exception is thrown a few times, the method may be recompiled.

出於效能的考慮,當一個異常被丟擲若干次後,該方法可能會被重新編譯。(重要)

After recompilation, the compiler may choose a faster tactic using preallocated exceptions that do not provide a stack trace.

在重新編譯之後,編譯器可能會選擇一種更快的策略,即不提供異常堆疊跟蹤的預分配異常。(重要)

To disable completely the use of preallocated exceptions, use this new flag: -XX:-OmitStackTraceInFastThrow.

如果要禁止使用預分配的異常,請使用這個新引數:-XX:-OmitStackTraceInFastThrow。

這幾句話先不管理解沒有。但是至少知道它這裡描述的場景不就是剛剛程式碼演示的場景嗎?

它最後提到了一個引數 -XX:-OmitStackTraceInFastThrow,二話不說,先拿來用了,看看效果再說:

同樣的程式碼,加入該啟動引數後,異常堆疊確實會從頭到尾一直列印。

不知道你感覺到沒有,加入該啟動引數後,程式執行時間明顯慢了很多。

在我的機器上沒加該引數,程式執行時間是 2826 ms,加上該引數執行時間是 5885 ms。

說明確實是有提升效能的功能。

到底是咋提升的,下一節說。

先說個其他的。

這裡都提到 JVM 引數了,我順便再分享一個網站:

https://club.perfma.com/topic/OmitStackTraceInFastThrow

該網站提供了很多功能,這是其中的幾個功能:

JVM 引數查詢功能那必須得有:

很好用的,你以後遇到不知道是幹啥用的 JVM 引數,可以在這個網站上查詢一下。

到底為啥?

前面講了是出於效能原因,從 JDK 5 開始會出現異常堆疊丟失的現象。

那麼效能問題到底在哪?

來,我們一起看一下最常見的空指標異常。

以本文為例,看一下異常丟擲的時候呼叫路徑:

最終會走到這個 native 方法:

java.lang.Throwable#fillInStackTrace(int)

fill In Stack Trace,顧名思義,填入堆疊跟蹤。

這個方法會去爬堆疊,而這個過程就是一個相對比較消耗效能的過程。

為啥比較耗時呢?

給你看個比較直觀的:

這類的異常堆疊才是我們比較常見的,這麼長的堆疊資訊,可不消耗效能嗎。

現在,我們現在再回去看這句話:

For performance purposes, when such an exception is thrown a few times, the method may be recompiled. After recompilation, the compiler may choose a faster tactic using preallocated exceptions that do not provide a stack trace.

出於效能的考慮,當一個異常被丟擲若干次後,該方法可能會被重新編譯。在重新編譯之後,編譯器可能會選擇一種更快的策略,即不提供異常堆疊跟蹤的預分配異常。

所以,你能明白,這個“出於效能的考慮”這句話,具體指的就是節約 fillInStackTrace(爬堆疊)的這個效能消耗。

更加深入一點的研究對比,你可以看看這個連結:

http://java-performance.info/throwing-an-exception-in-java-is-very-slow

我這裡貼一下結論:

關於消除異常的效能消耗,他提出了三個解決方案:

重構你的程式碼不使用它們。
快取異常例項。
重寫 fillInStackTrace 方法。

通過小日...小日子過的還不錯的日本的站點,輸入關鍵資訊後,知乎的這個連結排在第二個:

https://www.zhihu.com/question/21405047

這個問題下面,有一個R大的回答,貼上給你看看:

大家都不約而同的提到了重寫 fillInStackTrace 方法,這個效能優化小技巧,也就是我們可以這樣去自定義異常:

用一個不嚴謹的方式測試一下,你就看這個意思就行:

重寫了 fillInStackTrace 方法,直接返回 this 的物件,比呼叫了爬棧方法的原始方法,快了不是一星半點兒。

其實除了重寫 fillInStackTrace 方法之外,JDK 7 之後還提供了這樣的一個方法:

java.lang.Throwable#Throwable(java.lang.String, java.lang.Throwable, boolean, boolean)

可以通過 writableStackTrace 入參來控制是否需要去爬棧。

那麼到底什麼時候才應該去用這樣的一個效能優化手段呢?

其實R大的回答裡面說的很清楚了:

其實我們寫業務程式碼的,異常資訊列印還是非常有必要的。

但是對於一些追求效能的框架,就可以利用這個優勢。

比如我在 disruptor 和 kafka 的原始碼裡面都找到了這樣的優化落地原始碼。

先看 disruptor 的:

com.lmax.disruptor.AlertException

  • Overridden so the stack trace is not filled in for this exception for performance reasons.
  • 由於效能的原因,過載後的堆疊跟蹤不會被填入這個異常。

再看 kafka 的:

org.apache.kafka.common.errors.ApiException

  • avoid the expensive and useless stack trace for api exceptions
  • 避免對api異常進行昂貴而無用的堆疊跟蹤

而且你注意到了嗎,上面著兩個框架中,直接把 synchronized 都幹掉了。如果你也打算重寫,那麼也可以分析一下你的場景中是否可以去掉 synchronized,效能又可以來一點提升。

另外,R大的回答裡面還提到了這個優化是 C2 的優化。

我們可以簡單的證明一下。

分層編譯

前面提到的 C2,其實還有一個對應的 C1。這裡說的 C1、C2 都是即時編譯器。

你要是不熟悉 C1、C2,那我換個說法。

C1 其實就是 Client Compiler,即客戶端編譯器,特點是編譯時間較短但輸出程式碼優化程度較低。

C2 其實就是 Server Compiler,即服務端編譯器,特點是編譯耗時長但輸出程式碼優化質量也更高。

大家常常提到的 JVM 幫我們做的很多“激進”的為了提升效能的優化,比如內聯、快慢速路徑分析、窺孔優化,包括本文說的“不顯示異常堆疊”,都是 C2 搞的事情。

多說一句,在 JDK 10 的時候呢,又推出了 Graal 編譯器,其目的是為了替代 C2。

至於為什麼要替換 C2,額,原因之一是這樣的...

http://icyfenix.cn/tricks/2020/graalvm/graal-compiler.html

C2 的歷史已經非常長了,可以追溯到 Cliff Click 大神讀博士期間的作品,這個由 C++ 寫成的編譯器儘管目前依然效果拔群,但已經複雜到連 Cliff Click 本人都不願意繼續維護的程度。

你看前面我說的 C1、C1 的特點,剛好是互補的。

所以為了在程式啟動、響應速度和程式執行效率之間找到一個平衡點,在 JDK 6 之後,JVM 又支援了一種叫做分層編譯的模式。

也是為什麼大家會說:“Java 程式碼執行起來會越來越快、Java 程式碼需要預熱”的根本原因和理論支撐。

在這裡,我引用《深入理解Java虛擬機器HotSpot》一書中 7.2.1 小節[分層編譯]的內容,讓大家簡單瞭解一下這是個啥玩意。

首先,我們可以使用 -XX:+TieredCompilation 開啟分層編譯,它額外引入了四個編譯層級。

  • 第 0 級:解釋執行。
  • 第 1 級:C1 編譯,開啟所有優化(不帶 Profiling)。Profiling 即剖析。
  • 第 2 級:C1 編譯,帶呼叫計數和回邊計數的 Profiling 資訊(受限 Profiling).
  • 第 3 級:C1 編譯,帶所有Profiling資訊(完全Profiling).
  • 第 4 級:C2 編譯。

常見的分層編譯層級轉換路徑如下圖所示:

  • 0→3→4:常見層級轉換。用 C1 完全編譯,如果後續方法執行足夠頻繁再轉入 4 級。
  • 0→2→3→4:C2 編譯器繁忙。先以 2 級快速編譯,等收集到足夠的 Profiling 資訊後再轉為3級,最終當 C2 不再繁忙時再轉到 4 級。
  • 0→3→1/0→2→1:2/3級編譯後因為方法不太重要轉為 1 級。如果 C2 無法編譯也會轉到 1 級。
  • 0→(3→2)→4:C1 編譯器繁忙,編譯任務既可以等待 C1 也可以快速轉到 2 級,然後由 2 級轉向 4 級。

如果你之前不知道分層編譯這回事,沒關係,現在有這樣的一個概念就行了。面試不會考的,放心。

接下來,就要提到一個引數了:

-XX:TieredStopAtLevel=___

看名字你也知道了,這個引數的作用是讓分層編譯停在某一層,預設值為 4,也就是到 C2 編譯。

那我把該值修改為 3,豈不是就只能用 C1 了,那就不能利用 C2 幫我優化異常啦?

實驗一波:

果然如此,R大誠不欺我。

關於分層編譯,做這樣的一個簡單的介紹。

學問很大,你要是有興趣可以去研究研究。

以上。

相關文章