1. 程式人生 > 實用技巧 >20200730 尚矽谷 JVM 12 - 執行引擎

20200730 尚矽谷 JVM 12 - 執行引擎

12 - 執行引擎

1 - 執行引擎概述

執行引擎概述

  • 執行引擎是 Java 虛擬機器核心的組成部分之一。
  • “虛擬機器”是一個相對於“物理機”的概念,這兩種機器都有程式碼執行能力,其區別是物理機的執行引擎是直接建立在處理器、快取、指令集和作業系統層面上的,而虛擬機器的執行引擎則是由軟體自行實現的,因此可以不受物理條件制約地定製指令集與執行引擎的結構體系,能夠執行那些不被硬體直接支援的指令集格式
  • JVM 的主要任務是負責裝載位元組碼到其內部,但位元組碼並不能夠直接執行在作業系統之上,因為位元組碼指令並非等價於本地機器指令,它內部包含的僅僅只是一些能夠被 JVM 所識別的位元組碼指令、符號表,以及其他輔助資訊。
  • 那麼,如果想要讓一個 Java 程式執行起來,執行引擎( Execution Engine )的任務就是將位元組碼指令解釋/編譯為對應平臺上的本地機器指令オ可以。簡單來說, JVM 中的執行引擎充當了將高階語言翻譯為機器語言的譯者。

執行引擎的工作過程

  1. 執行引擎在執行的過程中究竟需要執行什麼樣的位元組碼指令完全依賴於 PC 暫存器。
  2. 每當執行完一項指令操作後, PC 暫存器就會更新下一條需要被執行的指令地址。
  3. 當然方法在執行的過程中,執行引擎有可能會通過儲存在區域性變量表中的物件引用準確定位到儲存在 Java 堆區中的物件例項資訊,以及通過物件頭中的元資料指標定位到目標物件的型別資訊。

2 - Java程式碼編譯和執行過程

大部分的程式程式碼轉換成物理機的目的碼或虛擬機器能執行的指令集之前,都需要經過上圖中的各個步驟。

Java 程式碼編譯是由 Java 原始碼編譯器來完成,流程圖如下所示:

  • 問題:什麼是直譯器( Interpreter ),什麼是 JIT 編譯器?

    直譯器:當 Java 虛擬機器啟動時會根據預定義的規範對位元組碼採用逐行解釋的方式執行,將每條位元組碼檔案中的內容“翻譯”為對應平臺的本地機器指令執行。

    JIT ( Just In Time Compiler )編譯器:就是虛擬機器將原始碼直接編譯成和本地機器平臺相關的機器語言

  • 問題:為什麼說 Java 是半編譯半解釋型語言?

    JDK1.0 時代,將 Java 語言定位為“解釋執行”還是比較準確的。再後來,Java 也發展出可以直接生成原生代碼的編譯器。

    現在 JVM 在執行 Java 程式碼的時候,通常都會將解釋執行與編譯執行二者結合起來進行

3 - 機器碼、指令、組合語言

機器碼

  • 各種用二進位制編碼方式表示的指令,叫做機器指令碼。開始,人們就用它採編寫程式,這就是機器語言
  • 機器語言雖然能夠被計算機理解和接受,但和人們的語言差別太大,不易被人們理解和記憶,並且用它程式設計容易出差錯。用它編寫的程式一經輸入計算機, CPU 直接讀取執行,因此和其他語言編的程式相比,執行速度最快
  • 機器指令與 CPU 緊密相關,所以不同種類的 CPU 所對應的機器指令也就不同。

指令

  • 由於機器碼是由 0 和 1 組成的二進位制序列,可讀性實在太差,於是人們發明了指令
  • 指令就是把機器碼中特定的 0 和 1 序列,簡化成對應的指令(一般為英文簡寫,如 mov,inc 等),可讀性稍好
  • 由於不同的硬體平臺,執行同一個操作,對應的機器碼可能不同,所以不同的硬體平臺的同一種指令(比如 mov ),對應的機器碼也可能不同。

