你好呀,我是歪歪。
那天我正在用鍵盤瘋狂的輸出:

突然微信彈出一個消息,是一個讀者發給我的。
我點開一看:

啊,這熟悉的味道,一看就是 HashMap,八股文夢開始的地方啊。
但是他問出的問題,似乎又不是一個屬於 HashMap 的八股文:
為什么這里要把 table 變量賦值給 tab 呢?
table 大家都知道,是 HashMap 的一個成員變量,往 map 里面放的數據就存儲在這個 table 里面的:

在 putVal 方法里面,先把 table 賦值給了 tab 這個局部變量,后續在方法里面都是操作的這個局部變量了。
其實,不只是 putVal 方法,在 HashMap 的源碼里面,“tab= table” 這樣的寫發多達 14 個,比如 getNode 里面也是這樣的用法:

我們先思考一下,如果不用 tab 這個局部變量,直接操作 table,會不會有問題?
從代碼邏輯和功能上來看,是不會有任何毛病的。
如果是其他人這樣寫,我會覺得可能是他的編程習慣,沒啥深意,反正又不是不能用。
但是這玩意可是 Doug Lea 寫的,隱約間覺得必然是有深意在里面的。

所以為什么要這樣寫呢?
巧了,我覺得我剛好知道答案是什么。
因為我在其他地方也看到過這種把成員變量賦值給局部變量的寫法,而且在注釋里面,備注了自己為什么這么寫。
而這個地方,就是 Java 的 String 類:

比如 String 類的 trim 方法,在這個方法里面就把 String 的 value 賦給了 val 這個局部變量。
然后旁邊給了一個非常簡短的注釋:
avoid getfield opcode
本文的故事,就從一行注釋開始,一路追溯到 2010 年,我終於抽絲剝繭找到了問題的答案。
一行注釋,就是說要避免使用 getfield 字節碼。
雖然我不懂是啥意思,但是至少我拿到了幾個關鍵詞,算是找到了一個“線頭”,接下來的事情就很簡單了,順着這個線頭往下縷就完事了。
而且直覺上告訴我這又是一個屬於字節碼層面的極端的優化,縷到最后一定是一個騷操作。
那么我就先給你說結論了:這個代碼確實是 Doug Lea 寫的,在當年確實是一種優化手段,但是時代變了,放到現在,確實沒有卵用。

答案藏在字節碼
既然這里提到了字節碼的操作,那么接下來的思路就是對比一下這兩種不同寫法分別的字節碼是長啥樣的不就清楚了嗎?
比如我先來一段這樣的測試代碼:
public class MainTest {
private final char[] CHARS = new char[5];
public void test() {
System.out.println(CHARS[0]);
System.out.println(CHARS[1]);
System.out.println(CHARS[2]);
}
public static void main(String[] args) {
MainTest mainTest = new MainTest();
mainTest.test();
}
}
上面代碼中的 test 方法,編譯成字節碼之后,是這樣的:

可以看到,三次輸出,對應着三次這樣的字節碼:

在網上隨便找個 JVM 字節碼指令表,就可以知道這幾個字節碼分別在干啥事兒:
-
getstatic:獲取指定類的靜態域, 並將其壓入棧頂 -
aload_0:將第一個引用類型本地變量推送至棧頂 -
getfield:獲取指定類的實例域, 並將其值壓入棧頂 -
iconst_0:將int型0推送至棧頂 -
caload:將char型數組指定索引的值推送至棧頂 -
invokevirtual:調用實例方法
如果,我把測試程序按照前面提到的寫法修改一下,並重新生成字節碼文件,就是這樣的:

可以看到,getfield 這個字節碼只出現了一次。
從三次到一次,這就是注釋中寫的“avoid getfield opcode”的具體意思。
確實是減少了生成的字節碼,理論上這就是一種極端的字節碼層面的優化。
具體到 getfield 這個命令來說,它干的事兒就是獲取指定對象的成員變量,然后把這個成員變量的值、或者引用放入操作數棧頂。
更具體的說,getfield 這個命令就是在訪問我們 MainTest 類中的 CHARS 變量。
往底層一點的說就是如果沒有局部變量來承接一下,每次通過 getfield 方法都要訪問堆里面的數據。
而讓一個局部變量來承接一下,只需要第一次獲取一次,之后都把這個堆上的數據,“緩存”到局部變量表里面,也就是搞到棧里面去。之后每次只需要調用 aload_
aload_
這一點,從 JVM 文檔中對於這兩個指令的描述的長度也能看出來:
https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-6.html#jvms-6.5.getfield

