90% 的 Java 程序員都說不上來的爲什麼 Java 代碼越執行越快(1)- JIT編譯優化

麻煩你們幫我投一票哈,謝謝前端

常常聽到 Java 性能不如 C/C++ 的言論,也常常據說 Java 程序須要預熱,那麼其中主要緣由是啥呢java

面試的時候談到 JVM,也有不少面試官喜歡問,爲啥 Java 程序越執行越快呢面試

通常人都能回答上來,類加載,緩存預熱等等,可是深刻下去,最重要的卻沒有答上來,今天本系列文章就來幫助你們理解這個問題的關鍵。首先是 JIT 優化算法

首先,咱們從一個簡單的例子看起,來感覺下程序是否愈來愈快:後端

package com.test;

import java.util.concurrent.TimeUnit;

public class CompileTest {
    public static void main(String[] args) throws InterruptedException {
        while (true) {
            test1();
            TimeUnit.SECONDS.sleep(1);
        }
    }

    public static void test1() {
        long time1 = System.nanoTime();
        long count1 = 0;
        for (int i = 0; i < 10000; i++) {
            count1++;
        }
        //爲了和編譯日誌區分,這裏輸出到error輸出
        System.err.println(System.nanoTime() - time1 + "-----" + count1);
    }
}

運行時,加上參數-XX:+PrintCompilation,打印一下編譯日誌(其實這個參數之後也許就過時了,建議使用 JVM 標準日誌參數:-Xlog:jit+compilation=info),能夠看到:緩存

432900-----10000
250800-----10000
194600-----10000
197200-----10000
131600-----10000
184000-----10000
   6369  374 %     3       com.test.CompileTest::test1 @ 9 (61 bytes)
162300-----10000
   7369  375       3       com.test.CompileTest::test1 (61 bytes)
68300-----10000
60300-----10000
47200-----10000
48100-----10000
  11371  378 %     4       com.test.CompileTest::test1 @ 9 (61 bytes)
55600-----10000
  11388  374 %     3       com.test.CompileTest::test1 @ 9 (61 bytes)   made not entrant
  12372  379       4       com.test.CompileTest::test1 (61 bytes)
157600-----10000
  12389  375       3       com.test.CompileTest::test1 (61 bytes)   made not entrant
600-----10000
700-----10000
600-----10000
1200-----10000
900-----10000
900-----10000

從輸出中能夠看出,貌似JVM對test1這段代碼作了一些事情,使方法運行愈來愈快了。這就是JIT作的優化,隨着代碼的執行,熱點代碼會被優化,讓執行更加迅速。這也是爲何,經過通常方法(javac命令)編譯出來java class文件在執行的時候,要預熱以後,才能發揮最大性能。接下來,咱們來詳細介紹下JIT。服務器

OpenJDK Hotspot JVM,是最普遍運用的Java JVM。主要包含兩部分,執行引擎(execution engine)和運行時(runtime)。執行引擎包括兩部分,一個是垃圾收集器,另外一個就是咱們今天的主題, JIT(just-in-time)編譯器。ide

什麼是JIT

JVM是Java一次編譯,跨平臺執行的基礎。當java被編譯爲字節碼形式的class文件以後,他能夠在任意的JVM運行。這裏說的編譯,主要是指前端編譯器。oop

Java中主要有兩種編譯器:性能

  1. 前端編譯器,將.java文件編譯爲JVM可執行的.class字節碼文件,即javac,主要職責包括:詞法、語法分析,填充符號表,語義分析,字節碼生成。輸出爲字節碼文件,也能夠理解爲是中間表達形式(稱爲IR:Intermediate Representation)。對應上面的例子就是將CompileTest.java編譯成符合Java規範的字節碼文件CompileTest.class

  2. 後端編譯器,在程序運行期間將字節碼轉變成機器碼,經過解釋器和運行時編譯器混合模式(如今的 Java 程序在運行時基本都是解釋執行加編譯執行),如 HotSpot 虛擬機自帶的解釋器還有 JIT(Just In Time Compiler)編譯器(分 Client 端和 Server 端),其中JIT還會將中間表達形式進行一些優化。對應上面的例子就是test1方法執行愈來愈快。

Java 9中還引入了實驗編譯器AOT(Ahead-Of-Time)編譯器,直接生成機器碼。主要用於減小JAVA啓動預熱時間,比較適用於單次執行時間有限須要高效執行的程序,或者是小集成芯片環境,對效率要求比較高。AOT與Graal咱們會在系列的最後着重介紹。對應上面的例子就是,test1方法不用預熱就會執行的和上面最會同樣那麼快。可是相應的,機器碼佔用的大小比字節碼大的多得多,並且不能跨平臺。

爲何要這麼區分呢?首先,不一樣機器的機器碼是不同的,編譯生成統一的字節碼保證了跨平臺應用的可能性。而後,將字節碼優化(中間表達形式優化)放到運行時優化,這樣低版本的java編譯出來的字節碼,在高版本的JVM運行,仍能享受高版本的JVM新的優化機制帶來的性能提高,這是一種很好的向後兼容機制。因此有的時候,咱們能夠先把JVM升級到新版原本享受更高效的優化算法

