JVM(1)---虛擬機器在執行期的優化策略

帥地發表於2018-08-10

1.直譯器與JIT編譯器

首先我們先來了解一下執行在虛擬機器之上的直譯器JIT編譯器
當我們的虛擬機器在執行一個java程式的時候,它可以採用兩種方式來執行這個java程式:

  1. 採用直譯器的形式,也就是說,在執行.class執行的時候,直譯器一邊把.class檔案翻譯成本地機器碼,一邊執行。顯然這種一邊解釋翻譯一邊執行發方式,可以使我們立即啟動和執行程式,省去編譯的時間。不過由於需要一遍解釋翻譯,會讓程式的執行速度比較慢。
  2. 採用JIT編譯器的方式:注意,JIT編譯器是把.class檔案翻譯成本地機器碼,而javac編譯器是把.java原始檔編譯成.class檔案。如果採用JIT編譯器的方式則是在啟動執行一個程式的時候,先把.class檔案全部翻譯成本地機器碼,然後再來執行,顯然,這種方式在執行的時候由於不用對.clasa檔案進行翻譯,所以執行的速度會比較快。當然,代價就是我們需要花銷一定的時間來把位元組碼翻譯成本地機器碼。這樣,程式在啟動的時候,會有更多的延遲。

這兩種方式可以說是各有優勢,虛擬機器(特指HotSpot虛擬機器)在執行的時候,一般會採用兩種方式結合的策略。

也就是說,在程式執行的時候,有些程式碼採用直譯器的方式,有些程式碼採用編譯器,稱之為即時編譯。一般我們會對熱點程式碼採用編譯器的方式。

2.編譯物件與觸發條件

上面已經說了,執行過程中,如果遇到熱點程式碼就會觸發對該程式碼進行編譯,編譯成本地機器碼。
什麼是熱點程式碼?
熱點程式碼主要有一下兩類:

  1. 被多次呼叫的方法。
  2. 被多次執行的迴圈體。
    不過這裡需要注意的是,由於迴圈體是存在方法之中的,儘管編譯動作是由迴圈體觸發的,但編譯器仍然會以這個方法來作為編譯的物件。

3.熱點探測

判斷一段程式碼是不是熱點程式碼,是不是需要觸發即時編譯,這樣的行為我們稱之為熱點探測。熱點探測判定有以下兩種方式:

  1. 基於取樣的熱點探測:這種方式虛擬機器會週期性著檢查各個執行緒的棧頂,如果發現某個方法經常出現在棧頂,那麼這個方法就是熱點方法。可能有人會問,所謂經常,那什麼樣才算經常,對於這個我只能告訴你,這個取決於你自己的設定,如果自己沒有進行相應的設定的話,就採用虛擬機器的預設設定。
  2. 基於計數器的熱點探測:這種方法我們會為每個方法設定一個計數器,統計方法被呼叫的次數,如果到達一定的次數,我們就把它當作是熱點方法

兩種方法的優缺點
顯然第一種方法在實現上是比較簡單、高效的,但是缺點也很明顯,精確度不高,容易受到執行緒阻塞等別的外界因素的干擾。
第二種方式的統計結果會很精確,但需要為每個方法建立並維護一個計數器。實現上會相對複雜一點並且開銷也會大點。
不過,這裡需要指出的是,我們的HotSpot虛擬機器採用的是基於計數器的方式。

說明:虛擬機器在執行方法的時候,會先判斷該方法是否存在已經編譯好的版本,如果存在,則執行編譯好的本地機器碼,否則,採用一邊解釋一邊編譯的方式。

4.編譯優化技術

先看一段程式碼:

int a = 1;
if(false){
    System.out.println("無用程式碼");
}
int b = 2;

對於這段程式碼,我們都知道是if語句體裡面的程式碼是一定不可能會被執行到的,也就是說,這實際上是一段一點用處也沒有的程式碼,在執行時只能浪費判斷時間。
實際上,對於我們書寫的程式碼,編譯器在編譯的時候是會進行優化的。對於上面的程式碼,編譯優化之後會變成這樣:

int a = 1;
int b = 2;

那段無用的程式碼會被消除掉。

各種編譯優化策略

我們剛才已經說了,對於有些被多次呼叫的方法或者迴圈體,虛擬機器會先把他們編譯成本地機器碼。由於這些熱點程式碼都是一些會被多次重複執行的程式碼,為了使得編譯好的程式碼更加完美,執行的更快。編譯器做了很多的編譯優化策略,例如上面的無用程式碼消除就是其中的一種。
下面我們來講講大概都有那些優化策略:
大概預覽一波:

  1. 公共子表示式消除。
  2. 陣列範圍檢查消除。
  3. 方法內聯。
  4. 逃逸分析。

(1).公共子表示式消除
含義:如果一個表示式 E 已經計算過了,並且從先前的計算到現在 E 中的所有變數的值都沒有發生變化,那個 E 的這次出現就成為了公共子表示式。對於這樣的表示式,沒有必要對它再次進行計算了,直接沿用之前的結果就可以了。
我們來舉個例子。例如

int d = (c * b) * 10 + a + (a + b * c);

這段程式碼到了即時編譯器的手裡,它會進行如下優化:
表示式中有兩個 b * c的表示式,並且在計算期間b與c的值並不會變。所以這條表示式可能會被視為:

int d = E * 10 + a+ (a + E);

接著繼續優化成

int d = E * 11 + a + a;

