閱讀本文大概須要 12.8 分鐘。html
JVM 可以跨計算機體系機構來執行 Java 字節碼,也就是咱們所說的 Java 能夠跨平臺執行,主要是因爲 JVM 屏蔽了與各個計算機平臺相關的軟件或者硬件之間的差別,使得與平臺相關的耦合統一由 JVM 提供者來實現。java
本文將介紹下面內容:緩存
在前面的章節,已經深刻介紹了 Class 類,而且簡單瞭解如何經過類加載器將 Java 字節碼加載到 JVM 中。下面來看看 JVM 的體系結構是如何設計的,這裏從宏觀的角度進行分析,讓你們瞭解一下最基本的 JVM 結構和工做模式。bash
首先咱們思考下面這個問題網絡
JVM 的全稱是 Java Virtual Machine,Java 虛擬機,它經過模擬一個計算機來達到一個計算機所具備的計算功能。數據結構
咱們先來看一下真實的計算機如何才能具有計算功能。架構
以計算爲中心看計算機的體系結構能夠分爲如下幾個部分:oracle
上面幾個部分和咱們所說的代碼執行最密切的仍是指令集,下面會詳細介紹指令集是如何定義的。jvm
什麼是指令集?有何做用?性能
指令集是在 CPU 中用來計算和控制計算機系統的一套指令的集合,每一種新型的 CPU 在設計時都規定了一系列與其餘硬件電路相配合的指令系統。而指令集的先進與否也關係到 CPU 的性能發揮,是 CPU 性能的一個重要標誌。
指令集和彙編語言有什麼關係?
指令集是能夠直接被機器識別的機器碼,也就是必須以二進制格式存在於計算機中。
彙編語言是可以被人識別的指令,彙編語言在順序和邏輯上是與機器指令一一對應的。也就是說,彙編語言是爲了讓人可以更容易地記住機器指令的助記符。
指令集和 CPU 架構有何關係?不一樣的 CPU 指令集是否兼容?CPU 的架構是否會影響指令集?
彙編語言中都是對寄存器和段的直接操做的命令,寄存器和段等芯片都是架構的一部分,因此不一樣的芯片架構設計必定會對應到不一樣的機器指令集合。可是不一樣的芯片廠商每每都會採用兼容的方式來兼容其餘不一樣架構的指令集,由於壟斷操做系統的微軟,操做系統是管理計算的的真正入口,幾乎全部程序都要通過操做系統的調用,若是操做系統不支持某種芯片的指令集,用戶的程序是不可能執行的。
如何查看 CPU 支持不一樣的指令集?
經過 cpu-z 軟件來查看 CPU 支持哪些指令集,看下圖:
在指令集這行咱們能看到,當前的 CPU 支持 11 種指令集。
說完了指令集,咱們回到 JVM 主題,想一下咱們在前面提出的問題 JVM 和實體機到底有何不一樣?
大致有以下幾點:
JVM 和實體機同樣也必須有一套合適的指令集,這個指令集可以被 JVM 解析執行,這個指令集咱們稱爲 JVM 字節碼指令集,符合 class 文件規範的字節碼均可以被 JVM 執行。
下面咱們再看看除了指令集以外,JVM 還須要哪些組成部分。以下圖所示,JVM 的結構基本上只由 4 部分組成。
那詳細描述下這 4 個部分:
類加載器:
ClassLoader 工做機制會在後面詳細寫一篇文章介紹。這裏須要說明的是,每一個被 JVM 裝載的類型都有一個對應的 java.lang.Class 類的實例來表示該類型,該實例能夠惟一表示被 JVM 裝載的 Class 類,這個實例和其餘類的實例同樣存放在 Java 的堆中。
執行引擎:
執行引擎是 JVM 的核心部分,執行引擎的做用就是解析 JVM 字節碼指令,獲得執行結果。在《Java 虛擬機規範》中詳細定義了執行引擎遇到每條字節碼指令時應該處理什麼,而且獲得什麼結果。可是並無規定執行引擎應該如何或採起什麼方式處理而獲得這個結果。由於執行引擎具體採起什麼方式由 JVM 的實現廠家去實現,是直接解釋執行仍是採用 JIT(just-in-time 即便編譯) 技術轉成本地代碼去執行,仍是採用寄存器這個芯片模式去執行均可以。因此執行引擎的具體實現有很大的發揮空間,如 SUN 的 hotspot 是基於棧的執行引擎,而 Google 的 Dalvik 是基於寄存器的執行引擎。
執行引擎也就是執行一條條代碼的一個流程,而代碼都是包含在方法體內的,因此執行引擎本質上就是執行一個個方法所串起來的流程,對應到操做系統中一個執行流程是一個 Java 線程,Java 進程能夠有多個同時執行的執行流程。這樣說,每一個 Java 線程就是一個執行引擎的實例,那麼在一個 JVM 實例中就會同時有多個執行引擎在工做,這些執行引擎有的在執行用戶的程序,有的在執行 JVM 內部的程序(如 Java 垃圾收集器)。
Java 內存管理
執行引擎在執行一段程序時須要存儲一些東西,如操做碼須要的操做數,操做碼的執行結果須要保存。class 類的字節碼還有類的對象等信息都須要在執行引擎執行以前就準備好。從最開始的圖中,能夠看出一個 JVM 實例會有一個方法區、Java 堆、Java 棧、PC 寄存器和本地方法區。其中方法區和 Java 堆是全部線程共享的,也就是能夠被全部的執行引擎實例訪問。每一個新的執行引擎實例被建立時會爲這個執行引擎建立一個 Java 棧和一個 PC 寄存器,若是當前正在執行一個 Java 方法,那麼在當前的這個 Java 棧中保存的是該線程中方法調用的狀態,包括方法的參數、方法的局部變量、方法的返回值以及運算符中間的結果等。而 PC 寄存器會指向即將執行的下一條指令。
本地方法調用
本地方法調用,則存儲在本地方法調用棧中或者特定實現中的某個內存區域中。
前面簡單分析了 JVM 的基本結構,下面簡單分析一下 JVM 是如何執行字節碼命令的,就是介紹執行引擎是如何進行工做的。
在分析 JVM 的執行引擎如何工做以前,咱們不妨先看看在普通的實體機上程序是如何執行的。經過下圖來帶你們理解一下。
計算機只接受機器指令,高級語言必需要通過編譯器編譯成機器指令才能被計算機執行,因此從高級語言到機器語言之間必需要有個翻譯過程。咱們知道機器語言是和硬件平臺密切相關,編譯器經過編譯解決了高級語言與硬件的耦合。那不一樣硬件平臺就會所需的編譯器也是不一樣的,如今的硬件平臺已經被更上一層的軟件平臺代替了,這個軟件平臺就是操做系統。因此就有了上圖中 C 語言的編譯器在不一樣的操做系統是不一樣的。固然也有不少不一樣的廠家的編譯器和操做系統關係不大,只是實現上有差別。
一般一個程序從編寫到執行會經歷如下一些階段:
除了源代碼和最後的可執行程序,中間的全部環節都是由現代意義上的編譯器統一完成的。
如在 Linux 平臺上咱們一般安裝一個軟件須要通過 confrgure、make、make install、make clean 這 4 個步驟來完成。
configure 爲這個程序在當前的操做系統環境下選擇合適的編譯器來編譯這個程序代碼,也就是爲這個程序代碼選擇合適的編譯器和一些環境參數;
make 對程序代碼進行編譯操做,它會將源碼編譯成可執行的目標文件,
make install 將已經編譯好的可執行文件安裝到操做系統指定或者默認的安裝目錄下。
make clean 刪除編譯臨時產生的目錄或文件。
咱們說的編譯器一般是高級語言翻譯成目標機器語言,也就是低級語言。還有一些編譯器是高級語言編輯成高級語言,高級語言編譯成虛擬機目標語言(Java 編譯器),低級語言翻譯成高級語言(反編譯)。
如何讓機器(不論是實體機仍是虛擬機)執行代碼呢?
指令集最基本的元素:加、減、乘、求餘、求模等。這些運算又能夠進一步分解成二進制運算:與、或、異或等。這些運算又經過指令來完成,而指令的核心目的就是肯定須要運算的種類(操做碼)和運算須要的數據(操做數),以及從哪裏(寄存器或棧)獲取操做數、將運算結果存到什麼地方(寄存器或是棧)等。這種不一樣的操做方式又將指令劃分紅:一指令地址、二指令地址、三指令地址和零指令地址等 n 地址指令。相應的這些指令集就會有相應的架構實現,如基於寄存器的架構實現或者基於棧的架構實現,這裏的基於寄存器或者棧都是指一個指令中的操做數是如何獲取的。
JVM 執行字節碼指令是基於棧的架構,也就是全部的操做數必須先入棧,而後根據指令中的操做數選擇從棧頂彈出若干元素進行計算,將計算的結果再壓入到棧中。
JVM 中操做數能夠存放在每個棧幀中的一個本地變量集中,每一個方法調用時會給這個方法分配一個本地變量集,這個本地變量集在編譯的時候已經肯定,因此操做數入棧能夠直接是常量入棧或者從本地變量中取出一個變量壓入棧中。
和通常的基於寄存器的操做有所不一樣,一個操做須要頻繁地入棧和出棧,進行一個加法運算,若是兩個操做數都在本地變量中,一個加法操做就要有 5 次棧操做,分別是將兩個操做數從本地變量入棧(2 次入棧操做),再將兩個操做數出棧用於加法運算(2 次出棧),再將結果壓入棧頂(1 次入棧)。若是是基於寄存器的通常只須要將兩個操做數存入寄存器進行加法運算後再將結果存入其中一個寄存器便可,不須要這麼多的數據移動操做。那爲何 JVM 還要基於棧來設計呢?
JVM 要設計成平臺無關的
要與平臺無關,就要保證在沒有或者有不多的寄存器的機器上也能運行 Java 代碼。
JVM 更好地優化代碼
對於 Java 來講,JVM 可做爲鏈接器(動態)使用,也能夠做爲優化器使用。這種以棧爲中心的體系結構能夠將運行時進行的優化工做與執行即時編譯或者自適應優化的執行引擎集合起來,從而能夠更好地優化執行 Java 字節碼指令。
爲了指令的緊湊性
操做碼能夠只佔一個字節大小,爲了儘可能讓編譯後的 class 文件更加緊湊,提升字節碼在網絡上的傳輸效率。
瞭解了 Java 以棧爲架構的緣由後,再詳細看一下 JVM 是如何設計 Java 的執行部件的,以下圖所示。
每當建立一個新的線程,JVM 會爲這個線程建立一個 Java 棧,同時會爲這個線程分配一個 PC 寄存器,而且這個 PC 寄存器會指向這個線程的第一行可執行代碼。每當調用一個新方法時會在這個棧上建立一個新的棧幀數據結構,這個棧幀會保留這個方法的一些元信息,如這個方法中定義的局部變量、一些用來支持常量池的解析、正常方法返回及異常處理機制等。
JVM 在調用某些指令時可能須要使用到常量池中的一些常量,或者是獲取常量表明的數據或者這個數據指向的實例化的對象,而這些信息都存儲在全部線程共享的方法區和 Java 堆中。
下面以一個具體的例子看一下執行引擎時如何將一段代碼在執行部件執行的,以下一段代碼:
public class Math {
public static void main(String[] args) {
int a=1;
int b=2;
int c=(a+b)*10;
}
}
複製代碼
看一下 main 方法的字節碼指令:
0: iconst_1 常量1入棧
1: istore_1 將棧頂元素移入到本地變量1存儲
2: iconst_2 常量2入棧
3: istore_2 將棧頂元素移入到本地變量2存儲
4: iload_1 本地變量1入棧
5: iload_2 本地變量2入棧
6: iadd 彈出棧頂兩個元素相加
7: bipush 10 將10入棧
9: imul 棧頂兩個元素相乘
10: istore_3 棧頂元素移入到本地變量3存儲
11: return 返回
複製代碼
對應到執行引擎各部件以下圖所示。
在開始執行方法以前,PC 寄存器存儲的指針是第 1 條指令的地址,局部變量區和操做棧都沒有數據。從第 1 條到第 4 條指令分別將 a、b 兩個本地變量賦值,對應到局部變量區就是 1 和 2 分別存儲常量 1 和 2,以下圖所示。
前 4 條指令執行完後,PC 寄存器當前指向的是下一條指令地址,也就是第 5 條指令,這時局部變量區已經保存了兩個局部變量(也就是 a 和 b 的值),而操做棧裏仍然沒有值,由於兩次常數入棧後又分別出棧了。
第 5 條和第 6 條指令分別是將兩個局部變量入棧,而後相加。
1 先入棧 2 後入棧,棧頂元素是 2,第 7 條指令是將棧頂的兩個元素彈出後相加,結果再入棧,這時整個部件狀態以下圖所示。
當前 PC 寄存器執行的地址是9,下一個操做時將當前棧的兩個操做數彈出進行相乘並把結果壓入棧中。
第 10 條指令是將當前的棧頂元素存入局部變量 3 中。
第 10 條指令執行完後棧中元素出棧,出棧的元素存儲在局部變量區 3 中,對應的是變量 c 的值。最後一條指令是 return,這條指令執行完後當前的這個方法對應的這些部件會被 JVM 回收,局部變量區的全部值將所有釋放,PC 寄存器會被銷燬,在 Java 棧中與這個方法對應的棧幀將消失。
JVM 的方法調用分爲兩種,一種是 Java 方法調用,另外一種是本地方法調用。本地方法調用因爲各個虛擬機的實現不太相同,因此這裏主要介紹 Java 的方法調用狀況。
以下面一段方法調用的代碼:
public class Math {
public static void main(String[] args) {
int a=1;
int b=2;
int c=math(a,b)/10;
}
public static int math(int a, int b){
return (a+b)*10;
}
}
複製代碼
對應的字節碼以下:
public static void main(java.lang.String[]);
Code:
0: iconst_1
1: istore_1
2: iconst_2
3: istore_2
4: iload_1
5: iload_2
6: invokestatic #2 // Method math:(II)I
9: bipush 10
11: idiv
12: istore_3
13: return
public static int math(int, int);
Code:
0: iload_0
1: iload_1
2: iadd
3: bipush 10
5: imul
6: ireturn
複製代碼
當 JVM 執行 main 方法時,首先將兩個常量 1 和 2 分別存儲到局部變量區 1 和 2 中,而後調用靜態 main 方法。從 math 的字節碼指令能夠看出,math 方法的兩個參數也存儲在對應的方法棧幀中的局部變量區 0 和 1中,先將這兩個局部變量分別入棧,而後進行相加操做再和常數 10 相乘,最後將結果返回。下面看一下世紀的執行操做部件中是如何操做的。
上圖是 JVM 執行到第 5 條指令時,執行引擎各部件的狀態圖,PC 寄存器指向的是下一條執行 main 方法的地址。 當執行 invokestatic 指令時 JVM 會爲 math 方法建立一個新的棧幀,而且將兩個參數存在 math 方法對應的棧幀的前兩個局部變量區中,這時 PC 寄存器會清零,而且會指向 math 方法對應棧幀的第一條指令地址,這時的狀態如圖所示。
執行 invokestatic 指令時,建立了一個新的棧幀,這時棧幀中的局部變量區已經有兩個變量了,這兩個變量是從 main 方法的棧幀中的操做棧中傳過來的。當執行 math 方法時,math 方法對應的棧幀成爲當前活動棧幀,PC 寄存器保存的是當前這個棧幀中的下一條指令地址,因此是 0.
math 方法先將 a、b 兩個變量相加,再乘 10,最後返回這個結果執行到第 5 條指令的狀態。
math 的操做棧中的棧頂元素相乘的結果是 30,最後一條指令是 ireturn,這條指令是將當前棧幀的棧頂元素返回到調用這個方法的棧中,而這個棧幀也將撤銷,PC 寄存器的值回覆調用棧的下一條指令地址。
當執行 return 指令時 main 方法對應的棧幀也將撤銷,若是當前線程對應的 Java 棧中沒有棧幀,這個 Java 棧也將被 JVM 撤銷,整個 JVM 退出。
本篇文章主要介紹了 JVM 的體系結構,以及 JVM 的執行引擎和 JVM 指令的過程,實際上 JVM 的設計很是複雜,包括 JVM 在執行字節碼時如何來自動優化這些字節碼,並將它們再編譯成本地代碼,也就是 JIT 技術,這個技術再咱們執行測試時可能會有影響,若是你的程序沒有通過充分的「預熱」,那麼得出的結果可能會不許確,例如,JVM 再執行程序時會記錄某個方法的執行次數,若是執行的次數到一個閥值時 JIT 就會編譯這個方法爲本地代碼。
在文章的結尾,你們考慮一個問題,爲何遞歸會引起棧溢出呢?
本文是從 《深刻分析 Java Web 技術內幕》第 7 章 摘錄的,我在這個基礎上加了一些圖片便於理解。電子書能夠關注個人公衆號,回覆【電子書】,便可獲取。
推薦閱讀