指令集

  • 不同的硬體平臺,各自支援的指令,是有差別的。因此每個平臺所支援的指令,稱之為對應平臺的指令集
  • 如常見的
    • x86 指令集,對應的是 x86 架構的平臺
    • ARM 指令集,對應的是 ARM 架構的平臺

組合語言

  • 由於指令的可讀性還是太差,於是人們又發明了組合語言。
  • 在組合語言中,用助記符( Mnemonics )代替機器指令的操作碼,用地址符號( Symbol )或標號( Label )代替指令或運算元的地址。
  • 在不同的硬體平臺,組合語言對應著不同的機器語言指令集,通過彙編過程轉換成機器指令。
    • 由於計算機只認識指令碼,所以用組合語言編寫的程式還必須翻譯成機器指令碼,計算機才能識別和執行。

高階語言

  • 為了使計算機使用者程式設計序更容易些,後來就出現了各種高階計算機語言。高階語言比機器語言、組合語言更接近人的語言
  • 當計算機執行高階語言編寫的程式時,仍然需要把程式解釋和編譯成機器的指令碼。完成這個過程的程式就叫做解釋程式或編譯程式。

位元組碼

  • 位元組碼是一種中間狀態(中間碼)的二進位制程式碼(檔案),它比機器碼更抽象,需要直譯器轉譯後才能成為機器碼
  • 位元組碼主要為了實現特定軟體執行和軟體環境、與硬體環境無關
  • 位元組碼的實現方式是通過編譯器和虛擬機器器。編譯器將原始碼編譯成位元組碼,特定平臺上的虛擬機器器將位元組碼轉譯為可以直接執行的指令。
    • 位元組碼的典型應用為 Java bytecode

4 - 直譯器

直譯器

JVM 設計者們的初衷僅僅只是單純地為了滿足 Java 程式實現跨平臺特性,因此避免採用靜態編譯的方式直接生成本地機器指令,從而誕生了實現直譯器在執行時採用逐行解釋位元組碼執行程式的想法。

直譯器工作機制(或工作任務)

  • 直譯器真正意義上所承擔的角色就是一個執行時“翻譯者”,將位元組碼檔案中的內容“翻譯”為對應平臺的本地機器指令執行
  • 當一條位元組碼指令被解釋執行完成後,接著再根據 PC 暫存器中記錄的下一條需要被執行的位元組碼指令執行解釋操作

直譯器分類

  • 在 Java 的發展歷史裡,一共有兩套解釋執行器,即古老的位元組碼直譯器、現在普遍使用的模板直譯器
  • 位元組碼直譯器在執行時通過純軟體程式碼模擬位元組碼的執行,效率非常低下。
  • 而模板直譯器將每一條位元組碼和一個模板函式相關聯,模板函式中直接產生這條位元組碼執行時的機器碼,從而很大程度上提高了直譯器的效能。
  • 在 HotSpot VM 中,直譯器主要由 Interpreter 模組和 Code 模組構成。
    • Interpreter 模組:實現瞭解釋器的核心功能
    • Code 模組:用於管理 Hotspot VM 在執行時生成的本地機器指令

現狀

  • 由於直譯器在設計和實現上非常簡單,因此除了 Java 語言之外,還有許多高階語言同樣也是基於直譯器執行的,比如 Python 、 Perl 、 Ruby 等。但是在今天,基於直譯器執行已經淪落為低效的代名詞,並且時常被一些 C/C ++程式設計師所調侃。
  • 為了解決這個問題, JVM 平臺支援一種叫作即時編譯的技術。即時編譯的目的是避免函式被解釋執行,而是將整個函式體編譯成為機器碼,每次函式執行時,只執行編譯後的機器碼即可,這種方式可以使執行效率大幅度提升。
  • 不過無論如何,基於直譯器的執行模式仍然為中間語言的發展做出了不可磨滅的貢獻

5 - JIT 編譯器