剛剛提到了JVM使用混合模式來從字節碼轉換成機器能夠運行的機器碼,混合模式包括解釋器和JIT:

解釋器工做機制:

image
在編譯時,主要是將java源代碼文件編譯爲java統一的字節碼,可是編譯成的字節碼並不能直接運行,而是經過JVM讀取運行。JVM中的解釋器就是將.class文件一行一行翻譯以後再運行,翻譯就是轉換成當前機器能夠運行的機器碼,它不會一次性把整個文件都翻譯過來,而是翻譯一句,執行一句,再翻譯,再執行,因此解釋器的程序運行起來會比較慢,每次都要解釋以後再執行。因此,有些時候,咱們想是否能夠把解釋以後的內容緩存起來,這樣不就能夠直接運行了?可是,若是每段代碼都要緩存起來,例如僅僅執行一次的代碼也緩存起來,這樣太浪費內存了。因此,引入一個新的運行時編譯器,JIT來解決這些問題,加速熱點代碼的執行。

JIT運行時編譯器工做機制:
image

JIT針對熱點代碼,進行編譯與深度優化,優化後的機器碼會被緩存起來,存入CodeCache中。對於非熱點代碼,例如只運行一次的代碼(類構造器等等),直接解釋執行,更加快速。JIT不只花更多時間去編譯優化,並且還多耗費了不少內存,而且 CodeCache 發生變化會發生部分或者全部線程進入 Safepoint 致使 Stop the world。字節碼轉換爲可執行的機器碼,大小會大不少不少倍。這也是爲啥,解釋器每次都要翻譯而且執行,JIT只針對熱點代碼進行編譯優化的緣由。JIT編譯器執行的一些常見優化操做包括數據分析,從堆棧操做到寄存器操做的轉換,經過寄存器分配減小內存訪問,消除常見子表達式等。JIT編譯器進行的優化程度越高,在執行階段花費的時間越多。所以,JIT編譯器沒法承擔全部靜態編譯器所作的優化,這不只是由於增長了執行時間的開銷,並且還由於它只對程序進行了限制。這也就解釋了爲何有些JVM會選擇不老是作JIT編譯,而是選擇用解釋器+JIT編譯器的混合執行引擎。

對於上面的例子,剛開始的時候,test1方法是解釋器執行的,因爲多了一步轉換,因此比較慢。後面隨着代碼的運行和JIT優化,test1方法的機器碼被優化而且存入代碼緩存,下次執行直接從代碼緩存讀取執行。

JIT的基本工做原理

首先,須要判斷一個方法是不是熱點方法:在HotSpot虛擬機中使用的基於計數器的熱點探測方法,他爲每一個方法都準備了兩個計數器:方法調用計數器和loop-back-edge計數器。

  • 方法調用計數器:顧名思義,這個計數器用於統計方法被調用的次數。在一個方法被調用時,根據前面所述,會先看看是否存在與codecache中,也就是jit編譯的版本,若是不存在,則將計數加一,判斷是否大於閾值,若是大於,則那麼將會向即時編譯器提交一個該方法的代碼編譯請求。若是不作任何設置,執行引擎並不會同步等待編譯請求完成,而是繼續進行解釋器按照解釋方式執行字節碼,直到提交的請求被編譯器編譯完成。當編譯工做完成以後,這個方法的調用入口地址就會系統自動改寫成新的,下一次調用該方法時就會使用已編譯的版本。
  • loop-back-edge計數器:專用來統計loop次數的,就是統計一個方法中循環體代碼執行的次數,在字節碼中遇到控制流向後跳轉的指令稱爲loop-back-edge。這個計數器機制與上面的方法調用計數器一致。

有了這些計數器,JIT能夠根據這些計數器裏面的統計信息,進行優化。固然,不止有這些計數器,還有一些其餘更復雜的採集點。JIT編譯器在JDK 8以前,例如JDK 7是區分client模式(C1編譯器)仍是server模式(C2編譯器)的,從JDK 8開始,不作這個區分了,都是C1+C2編譯器合做,分層優化。C1是一個簡單快速的編譯器,主要關注點在於局部優化,而放棄許多耗時較長的全局優化手段。C2則是專門面向服務器端的,併爲服務端的性能配置特別調整過的編譯器,是一個充分優化過的高級編譯器。從Java 8開始,JIT編譯優化是分層優化,分爲5層,每層都會有C1或者C2參與。

  • 第0層(Tier-0),只有解釋器參與,解釋執行
  • 第1層(Tier-1),執行不帶任何採集的的C1優化代碼
  • 第2層(Tier-2),執行僅帶方法調用計數器和loop-back-edge計數器profiling的C1優化代碼
  • 第3層(Tier-3),執行帶全部採集的的C1優化代碼
  • 第4層(Tier-4),執行C2優化代碼

每日一刷,輕鬆提高技術,斬獲各類offer:

image

相關文章
相關標籤/搜索