JVM 內部原理(四)— 基本概念之 JVM 結構

Richaaaard發表於2016-12-19

JVM 內部原理(四)— 基本概念之 JVM 結構

介紹

版本:Java SE 7

每位使用 Java 的程式設計師都知道 Java 位元組碼在 Java 執行時(JRE - Java Runtime Environment)裡執行。Java 虛擬機器(JVM - Java Virtual Machine)是 Java 執行時(JRE)的重要組成部分,它可以分析和執行 Java 位元組碼。Java 程式設計師不需要知道 JVM 是如何工作的。有很多應用程式和應用程式庫都已開發完成,但是它們並不需要開發者對 JVM 有深入的理解。但是,如果你理解 JVM ,那麼就可以對 Java 更有了解,這也使得那些看似簡單而又難以解決的問題得以解決。

在本篇文章中,我會解釋 JVM 是如何工作的,它的結構如何,位元組碼是如何執行的及其執行順序,與一些常見的錯誤及其解決方案,還有 Java 7 的新特性。

目錄

  • 虛擬機器(Virtual Machine)

  • Java 位元組碼

    • 症狀

    • 原因

  • 類檔案格式(Class File Format)

    • 症狀

    • 原因

  • JVM 結構

    • 類裝載器(Class Loader)

    • 執行時資料區

    • 執行引擎

  • Java 虛擬機器官方規範文件,第 7 版

    • 分支語句中的字串
  • 總結

內容

JVM 結構

用 Java 編寫的程式碼是按照一下流程執行的。

image

類裝載器將編譯好的 Java 位元組碼(Java Bytecode)載入到執行時資料區(Runtime Data Areas),執行引擎執行 Java 位元組碼。

類裝載器(Class Loader)

Java 提供了動態載入特性;它在執行時,而不是編譯時第一次使用類時對類進行裝載和連結處理。JVM 類裝載器執行動態載入。Java 類裝載器的特性如下:

  • 層次化結構(Hierarchical Structure):Java 裡的類裝載器以父子關係的層次結構來組織。啟動類裝載器(Bootstrap Class Loader)是所有類裝載器的父裝載器。

  • 代理模式(Delegation Mode):基於這個層次結構,裝載在不同類裝載器之間進行代理。當一個類在裝載時,它的父裝載器會檢查並確定這個類是否在父裝載器中。如果上層的裝載器裡有這個類,那麼這個類就會被載入,如果沒有,那麼類裝載器會請求載入這個類。

  • 可見受限(Visibility Limit):子的類裝載器可以在它的父裝載器內找到類(即,父裝載器中的類對子裝載器可見),而子裝載器中的類對父裝載器是不可見的。

  • 嚴禁解除安裝(Unload is Not Allowed):類裝載器可以裝載一個類,但是不能解除安裝它。不過,可以刪除當前的類裝載器,並建立一個新的。

每個類裝載器有它的名稱空間,儲存著裝載的類。當類裝載器裝載一個類時,它基於儲存在名稱空間下完整有效的類名稱(FQCN - Fully Qualified Class Name)來檢查判斷一個類是否以及被裝載。即使類有完全相同的 FQCN 但名稱空間不同,那麼它也會被認為是一個不同的類。不同的名稱空間表明類是通過另一個類裝載器載入的。

下圖展示了一個類裝載器的代理模型。

image

當類裝載器接受一個類載入的請求的時候,它會按以下順序檢查:這個類是否存在於類裝載器的快取中,是否在父裝載器中,是否已被自己載入。簡而言之,它會先檢查這個類是否已經被載入到裝載器的記憶體中,如果沒有,它會檢查父類裝載器。如果這個類無法在啟動類裝載器中找到,那麼被請求的類裝載器會從檔案系統查詢這個類。

  • 啟動類裝載器(Bootstrap class loader):它在 JVM 啟動時被建立。它載入 Java API,包括物件類。與其他的裝載器不同,它是用原生程式碼實現的,不是 Java 。

  • 擴充套件類裝載器(Extension class loader):它用來載入基本 Java API 以外的擴充套件類。它同時也載入各種安全相關的擴充套件功能。

  • 系統類裝載器(System class loader):如果啟動類裝載器和擴充套件類裝載器載入的是 JVM 元件,那麼系統類裝載器就載入應用程式類。它載入 $CLASSPATH 上使用者指定的類。

  • 使用者定義的類裝載器(User-defined class loader):這個是由應用使用者直接使用程式碼建立的類裝載器。