Java 程式碼的執行分類

  • 第一種是將原始碼編譯成位元組碼檔案,然後在執行時通過直譯器將位元組碼檔案轉為機器碼執行
  • 第二種是編譯執行(直接編譯成機器碼)。現代虛擬機器為了提高執行效率,會使用即時編譯技術( JIT )將方法編譯成機器碼後再執行
  • HotSpot VM 是目前市面上高效能虛擬機器的代表作之一。它採用直譯器與即時編譯器並存的架構。在 Java 虛擬機器執行時,直譯器和即時編譯器能夠相互協作,各自取長補短,盡力去選擇最合適的方式來權衡編譯原生代碼的時間和直接解釋執行程式碼的時間。
  • 在今天, Java 程式的執行效能早已脫胎換骨,已經達到了可以和 c / C ++程式一較高下的地步。

問題來了

有些開發人員會感覺到詫異,既然 HotSpot VM 中已經內建 JIT 編譯器了,那麼為什麼還需要再使用直譯器來“拖累”程式的執行效能呢?比如 JRockit 內部就不包含直譯器,位元組碼全部都依靠即時編譯器編譯後執行。

首先明確:當程式啟動後,直譯器可以馬上發揮作用,省去編譯的時間,立即執行。

編譯器要想發揮作用,把程式碼編譯成原生代碼,需要一定的執行時間。但編譯為原生代碼後,執行效率高。

所以:儘管 JRockit VM 中程式的執行效能會非常高效,但程式在啟動時必然需要花費更長的時間來進行編譯。對於服務端應用來說,啟動時間並非是關注重點,但對於那些看中啟動時間的應用場景而言,或許就需要採用直譯器與即時編譯器並存的架構來換取一個平衡點。在此模式下,當 Java 虛擬器啟動時,直譯器可以首先發揮作用,而不必等待即時編譯器全部編譯完成後再執行,這樣可以省去許多不必要的編譯時間。隨著時間的推移,編譯器發揮作用,把越來越多的程式碼編譯成原生代碼,獲得更高的執行效率。

同時,解釋執行在編譯器進行激進優化不成立的時候,作為編譯器的“逃生門”。

HotSpot JVM 的執行方式

當虛擬機器啟動的時候,直譯器可以首先發揮作用,而不必等待即時編譯器全部編譯完成再執行,這樣可以省去許多不必要的編譯時間。並且隨著程式執行時間的推移,即時編譯器逐漸發揮作用,根據熱點探測功能,將有價值的位元組碼編譯為本地機器指令,以換取更高的程式執行效率

案例來了

注意解釋執行與編譯執行在線上環境微妙的辯證關係。機器在熱機狀態可以承受的負載要大於冷機狀態。如果以熱機狀態時的流量進行切流,可能使處於冷機狀態的伺服器因無法承載流量而假死。

在生產環境釋出過程中,以分批的方式進行釋出,根據機器數量劃分成多個批次,每個批次的機器數至多佔到整個叢集的 1/8 。曾經有這樣的故障案例:某程式設計師在釋出平臺進行分批發布,在輸入釋出總批數時,誤填寫成分為兩批發布。如果是熱機狀態,在正常情況下一半的機器可以勉強承載流量,但由於剛啟動的 JVM 均是解釋執行,還沒有進行熱點程式碼統計和 JIT 動態編譯,導致機器啟動之後,當前 1/2 釋出成功的伺服器馬上全部宕機,此故障說明了 JIT 的存在 ——阿里團隊

JIT 編譯器

概念解釋:

  • Java 語言的“編譯期”其實是一段“不確定”的操作過程,因為它可能是指一個前端編譯器(其實叫“編譯器的前端”更準確一些)把 .java 檔案轉變成 .class 檔案的過程;

  • 也可能是指虛擬機器的後端執行期編譯器JIT 編譯器, Just In Time Compiler )把位元組碼轉變成機器碼的過程。

  • 還可能是指使用靜態提前編譯器( AOT 編譯器, Ahead Of Time Compiler )直接把 .java 檔案編譯成本地機器程式碼的過程。

  • 前端編譯器: Sun 的 Javac 、 Eclipse JDT 中的增量式編譯器(ECJ)。

  • JIT 編譯器: HotSpot VM 的 C1 、 C2 編譯器

  • AOT 編譯器: GNU Compiler for the Java (GCJ)、 Excelsior JET 。

如何選擇?

