詳解Java虛擬機器之執行引擎

2020-08-11 19:14:52

概述

執行引擎屬於JVM的下層,裏面包括 直譯器、及時編譯器、垃圾回收器

在这里插入图片描述

執行引擎是Java虛擬機器核心的組成部分之一

「虛擬機器」是一個相對於「物理機」的概念,這兩種機器都有程式碼執行能力,其區別是物理機的執行引擎是直接建立在處理器、快取、指令集和操作系統層面上的,而虛擬機器的執行引擎則是由軟體自行實現的,因此可以不受物理條件制約地定製指令集與執行引擎的結構體系,能夠執行那些不被硬體直接支援的指令集格式。

JVM的主要任務是負責裝載位元組碼到其內部,但位元組碼並不能夠直接執行在操作系統之上,因爲位元組碼指令並非等價於本地機器指令,它內部包含的僅僅只是一些能夠被JVM所識別的位元組碼指令、符號表,以及其他輔助資訊。

在这里插入图片描述

那麼,如果想要讓一個Java程式執行起來,執行引擎(Execution Engine)的任務就是將位元組碼指令解釋 / 編譯爲對應平臺上的本地機器指令纔可以。

這個與將原始碼編譯成位元組碼的編譯不同,那個稱爲前端編譯;而執行引擎的編譯稱爲後端編譯

簡單來說,JVM中的執行引擎充當了將高階語言翻譯爲機器語言的譯者

在这里插入图片描述

執行引擎的工作流程

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

在这里插入图片描述

從外觀上來看,所有的Java虛擬機器的執行引擎輸入,輸出都是一致的:

  • 輸入的是位元組碼二進制流
  • 處理過程是位元組碼解析執行的等效過程
  • 輸出的是執行過程。

Java程式碼編譯和執行過程

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

  • 前面橙色部分是生成位元組碼檔案的過程,和 JVM 無關
  • 後面藍色和綠色纔是JVM需要考慮的過程

在这里插入图片描述

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

在这里插入图片描述

Java位元組碼的執行是由JVM執行引擎來完成,流程圖 如下所示

在这里插入图片描述

我們用一個例子來說說 直譯器和編譯器:將人類的各種語言翻譯成機器能識別的指令

在这里插入图片描述

什麼是直譯器(Interpreter)

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

什麼是 JIT編譯器

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

爲什麼說Java是半編譯半直譯語言?

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

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

  • 翻譯成原生代碼後,就可以做一個快取操作,儲存在方法區中(JIT 程式碼快取)。

機器碼、指令、彙編語言

機器碼

各種用二進制編碼方式表示的指令,叫做機器指令碼。開始,人們就用它採編寫程式,這就是機器語言。

機器語言雖然能夠被計算機理解和接受,但和人們的語言差別太大,不易被人們理解和記憶,並且用它程式設計容易出差錯。

用它編寫的程式一經輸入計算機,CPU直接讀取執行,因此和其他語言編的程式相比,執行速度最快

機器指令與CPU緊密相關,所以不同種類的CPU所對應的機器指令也就不同

指令

由於機器碼是有0和1組成的二進制序列,可讀性實在太差,於是人們發明了指令。

指令就是把機器碼中特定的0和1序列,簡化成對應的指令(一般爲英文簡寫,如mov,inc等),可讀性稍好

由於不同的硬體平臺,執行同一個操作,對應的機器碼可能不同,所以不同的硬體平臺的同一種指令(比如mov),對應的機器碼也可能不同

指令集

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

  • x86指令集,對應的是x86架構的平臺
  • ARM指令集,對應的是ARM架構的平臺

彙編語言

由於指令的可讀性還是太差,於是人們又發明了彙編語言。

在彙編語言中,用助記符(Mnemonics)代替機器指令的操作碼,用地址符號(Symbo1)或標號(Labe1)代替指令或運算元的地址

不同的硬體平臺,彙編語言對應着不同的機器語言指令集,通過彙編過程轉換成機器指令。

由於計算機只認識指令碼,所以用彙編語言編寫的程式還必須翻譯成機器指令碼,計算機才能 纔能識別和執行。

高階語言

爲了使計算機使用者程式設計序更容易些,後來就出現了各種高階計算機語言。

高階語言比機器語言、彙編語言更接近人的語言;當計算機執行高階語言編寫的程式時,仍然需要把程式解釋和編譯成機器的指令碼。完成這個過程的程式就叫做解釋程式編譯程式

