(JVM | 第2部分:虛擬機執行子系統) 前言 參考資料: 《深入理解 Java 虛擬機 - JVM 高級特性與最佳實踐》 第1部分主題為自動記憶體管理,以此延伸出 Java 記憶體區域與記憶體溢出、垃圾收集器與記憶體分配策略、參數配置與性能調優等相關內容; 第2部分主題為虛擬機執行子系統,以此延伸出 c ...
目錄
前言
參考資料:
《深入理解 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 必須要對類進行初始化的五種時機(對類的主動引用)
- 遇到
new
、getstatic
、putstatic
或invokestatic
這 4 條位元組碼指令時沒初始化觸發初始化;(即:new 關鍵字實例化對象、讀取一個類的 finel 靜態欄位、調用一個類的靜態方法); - 使用
java.lang.reflect
包的方法對類進行反射調用; - 發現某類的父類還沒有進行初始化,先觸發其父類的初始化;
- 當虛擬機啟動時,用戶需指定一個要載入的主類(包含 main() 方法的那個類),虛擬機會先初始化這個主類;
- 當使用 JDK 1.7 的動態語言支持時,如果一個
java.lang.invoke.MethodHandle
實例最後的解析結果REF_getStatic
、REF_putStatic
、REF_invokeStatic
的方法句柄,並且這個方法句柄所對應的類沒有進行過初始化,則需先觸發其初始化;
6.2 類載入過程(生命周期)
- 程式主動使用某個類時,如果該類還未被載入到記憶體中,則 JVM 會通過載入、連接、初始化 3 個步驟來對該類進行初始化;
- 在程式運行期間完成;
- 1. 載入:將類的 class 文件讀入到記憶體。通過一個類的全限定名來獲取定義次類的二進位流。將這個位元組流所代表的靜態存儲結構轉換成方法區中的運行時數據結構。在堆中生成一個代表這個類的 java.lang.Class 對象,作為方法區類數據的訪問入口(反射介面)。這個過程需要類載入器參與;
- 數組類的特殊性:數組類本身不通過類載入器創建,它是由 Java 虛擬機直接創建的:
- 如果數組的組件類型是引用類型,那就遞歸採用類載入載入;
- 如果數組的組件類型不是引用類型,Java 虛擬機會把數組標記為引導類載入器關聯;
- 數組類的可見性與他的組件類型的可見性一致,如果組件類型不是引用類型,那數組類的可見性將預設為 public;
- 數組類的特殊性:數組類本身不通過類載入器創建,它是由 Java 虛擬機直接創建的:
- 連接:負責把類的二進位數據合併到 JRE 中(將 Java 類的二進位代碼合併到 JVM 的運行狀態之中);
- 2. 驗證:確保載入的類信息符合 JVM 規範,沒有安全方面的問題。驗證是否符合 Class 文件格式規範,並且是否能被當前的虛擬機載入處理;
- (驗證即其之前都是操作位元組流的,之後操作基於方法區的存儲結構);
- 驗證過程包括文件格式驗證、元數據驗證、位元組碼驗證(最複雜)、符號引用驗證
- 3. 準備:為類變數(static 變數)分配記憶體並設置類變數初始值的階段,這些記憶體都將在方法區中進行分配;(static 修飾的變數賦預設值,final 和 static 修飾的變數直接賦值(編譯時生成 ConstantValue 屬性));
- 4. 解析:(這裡是靜態解析)虛擬機常量池的符號引用替換為直接引用過程;
- 符號引用:以一組符號來描述所引用的目標,符號可以使任何形式的字面量。與虛擬機的記憶體佈局無關,引用的目標並不一定載入到記憶體中;
- 直接引用:可以使直接指向目標的指針、相對偏移量或是一個能間接定位到目標的句柄(與記憶體佈局有關)。與虛擬機佈局相關;
- (解析及其之前都是虛擬機主導,之後是 Java 代碼主導);
- 2. 驗證:確保載入的類信息符合 JVM 規範,沒有安全方面的問題。驗證是否符合 Class 文件格式規範,並且是否能被當前的虛擬機載入處理;
- 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;
- 啟動類載入器(Bootstrap Class Loader):用來載入 Java 的核心類,是用原生代碼來實現的,並不繼承自 java.lang.ClassLoader。載入
- 類載入器間的關係:
- 啟動類載入器: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)
;
- 'a' 首先是一個 char 類型:對應
- 如果對象 A 繼承 B,那麼對於語句:
- 動態分派:典型應用是
方法重寫
。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)。也就是只有一個宗量作為選擇依據;