熱點程式碼及探測方式

當然是否需要啟動 JIT 編譯器將位元組碼直接編譯為對應平臺的本地機器指令則需要根據程式碼被呼叫執行的頻率而定。關於那些需要被編譯為原生代碼的位元組碼,也被稱之為“熱點程式碼”,JIT 編譯器在執行時會針對那些頻繁被呼叫的“熱點程式碼”做出深度優化,將其直接編譯為對應平臺的本地機器指令,以此提升 Java 程式的執行效能。

  • 一個被多次呼叫的方法,或者是一個方法體內部迴圈次數較多的迴圈體都可以被稱之為“熱點程式碼”,因此都可以通過 JIT 編譯器編譯為本地機器指令。由於這種編譯方式發生在方法的執行過程中,因此也被稱之為棧上替換,或簡稱為 OSR ( On Stack Replacement )編譯。
  • 一個方法究竟要被呼叫多少次,或者一個迴圈體究竟需要執行多少次迴圈才可以達到這個標準?必然需要一個明確的閾值, JIT編譯器才會將這些“熱點程式碼”編譯為本地機器指令執行。這裡主要依靠熱點探測功能
  • 目前 HotSpot VM 所採用的熱點探測方式是基於計數器的熱點探測。
  • 採用基於計數器的熱點探測, HotSpot VM 將會為每一個方法都建立 2 個不同型別的計數器,分別為方法呼叫計數器( Invocation Counter )和回邊計數器( Back Edge Counter )。
    • 方法呼叫計數器用於統計方法的呼叫次數
    • 回邊計數器則用於統計迴圈體執行的迴圈次數

方法呼叫計數器

  • 這個計數器就用於統計方法被呼叫的次數,它的預設閾值在 Client 模式下是 1500 次,在 Server 模式下是 10000次。超過這個閾值,就會觸發 JIT 編譯。
  • 這個閾值可以通過虛擬機器引數 -XX:CompileThreshold 來人為設定。
  • 當一個方法被呼叫時,會先檢査該方法是否存在被 JIT 編譯過的版本,如果存在,則優先使用編譯後的原生代碼來執行。如果不存在已被編譯過的版本,則將此方法的呼叫計數器值加 1 ,然後判斷方法呼叫計數器與回邊計數器值之和是否超過方法呼叫計數器的閾值。如果已超過閱值,那麼將會向即時編譯器提交一個該方法的程式碼編譯請求。
熱度衰減
  • 如果不做任何設定,方法呼叫計數器統計的並不是方法被呼叫的絕對次數,而是一個相對的執行頻率,即一段時間之內方法被呼叫的次數。當超過一定的時間限度,如果方法的呼叫次數仍然不足以讓它提交給即時編譯器編譯,那這個方法的呼叫計數器就會被減少一半,這個過程稱為方法呼叫計數器熱度的衰減( Counter Decay ),而這段時間就稱為此方法統計的半衰週期( Counter Half Life Time )。
  • 進行熱度衰減的動作是在虛擬機器進行垃圾收集時順便進行的,可以使用虛擬機器引數 -XX:-UseCounterDecay 來關閉熱度衰減,讓方法計數器統計方法呼叫的絕對次數,這樣,只要系統執行時間足夠長,絕大部分方法都會被編譯成原生代碼
  • 另外,可以使用 -XX:CounterHalfLifeTime 引數設定半衰週期的時間,單位是秒

HotSpot VM 可以設定程式執行方式

預設情況下 HotSpot VM 是採用直譯器與即時編譯器並存的架構,當然開發人員可以根據具體的應用場景,通過命令顯式地為 Java 虛擬機器指定在執行時到底是完全採用直譯器執行,還是完全採用即時編譯器執行。如下所示:

  • -Xint :完全採用直譯器模式執行程式;
  • -Xcomp :完全採用即時編譯器模式執行程式。如果即時編譯出現問題,直譯器會介入執行。
  • -Mixed :預設情況,採用直譯器+即時編譯器的混合模式共同執行程式。
##	切換模式
java -Xint -version
java -Xcomp -version
java -Mixed -version