就不細說了,看到這里你應該明白:把成員變量賦值到局部變量之后再進行操作,確實是一種優化手段,可以達到“avoid getfield opcode”的目的。
看到這里你的心開始有點蠢蠢欲動了,感覺這個代碼很棒啊,我是不是也可以搞一波呢?
不要着急,還有更棒的,我還沒給你講完呢。

stackoverflow
在 Java 里面,我們其實可以看到很多地方都有這樣的寫法,比如我們前面提到的 HashMap 和 String,你仔細看 J.U.C 包里面的源碼,很多都是這樣寫的。
但是,也有很多代碼並沒有這樣寫。
比如在 stackoverflow 就有這樣的一個提問:

提問的哥們說為什么 BigInteger 沒有采用 String 的 trim 方法 “avoid getfield opcode” 這樣的寫法呢?
下面的回答是這樣說的:

在 JVM 中,String 是一個非常重要的類,這種微小的優化可能會提高一點啟動速度。另一方面,BigInteger 對於 JVM 的啟動並不重要。
所以,如果你看了這篇文章,自己也想在代碼里面用這樣的“棒”寫法,三思。
醒醒吧,你才幾個流量呀,值得你優化到這個程度?

而且,我就告訴你,前面字節碼層面是有優化不假,我們都眼見為實了。
但是這個老哥提醒了我:

他提到了 JIT,是這樣說的:這些微小的優化通常是不必要的,這只是減少了方法的字節碼大小,一旦代碼變得足夠熱而被 JIT 優化,它並不真正影響最終生成的匯編。
於是,我在 stackoverflow 上一頓亂翻,終於在萬千線索中,找出了我覺得最有價值的一個。
這個問題,就和文章開頭的讀者問我的可以說一模一樣了:
https://stackoverflow.com/questions/28975415/why-jdk-code-style-uses-a-variable-assignment-and-read-on-the-same-line-eg-i

這個哥們說:在 jdk 源碼中,更具體地說,是在集合框架中,有一個編碼的小癖好,就是在表達式中讀取變量之前,先將其賦值到一個局部變量中。這只是一個簡單的小癖好嗎,還是里面藏着一下我沒有注意到的更重要的東西?
隨后,還有人幫他補充了幾句:

這代碼是 Doug Lea 寫的,小 Lea 子這人吧,經常搞一些出其不意的代碼和優化。他也因為這些“莫名其妙”的代碼聞名,習慣就好了。
然后這個問題下面有個回答是這樣說的:

Doug Lea 是集合框架和並發包的主要作者之一,他編碼的時候傾向於進行一些優化。但是這些優化這可能會違反直覺,讓普通人感到困惑。
畢竟人家是在大氣層。
接着他給出了一段代碼,里面有三個方法,來驗證了不同的寫法生成的不同的字節碼:

三個方法分別如下:

對應的字節碼我就不貼了,直接說結論:
The testSeparate method uses 41 instructions
The testInlined method indeed is a tad smaller, with 39 instructions
Finally, the testRepeated method uses a whopping 63 instructions
同樣的功能,但是最后一種直接使用成員變量的寫法生成的字節碼是最多的。
所以他給出了和我前面一樣的結論:

這種寫法確實可以節省幾個字節的字節碼,這可能就是使用這種方式的原因。
但是...
主要啊,他要開始 but 了:

但是,在不論是哪個方法,在被 JIT 優化之后,產生的機器代碼將與原始字節碼“無關”。
可以非常確定的是:三個版本的代碼最終都會編譯成相同的機器碼(匯編)。
因此,他的建議是:不要使用這種風格,只需編寫易於閱讀和維護的“愚蠢”代碼。你會知道什么時候輪到你使用這些“優化”。
可以看到他在“write dumb code”上附了一個超鏈接,我挺建議你去讀一讀的:
https://www.oracle.com/technical-resources/articles/javase/devinsight-1.html
在這里面,你可以看到《Java Concurrency in Practice》的作者 Brian Goetz:

他對於“dumb code”這個東西的解讀:

他說:通常,在 Java 應用程序中編寫快速代碼的方法是編寫“dumb code”——簡單、干凈,並遵循最明顯的面向對象原則的代碼。
很明顯,tab = table 這種寫法,並不是 “dumb code”。

