15個問題自查你真的瞭解java編譯優化嗎?

華為雲開發者社群發表於2021-10-30
摘要:為什麼C++的編譯速度會比java慢很多?二者執行程式的速度差異在哪? 瞭解了java的早期和晚期過程,就能理解這個問題了。

本文分享自華為雲社群《你真的瞭解java編譯優化嗎?15個問題考察自己是否理解》,作者:breakDraw 。

首先提出一個問題,為什麼C++的編譯速度會比java慢很多?二者執行程式的速度差異在哪? 瞭解了java的早期和晚期過程,就能理解這個問題了。

這裡會提15個問題確認是否真的理解,如果完全沒這方面的概念,則好好看一下文章末尾的“jvm編譯優化筆記”章節。

早期編譯過程

Q: java早期編譯過程分為哪3步?
A:

  1. 詞法語法解析、填充符號表
  2. 註解處理
  3. 語義分析與位元組碼生成。

Q: 上面的步驟中, 符號表是幹嗎的?
A:
符號表是符號地址和符號資訊構成的表格。

  • 用於後面階段做語法檢查時,從表裡取出資訊進行對比。
  • 符號表是目的碼生成時的地址分配的依據

Q: 註解處理器做的什麼事情?
A: 註解處理器會掃描抽象語法樹中帶註解的元素, 並進行語法樹的更新。
重點就是他是基於語法樹做更新。
更新之後我們會重新走回解析與填充的過程,重新處理。

Q: 上面的3個步驟中, 解語法糖是哪一步?
A:
是第三步,在生成位元組碼的時候才做的語法糖處理。

Q: 什麼是解語法糖?大概有哪些?
A:

  • 虛擬機器本身不支援這種語法, 但是會在編譯階段 把這些語法糖轉為 普通的語法結構。
  • 包含自動裝拆箱、 泛型強轉應用。

Q: 生成位元組碼class檔案的時候, final和非final的區域性變數, 會有區別不?
A:
沒有區別。
區域性變數不會在常量池中持有符號引用, 所以不會有acesses_flasg資訊。
** 因此final區域性變數在執行期沒有任何作用, 只會在編譯期去校驗。**

Q: a= 1 + 2會在什麼階段進行優化?
A: 會在早期編譯過程的語義分析過程中,進行常量摺疊, 變成a=3
同理, 字串+號優化成stringBuilder.append()這個動作也是該階段優化的。