在这里插入图片描述

  • 高階語言也不是直接翻譯成 機器指令,而是先翻譯成彙編語言再翻譯成機器指令,如下面 下麪說的C和C++

C、C++源程式執行過程

編譯過程又可以分成兩個階段:編譯彙編

  • 編譯過程:是讀取源程式(字元流),對之進行詞法和語法的分析,將高階語言指令轉換爲功能等效的彙編程式碼

  • 彙編過程:實際上指把彙編語言程式碼翻譯成目標機器指令的過程。

在这里插入图片描述

位元組碼

位元組碼是一種中間狀態(中間碼)的二進制程式碼(檔案),它比機器碼更抽象需要直譯器轉譯後才能 纔能成爲機器碼

位元組碼主要爲了實現特定軟體執行和軟體環境、與硬體環境無關

位元組碼的實現方式是通過編譯器和虛擬機器器

  • 編譯器將原始碼編譯成位元組碼,特定平臺上的虛擬機器器將位元組碼轉譯爲可以直接執行的指令
  • 位元組碼典型的應用爲:Java bytecode

直譯器

爲什麼不直接將原始碼翻譯成彙編,再翻譯成機器指令執行,而是翻譯成位元組碼檔案後再去翻譯成彙編和機器指令?

  • 可能是因爲直接翻譯的程式碼是比較大的,工作量巨大
  • 也可能是因爲如果直接將原始碼翻譯成彙編,那對應多種機器就有多種翻譯方式,會加大工作量;而如果通過位元組碼,就可以將各個工作細分模組化,例如現在的前後端分離,前端編譯將原始碼翻譯成位元組碼檔案,那後端編譯就只需要翻譯位元組碼即可。

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

在这里插入图片描述

直譯器真正意義上所承擔的角色就是一個執行時「翻譯者」,將位元組碼檔案中的內容「翻譯」爲對應平臺的本地機器指令執行。

當一條位元組碼指令被解釋執行完成後,接着再根據PC暫存器中記錄的下一條需要被執行的位元組碼指令執行解釋操作。

分類

在Java的發展歷史裏,一共有兩套解釋執行器,即古老的位元組碼直譯器、現在普遍使用的模板直譯器

  • 位元組碼直譯器在執行時通過純軟體程式碼模擬位元組碼的執行,效率非常低下。

  • 而模板直譯器將每一條位元組碼和一個模板函數相關聯,模板函數中直接產生這條位元組碼執行時的機器碼,從而很大程度上提高瞭直譯器的效能。

在HotSpot VM中,直譯器主要由Interpreter模組和Code模組構成。

  • Interpreter模組:實現瞭直譯器的核心功能
  • Code模組:用於管理HotSpot VM在執行時生成的本地機器指令

現狀

由於直譯器在設計和實現上非常簡單,因此除了Java語言之外,還有許多高階語言同樣也是基於直譯器執行的,比如Python、Perl、Ruby等。

但是在今天,基於直譯器執行已經淪落爲低效的代名詞,並且時常被一些C/C++程式設計師所調侃。

爲了解決這個問題,JVM平臺支援一種叫作即時編譯的技術。即時編譯的目的是避免函數被解釋執行,而是將整個函數體編譯成爲機器碼,每次函數執行時,只執行編譯後的機器碼即可,這種方式可以使執行效率大幅度提升。

不過無論如何,基於直譯器的執行模式仍然爲中間語言的發展做出了不可磨滅的貢獻。

JIT 編譯器

Java程式碼的執行分類

第一種是將原始碼編譯成位元組碼檔案,然後在執行時通過直譯器將位元組碼檔案轉爲機器碼執行

第二種是編譯執行(直接編譯成機器碼)。現代虛擬機器爲了提高執行效率,會使用即時編譯技術(JIT,Just In Time)將方法編譯成機器碼後再執行

HotSpot VM是目前市面上高效能虛擬機器的代表作之一。它採用直譯器與即時編譯器並存的架構。在Java虛擬機器執行時,直譯器和即時編譯器能夠相互共同作業,各自取長補短,盡力去選擇最合適的方式來權衡編譯原生代碼的時間和直接解釋執行程式碼的時間。

在今天,Java程式的執行效能早已脫胎換骨,已經達到了可以和C/C++ 程式一較高下的地步。

問題來了