像 Web 應用程式伺服器(WAS - Web application server)這樣的框架讓 Web 應用和企業應用得以獨立執行。換句話說,它可以保證程式可以通過類裝載器的代理模式獨立執行。WAS 類裝載器同樣使用層次結構,但不同 WAS 供應商的實現又有些許不同。

如果類裝載器發現一個類未載入,這個類會通過以下流程進行載入和連結。

image

每個階段具體工作的描述如下:

  • 載入(Loading):類是從檔案獲取的並載入到 JVM 記憶體中。

  • 驗證(Verifying):檢查讀取的類是否按照 Java 語言規範和 JVM 規範。這是類載入過程中最複雜的一個測試過程,消耗很長的時間也是最多的。大多數 JVM TCK 測試用例都是來測試在載入一個錯誤類時,驗證過程是否會丟擲錯誤。

  • 準備(Preparing):準備類及其欄位、方法和介面所需指定記憶體的儲存結構。

  • 解析(Resolving):將類常量池內所有識別符號更改成直接引用。

  • 初始化(Initializing):將類變數以合適的值進行初始化。執行靜態初始器,初始化靜態欄位。

JVM 規範定義了任務。然而,它對執行時間的卻要求比較靈活。

執行時資料區(Runtime Data Areas)

image

執行時資料區是 JVM 在 OS 上執行是分配的記憶體區域。執行時資料區可以分為 6 個部分。其中,程式計數暫存器(PC Register)、JVM 棧(JVM Stack)以及原生方法棧(Native Method Stack)是執行緒獨有的。堆(Heap)、方法區(Method Area)、執行時常量池(Runtime Constant Pool)由所有執行緒共有。

  • 程式計數暫存器(PC register):一個程式計數(Program Counter)暫存器存在於執行緒中,它線上程開始時建立。程式計數(Program Counter)暫存器有當前正在執行 JVM 指令的地址。

  • JVM 棧(JVM Stack):JVM 棧存在於執行緒中,也是線上程開始時建立。這個棧內的儲存結構是棧幀(Stack Frame)為單位。JVM 只是對 JVM 棧進行入棧和出棧操作。如果有錯誤出現,棧跟蹤的每行內容都會通過 printStackTrace() 將一個棧楨作為輸出。

    image

    • 棧楨(Stack Frame):棧楨是在方法執行期間建立的,並加入到執行緒的 JVM 棧中。當方法結束時,棧楨被移除。每個棧楨都有方法在類裡執行時,對的本地變數列表(Local Variable Array)、運算元棧(Operand Stack)和執行時常量池(Runtime Constant Pool)的引用。本地變數列表(Local Variable Array)的大小以及運算元棧都是在編譯時決定好的。因此,棧楨的大小根據方法的不同是固定的。

    • 本地變數列表(Local Variable Array):它的索引從 0 開始。0 位置是方法所屬例項的引用。從 1 開始,儲存這方法的引數。在方法引數之後,儲存的是方法的本地變數。

    • 運算元棧(Operand Stack):方法真實的工作區。每個方法都在運算元棧與本地變數列表之間進行資料交換,對其他方法呼叫的結果進行入棧和出棧操作。運算元棧空間的必要大小在編譯時決定。因此,運算元棧的大小也可以在編譯時決定。

  • 原地方法棧(Native method stack):一個供 Java 以外的本地語言程式碼使用的棧。換句話說,這個棧通過 Java 本地介面(JNI - Java Native Interface)執行 C/C++ 程式碼。會根據語言不同,分別建立 C 語言棧和 C++ 語言棧。

  • 方法區(Method area):方法區是由所有執行緒共享的,它在 JVM 啟動時建立。它儲存了執行時常量池(runtime constant pool),欄位(field)和方法(method)資訊,靜態變數(static variable),每個類與介面的方法位元組,供 JVM 讀取。方法區可以有多種不同的實現方式。Oracle Hotspot JVM 將其叫做永久區或永久代(PermGen)。方法區的垃圾收集對於 JVM 的提供商來說是可選實現。

  • 執行時常量池(Runtime constant pool):與類檔案格式中常量池表(constant_pool table)對應的區域。因此,JVM 規範特別強調了它的重要性。除了包括每個類與每個介面的常量,它還包括所有方法和欄位的引用。簡而言之,當方法和欄位被引用時,JVM 會通過使用執行時常量池(runtime constant pool)搜尋方法或欄位在記憶體中的真實地址。

  • 堆(Heap):儲存例項或物件的空間,垃圾收集的目標區域。在我們討論 JVM 效能問題時,會經常提及這個區域。JVM 提供商可以自行決定堆的配置方式,是否需要進行垃圾回收。

