JVM優化過頭了,直接把異常資訊優化沒了?
你好呀,我是why。
你猜這次我又要寫個啥沒有卵用的知識點呢?
不好意思,問的稍微有點早了,啥提示都沒給,咋猜呢,對吧?
先給你上個程式碼:
publicclassExceptionTest{
publicstaticvoidmain(String[]args){
Stringmsg=null;
for(inti=0;i<500000;i++){
try{
msg.toString();
}catch(Exceptione){
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大誠不欺我。
關於分層編譯,做這樣的一個簡單的介紹。
學問很大,你要是有興趣可以去研究研究。
以上。