HotSpot VM 中 JIT 分類

在 HotSpot VM 中內嵌有兩個 JIT 編譯器,分別為 C1 Compiler 和 Server Compiler ,但大多數情況下我們簡稱為 C1 編譯器和 C2 編譯器。開發人員可以通過如下命令顯式指定 Java 虛擬機器在執行時到底使用哪一種即時編譯器,如下所示:

  • -client :指定 Java 虛擬機器執行在 Client 模式下,並使用 C1 編譯器;
    • C1 編譯器會對位元組碼進行簡單和可靠的優化,耗時短。以達到更快的編譯速度
  • -server :指定 Java 虛擬機器執行在 Server 模式下,並使用 C2 編譯器,64位機器預設且僅支援 Server 模式
    • C2 進行耗時較長的優化,以及激進優化。但優化的程式碼執行效率更高。

C1 和 C2 編譯器不同的優化策略

  • 在不同的編譯器上有不同的優化策略, C1 編譯器上主要有方法內聯,去虛擬化、冗餘消除。
    • 方法內聯:將引用的函式程式碼編譯到引用點處,這樣可以減少棧幀的生成,減少引數傳遞以及跳轉過程
    • 去虛擬化:對唯一的實現類進行內聯
    • 冗餘消除:在執行期間把一些不會執行的程式碼摺疊掉
  • C2 的優化主要是在全域性層面,逃逸分析是優化的基礎。基於逃逸分析在 C2 上有如下幾種優化:
    • 標量替換:用標量值代替聚合物件的屬性值
    • 棧上分配:對於未逃逸的物件分配物件在棧而不是堆
    • 同步消除:清除同步操作,通常指 synchronized

分層編譯( Tiered Compilation )策略:程式解釋執行(不開啟效能監控)可以觸發 C1 編譯,將位元組碼編譯成機器碼,可以進行簡單優化,也可以加上效能監控, C2 編譯會根據效能監控資訊進行激進優化。

不過在 Java 7 版本之後,一旦開發人員在程式中顯式指定命令 -server 時,預設將會開啟分層編譯策略,由 C1 編器和 C2 編譯器相互協作共同來執行編譯任務。

總結

  • 一般來講,JIT 編譯出來的機器碼效能比直譯器高。
  • C2 編譯器啟動時長比 C1 編譯器慢,系統穩定執行以後, C2 編譯器執行速度遠遠快於 C1 編譯器

寫在最後 1

  • 自 JDK 10 起, HotSpot 又加入一個全新的即時編譯器:Graal 編譯器,級別與 C1、C2 相同。
  • 編譯效果短短几年時間就追平了 C2 編譯器。未來可期
  • 目前,帶著實驗狀態標籤,需要使用開關引數 XX:+UnlockExperimentalVmOptions -XX:+UseJVMCICompiler 啟用,才可以使用。

寫在最後 2 :關於 AOT 編譯器

  • jdk9 引入了 AOT 編譯器(靜態提前編譯器, Ahead Of Time Compiler),級別與 JIT 編譯器相同

  • Java 9 引入了實驗性 AOT 編譯工具 Jaotc 。它藉助了 Graal 編譯器,將所輸入的 Java 類檔案轉換為機器碼,並存放至生成的動態共享庫之中。

  • 所謂 AOT 編譯,是與即時編譯相對立的一個概念。我們知道,即時編譯指的是在程式的執行過程中,將位元組碼轉換為可在硬體上直接執行的機器碼,並部署至託管環境中的過程。而 AOT 編譯指的則是,在程式執行之前,便將位元組碼轉換為機器碼的過程。

  • 最大好處: Java 虛擬機器載入已經預編譯成二進位制庫,可以直接執行。不必等待即時編譯器的預熱,減少 Java 應用給人帶來“第一次執行慢”的不良體驗。

  • 缺點

    • 破壞了 Java “一次編譯,到處執行”,必須為每個不同硬體、 OS 編譯對應的發行包。
    • 降低了 Java 連結過程的動態性,載入的程式碼在編譯期就必須全部已知。
    • 還需要繼續優化中,最初只支援 Linux x64 java base