有些開發人員會感覺到詫異,既然 HotSpot VM 中已經內建 JIT編譯器了,那麼爲什麼還需要再使用直譯器來「拖累」程式的執行效能呢?

  • 比如 JRockit VM 內部就不包含直譯器,位元組碼全部都依靠即時編譯器編譯後執行。

JRockit 虛擬機器是砍掉瞭直譯器,也就是隻採及時編譯器。那是因爲呢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編譯器編譯爲本地機器指令

  • 由於這種編譯方式發生在方法的執行過程中,因此被稱之爲棧上替換,或簡稱爲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)

  • 半衰週期是化學中的概念,比如出土的文物通過檢視C60來獲得文物的年齡

進行熱度衰減的動作是在虛擬機器進行垃圾收集時順便進行的,可以使用虛擬機器參數 -XX:-UseCounterDecay 來關閉熱度衰減,讓方法計數器統計方法呼叫的絕對次數,這樣,只要系統執行時間足夠長,絕大部分方法都會被編譯成原生代碼。

  • 另外,可以使用-XX:CounterHalfLifeTime參數設定半衰週期的時間,單位是秒。

回邊計數器

它的作用是統計一個方法中回圈體程式碼執行的次數,在位元組碼中遇到控制流向後跳轉的指令稱爲「回邊」(Back Edge)。

  • 顯然,建立回邊計數器統計的目的就是爲了觸發OSR編譯。

在这里插入图片描述

HotSpotVM 可以設定程式執行方法

預設情況下HotSpot VM是採用直譯器與即時編譯器並存的架構

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

  • -Xint:完全採用直譯器模式執行程式;
  • -Xcomp:完全採用即時編譯器模式執行程式。如果即時編譯出現問題,直譯器會介入執行
  • -Xmixed:採用直譯器+即時編譯器的混合模式共同執行程式。

在这里插入图片描述

HotSpotVM 中 JIT 分類

JIT的編譯器還分爲了兩種,分別是C1C2,在 HotSpot VM 中內嵌有兩個JIT編譯器,分別爲Client CompilerServer Compiler,但大多數情況下我們簡稱爲C1編譯器C2編譯器

  • C2編譯器 是使用C++編寫的

開發人員可以通過如下命令顯式指定Java虛擬機器在執行時到底使用哪一種即時編譯器,如下所示:

  • -client:指定Java虛擬機器執行在Client模式下,並使用C1編譯器;
    • C1編譯器會對位元組碼進行簡單和可靠的優化,耗時短。以達到更快的編譯速度。
  • -server:指定Java虛擬機器執行在Server模式下,並使用C2編譯器。
    • C2進行耗時較長的優化,以及激進優化。但優化的程式碼執行效率更高。(使用C++)
    • 64位元系統就只能是 Server 模式,且不能改成 Client模式,即使設定了也會被忽略

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

在不同的編譯器上有不同的優化策略,C1編譯器上主要有方法內聯,去虛擬化、元餘消除

  • 方法內聯:將參照的函數程式碼編譯到參照點處,這樣可以減少棧幀的生成減少參數傳遞以及跳轉過程
  • 去虛擬化:對唯一的實現類進行內聯
  • 冗餘消除:在執行期間把一些不會執行的程式碼摺疊掉

C2的優化主要是在全域性層面逃逸分析是優化的基礎

基於逃逸分析在C2上有如下幾種優化:

  • 標量替換:用標量值代替聚合物件的屬性值
  • 棧上分配:對於未逃逸的物件分配物件在棧而不是堆
  • 同步消除:清除同步操作,通常指synchronized

分層編譯策略

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

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

總結

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

AOT編譯器

JDK9 引入了 AOT 編譯器(靜態提前編譯器Ahead of Time Compiler

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

.java -> .class -> (使用jaotc) -> .so

所謂AOT編譯,是與即時編譯相對立的一個概念。

我們知道,即時編譯指的是在程式的執行過程中,將位元組碼轉換爲可在硬體上直接執行的機器碼,並部署至託管環境中的過程。

而AOT編譯指的則是,在程式執行之前,便將位元組碼轉換爲機器碼的過程。

最大的好處

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

缺點:

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

自JDK10起,HotSpot 又加入了一個全新的及時編譯器:Graal編譯器

編譯效果短短幾年時間就追平了G2編譯器,未來可期

目前,帶着實驗狀態標籤,需要使用開關參數去啓用才能 纔能使用

  • -XX:+UnlockExperimentalvMOptions -XX:+UseJVMCICompiler