Java如今還比C++執行慢嗎?——JVM運行期優化

大綱

前言

是否是不少人的印象中,Java要比C++運行的慢?若是如今還停留在這個想法,那或許該更新下想法了,由於Java近幾年在運行優化方面作了很是多的研究和改進,能夠說已經基本不怎麼輸於C++的運行速度了。前端

咱們參照HotSpot虛擬機的優化來講明,不一樣虛擬機確定是不一樣的,可是也有參考價值。java


個人全部文章同步更新與Github--Java-Notes,想了解JVM,HashMap源碼分析,spring相關,劍指offer題解(Java版),能夠點個star。能夠看個人github主頁,天天都在更新喲。git

邀請您跟我一同完成 repo程序員


解釋器和即時編譯器

所謂無風不起浪,**爲啥你們以爲Java的執行速度要比C++慢呢?**如今又怎麼說兩者如今的執行速度差異不太大了呢?github

由於早期的Java代碼主要都是由解釋器來完成,而且即便用到即時編譯器,即時編譯器的性能優化也作得不是太好,因此早期纔有了上面說的"Java比C++執行慢",因此才說那是早期的思想。,如今Java優化作的很是好,咱們就挨個說下吧算法

如今主流的(不是所有)虛擬機都採用解釋器編譯器共存的架構,兩者互相配合工做,以下圖所示spring

兩者互有優缺點,優缺點正好相對。後端

  • 解釋器
    • 優勢
      • 程序須要快速啓動時,能夠省去編譯的時間
      • 不佔用內存資源,由於不須要編譯成本地代碼
    • 缺點
      • 執行速度低
    • 還有個做用,做爲**"激進優化"逃生門**,本篇後面會講到
  • 編譯器
    • 優勢
      • 把更多的代碼編譯成本地代碼,提升執行效率
    • 缺點
      • 不能當即啓動,編譯須要時間
      • 佔用一些內存資源

因此啊,虛擬機纔會配合兩者進行使用。那些"熱點代碼"(這個概念後面會提到)會被即時編譯器編譯成本地代碼,以此來提升執行速度,"非熱點代碼"就用解釋器來執行。爲啥這樣呢?可能符合"二八定理"吧。20%的代碼是熱點代碼,可是他們卻可能佔用80%的執行資源。數組

咱們看到上面的圖片中,編譯器有兩種,一個是客戶端的,一個是服務端的,前者被稱爲C1編譯器,後者稱爲C2編譯器,兩個的參數設置也不同。優化手段也不太同樣,後面會講到緩存

編譯對象與觸發條件

對象

咱們以前提到,即時編譯器只編譯那些"熱點代碼",那麼啥是熱點代碼呢?只有如下兩類

  • 被屢次調用的方法
  • 被屢次執行的循環體

前者比較好理解,後者有點很差理解。若是一個方法中,存在循環屢次的循環體,那麼這個循環體的代碼也被執行了不少次,因此也認爲是"熱點代碼"。可是雖然是因爲循環體形成他是熱點代碼的,可是編譯器編譯的時候,是根據整個方法進行編譯(而不是隻編譯循環體)。這種編譯方式因爲編譯發生在方法執行過程當中,所以被形象的稱爲棧上替換(On Stack Replacement,簡稱OSR)。前者也是以整個方法做爲編譯對象

條件

那麼啥是度呢?多少次才叫屢次?纔會觸發即時編譯條件呢?

判斷一段代碼是否是熱點代碼,是否是須要觸發即時編譯,咱們稱之爲熱點探測,主流的熱點探測斷定方式有兩種

  • 基於採樣的熱點探測
  • 基於計數器的熱點探測(JVM採用)
  • 看到這兩個有沒有想起之前學過的哪裏和這個看上去很像?還記得斷定對象是否死亡的兩種方法嗎?可達性分析和引用計數,其實第一種和可達性分析還真有些地方很像,他能夠看調用關係,就像可達性分析的路徑。(學知識,這樣對照學纔會印象深入,能夠說有這樣的想法,東西已經算是你的了)

基於採樣的熱點探測:採用這種方法的虛擬機會週期性的檢查各個線程的棧頂,若是發現某個(或者某些)方法常常出如今棧頂,那這個方法就是"熱點方法"。(有沒有像可達性分析法搜索可達路徑?)

