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


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虛擬機

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM