1. 程式人生 > 其它 >JVM | 第2部分:虛擬機器執行子系統《深入理解 Java 虛擬機器》

JVM | 第2部分:虛擬機器執行子系統《深入理解 Java 虛擬機器》

目錄

前言

參考資料
《深入理解 Java 虛擬機器 - JVM 高階特性與最佳實踐》

第1部分主題為自動記憶體管理,以此延伸出 Java 記憶體區域與記憶體溢位、垃圾收集器與記憶體分配策略、引數配置與效能調優等相關內容;

第2部分主題為虛擬機器執行子系統,以此延伸出 class 類檔案結構、虛擬機器類載入機制、虛擬機器位元組碼執行引擎等相關內容;

第3部分主題為程式編譯與程式碼優化,以此延伸出程式前後端編譯優化、前端易用性優化、後端效能優化等相關內容;

第4部分主題為高效併發,以此延伸出 Java 記憶體模型、執行緒與協程、執行緒安全與鎖優化等相關內容;

本系列學習筆記可看做《深入理解 Java 虛擬機器 - JVM 高階特性與最佳實踐》書籍的縮減版與總結版,想要了解細節請見紙質版書籍;


5. 類檔案結構

5.1 無關性概述

  • 實現語言無關性的基礎是虛擬機器和位元組碼儲存格式;
  • Java 虛擬機器不和包括 Java 在內的任何語言繫結,它只與 class 檔案這種特定的二進位制檔案格式所關聯;
  • Java 虛擬機器不關心 class 的來源是何種語言。比如 Groovy、Scala 等語言都能產出符合規範的class檔案;
  • Java 虛擬機器規範要求在 class 檔案中使用許多強制性的語法和結構化約束;

5.2 Class 類檔案結構

  • class 檔案是一組以 8位bit(1位元組)為基礎單位 的二進位制流,各個資料專案嚴格按照順序緊湊的排列在 class 檔案之中,中間沒有任何分隔符。當遇到需要佔用 1 位元組以上空間的資料項時,則會按照高位在前的方式分割成若干個 1 位元組進行儲存;
  • 包含兩種資料型別:
    • 無符號數:基本的資料型別,以 u1、u2、u4、u8 來分別代表 1 個位元組、2 個位元組、4 個位元組和 8 個位元組的無符號數。無符號數可以用來描述數字、索引引用、數量值或者字串值;
    • :由多個無符號數或者其他表作為資料項構成的複合資料型別。表用於描述有層次關係的複合結構的資料,整個 class 檔案本質上就是一張表;
  • class 檔案的資料項如下表:

5.3 class 檔案的資料項

  • u4 魔數(Magic Number):唯一的作用是確定這個檔案是否為一個能被虛擬機器接受的 class 檔案,固定為 0xCAFEBABE;
  • u2+u2 版本:虛擬機器也必須拒絕執行超過其版本號的 class 檔案;
  • u2+ 常量池:常量池容量計數器用來記錄常量個數。常量池中主要存放兩大類常量:
    • 字面量:近於 Java 語言層面的常量概念,如文字字串、final 修飾的常量值等;
    • 符號引用:編譯原理方面的概念,包括了:類和介面的全限定名、欄位的名稱和描述符、方法的名稱和描述符。常量池中的每一項常量都是一個表。可以用 javap 分析 class 檔案;
  • u2 訪問標記:用於標識一些類或者介面層次的訪問資訊;
  • 4*u2 類與介面索引集合:由這 4 項資料確定類的繼承關係;
  • u2+ 欄位表集合:用於描述介面或者類中宣告的變數。包括類級變數和例項級變數,不包括在方法內部宣告的區域性變數;(public、static、final、volatile、transient 等)
  • u2+ 方法表集合:類似上面欄位表。方法裡的 Java 程式碼,經過編譯器編譯成位元組碼指令後,存放在方法屬性表集合中一個名為"Code"的屬性裡。方法呼叫指令以常量池中指向方法的符號引用作為引數;
  • u2+ 屬性表集合:不是單獨的一部分,而是由 class 檔案、欄位表、方法表等攜帶,以描述某些場景專有的資訊;

5.4 位元組碼指令

  • 由一個位元組長度的、代表著某種特定操作含義的數字(稱為操作碼,Opcode)以及跟隨其後的0至多個所需引數(稱為運算元,Operands)構成;
  • 由於 Java 虛擬機器採用面向運算元棧的架構,而不是暫存器,所以多大數的指令都不包含運算元,只有一個操作碼(追求小數量、高傳輸效率),對運算元棧進行出棧、入棧操作;
  • 指令集的操作碼總數不超過 256 條(操作碼只有1位元組)。因此 Java 虛擬機器的指令集對於特定的操作只提供了有限的型別相關指令去支援(例如有 int 型別的 iload,沒有 byte 型別的同類指令);
  • 對於沒有定義的資料型別的相關指令,大多數會在編譯期或執行期轉換成 int 型別作為運算型別;

5.5 位元組碼用途分類

  • 載入和儲存指令:用於將資料在棧幀中的區域性變量表和運算元棧之間來回傳輸。比如 iload、istore、bipush等;
  • 運算指令:用於對兩個運算元棧上的值進行某種特定運算,並把結果重新存入到運算元棧頂。比如加法指令:iadd,減法指令:isub 等等;
  • 型別轉換指令:將兩種不同的數值型別進行相互轉換,這些轉換操作一般用於實現使用者程式碼中的顯示型別轉換操作,或者處理前面提到的指令集中資料型別相關指令無法與資料型別一一對應的問題(byte、short等擴充套件為int);
  • 物件建立與訪問指令:要注意 Java 虛擬機器對類例項和陣列的建立與操作使用了不同的位元組碼指令。建立類例項:new,建立陣列:nwarray、anewarray 等;
  • 運算元棧管理指令:類似於操作普通資料結構中的棧,Java虛擬機器提供了一些用於直接操作運算元棧的指令。比如pop、dup、swap等;
  • 控制轉移指令:可以讓 Java 虛擬機器有條件或無條件的修改程式計數器的值。包括條件分支(比如ifeq)、複合條件分支(比如tableswitch)、無條件分支(比如goto)等等;
  • 方法呼叫和返回指令:方法呼叫指令包括,像 invokevirtual 指令:用於呼叫物件的例項方法,invokespecial指令:呼叫一些需要特殊處理的方法,包括例項初始化方法、私有方法和父類方法;方法呼叫指令與資料型別無關,但方法返回指令是根據返回值型別區分的,包括ireturn(返回boolean、byte、char、short、int),lreturn、freturn、dreturn和areturn,另外還有一條return指令供宣告為void的方法、例項初始化方法以及類和介面類初始化方法使用;
  • 異常處理指令:Java 程式中顯示丟擲異常的操作(throw)都是用 athrow 指令來實現。除此之外,Java 虛擬機器規範還規定了許多執行時異常會在其他 Java 虛擬機器指令檢測到異常狀況時自動丟擲。比如在整數運算中,當除數為 0 時,虛擬機器會在 idiv 或 ldiv 指令中丟擲 ArithmeticException 異常。現在在 Java 虛擬機器中處理異常是採用異常表完成的,以前則使用的是 jsr 和 ret 指令實現;
  • 同步指令:synchronized 語句塊對應的指令就是 monitorenter 和 monitorexit。編譯器必須確保無論方法通過何種方式完成,方法中呼叫過的每條 monitorenter 指令都必須執行其對應的 monitorexit 指令。所以為了保證在方法異常完成時,monitorenter 和 monitorexit 指令依然可以正確配對執行,編譯器會自動產生一個異常處理器,這個異常處理器宣告可以處理所有的異常;

6. 類載入機制

6.1 必須要對類進行初始化的五種時機(對類的主動引用)

  • 遇到 newgetstaticputstaticinvokestatic 這 4 條位元組碼指令時沒初始化觸發初始化;(即:new 關鍵字例項化物件、讀取一個類的 finel 靜態欄位、呼叫一個類的靜態方法);
  • 使用 java.lang.reflect 包的方法對類進行反射呼叫;
  • 發現某類的父類還沒有進行初始化,先觸發其父類的初始化;
  • 當虛擬機器啟動時,使用者需指定一個要載入的主類(包含 main() 方法的那個類),虛擬機器會先初始化這個主類;
  • 當使用 JDK 1.7 的動態語言支援時,如果一個 java.lang.invoke.MethodHandle 例項最後的解析結果 REF_getStaticREF_putStaticREF_invokeStatic 的方法控制代碼,並且這個方法控制代碼所對應的類沒有進行過初始化,則需先觸發其初始化;

6.2 類載入過程(生命週期)

  • 程式主動使用某個類時,如果該類還未被載入到記憶體中,則 JVM 會通過載入、連線、初始化 3 個步驟來對該類進行初始化;
  • 在程式執行期間完成;
  • 1. 載入:將類的 class 檔案讀入到記憶體。通過一個類的全限定名來獲取定義次類的二進位制流。將這個位元組流所代表的靜態儲存結構轉換成方法區中的執行時資料結構。在堆中生成一個代表這個類的 java.lang.Class 物件,作為方法區類資料的訪問入口(反射介面)。這個過程需要類載入器參與;
    • 陣列類的特殊性:陣列類本身不通過類載入器建立,它是由 Java 虛擬機器直接建立的:
      • 如果陣列的元件型別是引用型別,那就遞迴採用類載入載入;
      • 如果陣列的元件型別不是引用型別,Java 虛擬機器會把陣列標記為引導類載入器關聯;
      • 陣列類的可見性與他的元件型別的可見性一致,如果元件型別不是引用型別,那陣列類的可見性將預設為 public;
  • 連線:負責把類的二進位制資料合併到 JRE 中(將 Java 類的二進位制程式碼合併到 JVM 的執行狀態之中);
    • 2. 驗證:確保載入的類資訊符合 JVM 規範,沒有安全方面的問題。驗證是否符合 Class 檔案格式規範,並且是否能被當前的虛擬機器載入處理;
      • (驗證即其之前都是操作位元組流的,之後操作基於方法區的儲存結構);
      • 驗證過程包括檔案格式驗證、元資料驗證、位元組碼驗證(最複雜)、符號引用驗證
    • 3. 準備:為類變數(static 變數)分配記憶體並設定類變數初始值的階段,這些記憶體都將在方法區中進行分配;(static 修飾的變數賦預設值,final 和 static 修飾的變數直接賦值(編譯時生成 ConstantValue 屬性));
    • 4. 解析:(這裡是靜態解析)虛擬機器常量池的符號引用替換為直接引用過程;
      • 符號引用:以一組符號來描述所引用的目標,符號可以使任何形式的字面量。與虛擬機器的記憶體佈局無關,引用的目標並不一定載入到記憶體中;
      • 直接引用:可以使直接指向目標的指標、相對偏移量或是一個能間接定位到目標的控制代碼(與記憶體佈局有關)。與虛擬機器佈局相關;
      • (解析及其之前都是虛擬機器主導,之後是 Java 程式碼主導);
  • 5. 初始化:執行類構造器 <clinit>() 方法的過程。為類的變數賦予正確的初始值。類構造器 <clinit>() 方法是由編譯器自動收藏類中的所有類變數的賦值動作和靜態語句塊(static塊)中的語句合併產生,程式碼從上往下執行。如果發現父類還沒有進行過初始化,則需要先觸發其父類的初始化。虛擬機器保證一個類的 <clinit>() 方法在多執行緒環境中被正確加鎖和同步;
  • 6. 使用
  • 7. 解除安裝

6.3 類載入器

  • 概述
    • 由 JVM 提供,是所有程式執行的基礎;
    • 開發者可以通過繼承 ClassLoader 基類來建立自己的類載入器;
    • 類載入器的任務就是根據一個類的全限定名來讀取此類的二進位制位元組流到 JVM 中,然後轉換為一個與目標類對應的 java.lang.Class 物件例項;
    • 最終產物就是位於堆中的 Class 物件,該物件封裝了類在方法區中的資料結構,並且向用戶提供了訪問方法區資料結構的介面,即 Java 反射的介面;
  • 幾種類載入器
    • 啟動類載入器(Bootstrap Class Loader):用來載入 Java 的核心類,是用原生程式碼來實現的,並不繼承自 java.lang.ClassLoader。載入 lib 下或被 -Xbootclasspath 路徑下的類。C++ 實現。不允許直接通過引用啟動類載入器進行操作。
    • 擴充套件類載入器(Extensions Class Loader):Sun 公司(已被 Oracle 收購)實現的 sun.misc.Launcher$ExtClassLoader 類,由 Java 語言實現的,是 Launcher 的靜態內部類,它負責載入 <JAVA_HOME>/lib/ext 目錄下或者由系統變數 -Djava.ext.dir 指定位路徑中的類庫。開發者可以直接使用標準擴充套件類載入器;
    • 系統類載入器(System Class Loader)、應用程式類載入器(Application Class Loade):負責在 JVM 啟動時載入來自 Java 命令的 -classpath 選項、java.class.path 系統屬性,或者 CLASSPATH 將變數所指定的 JAR 包和類路徑。程式可以通過 ClassLoader 的靜態方法 getSystemClassLoader() 來獲取系統類載入器。如果沒有特別指定,則使用者自定義的類載入器都以此類載入器作為父載入器。由 Java 語言實現,父類載入器為 ExtClassLoader;
  • 類載入器間的關係
    • 啟動類載入器:C++ 實現,沒有父類;
    • 拓展類載入器(ExtClassLoader):Java 實現,父類載入器為 Null;
    • 系統類載入器(AppClassLoader):Java 實現,父類載入器為 ExtClassLoader;
    • 自定義類載入器,父類載入器為 AppClassLoader;
  • 類載入器的執行步驟
    • 1. 判斷緩衝區中是否有此 Class,如果有直接進入第 8 步。否則進入第 2 步;
    • 2. 判斷父類載入器是否存在,存在則進入第 3 步。否則說明 Parent / 本身是啟動類載入器,則跳到第 4 步;
    • 3. 請求使用父類載入器去載入目標類,如果載入成功則跳至第 8 步。否則接著執行第 5 步;
    • 4. 請求使用啟動類載入器去載入目標類,如果載入成功則跳至第 8 步。否則跳至第 7 步;
    • 5. 當前類載入器嘗試尋找 Class 檔案,如果找到則執行第 6 步。如果找不到則執行第 7 步;
    • 6. 從檔案中載入 Class,成功後跳至第 8 步;
    • 7. 丟擲 ClassNotFountException 異常;
    • 8. 返回對應的 java.lang.Class 物件;