基於計數器的熱點探測:爲每個方法(甚至是代碼塊)創建一個計數器,統計是否超過閾值。還記得兩種熱點代碼嗎?他專門準備了兩種相對的計數器。前者是方法調用計數器,後者是回邊計數器

兩個方法優劣對比:

  • 基於採樣

    • 好處:
      • 實現簡單、高效
      • 能夠很容易獲取方法調用關係(將調用棧展開便可)
    • 缺點
      • 難精確確認一個方法的熱度,容易受到線程阻塞或別的因素影響
  • 基於計數器

    • 好處
      • 統計精確
    • 缺點
      • 要爲每一個方法創建一個計數器
      • 不能獲取調用關係

工做步驟

咱們先看圖

方法調用計數器

當一個方法執行時,

  • 首先檢查這個方法是否被JIT編譯過(便是否存在被JIT編譯過的代碼版本)
    • 若是有,先執行編譯過的代碼版本
    • 沒有,調用計數+1,判斷兩個計數器之和是否超過閾值(注意是兩個計數器)
      • 超過閾值,向即時編譯器提交該方法的編譯請求
      • 沒有超過,繼續以解釋器方式執行。
      • 若是不設置,那麼他不會等到編譯器編譯完成,執行編譯後的代碼,而是執行解釋器,下一次調用才執行編譯後的代碼

這裏的方法調用計數器裏面的值不是絕對值,而是一個相對的執行頻率,即一段時間以內方法被調用的次數。超過必定時間,調用次數仍然未達到閾值,那麼方法調用計數器的值就會減小一半,這個過程稱爲方法調用計數器熱度的衰減。這個像不像原子的衰變?而後這個時間,他們就取名爲半衰週期。可是這個是能夠關掉的,相關參數設置在參數那段講

回邊計數器

啥是回邊呢?在字節碼中遇到控制流向後跳轉的指令,被稱爲"回邊",這個計數器也是爲了觸發OSR編譯(這個概念,上文講過)

執行步驟和方法調用計數器類似

不一樣的是,這個計數器沒有所謂的半衰

相關參數

  • -client/-server:指定JVM運行哪一種模式
  • -Xint:強制JVM使用解釋模式,編譯器不工做
  • -Xcomp:和上面不一樣的是,他只是優先編譯器工做,當編譯沒法進行的狀況下,解釋器仍是會介入
  • -XX: -UseCounterDecay:關閉熱度衰減
  • -XX: CounterHalfLifeTime:設置半衰期時長,單位秒
  • -XX: CompileThreshold:設置方法調用閾值
  • -XX: BackEdgeThreshold:設置回邊計數器閾值
  • -XX: -BackgroundCompilation:禁止後臺編譯,達到閾值,等到編譯完再往下執行,且執行編譯好的代碼

方法在JVM中的內存佈局

一行長度爲32bit

後臺編譯過程

後臺編譯過程,Client Compiler (C1)和Server Compiler(C2)的工做是不同的,後者更爲複雜,是全局優化,前者主要是局部性的優化

C1

共分爲三個階段:

  1. 一個平臺獨立的前端將字節碼構形成一種高級中間代碼表示(High-Level Intermediate Representation,HIR)
    • HIR使用靜態單分配(SSA)的形式來表明代碼值,這能夠是的一些在HIR的構造過程之中和以後進行的優化動做更容易實現。
    • 再此以前編譯器會在字節碼上完成一部分基礎優化,如方法內聯、常量傳播等優化將會在字節碼被構形成HIR以前完成
  2. 一個平臺相關的後端從HIR中產生低級中間代碼表示(Low-Level Intermediate Representation)
    • 而在此以前會在HIR上完成一些優化,如空值檢查消除、範圍檢查消除等
  3. 平臺相關的後端使用線性掃描算法(Linear Scan Register Allocation)在LIR上分配寄存器,並在LIR上作窺孔(Peephole)優化,而後產生機器代碼。

編譯優化技術

一共有很是多的技術

不過最具備表明性的是如下四個:

  • 語言無關的經典優化技術之一:公共子表達式消除
  • 語言相關的經典優化技術之一:數組範圍檢查消除
  • 最重要的優化技術之一:方法內聯
  • 最前沿的優化技術之一:逃逸分析(形成java實例不必定所有在堆中分配)

公共子表達式消除

若是一個表達式E已經計算過了,而且先前的計算到如今E中全部變量的值都沒有發生變化,那麼E的此次出現就成爲了公共子表達式