Q: 類物件載入的過程有一堆順序(具體見類初始化順序, 這個順序在位元組碼中體現的嗎?還是執行的時候再判斷順序?
A:
位元組碼中體現的。

  • 在位元組碼生成時, 編譯器針對物件new的過程,會生成了一個<init>方法,裡面寫明瞭成員、構造方法的呼叫順序。
  • 類靜態成員的呼叫順序同理封裝在<cinit>中。

晚期編譯優化

Q:
早期編譯優化和晚期編譯優化的區別?
A:

  • 早期編譯優化, 是把 java檔案轉成位元組碼,轉位元組碼的過程中做一些簡單優化和語法糖處理。
  • 晚期編譯優化,是將位元組碼轉機器碼執行的過程中,結合一些資訊進行動態優化,或者應用上很多的機器碼優化措施。

Q: java程式執行的時候,是直接全部轉成優化後的機器碼再執行嗎?
A:
錯誤。

  • 當程式剛啟動時,會先馬上使用直譯器發揮作用,這時候沒做太多優化,直接解釋執行。
  • 在程式執行後, 編譯器逐步發揮作用,把還沒用到的程式碼逐步編譯成機器碼。

注意這裡的編譯器和之前提到的編譯器的區別,一個是編譯成位元組碼,另一個是編譯成機器碼。

Q: 有兩種晚期優化編譯器

  • Client Compiler ——C1編譯器
  • Server Compiler——C2編譯器
    他們二者的區別是什麼?

A:

  • 速度和質量的區別:
    C1編譯器, 更高的編譯速度,編譯質量一般。
    C2編譯器, 更好的編譯質量,但是速度慢。
  • 優化特性的區別
    C1編譯器都是一些不需要執行期資訊就能做優化的操作。
    C2編譯器則會根據直譯器提供的監控資訊,進行激進且動態的優化

Q: java中怎麼區分用C1還是C2?
A:
關於這2種編譯器的引數:

  • -Xint引數: 強制使用解釋模式
  • -Xcomp引數: 強制使用編譯模式( 但是如果編譯無法進行時, 解釋會介入)
  • 選擇編譯模式時,有-client、-server還有MixedMode(混合模式)可以選擇

混合模式中, JDK7引入了分層編譯策略:
第0層: 解釋執行。 不開啟效能監控。
第1層: C1編譯, 把位元組碼編譯為原生程式碼, 進行一些簡單優化, 加入效能監控
第2層: C2編譯, 啟動耗時較長的優化, 根據效能監控資訊進行激進優化

Q: 分層優化中,如果正在執行,jvm是怎麼知道需要對哪些程式碼做JIT或者OSR優化?
A:

  1. 被多次呼叫的方法。 會觸發JIT編譯(熱點程式碼計數器)
  2. 被多次執行的迴圈體, 會觸發OSR編譯(棧上替換), 發生在方法執行過程中, 所以是在棧上編譯並切換方法。(使用回邊計數器)

Q: 哪些方法會在早期優化中做內聯,哪些方法會在晚期優化中做內聯?
A:

  • 不能被繼承重寫的方法,比如私有、構造器、靜態之類的方法,可以直接在早期優化中做內聯優化。
  • 其他會被抽象繼承實現的方法在早期無法做內聯,因為他不知道實際是用哪一段程式碼.
  • 晚期優化中可以根據一些執行資訊,判斷是否總是隻用某個子類方法跑,是的話做一下嘗試內聯,如果後面來了其他的子類就切回去。

Q: java陣列一般都會自動做邊界檢查,不滿足就拋異常。 什麼情況下會優化掉這個自動檢查?
A:
執行期,發現傳入的引數放到陣列中用的時候, 肯定不會超出邊界,則會優化掉這個檢查動作。

看完上面的,就可以給出C++和java編譯和執行速度差距的原因了:

  1. java即時編譯可能會影響使用者體驗,如果在執行中出現較大影響的延遲的話。
  2. java中虛方法比C++要多, 因為做各種內聯分析消耗的檢查和優化的就越多越大
  3. java中總是要做安全檢查, C++中不做,出錯了我就直接崩潰了越界了
  4. C++中記憶體釋放讓使用者控制, 無需後臺弄一個垃圾回收器總是去檢查和操作
  5. java好處: 即時編譯能夠以執行期的效能監控進行優化,這個是C++無法做到的

jvm編譯優化學習筆記

早期

編譯過程大致分為3類:

  1. 解析與填充符號表
  2. 註解處理
  3. 分析與位元組碼生成
    關鍵點:
  • 詞法語法解析是第一步,生成符號
  • 註解處理是第二步
  • 然後語法糖、位元組碼都是第三步的事情。

上述步驟的詳細解釋:

第一步:

-------詞法分析:

就是程式碼轉成token標記。
例如int a=b+2 轉成 Int \a=\b+\2 這6個token。

-------語法分析(注意實際上只是生成一個語法樹,還沒做語法的校驗):

根據生成的token,構造一個抽象語法樹。

-------填充符號表:

生成一個符號地址和符號資訊構成的表格。
(後面第三步的階段會用於語義分析中的標註檢查, 比如名字的使用是否和說明一致,也會用於產生中間程式碼)
符號表是目的碼生成時的地址分配的依據

第二步:

-------註解處理器:

註解處理器會掃描抽象語法樹中帶註解的元素, 並進行語法樹的更新。
更新之後我們會重新走回解析與填充的過程,重新處理。
這個處理器是一種外掛,我們可以自己不斷往其中去新增。

注意,上面這2步只是簡單去對原始檔做轉換, 還不涉及任何語法相關的規則。

第三步:

-------語義分析:

判斷語法樹是否正確。分為2種檢查:

  1. 標註檢查: 檢查變數是否已被宣告、 賦值、等式的資料型別是否匹配
    標註檢查中會進行常量摺疊, 把a=1+2摺疊成3
    標註檢查的範圍比較小,不會有太多上下文依賴。
  2. 資料及控制流分析
    對程式上下文邏輯更進一步驗證
    這裡會涉及很多互動的上下文互動依賴
    比如 帶返回值的方法是否全路徑都包含了返回、 受檢異常是否被外部處理、區域性變數使用前是否被賦值。

final 區域性變數(或者final引數)和非final區域性變數,生成的class檔案沒有區別。
因為區域性變數不會在常量池中持有符號引用, 所以不會有acesses_flasg資訊。
所以class檔案不知道區域性變數是不是final, 因此final區域性對執行期沒有任何影響, 只會在編譯期去校驗。

-------解語法糖:

虛擬機器本身不支援這種語法, 但是會在編譯階段 把這些語法糖轉為 普通的語法結構(換句話說做了把語法糖程式碼變成了普通程式碼, 例如自動裝拆箱,可能就是轉成了包裝方法的特定呼叫)

-------位元組碼生成:

物件的初始化順序, 實際上會在位元組碼生成階段, 收斂到一個<init>方法中。 即init中控制了那些成員、以及構造方法的呼叫順序
類初始化同理,也是收斂到一個 <cinit>中
PS: 注意,預設構造器是在填充符號表階段完成的。
字串的替換(+操作轉成sb) 是在位元組碼階段生成的。
完成了對語法樹的遍歷之後,會把最終的符號表交給ClassWRITE類,設計概念從一個位元組碼和檔案

晚期

HotSpot中, 直譯器與編譯器共存
當程式剛啟動時,會先馬上使用直譯器發揮作用。
在程式執行後, 編譯器逐步發揮作用,把還沒用到的程式碼逐步編譯。
記憶體資源比較少的情況下,可以用直譯器來跑程式,減少編譯生成的檔案。
如果編譯器的優化出現bug,可以通過“逆優化”回退到最初的直譯器模式來執行

直譯器Interperter

編譯器

有兩種編譯器

  • Client Compiler ——C1編譯器, 更高的編譯速度
  • Server Compiler——C2編譯器, 更好的編譯質量
    即選擇了-client或者-server時會用到。
  • 預設混合模式: 直譯器和編譯器共存, 即MixedMode。

關於這2種編譯器的引數:

  • -Xint引數: 強制使用解釋模式
  • -Xcomp引數: 強制使用編譯模式( 但是如果編譯無法進行時, 解釋會介入)

混合模式中, 直譯器需要收集效能資訊,提供給編譯階段判斷和優化, 這個效能資訊有點浪費
因此JDK7引入了分層編譯策略:

  • 第0層: 解釋執行。 不開啟效能監控。
  • 第1層: C1編譯, 把位元組碼編譯為原生程式碼, 進行一些簡單優化, 加入效能監控
  • 第2層: C2編譯, 啟動耗時較長的優化, 根據效能監控資訊進行激進優化

CC和SC編譯過程的區別:

  • client Compiler 編譯過程:
    前端位元組碼-》 方法內聯/常量傳播(基礎優化)-》 HIR(高階中間程式碼)-》 空值檢查消除/範圍檢查消除
    -》 後端把HIR轉成LLR(低階中間程式碼)-》 線性掃描演算法分配暫存器-》窺孔優化-》機器碼生成-》原生程式碼生成
    都是一些不需要執行期資訊就能做優化的操作
  • serverCompiler編譯過程:
    會執行所有的經典優化動作
    會根據cc或者直譯器提供的監控資訊,進行激進的優化
    暫存器分配器是一個全域性圖著色分配器

晚期優化的一些常見措施(即執行中才會做優化的步驟)

----熱點程式碼

  1. 被多次呼叫的方法。 會觸發JIT編譯
  2. 被多次執行的迴圈體, 會觸發OSR編譯(棧上替換), 發生在方法執行過程中, 所以是在棧上編譯並切換方法。

HotSpot 使用 計數器的熱點探測法確定熱點程式碼。
* 給每個方法建立方法計數器, 在一個週期中如果超過閾值, 就觸發JIT編譯,編譯後替換方法入口。
* 如果一個週期內沒超過,則計數器/2(半衰)
* 如果沒有觸發時, 都是用解釋方式 按照位元組碼內容死板地執行。

該計數器的相關引數
-XX:-UserCounterDecay 關閉熱度衰減
-XX: CounterHalfLifeTime 設定半衰期-XX:CompileThreshold 設定方法編譯閾值

回邊計數器就是計算迴圈次數的計數器
* 沒有半衰
* 但是當觸發OSR編譯時,會把計數器降低,避免還在執行時重複觸發。
* 會溢位, 並且會把方法計數器也調整到溢位。
* clint模式和server模式中, OSR的閾值計算公式不同, clint= CompileThredshold * osr比率, server= CompileThredshold * (osr比率 - 直譯器監控比率)

—冗餘訪問消除:

如果已經拿到了 a.value, 該方法內a.value一定不會變的話, 那麼後續用到時就不再從a中取value了
複寫傳播:

y=b.value
z=y
c = z + y

變成

y = b.value
y = y
c = y + y

無用程式碼消除:
去掉上面的Y=y

----公共子表示式消除

就是對一些比較長的計算公式做化簡
a+(a+b)2
會優化成
a3+b*2
儘可能減少計算次數

—陣列邊界檢查:

如果能確定某個for迴圈裡的陣列取值操作一定不會超出陣列範圍,那麼在做[]取值操作時,不會做陣列邊界檢查。

—隱式異常處理:

if(a == null) {
xxx
}
else{
throw Exception
}
優化成
try {
xxx
} catch(Exception e) {
throw e
}

----方法內聯:

不能被繼承重寫的方法,比如私有、構造器、靜態之類的方法,可以直接在早期優化中做內聯優化。
而其他會被抽象繼承實現的方法在編譯器無法做內聯,因為他不知道實際是用哪一段程式碼。

  • final方法並不是非虛方法(為什麼呢)
  • 型別繼承關係分析CHA: 如果發現虛方法,CHA會查一下當前虛擬機器內該方法是否有多個實現, 如果發現只有這一種實現,那麼就可以直接內聯。
  • 如果後續有其他的class動態載入進來後,該方法有多個實現了,並且被使用到了,那麼就會拋棄已編譯的內聯程式碼,回退到解釋狀態執行。
  • 內聯快取: 即使程式中發現該方法有多個實現, 依然對第一個使用的那個方法做內聯,除非有其他重寫方法被呼叫(即雖然你定義了,但是你很可能不用,所以我一直使用你的第一個方法,除非你真的用了多種重寫方法去跑。

----逃逸分析:

分析new 出來的物件是否不會逃逸到方法外, 如果確認只在方法內使用,外部不會有人引用他, 那麼就會做優化,比如:
* 不把new出來的物件放到堆,而是放到方法棧上,方法結束了物件直接消失。
* 不需要對這種物件做加鎖、同步操作了
* 標量替換: 把這個物件裡的最小基本型別成員拆出來作為區域性變數使用。

----java和C++, 即時編譯和靜態編譯的區別:

  1. 即時編譯可能會影響使用者體驗,如果在執行中出現較大影響的延遲的話

java中虛方法比C++要多, 因為做各種內聯分析消耗的檢查和優化的就越多越大
3.
java中總是要做安全檢查, C++中不做,出錯了我就直接崩潰了越界了
4.
C++中記憶體釋放讓使用者控制, 無需後臺弄一個垃圾回收器總是去檢查和操作
5.
java好處: 即時編譯能夠以執行期的效能監控進行優化,這個是C++無法做到的。

 

點選關注,第一時間瞭解華為雲新鮮技術~

相關文章