天天看點

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

摘要:為什麼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++無法做到的。

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