回頭看看之前討論過的反編譯後的位元組碼

public void add(java.lang.String);
  Code:
   0:   aload_0
   1:   getfield        #15; //Field admin:Lcom/nhn/user/UserAdmin;
   4:   aload_1
   5:   invokevirtual   #23; //Method com/nhn/user/UserAdmin.addUser:(Ljava/lang/String;)Lcom/nhn/user/User;
   8:   pop
   9:   return

比較反編譯之後的程式碼與 x86 架構下的組合語言的操作碼,兩者有相似的格式;但是,Java 位元組碼不同的是它沒有暫存器名,記憶體定址器或運算元的偏移量。如之前描述的那樣,JVM 使用棧,因此它不使用暫存器,取而代之的是使用如 15 和 23 這樣的索引數字代替記憶體地址。因為它是自行管理記憶體的。15 和 23 是當前類常量池的索引(這裡,UserService.class)。簡言之,JVM 為每個類建立常量池,常量池中儲存了引用的真實目標。

UserService.class 程式碼

// UserService.java
…
public void add(String userName) {
    admin.addUser(userName);
}

逐行解釋反編譯程式碼如下:

  • aload_0:將索引為 #0 的本地變數列表加到運算元棧下。#0 索引的本地變數列表永遠是 this ,當前類例項的引用。

  • getfield #15: 在當前類的常量池下,將 #15 索引加入到運算元棧中。UserAdmin admin 欄位被增加。因為 admin 欄位是類的例項,因此加入的是它的引用。

  • aload_1:將 #1 本地變數列表的索引加入到運算元棧中。本地變數列表的 #1 索引是方法的引數。因此,當呼叫 add() 時,字串 userName 的引用被加入到棧中。

  • invokevirtual #23:呼叫當前類常量池中對應 #23 索引的方法。此時,引用是通過使用 getfield 增加的,引數是通過使用 aload_1 將其送入被呼叫的方法的。當呼叫結束時,返回值被加入到運算元棧中。

  • pop:使用 invokevirtual 對呼叫方法的返回值進行出棧操作。可以看到通過之前庫編譯的程式碼沒有返回值。正因為如此,也無須對做出棧操作獲取返回值。

  • return:完成方法。

下圖將有助於理解上述過程。

image

在本例中,本地變數列表並沒有發生改變。所以上圖只顯示了運算元棧發生的改變。但是,在大多數情況下,本地變數列表也同樣會發生變化。本地變數列表與運算元棧之間的資料傳輸通過很多裝載命令(aload,iload)以及儲存指令(astore,istore)來完成。

在本場景中,我們對執行時常量池和 JVM 棧有了一個簡單的描述。當 JVM 執行時,每個類例項都會儲存在堆上,而包括 User、UserAdmin、UserService 和 String 的類資訊會儲存在方法區。

執行引擎(Execution Engine)

通過類裝載器載入到 JVM 執行時資料區的位元組碼是通過執行引擎來執行的。執行引擎以單元指令的形式讀取 Java 位元組碼(Java Bytecode)。這就和 CPU 逐行執行機器命令一樣。每條位元組碼命令都包括 1 位元組的操作碼以及運算元。執行引擎獲取一個操作碼,再通過運算元執行任務,然後執行下一條操作碼。

Java 位元組碼是人可以理解的語言,這和機器直接執行的語言還不一樣。因此,執行引擎必須在 JVM 中將位元組碼語言轉換成機器可執行的語言。位元組碼可以通過以下的兩種方式將自身轉換成合適的語言。

  • 直譯器(Interpreter):逐行讀取、解釋以及執行位元組碼。正因為它逐行解釋和執行命令,它可以快速的解釋位元組碼,但是執行解釋的結果就會比較慢。這是解釋型語言的缺點。位元組碼“語言”基本上是以直譯器的方式執行的。

  • JIT(Just-In-Time)編譯器:JIT 編譯器的引入是為了彌補直譯器的缺陷。執行引擎在合適的時間先執行直譯器,JIT 編譯器編譯整個位元組碼並將其轉換成原生程式碼(native code)。在此之後,執行引擎不再解釋改方法,而是直接執行原生程式碼。以原生程式碼的方式來執行要比逐行解釋指令要快得多。因為原生程式碼是存於快取中的,所以編譯的程式碼得以快速執行。