好了,說回這個問題。這個老哥接着做了進一步的測試,測試結果是這樣的:

他對比了 testSeparate 和 TestInLine 方法經過 JIT 優化之后的匯編,這兩個方法的匯編是相同的。
但是,你要搞清楚的是這個小哥在這里說的是 testSeparate 和 testInLine 方法,這兩個方法都是采用了局部變量的方式:

只是 testSeparate 的可讀性比 testInLine 高了很多。
而 testInLine 的寫法,就是 HashMap 的寫法。
所以,他才說:我們程序員可以只專注於編寫可讀性更強的代碼,而不是搞這些“騷”操作。JIT 會幫我們做好這些東西。
從 testInLine 的方法命名上來看,也可以猜到,這就是個內聯優化。
它提供了一種(非常有限,但有時很方便)“線程安全”的形式:它確保數組的長度(如 HashMap 的 getNode 方法中的 tab 數組)在方法執行時不會改變。
他為什么沒有提到我們更關心的 testRepeated 方法呢?
他也在回答里面提到這一點:

他對之前的一個說法進行了 a minor correction/clarification。
啥意思,直接翻譯過來就是進行一個小的修正或者澄清。用我的話說就是,前面話說的有點滿,現在打臉了,你聽我狡辯一下。
前面他說的是什么?

他說:這都不用看,這三個方法最終生成的匯編肯定是一模一樣的。
但是現在他說的是:
it can not result in the same machine code
它不能產生相同的匯編

最后,這個老哥還補充了這個寫法除了字節碼層面優化之外的另一個好處:
一旦在這里對 n 進行了賦值,在 getNode 這個方法中 n 是不會變的。如果直接使用數組的長度,假設其他方法也同時操作了 HashMap,在 getNode 方法中是有可能感知到這個變化的。
這個小知識點我相信大家都知道,很直觀,不多說了。
但是,看到這里,我們好像還是沒找到問題的答案。
那就接着往下挖吧。
繼續挖
繼續往下挖的線索,其實已經在前面出現過了:

通過這個鏈接,我們可以來到這個地方:
https://stackoverflow.com/questions/2785964/in-arrayblockingqueue-why-copy-final-member-field-into-local-final-variable

瞟一眼我框起來的代碼,你會發現這里拋出的問題其實又是和前面是一樣。
我為什么又要把它拿出來說一次呢?
因為它只是一個跳板而已,我想引出這下面的一個回答:

這個回答說里面有兩個吸引到我注意的地方。
第一個就是這個回答本身,他說:這是該類的作者 Doug Lea 喜歡使用的一種極端優化。這里有個超鏈接,你可以去看看,能很好地回答你的問題。
這里面提到的這個超鏈接,很有故事:
http://mail.openjdk.java.net/pipermail/core-libs-dev/2010-May/004165.html
但是在說這個故事之前,我想先說說這個回答下面的評論,也就是我框起來的部分。
這個評論觀點鮮明的說:需要着重強調“極端”!這不是每個人都應該效仿的、通用的、良好的寫法。
憑借我在 stackoverflow 混了這么幾年的自覺,這里藏龍卧虎,一般來說 說話底氣這么足的,都是大佬。
於是我點了他的名字,去看了一眼,果然是大佬:

這哥們是谷歌的,參與了很多項目,其中就有我們非常熟悉的 Guava,而且不是普通開發者,而是 lead developer。同時也參與了 Google 的 Java 風格指南編寫。
所以他說的話還是很有分量的,得聽。
然后,我們去到那個很有故事的超鏈接。
這個超鏈接里面是一個叫做 Ulf Zibis 的哥們提出的問題:

Ulf 同學的提問里面提到說:在 String 類中,我經常看到成員變量被復制到局部變量。我在想,為什么要做這樣的緩存呢,就這么不信任 JVM 嗎,有沒有人能幫我解答一下?
Ulf 同學的問題和我們文章中的問題也是一樣的,而他這個問題提出的時間是 2010 年,應該是我能找到的關於這個問題最早出現的地方。
所以你要記住,下面的這些郵件中的對話,已經是距今 12 年前的對話了。
在對話中,針對這個問題,有比較官方的回答:

回答他問題這個人叫做 Martin Buchholz,也是 JDK 的開發者之一,Doug Lea 的同事,他在《Java並發編程實戰》一書里面也出現過:

