介紹
java 作為靜態語言十分特殊,他需要編譯,但並不是在執行之前就編譯為本地機器碼。
所以,在談到 java的編譯機制的時候,其實應該按時期,分為兩個部分。一個是 javac指令 將java源碼變為 java字節碼的靜態編譯過程。 另一個是 java字節碼編譯為 本地機器碼的過程,並且因為這個過程是在程序運行時期完成的所以稱之為即時編譯。
靜態編譯過程,通過javac 完成,而即時編譯是通過虛擬機來完成的,即時編譯機制,被內嵌於 java字節碼執行引擎之中,可以算的上是 jvm的一個內存組件。
jvm的執行引擎中 有 一個解釋器用來識別字節碼指令,並將字節碼指令映射為機器指令 調用操作系統來完成程序的運行。 這樣來看,雖然實現了 java的跨平台特性,但是 卻以犧牲了極大的的性能為代價。 為了提高java程序的性能,jvm實現了 即時編譯機制。即,在程序運行期間,根據對熱點字節碼的探測(運行次數超過某個閥值的代碼),將這部分熱點代碼進行特別的優化,將其直接編譯為本地機器碼執行。 這個過程由java字節碼執行引擎中的 兩個編譯器完成,C1與C2編輯器,一個用於客戶端,一個用於服務器。 c1相比較與c2他的編譯優化程度要低一些,c2將針對服務器進行一些激進的優化,以保證代碼在服務器運行時性能更加突出。
分層編譯:
現代虛擬機實現中,制定了多種不同的編譯級別以達到適應多種開發場景的目的。 即時編譯機制本身也需要占用用戶內存。造成一定的內存開銷,在一些場景下可能會造成較高的延遲。
同樣,對於服務器端程序來說會長久的運行,花銷一定的編譯時間可以換來之后更高的性能,所以直接全部編譯可能效果更佳。
而還存在一些 很久都不會使用一次的代碼,編譯這些代碼就顯得浪費時間。
為了解決上述的問題, 現代虛擬機,提出了 分層編譯策略,類似於 分代垃圾回收機制,一種根據不同時期場景調整編譯級別的優化策略。
分層編譯分為 三層:
一層: 僅進行解釋執行,c1與c2編譯器被禁用。 這時不存在即時編譯情況。
二層: 僅c1編譯器運行,c1編譯器是客戶端編譯器,僅會進行一些常規的 編譯優化機制。使用大多數情況。
三層: 混合編譯 c1與c2同時使用,c2編譯器是服務端編譯器,可以對代碼進行 高性能的 激進優化,同樣設定逃生門,在一些特殊情況下,激進優化后的代碼並不能有更高的性能。需要進行優化回退,將重新對代碼進行解釋執行。
對於分層編譯來說代碼的編譯優化級別是可以提升的,也可以使用 jvm參數進行控制。
即時編譯的基本流程:
方法調用棧上運行着 方法棧幀,即時編譯的流程從這里開始:
字節碼開始是解釋執行的,解釋字節碼的任務由解釋器完成,但真實操作的是內存中的 方法棧中棧幀內的操作數棧與局部變量表。所以 java程序解釋執行時運行速度相對較慢。
java程序的執行伴隨着 棧幀的彈棧出棧(方法調用)以及pc寄存器的順序執行及跳轉。 即時編譯的第一步就是要 探測 熱點代碼.
使用 熱點探測技術 來統計 熱點代碼。 熱點探測技術 實質就是 統計 某段代碼頻繁調用的次數,一旦超過指定的閾值就會觸發即時編譯。
觸發條件: 熱點探測
jvm 通過統計 每個方法調用棧的棧頂 一個方法棧幀的彈出頻率 來作為一個指標。有兩種方法,第一使用精確的計數器進行精確計數,超過閾值觸發編譯。二是記錄一段時間內方法調用次數(方法調用的頻 率) 超過閾值觸發編譯。並存在熱度衰減,超過一定時間范圍沒有繼續調用 該方法則會 將其值減半。 二者各有優缺點,前者 精確計算開銷大,后者不夠嚴謹但適用大多數情況。
一旦超過閾值將觸發方法級別的即時編譯,以整個方法為編譯對象。
還存在 循環體級別的熱點探測,適用回邊計數器來進行計數,pc寄存器向后跳轉一次記為 一個回邊。 當每次跳轉時,都會觸發計數器加一,並將計數器的值與該循環體所在方法的頻率計數器的值相加。
其值超過閾值就會觸發即時編譯,若沒超過閾值並不存在半衰,繼續以解釋形式執行代碼。
循環體級別的探測,也是會將整個方法進行編譯的。
即時編譯:
一旦判定代碼段是熱點代碼,則解釋器將發送一次請求編譯器,進行編譯,在編譯成功之前 解釋器仍舊運行着。 等編譯完成后,直接將pc寄存器中方法的調用地址進行替換,替換為編譯后的方法地址。
這一過程就是 棧上替換---OSR.
編譯優化:
javac只能進行一些 靜態優化,優化上存在一些局限性。而在jvm中即時編譯過程中進行的優化,是一種動態編譯優化。
即時編譯器會進行很多優化,介紹幾種比較 經典的優化。
公共子表達式的消除:
在一個表達式中 有一部門表達式被計算過,並且在之后的代碼中出現了同樣的表達式並且表達式的值沒有發生改變。那么編譯器就會將 這部分表達式用計算結果進行替換。以避免重復計算造成的時間開銷。
方法內聯:
c/c++這種靜態編譯的語言,實現方法內聯是很簡單的,但java作為動態編譯語言,方法內聯存在不確定性。
在編譯時,將方法調用 直接使用 方法體中的代碼進行替換,這就是方法內聯,這樣做,減少了 方法調用過程中 壓棧與入棧的開銷。同時 為之后的一些優化手段提供條件。
對非虛方法進行內聯是容易的,但對虛方法而言就比較復雜了,需要禁用 運行時類型繼承分析機制 來確定虛方法的實際調用者。 因為多態機制的存在,方法的調用者僅在運行時期才能知曉。並且會發生改變。 這就要求對虛方法的內聯必須存在 逃生門,可以在 方法調用者,也就是繼承關系發生變化時 取消內聯。
逃逸分析:
如果一個變量的使用,在運行期檢測 他的作用范圍不會超過一個方法或者一個線程的作用域。那么這個變量就不會被多個線程所共享,也就是說 可以不將其分配在堆空間中,而是將其線程私有化。
那么 如何來檢測一個變量的作用域僅在 一個方法或者線程中呢? jvm中使用 數據流分析機制 實現的一種機制。 稱之為 逃逸分析,作為其他一些激進優化的前提判斷條件。
棧上分配:
如果一個變量經過逃逸分析后,判定可以被線程私有的,那么jvm將進行 一個大膽的優化手段, 棧上分配。 java 僅允許在 堆空間創建對象,但jvm的發展已經打破了這一規定。 如果一個對象,注定是線程私有的 那么為什么要放在堆空間,GC的回收以及主存與工作內存的同步都需要消耗大量資源。 而放在棧空間則不在需要擔憂這些,對象將跟隨棧的創建而創建,銷毀而銷毀。
標量替換:
標量,指的是 jvm中描述數據最基本的單位。 列如 原始數據類型等。
當確定一個對象不會逃逸后,那么就要分配他到棧空間上,然而棧空間是有限的,為了進一節省棧空間,就需要將 對象(聚合量) 拆散為標量。 這樣 在jvm不會在棧中創建 對象而是僅僅創建對象的成員變量。
這樣就節省了空間,因為沒有對象頭以及對齊填充的空間浪費。
同步消除:
同樣基於 逃逸分析,當加鎖的變量不會發生逃逸,是線程私有的那么,完全沒有必要加鎖。 在jit編譯時期就可以將同步鎖去掉,以減少 加鎖與解鎖造成的資源開銷。