不過,JIT 編譯器編譯程式碼的時間要比直譯器逐行解釋程式碼的時間長。因此,如果程式碼只是執行一次,那麼解釋是優於編譯的。正因為這樣,JVM 用 JIT 編譯器從內部檢查方法執行的頻次,只對那些超過一定執行頻次的方法才進行編譯。

image

JVM 規範並沒有規定執行引擎具體的執行方式。因此,JVM 廠商會用各種不同的技術來提升執行引擎的效能,同時也引入了各種不同型別的 JIT 編譯器。

大多數編譯器按下圖方式執行:

image

JIT 編譯器將位元組碼轉換成中間層表示式,中間表現形式(IR - Intermediate Representation),執行優化,然後將表示式轉換成原生程式碼(native code)。

Oracle Hotspot VM 使用 JIT 編譯器被稱為 Hotspot 編譯器。之所以被稱為 Hotspot 是因為 Hotspot 編譯器通過效能分析搜尋處於最高優先順序的待編譯的 “Hotspot”,然後將 Hotspot 編譯成為原生程式碼(native code)。如果被編譯位元組碼方法不再被頻繁呼叫,換句話說也就是如果方法不再是 Hotspot ,Hotspot VM 會將原生程式碼從快取中移除並以解釋模式執行。Hotspot VM 被分為服務端 VM(Server VM)和客戶端 VM(Client VM),這兩個 VM 使用不同的 JIT 編譯器。

image

客戶端 VM 和伺服器 VM 使用著相同的執行時;但是,它們使用著不同的 JIT 編譯器,如上圖所示。高階動態優化編譯器(Advanced Dynamic Optimizing Compiler)是在服務端的 VM 中使用的,它應用了更為複雜和更為多樣的效能優化技術。

IBM JVM 從 IBM JDK 6 開始引入了 AOT(Ahead-Of-Time)編譯器以及 JIT 編譯器。這意味著很多 JVM 會通過共享快取共享已編譯好的原生程式碼(native code)。簡而言之,已經被 AOT 編譯器編譯過的程式碼可以直接在另外一個 JVM 中使用不需要重新編譯。除此之外,IBM JVM 還用 AOT 編譯器將預編譯程式碼編譯成 JXE(Java EXecutable)檔案格式以提供更快的執行方式。

大多數 Java 效能的提升是通過提升執行引擎的能力獲得的。與 JIT 編譯器一樣,由於引入了各式各樣的效能優化技術,JVM 的效能也得以長足的提升。初期 JVM 與最近的 JVM 之間的最大差異之處就在於執行引擎。

Hotspot 編譯器是自 Oracle Hotspot VM 的 1.3 版本引入的,Dalvik VM 引入 JIT 編譯器是從 Android 2.2 開始的。

注意

引入如位元組碼這樣中間語言的技術後,VM 執行位元組碼,JIT 編譯器提高 JVM 效能的這種方式也通常應用於其他的語言。例如 Microsoft 的 .Net ,CLR(Common Language Runtime)也是一種虛擬機器,執行某種稱為 CIL(Common Intermediate Language)的位元組碼。CLR 同時提供 AOT 編譯器和 JIT 編譯器。因此,如果原始碼是用 C# 或 VB.NET 編譯的,編譯器建立 CIL 並通過 JIT 編譯器在 CLR 執行建立的 CIL 。CLR 也會使用垃圾回收策略,同時與 JVM 一樣,它也是以堆疊結構機器的模式執行。

參考

參考來源:

JVM Specification SE 7 - Run-Time Data Areas

2011.01 Java Bytecode Fundamentals

2012.02 Understanding JVM Internals

2013.04 JVM Run-Time Data Areas

Chapter 5 of Inside the Java Virtual Machine

2012.10 Understanding JVM Internals, from Basic Structure to Java SE 7 Features

2016.05 深入理解java虛擬機器

結束

相關文章