來自 SUN 公司的 JDK 並發大師,就問你怕不怕。
他說:這是一種由 Doug Lea 發起的編碼風格。這是一種極端的優化,可能沒有必要。你可以期待 JIT 做出同樣的優化。但是,對於這類非常底層的代碼來說,寫出的代碼更接近於機器碼也是一件很 nice 的事情。
關於這個問題,這幾個人有來有回的討論了幾個回合:

在郵件的下方,有這樣的鏈接可以點擊,可以看到他們討論的內容:

主要再看看這個叫做 Osvaldo 對線 Martin 的郵件:
https://mail.openjdk.java.net/pipermail/core-libs-dev/2010-May/004168.html

Osvaldo 老哥寫了這么多內容,主要是想噴 Martin 的這句話:這是一種極端的優化,可能沒有必要。你可以期待 JIT 做出同樣的優化。
他說他做了實驗,得出的結論是這個優化對以 Server 模式運行的 Hotspot 來說沒有什么區別,但對於 Client 模式運行的 Hotspot 來說卻非常重要。在他的測試案例中,這種寫法帶來了 6% 的性能提升。
然后他說他現在包括未來幾年寫的代碼應該都會運行在以 Client 模式運行的 Hotspot 中。所以請不要亂動 Doug 特意寫的這種優化代碼,我謝謝你全家。
同時他還提到了 JavaME、JavaFX Mobile&TV,讓我不得不再次提醒你:這段對話發生在 12 年前,他提到的這些技術,在我的眼里已經是過眼雲煙了,只聽過,沒見過。
哦,也不能算沒見過,畢竟當年讀初中的時候還玩過 JavaME 寫的游戲。
就在 Osvaldo 老哥言辭比較激烈的情況下,Martin 還是做出了積極的回應:

Martin 說謝謝你的測試,我也已經把這種編碼風格融合到我的代碼里面了,但是我一直在糾結的事情是是否也要推動大家這樣去做。因為我覺得我們可以在 JIT 層面優化這個事情。
接下來,最后一封郵件,來自一位叫做 David Holmes 的老哥。
巧了,這位老哥的名字在《Java並發編程實戰》一書里面,也可以找到。
人家就是作者,我介紹他的意思就是想表達他的話也是很有分量的:

因為他的這一封郵件,算是給這個問題做了一個最終的回答。

我帶着自己的理解,用我話來給你全文翻譯一下,他是這樣說的:
我已經把這個問題轉給了 hotspot-compiler-dev,讓他們來跟進一下。
我知道當時 Doug 這樣寫的原因是因為當時的編譯器並沒有相應的優化,所以他這樣寫了一下,幫助編譯器進行優化了一波。但是,我認為這個問題至少在 C2 階段早就已經解決了。如果是 C1 沒有解決這個問題的話,我覺得是需要解決一下的。
最后針對這種寫法,我的建議是:在 Java 層面上不應該按照這樣的方式去敲代碼。
There should not be a need to code this way at the Java-level.
至此,問題就梳理的很清楚了。
首先結論是不建議使用這樣的寫法。
其次,Doug 當年這樣寫確實是一種優化,但是隨着編譯器的發展,這種優化下沉到編譯器層面了,它幫我們做了。
最后,如果你不明白前面提到的 C1,C2 的話,那我換個說法。
C1 其實就是 Client Compiler,即客戶端編譯器,特點是編譯時間較短但輸出代碼優化程度較低。
C2 其實就是 Server Compiler,即服務端編譯器,特點是編譯耗時長但輸出代碼優化質量也更高。
前面那個 Osvaldo 說他主要是用客戶端編譯器,也就是 C1。所以后面的 David Holmes 才一直在說 C2 是優化了這個問題的,C1 如果沒有的話可以跟進一下,巴拉巴拉巴拉的...
關於 C2 的話,簡單提一下,記得住就記,記不住也沒關系,這玩意一般面試也不考。
大家常常提到的 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 級。
如果你之前不知道分層編譯這回事,沒關系,現在有這樣的一個概念就行了。
再說一次,面試不會考的,放心。
好了,恭喜你看到這里了。回想全文,你學到了什么東西呢?
是的,除了一個沒啥卵用的知識點外,什么都沒有學到。

本文首發於公眾號why技術,轉載請注明出處和鏈接。