若是有一下程序代碼

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

當這段代碼進入虛擬機即時編譯器後,他將進行以下優化:

  • 檢測到 c * b 與 b*c 是同樣的
  • 而且計算期間值沒有變化
  • 就會被視爲 int d = E * 12 + a + (a+E)
  • 也可能發生以下變化
    • int d =E * 13 + a *2

數組邊界檢查消除

咱們知道,相同的代碼,Java要比C++執行的慢,這也是人們會有一開始的那種想法的緣由之一。那麼爲何呢?

Java程序因爲有虛擬機,不少東西都會替你來作,好比越界檢查,除零檢查,垃圾回收這種。

好比數組 nums[],Java中若是你要訪問其中一個元素,你的下標必定是在[0,num.length-1]的,若是超出了,他就會報錯java.lang.ArrayIndexOutOfBoundsException.可是C或者C++,使用的是裸指針,所以這種判斷須要程序員本身操做。

Java中,若是你要對數組進行讀寫操做,那麼就必定要檢查他的範圍是否超出,這是由JVM進行,可是大量的執行步驟中,都會帶有這個,哪怕你知道不可能超出,可是JVM可不知道。

提早到編譯期檢查

因此能不能告訴他,"咱們的必定不會超出,你別再檢查了呢?"

事實上,不行,,由於數組越界檢查是必須作的。可是能夠跟他談判,讓他減小次數提早到編譯期檢查一次

好比咱們有下面的程序 nums[3],只要在編譯期間根據數據流分析來肯定nums.length的值,而且判斷下標 3並無越界,那麼執行的時候就不必判斷了。

隱式異常處理

用於:

  • Java中空指針檢查
  • 算術運算符中除數爲0的檢查

若是有下列代碼

if(num != null){
    return num.getVal();
}else {
    throw new NullPointerException();
}
複製代碼

使用隱式異常優化後

try{
  return num.val;
}catch(segment_fault){
  uncommon_trap();
}
複製代碼

虛擬機會註冊一個Segment Fault信號的異常處理器,

  • 當num不爲空的話,對val的訪問是不會額外消耗一次對num判空的開銷的。
  • 當num爲空
    • 必須轉到異常處理器中恢復,而且拋出NullPointerException異常
      • 這個過程必須從用戶態轉到內核態中處理
      • 結束後在返回用戶態處理,速度更慢
  • 因此當,空值極少時,這種隱式異常優化是值得的

方法內聯

他的重要意義是爲其餘優化手段打下基礎,爲啥這樣說呢,請看下面的示例:

public class DeadCode {
    public static void testInline(String[] args) {
        Object object = null;
        foo(object);
    }
    
    public static void foo(Object object){
        if(object != null){
            System.out.println("do something");
        }
    }
}
複製代碼

咱們本身看下就應該知道,testInline()方法中的代碼根本沒有一點意義。咱們稱這種代碼是"Dead Code"

若是不進行內聯,虛擬機根本不會發現,即便他進行了無用代碼消除,他在即時編譯的時候也不會消除這些代碼。由於分開來看,這兩個方法都是有意義的啊

方法內聯的行爲看起來彷佛很簡單

  • 把目標方法的代碼"複製"到發起調用的方法中,避免真實的方法調用而已

可是真的這麼簡單嗎?不是的,根據經典編譯原理的優化理論,大部分的Java方法都不能夠內聯

咱們知道方法的調用分爲:解析和分派,尤爲是分派中的動態分派,編譯期是不能知道調用的是哪一個類的方法(能夠參考個人這篇文章,重載和重寫的區別-方法調用層次)

好比下面的代碼

public class DeadCode {
    
    static class ParentB{
        int val;
        public int getVal(){
            return val;
        }
        public ParentB(int val){
            this.val = val;
        }
        public ParentB(){
        }

    }
    static class SubB extends ParentB{
        public int getVal(){
            return val;
        }
        public SubB(int val){
            this.val = val;
        }

        public SubB() {
        }
    }

    public static void main(String[] args) {
        ParentB subB = new SubB(5);
        System.out.println(subB.getVal());
    }
}
複製代碼

編譯期,他是不會知道 getVal()調用的是 sub的仍是Parent的方法,只有運行期纔會知道

那怎麼辦呢?

解決方法

