摘要:為什麼C++的編譯速度會比java慢很多?二者運作程式的速度差異在哪? 了解了java的早期和晚期過程,就能了解這個問題了。
本文分享自華為雲社群《你真的了解java編譯優化嗎?15個問題考察自己是否了解》,作者:breakDraw 。
首先提出一個問題,為什麼C++的編譯速度會比java慢很多?二者運作程式的速度差異在哪? 了解了java的早期和晚期過程,就能了解這個問題了。
這裡會提15個問題确認是否真的了解,如果完全沒這方面的概念,則好好看一下文章末尾的“jvm編譯優化筆記”章節。
Q: java早期編譯過程分為哪3步?
A:
詞法文法解析、填充符号表
注解處理
語義分析與位元組碼生成。
Q: 上面的步驟中, 符号表是幹嗎的?
符号表是符号位址和符号資訊構成的表格。
用于後面階段做文法檢查時,從表裡取出資訊進行對比。
符号表是目标代碼生成時的位址配置設定的依據
Q: 注解處理器做的什麼事情?
A: 注解處理器會掃描抽象文法樹中帶注解的元素, 并進行文法樹的更新。
重點就是他是基于文法樹做更新。
更新之後我們會重新走回解析與填充的過程,重新處理。
Q: 上面的3個步驟中, 解文法糖是哪一步?
是第三步,在生成位元組碼的時候才做的文法糖處理。
Q: 什麼是解文法糖?大概有哪些?
虛拟機本身不支援這種文法, 但是會在編譯階段 把這些文法糖轉為 普通的文法結構。
包含自動裝拆箱、 泛型強轉應用。
Q: 生成位元組碼class檔案的時候, final和非final的局部變量, 會有差別不?
沒有差別。
局部變量不會在常量池中持有符号引用, 是以不會有acesses_flasg資訊。
** 是以final局部變量在運作期沒有任何作用, 隻會在編譯期去校驗。**
Q: a= 1 + 2會在什麼階段進行優化?
A: 會在早期編譯過程的語義分析過程中,進行常量折疊, 變成a=3
同理, 字元串+号優化成stringBuilder.append()這個動作也是該階段優化的。
Q: 類對象加載的過程有一堆順序(具體見類初始化順序, 這個順序在位元組碼中展現的嗎?還是運作的時候再判斷順序?
位元組碼中展現的。
在位元組碼生成時, 編譯器針對對象new的過程,會生成了一個<init>方法,裡面寫明了成員、構造方法的調用順序。
類靜态成員的調用順序同理封裝在<cinit>中。
Q:
早期編譯優化和晚期編譯優化的差別?
早期編譯優化, 是把 java檔案轉成位元組碼,轉位元組碼的過程中做一些簡單優化和文法糖處理。
晚期編譯優化,是将位元組碼轉機器碼執行的過程中,結合一些資訊進行動态優化,或者應用上很多的機器碼優化措施。
Q: java程式運作的時候,是直接全部轉成優化後的機器碼再運作嗎?
錯誤。
當程式剛啟動時,會先馬上使用解釋器發揮作用,這時候沒做太多優化,直接解釋執行。
在程式運作後, 編譯器逐漸發揮作用,把還沒用到的代碼逐漸編譯成機器碼。
注意這裡的編譯器和之前提到的編譯器的差別,一個是編譯成位元組碼,另一個是編譯成機器碼。
Q: 有兩種晚期優化編譯器
Client Compiler ——C1編譯器
Server Compiler——C2編譯器
他們二者的差別是什麼?
速度和品質的差別:
C1編譯器, 更高的編譯速度,編譯品質一般。
C2編譯器, 更好的編譯品質,但是速度慢。
優化特性的差別
C1編譯器都是一些不需要運作期資訊就能做優化的操作。
C2編譯器則會根據解釋器提供的監控資訊,進行激進且動态的優化
Q: java中怎麼區分用C1還是C2?
關于這2種編譯器的參數:
-Xint參數: 強制使用解釋模式
-Xcomp參數: 強制使用編譯模式( 但是如果編譯無法進行時, 解釋會介入)
選擇編譯模式時,有-client、-server還有MixedMode(混合模式)可以選擇
混合模式中, JDK7引入了分層編譯政策:
第0層: 解釋執行。 不開啟性能監控。
第1層: C1編譯, 把位元組碼編譯為本地代碼, 進行一些簡單優化, 加入性能監控
第2層: C2編譯, 啟動耗時較長的優化, 根據性能監控資訊進行激進優化
Q: 分層優化中,如果正在運作,jvm是怎麼知道需要對哪些代碼做JIT或者OSR優化?
被多次調用的方法。 會觸發JIT編譯(熱點代碼計數器)
被多次執行的循環體, 會觸發OSR編譯(棧上替換), 發生在方法執行過程中, 是以是在棧上編譯并切換方法。(使用回邊計數器)
Q: 哪些方法會在早期優化中做内聯,哪些方法會在晚期優化中做内聯?
不能被繼承重寫的方法,比如私有、構造器、靜态之類的方法,可以直接在早期優化中做内聯優化。
其他會被抽象繼承實作的方法在早期無法做内聯,因為他不知道實際是用哪一段代碼.
晚期優化中可以根據一些運作資訊,判斷是否總是隻用某個子類方法跑,是的話做一下嘗試内聯,如果後面來了其他的子類就切回去。
Q: java數組一般都會自動做邊界檢查,不滿足就抛異常。 什麼情況下會優化掉這個自動檢查?
運作期,發現傳入的參數放到數組中用的時候, 肯定不會超出邊界,則會優化掉這個檢查動作。
看完上面的,就可以給出C++和java編譯和運作速度差距的原因了:
java即時編譯可能會影響使用者體驗,如果在運作中出現較大影響的延遲的話。
java中虛方法比C++要多, 因為做各種内聯分析消耗的檢查和優化的就越多越大
java中總是要做安全檢查, C++中不做,出錯了我就直接崩潰了越界了
C++中記憶體釋放讓使用者控制, 無需背景弄一個垃圾回收器總是去檢查和操作
java好處: 即時編譯能夠以運作期的性能監控進行優化,這個是C++無法做到的
編譯過程大緻分為3類:
解析與填充符号表
分析與位元組碼生成
關鍵點:
詞法文法解析是第一步,生成符号
注解處理是第二步
然後文法糖、位元組碼都是第三步的事情。
上述步驟的詳細解釋:
就是代碼轉成token标記。
例如int a=b+2 轉成 Int \a=\b+\2 這6個token。
根據生成的token,構造一個抽象文法樹。
生成一個符号位址和符号資訊構成的表格。
(後面第三步的階段會用于語義分析中的标注檢查, 比如名字的使用是否和說明一緻,也會用于産生中間代碼)
注解處理器會掃描抽象文法樹中帶注解的元素, 并進行文法樹的更新。
這個處理器是一種插件,我們可以自己不斷往其中去添加。
注意,上面這2步隻是簡單去對源檔案做轉換, 還不涉及任何文法相關的規則。
判斷文法樹是否正确。分為2種檢查:
标注檢查: 檢查變量是否已被聲明、 指派、等式的資料類型是否比對
标注檢查中會進行常量折疊, 把a=1+2折疊成3
标注檢查的範圍比較小,不會有太多上下文依賴。
資料及控制流分析
對程式上下文邏輯更進一步驗證
這裡會涉及很多互動的上下文互動依賴
比如 帶傳回值的方法是否全路徑都包含了傳回、 受檢異常是否被外部處理、局部變量使用前是否被指派。
final 局部變量(或者final參數)和非final局部變量,生成的class檔案沒有差別。
因為局部變量不會在常量池中持有符号引用, 是以不會有acesses_flasg資訊。
是以class檔案不知道局部變量是不是final, 是以final局部對運作期沒有任何影響, 隻會在編譯期去校驗。
虛拟機本身不支援這種文法, 但是會在編譯階段 把這些文法糖轉為 普通的文法結構(換句話說做了把文法糖代碼變成了普通代碼, 例如自動裝拆箱,可能就是轉成了包裝方法的特定調用)
對象的初始化順序, 實際上會在位元組碼生成階段, 收斂到一個<init>方法中。 即init中控制了那些成員、以及構造方法的調用順序
類初始化同理,也是收斂到一個 <cinit>中
PS: 注意,預設構造器是在填充符号表階段完成的。
字元串的替換(+操作轉成sb) 是在位元組碼階段生成的。
完成了對文法樹的周遊之後,會把最終的符号表交給ClassWRITE類,設計概念從一個位元組碼和檔案
HotSpot中, 解釋器與編譯器共存。
當程式剛啟動時,會先馬上使用解釋器發揮作用。
在程式運作後, 編譯器逐漸發揮作用,把還沒用到的代碼逐漸編譯。
記憶體資源比較少的情況下,可以用解釋器來跑程式,減少編譯生成的檔案。
如果編譯器的優化出現bug,可以通過“逆優化”回退到最初的解釋器模式來運作
有兩種編譯器
Client Compiler ——C1編譯器, 更高的編譯速度
Server Compiler——C2編譯器, 更好的編譯品質
即選擇了-client或者-server時會用到。
預設混合模式: 解釋器和編譯器共存, 即MixedMode。
混合模式中, 解釋器需要收集性能資訊,提供給編譯階段判斷和優化, 這個性能資訊有點浪費
是以JDK7引入了分層編譯政策:
CC和SC編譯過程的差別:
client Compiler 編譯過程:
前端位元組碼-》 方法内聯/常量傳播(基礎優化)-》 HIR(進階中間代碼)-》 空值檢查消除/範圍檢查消除
-》 後端把HIR轉成LLR(低級中間代碼)-》 線性掃描算法配置設定寄存器-》窺孔優化-》機器碼生成-》本地代碼生成
都是一些不需要運作期資訊就能做優化的操作
serverCompiler編譯過程:
會執行所有的經典優化動作
會根據cc或者解釋器提供的監控資訊,進行激進的優化
寄存器配置設定器是一個全局圖着色配置設定器
被多次調用的方法。 會觸發JIT編譯
被多次執行的循環體, 會觸發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=y
就是對一些比較長的計算公式做化簡
a+(a+b)2
會優化成
a3+b*2
盡可能減少計算次數
如果能确定某個for循環裡的數組取值操作一定不會超出數組範圍,那麼在做[]取值操作時,不會做數組邊界檢查。
而其他會被抽象繼承實作的方法在編譯器無法做内聯,因為他不知道實際是用哪一段代碼。
final方法并不是非虛方法(為什麼呢)
類型繼承關系分析CHA: 如果發現虛方法,CHA會查一下目前虛拟機内該方法是否有多個實作, 如果發現隻有這一種實作,那麼就可以直接内聯。
如果後續有其他的class動态加載進來後,該方法有多個實作了,并且被使用到了,那麼就會抛棄已編譯的内聯代碼,回退到解釋狀态執行。
内聯緩存: 即使程式中發現該方法有多個實作, 依然對第一個使用的那個方法做内聯,除非有其他重寫方法被調用(即雖然你定義了,但是你很可能不用,是以我一直使用你的第一個方法,除非你真的用了多種重寫方法去跑。
分析new 出來的對象是否不會逃逸到方法外, 如果确認隻在方法内使用,外部不會有人引用他, 那麼就會做優化,比如:
* 不把new出來的對象放到堆,而是放到方法棧上,方法結束了對象直接消失。
* 不需要對這種對象做加鎖、同步操作了
* 标量替換: 把這個對象裡的最小基本類型成員拆出來作為局部變量使用。
即時編譯可能會影響使用者體驗,如果在運作中出現較大影響的延遲的話
3.
4.
5.
java好處: 即時編譯能夠以運作期的性能監控進行優化,這個是C++無法做到的。
點選關注,第一時間了解華為雲新鮮技術~