6.3 雙親委派模式

  • 工作原理:如果一個類載入器收到了類載入請求,它並不會自己先去載入,而是把這個請求委託給父類的載入器去執行,如果父類載入器還存在其父類載入器,則進一步向上委託,依次遞迴,請求最終將到達頂層的啟動類載入器,如果父類載入器可以完成類載入任務,就成功返回,倘若父類載入器無法完成此載入任務,子載入器才會嘗試自己去載入;
    • 優勢:Java 類隨著它的類載入器一起具備了一種帶有優先順序的層次關係,通過這種層級關可以避免類的重複載入。即:當父親已經載入了該類時,就沒有必要子 ClassLoader 再載入一次。安全因素,Java 核心 API 中定義型別不會被隨意替換(父類已經載入過,從父類中查詢返回);

6.4 破壞雙親委派模式

  • 到目前為止,雙親委派模型主要出現過3次較大規模的“被破壞的”情況:
  • 第一次:主要是歷史問題。雙親委派模型在 JDK1.2 之後才被引入,在這之前使用者都是通過重寫 loadClass() 方法實現自定義載入器。為了向前相容,JDK1.2 之後的 java.Lang.ClassLoader 添加了一個新的 protected 方法 findClass()。以此保證雙親委派模型;
  • 第二次:由模型本身的缺陷導致的,缺陷在於:當某個類的介面使用父類載入器,而其實現類使用子類載入器時,父類載入器無法委託子類載入器工作。Java 服務介面 SPI 由 Java 核心庫提供,靠啟動類載入器來載入的。而 SPI 的實現類需要由應用程式類載入器來載入。在載入 SPI 的實現類時,啟動類載入器無法找到應用程式類載入器。因為依照雙親委派模型,BootstrapClassloader 無法委派 AppClassLoader 來載入類。JDK 設定執行緒上下文類載入器(Thread Context ClassLoader),當父類載入器需要使用子類載入器(子類載入器未建立)時,會從父執行緒中繼承一個執行緒上下文類載入器,以此請求子類載入器去完成類載入的動作。這種行為實際上已經打破了雙親委派模型的層次結構來逆向使用類載入器,已經違背了雙親委派模型的一般性原則;
  • 第三次:由開發者對程式動態性的追求而導致。動態性指:程式碼熱替換、模組熱部署等。OSGi(面向Java的動態模組化系統)實現模組化熱部署的關鍵就是它自定義的類載入器機制的實現,當需要更換一個 Bundlle(程式模組)時,就把 Bundle 連同類載入器一起換掉以實現程式碼的熱替換。在替換時需要在平級間呼叫類載入器,在原則上破壞了雙親委派模型;

7. 虛擬機器位元組碼執行引擎

“棧幀”的概念在《JVM | 第1部分:自動記憶體管理與效能調優》提到,這裡不再贅述;

7.1 確定被呼叫的方法

  • 解析:所有方法呼叫的目標方法在 Class 檔案裡都是一個常量池中的符號引用。有兩種解析:
    • 靜態解析:其中的一部分符號引用在類載入的解析階段會被轉化為直接引用(即:靜態方法、final 修飾的方法、私有方法、父類方法、<init>方法,統稱非虛方法);
    • 動態連結:其他的符號引用會在執行期被解析為直接引用;
    • Java 虛擬機器提供了 5 條方法呼叫位元組碼指令:invokestatic(靜態方法)、invokespecial(例項構造器 <init> 方法、私有方法和父類方法)、invokevirtual(虛方法)、invokeinterface(介面方法)、invokedynamic(動態解析);
  • 分派:用來確定虛方法的目標方法。體現 Java 面向物件的繼承、封裝和多型 3 大特性。有如下 4 種:
    • 靜態分派:典型應用是處理方法過載。過載的方法在經過編譯期編譯後得到相同的方法呼叫位元組碼指令和指令引數。虛擬機器在處理過載時是通過引數的靜態型別。方法引數的允許傳送型別轉變,但方法接收者本身靜態型別不變;
      • 如果物件 A 繼承 B,那麼對於語句:B b = new A(); 其中 B 稱為 b 變數的靜態型別(Static Type,編譯器可知),A 稱為 b 變數的實際型別(Actual Type,執行期可知);
      • 選擇靜態分派目標的過程(過載的本質)。例如:嘗試呼叫方法 say('a')
        • 'a' 首先是一個 char 型別:對應 say(char arg)
        • 其次還可以代表數字 97(參照 ASCII 碼):對應 say(int arg)
        • 而轉化為 97 之後,還可以轉型為 long 型別的 97L:對應 say(long arg)
        • 另外還能被自動裝箱包裝為 Character:對應 say(Character arg)
        • 裝箱類 Character 還實現了 Serializable 介面(若直接或間接實現了多個介面,優先順序都是一樣的,如果出現能適配多個介面的多個過載方法,會提示型別模糊,拒絕編譯):對應 say(Serializable)
        • 而且 Character 還繼承自 Object(如果有多個父類,那將在繼承關係中從下往上開始搜尋,越接近上層的優先順序越低),對應 say(Object arg)
        • 最終還能匹配到變長型別:對應 say(char... arg)
    • 動態分派:典型應用是方法重寫。Java 虛擬機器在執行期會依據 invokevirtual 指令的多型查詢過程,通過實際型別來分派方法執行版本的。過程如下:
      • 1. 找到運算元棧頂的第一個元素所指向的物件的實際型別,記做 M;
      • 2. 如果在型別 M 中找到與常量中的描述符和簡單名稱都相符的方法,則進行訪問許可權校驗,若通過則返回這個方法的直接引用,查詢過程結束;否則則返回 IllegalAccessError 異常;
      • 3. 否則,按照繼承關係從下往上依次對 M 的各個父類進行第 2 步的搜尋和驗證過程;
      • 4. 如果始終沒有找到合適的方法,則丟擲 AbstractMethodError 異常;
    • 單分派和多分派:方法的接收者和方法的引數統稱為方法的宗量。 根據分派基於多少種宗量,可以將分派劃分為單分派和多分派兩種;
  • 解析和分派不強調二選一的關係,強調的是在不同層次上的解決方案。例如:靜態方法會在類載入的解析階段就進行直接引用的轉化,而靜態方法也是可以擁有過載版本的,選擇過載版本的過程也是通過靜態分派完成的;
  • Java 語言的 靜態多分派、動態單分派 示例:
    • 方法過載:編譯期看靜態分派,執行期看動態分派
public class Main {
    static class A {
    }
    static class B extends A {
    }
    static class C extends B {
    }
    public void say(A a) {
        System.out.println("A");
    }
    public void say(B b) {
        System.out.println("B");
    }
    public void say(C c) {
        System.out.println("C");
    }
    public static void main(String[] args) throws Exception {
        Main main = new Main();
        Main superMain = new Super();
        B os = new C();
        main.say(os);
        superMain.say((A) os);
        //輸出 B S-A
    }
}
 
class Super extends Main {
    public void say(A a) {
        System.out.println("S-A");
    }
    public void say(B b) {
        System.out.println("S-B");
    }
    public void say(C c) {
        System.out.println("S-C");
    }
}
  • 編譯期看靜態分派 - 多分派:
    • main 和 superMain 的靜態型別都是 Main,方法引數的靜態型別一個是 B,一個是 A,所以此次選擇產生的兩條 invokevitrual 指令的引數分別為常量池中指向 Main.say(B) 和 Main.say(A) 的方法的符號引用。這裡根據兩個宗量(方法接受者和引數)進行選擇;
  • 執行期看動態分派 - 單分派:
    • 這階段 Java 虛擬機器此時不用關心引數的靜態型別、實際型別,只有方法接收者的實際型別會影響到方法版本的選擇。Main.say(B) 和 Main.say(A) 方法的實際型別分別是 Main.say(B) 和 Super.say(A)。也就是隻有一個宗量作為選擇依據;


最後

新人制作,如有錯誤,歡迎指出,感激不盡!
歡迎關注公眾號,會分享一些更日常的東西!
如需轉載,請標註出處!