首先JVM團隊想到CHA(類型繼承關係分析,Class Hierarchy Analysis)技術

一種基於整個應用程序的類型肥西技術,用於肯定在目前已加載的類中,某個接口是否有多於一種的實現,某各種是否存在子類、子類是否爲抽象類等信息。

  • 編譯器在內聯時,若是是非虛方法(若是不知道這個概念,能夠參考個人這篇方法調用的文章,重載和重寫的區別-方法調用層次),那麼直接內聯就行

  • 若是遇到虛方法,則會向CHA查詢此方法在當前程序下是否有多個目標版本可供選擇,

    • 若是查詢結果結果只有一個版本,能夠進行內聯,不過這個是激進優化(能夠類比內存分配策略的空間擔保),須要預留一個"逃生門"(可能由解釋器或者沒有解釋器版本的虛擬機用沒有進行激進優化的C1做爲逃生門),稱爲"內聯守護"
      • 若是程序的後續執行過程當中,JVM一直沒有加載到會令這個方法的接收者的繼承關係發生變化的類,那這個內聯優化的代碼就能夠一直使用下去。
      • 若是出現了那個新類,那就須要拋棄已經編譯好的代碼,退回到解釋狀態執行,或者C1從新編譯
  • 若是向CHA查詢的結果是有多個版本的目標方法能夠選擇,

    • 編譯器會進行最後一次嘗試,使用內聯緩存完成內聯

      ​ 這個是創建在目標方法正常入口以前的緩存

    • 未發生方法調用以前,內聯緩存爲空

    • 發生第一次調用,緩存記錄下方法接收者的版本信息,而且每次調用都比較接收者版本

      • 若是之後每次進來的方法接收者版本都是同樣的,那這個內聯還能夠進行下去
      • 不一致,則說明程序真正使用了虛方法的多態特性,這時候纔會取消內聯,查找虛方法表進行分派(這個表也能夠在這找到重載和重寫的區別-方法調用層次)

逃逸分析

他和上面的CHA(類型關係繼承分析)同樣,都不是直接優化的手段,而是爲其餘優化手段提供依據的分析技術

幹什麼

逃逸分析的基本行爲就是:分析對象動態做用域

  • 方法逃逸:當一個對象在方法中被定義後,他可能被外部方法引用,例如做爲調用參數傳遞到其餘方法
  • 線程逃逸:上述方法甚至還能被外部線程訪問到,譬如給類變量賦值能夠在其餘線程訪問到的實例變量

優化手段

若是能證實一個對象不會有方法逃逸和線程逃逸,那就能夠對其進行高效的優化:

  • 棧上分配
    • 正常來講對象是在Java堆上進行分配的(不知道的能夠看我這篇 JVM運行時數據區)。可是放到堆上就意味着垃圾回收的時候要進行判斷還要進行回收,而且還要整理空間。可是咱們知道若是他不會逃逸,那麼就在棧上分配,工做完隨着棧銷燬就好了,不須要垃圾回收器那些工做。
  • 同步消除
    • 和上面同樣,若是一個變量不發生逃逸的,就不會存在線程間的競爭,那麼就不必對其進行同步措施了
  • 標量替換
    • 概念
      • 標量:指一個數據已經沒法再分解成更小的數據來表示了,Java中的原始數據(int,long、reference等)
      • 聚合量:相對於標量。一個數據能夠進行分解。好比Java對象
      • 標量替換:若是把一個Java對象分解,根據程序的訪問狀況,將其使用到的成員變量恢復原始數據來訪問
    • 若是根據逃逸分析證實一個對象不會逃逸,而且能夠拆分,
      • 那麼就可能不會建立對象,直接建立若干個他的(對象)被這個方法用到的成員變量來代替
      • 而且這些成員變量不只是分配在棧上,並且能夠作進一步優化

總結

  • 通常主流的虛擬機有兩種部分
    • 解釋器
    • 編譯器
      • 編譯對象——熱點代碼
  • HotSpot採用的熱點探測
    • 基於計數器的熱點探測
      • 計數器
        • 方法調用計數器
        • 回邊計數器
  • 編譯優化技術(表明)
    • 公共子表達式消除
    • 數組邊界檢查消除
      • 提早到編譯期
      • 隱式異常處理
    • 方法內聯
    • 逃逸分析
      • 棧上分配
      • 標量替換
      • 同步消除
相關文章
相關標籤/搜索