接著

int d = E * 11 + 2a;

這樣,程式碼在執行的時候,就會節省了一些時間了。

(2).陣列範圍檢查消除
我們知道,java是一門動態安全的語言,對陣列的訪問不像c/c++那樣,可以採用指標指向一塊可能不存在的區域。例如假如有一個陣列arr[],在java語言中訪問陣列arr[i]的時候,是會先進行上下界範圍檢查的,即先檢查i是否滿足i >= 0 && i < arr.length這個條件。如果不滿足則會丟擲相應的異常。這種安全檢查策略可以避免溢位。但每次陣列訪問都會進行這樣一次檢查無疑在速度效能上造成一定的影響。
實際上,對於這樣一種情況,編譯器也是可以幫助我們做出相應的優化的。例如對於陣列的下標是一個常量的,如arr[2],只要在編譯期根據資料流分析來確定arr.length的值,並判斷下標‘2’並沒有越界,這樣在執行的時候就無需在判斷了。
更常見的情況是陣列訪問發生在迴圈體中,並且使用迴圈變數來進行陣列的訪問,對於這樣的情況,只要編譯器通過資料流就可以判斷迴圈變數的取值範圍是否在[0, arr.length)之內,如果是,那麼整個迴圈中就可以節省很多次陣列邊界檢測判斷的操勞了。

對於這些安全檢查所消耗的時間,實際上,我們還可以採用另外一種策略—隱式異常處理。例如當我們在訪問一個物件arr的屬性arr.value的時候,沒有優化之前虛擬機器是這樣處理的:

if(arr != null){
    return arr.value;
}else{
    throw new NollPointException();
}

採用優化策略之後程式設計這樣子:

try{
    return arr.value;
}catch(segment_fault){
    uncommon_trap();
}

就是說,虛擬機器會註冊一個Segment Fault訊號的異常處理器(uncommon_trap()),這樣當arr不為空的時候,對value的訪問可以省去對arr的判斷。代價就是當arr為空時,必須轉入到異常處理器中恢復並丟擲NullPointException異常,這個過程會從使用者態轉到核心態中處理,結束後在回到使用者態,速度遠比一次判斷空檢查慢。當arr極少為null的時候,這樣做是值得的,但假如arr經常為null時,那麼會得不償失。
不過,虛擬機器還是挺聰明的,它會根據執行期收集到的資訊來自動選擇最優方案。

(3).方法內聯
先看一段程式碼

public static void f(Object obj){
    if)(obj != null){
        System.out.println("do something");
    }
}
public static void test(String[] args){
    Object obj = null;
    f(obj);
}

對於這段程式碼,如果把兩個方法結合在一起看,我們可以發現test()方法裡面都是一些無用的程式碼。因為f(obj)這個方法的呼叫,沒啥卵用。但是如果不做內聯優化,後續儘管進行了無用程式碼的消除,也是無法發現任何無用程式碼的,因為如果把f(Object obj)和test(String[] args)兩個發放分開看的話,我們就無法得只f(obj)是否有用了。
內聯優化後的程式碼可以是這樣:

public static void f(Object obj){
    if)(obj != null){
        System.out.println("do something");
    }
}
public static void test(String[] args){
    Object obj = null;
    //該方法直接不執行了
}

(4).逃逸分析
逃逸分析是目前Java虛擬機器比較前沿的優化技術,它並非是直接優化程式碼,而是為其他優化手段提供依據發分析技術。
逃逸分析主要是對物件動態作用域進行分析:當一個物件在某個方法被定義後,它有可能被外部的其他方法所引用,例如作為引數傳遞給其他方法,稱之為方法逃逸,也有可能被外部執行緒訪問到,例如類變數,稱之為執行緒逃逸


假如我們可以證明一個物件並不會發生逃逸的話,我們就可以通過一些方式對這個變數進行一些高效的優化了。如下所示:


1).棧上分配
我們都知道一個物件建立之後是放在上的,這個物件可以被其他執行緒所共享,並且我們知道在堆上的物件如果不再使用時,虛擬機器的垃圾收集系統就會對它進行帥選並回收。但無論是回收還是帥選,都是需要花費時間的。
但是假如我們知道這個物件不會逃逸的話,我們就可以直接在棧上對這個物件進行記憶體分配了,這樣,這個物件所佔用的記憶體空間就可以隨進棧和出棧而自動被銷燬了。這樣,垃圾收集系統就可以省了很多帥選、銷燬的時間了。

2).同步消除
執行緒同步本身是一個相對耗時的過程,如果我們能判斷這個變數不會逃出執行緒的話,那麼我們就可以對這個變數的同步措施進行消除了。


3).標量替換
什麼是標量?
當一個資料無法分解成更小的時候,我們稱之為變數,例如像int,long,char等基本資料型別。相對地,如果一個變數可以分解成更小的,我們稱之為聚合量,例如Java中的物件。
假如這個物件不會發生逃逸。
我們可以根據程式訪問的情況,如果一個方法只是用到一個物件裡面的若干個屬性,我們在真正執行這個方法的時候,我們可以不建立這個物件,而是直接建立它那幾個被使用到的變數來代替。這樣,不僅可以節省記憶體以及時間,而且這些變數可以隨出棧入棧而銷燬。

不過,對於編譯器優化的技術還有很多,上面這幾種算是比較典型的。
本次講解到這裡。

參考書籍:深入Java虛擬機器

 

相關文章