一、類文件的結構
我們都知道,各種不同平臺的虛擬機,都支持 “字節碼 Byte Code” 這種程序存儲格式,這構成了 Java 平臺無關性的基石。甚至現在平臺無關性也開始演變出 “語言無關性” ,就是其他語言也可以運行在 Java 虛擬機之上,比如現在的 Kotlin、Scala 等。
實現語言無關性的基礎仍然是虛擬機和字節碼存儲格式,Java 虛擬機<typo id="typo-181" data-origin="步" ignoretag="true">步</typo>包括 Java 語言在內的任何語言綁定,他只和 “Class 文件” 這種特定的二進制文件格式所關聯,Class 文件中包含了 Java 虛擬機指令集、符號表以及其他若干輔助信息。
Java 的各種語法、關鍵字、常量變量和運算符號的語義最終都會由多條字節碼指令組合來表達,這決定了字節碼指令所能提供的語言描述能力必須比 Java 語言本身更強大才行。
jvm 提供的語言無關性如下圖所示:
Java 技術能夠一直保持著非常良好的向后兼容性,Class文件結構的穩定功不可沒。JDK1.2 時代的 Java 虛擬機中就定義好的 Class 文件格式的各項細節,到今天幾乎沒有出現任何改變。Class文件格式進行了幾次更新,但基本上只是在原有結構基礎上新增內容、擴充功能。
類文件格式
Class 文件格式采用一種類似 C 語言結構體的偽結構來存儲數據,這種偽結構中只有兩種數據類型:“無符號數”和“表”。后面的解析都要以這兩種數據類型為基礎。
- 無符號數屬于基本數據類型,以 u1、u2、u4、u8分別代表1、2、4、8個字節的無符號數,無符號數可以描述數字、索引引用、數量值或者utf-8 編碼構成字符串值。
- 表是多個無符號數或者其他表組成的復合類型,為了便于區分,所有表的命名都是習慣性地以 “_info” 結尾。整個 Class 文件本質上也可以視為一張表。
Class 文件格式的數據項如下所示:
這里面可以看到,一個 Class 文件的有些數據項是固定的 數量 × 長度,有些則不是。如果一個類型的數據數量不定,會采用多一個數據項來實現,一個前置的數據項作為容量計數器,后面連續的數據項,而數量就是前面的容量計數器的值,這時候這一系列連續的某一類型的數據稱為某一類型的 “集合”。
比如上面的這個,從字面意思也看得出來,因為常量池本身就是很多常量復合組成的,數量就會先用一個 u2 類型的數據項來表示,也就是我們剛說過的容量計數器,然后接著這個常量池集合本身就有了數量。
這么嚴格要求的原因是,Class 文件沒有任何分隔符,所以整個 Class 文件的格式,順序、數量這樣的細節,都是嚴格限定的,全都不允許改變。
接下來,我們來看各個數據項的含義。總共分為 7 項,按照上面的那張圖的顏色框劃分也很容易看出來,并且表示的信息也是見名知意的。
1.1 魔數與 Class 文件的版本
通常常量池是占用 Class 文件空間最大的數據項之一。
Class 文件的魔數是 0xCAFEBABE,咖啡寶貝。是因為 java 開發小組最初的關鍵成員覺得他象征著名咖啡品牌最受歡迎的咖啡,似乎對 java 的商標也有預示
- 魔數后面的 4 個字節是 Class 文件的版本號:5、6 字節是次版本號,7、8字節是主版本號。
1.2 常量池
通常常量池是占用 Class 文件空間最大的數據項之一。
分為兩個部分,一個2字節的數據代表常量池容量計數值;下面是常量池的內容,可以看到這里使用容量的大小用的是 constan_pool_count-1 ,因為常量池的容量計數是 1 開始,而不是 0,比如這個 constan_pool_count 值翻譯成十進制是 22,那么代表常量池有 21 項常量。
除了常量池,剩下的數據項表示都是從 0 開始計數的。
常量池中存放兩大類常量:字面量和符號引用,具體含義和分類很復雜,這里不介紹了。
1.3 訪問標志
2 個字節,用于識別一些類或者接口層次的訪問信息,包括 “這個 Class 是類還是接口” ,“是否定義為 public 類型”;“是否定義為 abstract 類型”,“如果是的話,是否被聲明為final”。
2 個字節總共有 16 個標志位,目前只定義了 9 個,沒有使用的標志位一律置為 0。
1.4 類索引、父類索引和接口索引集合
Class 文件中由這三項數據來確定該類的繼承關系,顯然因為 java 是單繼承,卻可以實現多個接口,所以有了 super_class 是一個 u2 的數據,而 interfaces 則需要一個 interfaces_count 。
類索引+父類索引這兩項的值,就指向的是一個 類描述符常量,通過這個索引值就能找到對應的類。
1.5 字段表集合
1.7 屬性表集合
- 類級變量;
- 實例級變量。
但是不包括在方法內部聲明的局部變量。
因為 field_info 本身也是一個表,具體的這里就不說明。
1.6 方法表集合
和字段表集合類似。
但是放發表的結構有一個特點,就是里面并沒有方法體里的代碼,方法體的代碼在下一個屬性表里。
1.7 屬性表集合
Class 文件,字段表,方法表,三個集合內部都可以嵌套攜帶屬性表集合。
具體屬性表的格式之類的,也是很復雜,這里不贅述。
1.8 字節碼指令
Java 虛擬機的指令由一個字節長度的、代表著某種特定操作含義的數字以及跟隨其后的零至多個代表此操作所需的參數構成。
由于Java虛擬機采用面向操作數棧而不是面向寄存器的架構,所以大多數指令都不包含操作數,只有一個操作碼,指令參數都存放在操作數棧中。
在Java虛擬機的指令集中,大多數指令都包含其操作所對應的數據類型信息。
舉個例子,iload指令用于從局部變量表中加載 int 型的數據到操作數棧中,而 fload 指令加載的則是 float 類型的數據。這兩條指令的操作在虛擬機內部可能會是由同一段代碼來實現的,但在 Class文件中它們必須擁有各自獨立的操作碼。
對于大部分與數據類型相關的字節碼指令,它們的操作碼助記符中都有特殊的字符來表明專門為哪種數據類型服務:i <typo id="typo-2577" data-origin="代表對" ignoretag="true">代表對</typo> int 類型的數據操作, l 代表 long,s 代表 short,b 代表 byte,c 代表 char,f 代表 float,d 代表 double,a 代表 reference。
字節碼指令可以分為:
- 加載和存儲指令:講數據在棧幀中的局部變量表和操作數棧之間來回傳輸;
- 運算指令:對兩個操作數棧上的值進行運算,并把結果重新存入操作數棧頂;
- 類型轉換指令:將不同數值類型相互轉換;
- 對象創建與訪問指令;
- 操作數棧管理指令:直接操作操作數棧的指令,出棧入棧等;
- 控制轉移指令:讓jvm從指定位置的下一條指令繼續執行程序,可以認為是在修改PC寄存器的值;
- 方法調用和返回指令;
- 異常處理指令;
- 同步指令:Java虛擬機可以支持方法級的同步和方法內部一段指令序列的同步,這兩種同步結構都是使用管程( Monitor,更常見的是直接將它稱為“鎖”) 來實現的。方法級的同步是隱式的,無須通過字節碼指令來控制,它實現在方法調用和返回操作之中。虛擬機可以從方法常量池中的方法表結構中的 ACC_SYNCHRONIZED 訪問標志得知一個方法是否被聲明為同步方法。當方法調用時,調用指令將會檢查方法的 ACC_SYNCHRONIZED 訪問標志是否被設置,如果設置了,執行線程就要求先成功持有管程,然后才能執行方法,最后當方法完成 (無論是正常完成還是非正常完成)時釋放管程。在方法執行期間,執行線程持有了管程,其他任何線程都無法再獲取到同一個管程。如果一個同步方法執行期間拋出了異常,并且在方法內部無法處理此異常,那這個同步方法所持有的管程將在異常拋到同步方法邊界之外時自動釋放。同步一段指令集序列通常是由 Java 語言中的 synchronized 語句塊來表示的,Java虛擬機的指令集中有 monitor enter 和 monitor exit 兩條指令來支持 synchronized 關鍵字的語義。正確實現 synchronized 關鍵字需要 Javac 編譯器與 Java 虛擬機兩者共同協作支持。
二、類加載機制
定義:
Java 虛擬機把描述類的數據從 Class 文件加載到內存,并對數據進行校驗、轉換解析和初始化,最終形成可以被虛擬機直接使用的 Java 類型,這個過程被稱作虛擬機的類加載機制。
從定義里就可以看出來,java 和哪些編譯時要進行連接的語言不同,java 的類型的加載、連接、初始化都是程序運行期間完成的,這給 java 應用提供了極高的擴展性,java 的可動態擴展的語言特性就是依賴于運行期動態加載和動態連接這個特點實現的。
例如,編寫一個面向接口的程序,可以等到運行時再指定其實際的實現類,用戶可以通過 java 預置或自定義類加載器,讓某個本地應用程序在運行時從網絡或者其他地方加載一個二進制流作為其程序代碼的一部分。
(后面說的類加載的“類”,實際上可能是接口或者類)
2.1 一個類的生命周期
如上圖所示,一個類從被加載到虛擬機的內存中開始,到卸載出內存為止,生命周期分為 7 個階段:
- 加載;
- 連接:驗證;準備;解析;
- 初始化;
- 使用;
- 卸載。
其中驗證、準備、解析三個階段可以合起來稱為連接。
加載、驗證、準備、初始化和卸載這五個階段的順序是確定的,類型的加載過程必須按照這種順序按部就班地開始,而解析階段則不一定:它在某些情況下可以在初始化階段之后再開始,這是為了支持Java語言的運行時綁定特性(也稱為動態綁定或晚期綁定)。
請注意“開始”,而不是按部就班地“進行“,或按部就班地“完成”,強調這點是因為這些階段通常都是互相交叉地混合進行的,會在一個階段執行的過程中調用、激活另一個階段。
2.2 什么時候類會被加載
關于什么時候需要開始類加載過程的第一個階段“加載”,虛擬機規范沒有強制約束,可以交給虛擬機的具體實現。但是初始化階段,嚴格規定了有且只有 6 種情況必須立即對類進行初始化(這就意味著,加載驗證準備都必須在此之前開始):
- 遇到 new、 getstatic、 putstatic 或 invokestatic 這四條字節碼指令時,如果類型沒有進行過初始化,則需要先觸發其初始化階段。能夠生成這四條指令的典型 Java 代碼場景有:
- 使用new關鍵字實例化對象的時候。
- 讀取或設置一個類型的靜態字段(被 final修飾、已在編譯期把結果放入常量池的靜態字段除外)的時候。
- 調用一個類型的靜態方法的時候。
- 使用 java.lang.reflect 包的方法對類型進行反射調用的時候,如果類型沒有進行過初始化,則需要先觸發其初始化。
- 當初始化類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化。
- 當虛擬機啟動時,用戶需要指定一個要執行的主類 (包含 main方法的那個類),虛擬機會先初始化這個主類。
- 使用 JDK7 新加入的動態語言支持時,如果一個 java.lang.invoke.Methodhandle 實例最后的解析結果為 REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newinvokeSpecial 四種類型的方法句柄,并且這個方法句柄對應的類沒有進行過初始化,則需要先觸發其初始化。
- 當一個接口中定義了 JDK8 新加入的默認方法 (被 default 關鍵字修飾的接口方法) 時,如果有這個接口的實現類發生了初始化,那該接口要在其之前被初始化。
上面的六種場景中的行為,叫做對一個類型進行主動引用。除了這六種外的引用類型的方式都不會觸發初始化,被稱為被動引用。
2.3 類加載的過程
接下來看詳細過程。
2.3.1 加載
注意啊,“加載”只是整個“類加載”中的一個階段。
加載階段,虛擬機主要做三件事:
- 通過一個類的全限定名來獲取定義此類的二進制字節流;
- 將這個字節流代表的靜態存儲結構轉化為方法區的運行時數據結構;
- 在內存中生成一個代表這個類的 java.lang.Class 對象,作為方法區這個類的各種數據的訪問入口。
其中,第一點的來源可以是各種各樣,zip包,網絡中,也可以利用動態代理技術在運行時計算生成。
第二點是要用到類加載器的,相對于五個階段的其他階段:
- 非數組類型的加載階段(就是當前階段)是可控性最強的,可以通過 jvm 內置的引導類加載器,也可以自定義類加載器,開發人員通過定義自己的類加載器去控制字節流的獲取方式(重寫一個類加載器的 findClass() 或 loadClass() 方法)。
- 數組類型來說,數組類不通過類加載器創建,而是由 jvm 直接在內存中動態構造出來,但是數組的元素類本身最終還是要靠類加載器來完成加載,這個過程就是 jvm 把數組降一個維度,然后決定繼續遞歸還是直接可以和類加載器關聯。
第三點就是如上面所說。
2.3.2 驗證(連接之 1 )
驗證是連接階段的第一步,這一階段的目的是確保 Class 文件的字節流中包含的信息符合《Java虛擬機規范》的全部約束要求,保證這些信息被當作代碼運行后不會危害虛擬機自身的安全。
為什么要驗證?
結合上一個步驟,就是因為 Class 文件不一定就是 java 源碼編譯來的,可能是各種途徑,甚至是自己手敲的 01 碼,所以有必要驗證字節碼。
一般驗證的內容分為四個:
- 文件格式驗證:檢查字節流是否符合 Class 文件格式的規范,就是魔數啊、版本號之類的;
- 元數據驗證:上一步格式沒問題,然后對字節碼描述的信息進行語義分析,看類關系、字段方法有沒有矛盾之類;
- 字節碼驗證:最復雜的一步,通過數據流分析和控制流分析,確定程序語義合法、合邏輯,上一步數據類型等到沒問題,這一步就要進入方法體的邏輯分析;
- 符號引用驗證:發生在虛擬機將符號引用轉化為直接引用的時候,轉化動作本身實在連接之3階段——解析階段發生的。(所以前面說這些順序只是開始順序,執行的時候是互相切換的),目的是驗證引用的類、字段等內容是否能找到并訪問之類的。
驗證階段很重要,卻不一定必須執行,因為通過了驗證階段,后面對程序執行就沒有影響了,如果程序反復被驗證和使用過就可以用參數關閉大部分的類驗證措施:
-Xverify: none
2.3.3 準備(連接之 2 )
準備階段是正式為類中定義的變量(即靜態變量,被 static 修飾的變量)分配內存、并設置類變量初始值的階段。
從概念上講,這些變量所使用的內存都應當在方法區中進行分配,但方法區本身是一個邏輯上的區域。
在上一篇,jvm 的內存結構里多次強調。在 JDK7及之前, HotSpot 使用永久代來實現方法區,所以還可以勉強把方法區這個概念保留;而在 JDK8 及之后,永久代也沒有了,所以類變量隨著 Class 對象一起存放在 Java 堆中,這時候 “類變量在方法區” 就有點牽強。
注意:
- 這個階段進行內存分配的僅包括類變量,不包括實例變量。現在講的整個過程都只是類加載的過程,實例變量會在對象實例化的時候隨對象一起分配在 java 堆中。
- 設置初始值通常指的是數據類型的 0 值。
比如:
public static int value = 123;
經過這里的準備階段,初始值 value 是 0,因為這個時候任何 java 方法都沒有執行,初始化的指令是 putstatic ,這個指令是在類構造器的 <clinit>() 方法里的。
所以 value 變成 123 是在類的初始化階段才會執行的,就是 2.3.5 。
2.3.4 解析(連接之 3 )
解析階段是 Java 虛擬機將常量池內的符號引用替換為直接引用的過程。
前面的 Class 文件格式部分提過一次,那解析階段中所說的直接引用與符號引用又有什么關聯呢?
- 符號引用(Symbolic References):一組符號來描述引用目標,任何字面量,只要能定位就行。
- 直接引用(Direct References):直接指向目標的指針、相對偏移量或者一個間接定位到目標的句柄。
解析動作主要針對 7 類符號引用進行轉換:
- 類或接口;
- 字段;
- 類方法;
- 接口方法;
- 方法類型;
- 方法句柄;
- 調用點限定符。
2.3.5 初始化
類的初始化階段是類加載過程的最后一個步驟。
之前介紹的幾個類加載的動作里,除了在加載階段用戶應用程序可以通過自定義類加載器的方式局部參與外,其余動作都完全由Java虛擬機來主導控制。直到初始化階段,Java 虛擬機才真正開始執行類中編寫的Java程序代碼,將主導權移交給應用程序。
2.3.3 的準備階段,已經給變量賦過值了,是初始 0 值,而初始化階段,會根據代碼初始化類變量和其他資源,另一種更直接的形式來表達這個過程:
初始化階段就是執行類構造器的 <clinit>() 方法的過程,這個方法不是程序員自己寫的,是 javac 編譯器的自動生成物。
2.4 類加載器
Java虛擬機設計團隊有意把類加載階段中的:
“通過一個類的全限定名來獲取描述該類的二進制字節流”
這個動作放到 Java 虛擬機外部去實現,以便讓應用程序自己決定如何去獲取所需的類。(就是上面講的類加載過程的第一個步驟)
實現這個動作的代碼被稱為 “類加載器” ( Class loader)。
2.4.1 類與類加載器
類加載器雖然只用于實現類的加載動作,但它在Java程序中起到的作用卻遠超類加載階段。
對于任意一個類,都必須由加載它的類加載器、和這個類本身一起共同確立其在Java虛擬機中的唯一性,每個類加載器,都擁有一個獨立的類名稱空間。
這句話可以表達得更通俗一些:比較兩個類是否“相等”,只有在這兩個類是由同一個類加載器加載的前提下才有意義,否則,即使這兩個類來源于同一個Class文件,被同一個Java虛擬機加載,只要加載它們的類加載器不同,那這兩個類就必定不相等。
這里的相等,包括比較對象的 equals() 方法,isInstance() 方法、isAssignableFrom() 等的返回結果,以及 instanceof 關鍵字的判定結果。
2.4.2 雙親委派模型
站在Java虛擬機的角度來看,只存在兩種不同的類加載器:
- 一種是啟動類加載器( BootstrapClassloader),這個類加載器使用C++語言實現,是虛擬機自身的一部分;
- 另外一種就是其他所有類加載器,這些類加載器都由 Java 語言實現,獨立存在于虛擬機外部,并且全都繼承自抽象類 java.lang.ClassLoader 。
站在Java開發人員的角度來看,類加載器就應當劃分得更細致一些。自 JDK12 以來,Java 一直保著三層類加載器、雙親委派的類加載架構。
2.4.2.1 三層類加載器
注意:下面提及的源碼目錄在JDK9之后,因為模塊化的改變,所以按照這些目錄大概率自己的 jdk 文件里找不到的。
- 啟動類加載器 ( Bootstrap Class Loader)
前面已經介紹過,這個類加載器負責加載存放在 <JAVA_HOME>\lib 目錄,或者被 -Xbootclasspath 參數所指定的路徑中存放的,而且是 Java 虛擬機能夠識別的(按照文件名) 類庫加載到虛擬機的內存中。
啟動類加載器無法被 Java 程序直接引用,用戶在編寫自定義類加載器時,如果需要把加載請求委派給引導類加載器去處理,那直接使用 null 代替即可。
- 擴展類加載器(Extension Class Loader)
這個類加載器是在類 sun.misc.Launcher$ExtClassLoader 中以 Java 代碼的形式實現的。
它負責加載 <JAVA_HOME>\lib\ext 目錄中,或者被 java.ext.dirs 系統變量所指定的路徑中所有的類庫。
根據 “擴展類加載器” 這個名稱,就可以推斷出這是一種 Java 系統類庫的擴展機制,JDK 的開發團隊允許用戶將具有通用性的類庫放置在 ext 目錄里以擴展 JavaSe 的功能,在JDK9之后,這種擴展機制被模塊化帶來的天然的擴展能力所取代。由于擴展類加載器是由 Java 代碼實現的,開發者可以直接在程序中使用擴展類加載器來加載 Class 文件。
- 應用程序類加載器(Application Class Loader)
這個類加載器由 sun.misc.LaunchersappClassloader 來實現。由于這個類加載器是 Classloader 類中的 getSystemClassloader() 方法的返回值,所以有些場合中也稱它為“系統類加載器”。
它負責加載用戶類路徑 (ClassPath) 上所有的類庫,開發者同樣可以直接在代碼中使用這個類加載器。如果應用程序中沒有自定義過自己的類加載器,一般情況下這個就是程序中默認的類加載器。
除了這三種外,如果用戶有必要,還可以自定義來進行擴展:
2.4.2.2 雙親委派模型
上面的圖畫出來的關系,就被稱為類加載器的 “雙親委派模型( Parents DelegationModel)”。
雙親委派模型要求除了頂層的啟動類加載器外,其余的類加載器都應有自己的父類加載器。不過這里類加載器之間的父子關系一般不是以類繼承的關系來實現的,而是通常使用組合關系來復用父加載器的代碼。
雙親委派模型的工作過程:
如果一個類加載器收到了類加載的請求,它首先不會自己去嘗試加載這個類,而是把這個請求委派給父類加載器去完成,每一個層次的類加載器都是如此。
因此所有的加載請求最終都應該傳送到最頂層的啟動類加載器中,只有當父加載器反饋自己無法完成這個加載請求 (它的搜索范圍中沒有找到所需的類) 時,子加載器才會嘗試自己去完成加載。
使用雙親委派模型來組織加載器之間的關系,一個顯而易見的好處就是:java 類隨著類加載器就具有了一種層級關系,比如 Object 類,不論哪個類加載器加載他,都會委派給模型最頂端的啟動類加載器紀念性加載,因此 Object 類在各種類加載器環境里都能保證是同一個類,這樣 java 整個體系的最基礎行為就得到了保證。
雙親委派模型的實現,可以在 java.lang.ClassLoader 的 loadClass() 方法里看到: