從原始碼裡的一個註釋,我追溯到了12年前,有點意思。

why技術發表於2022-04-18

你好呀,我是歪歪。

那天我正在用鍵盤瘋狂的輸出:

突然微信彈出一個訊息,是一個讀者發給我的。

我點開一看:

啊,這熟悉的味道,一看就是 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_ 的操作,比起 getfield 來說,是一個更加輕量級的操作。

這一點,從 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技術,轉載請註明出處和連結。

相關文章