jvm位元組碼和類載入機制
Class類文件的結構
任何一個Class文件都對應著唯一一個類或介面的定義資訊,但反過來說,類或介面並不一定都得定義在文件里(類和介面也可以用反射的方式通過類載入器直接生成)
Class文件時一組以8位位元組為基礎單位的二進位流,各個數據都嚴格按照順序緊湊排列在Class文件中,沒有任何分隔符。
Class文件格式採用一種類似C語言結構體的偽結構存儲數據,這種結構中只包含無符號數和表兩種類型。
無符號數
- 無符號數屬於基本數據類型,以u1、u2、u4、u8來分別代表1個位元組、2個位元組、4個位元組、8個位元組的無符號數
- 無符號數可以用來描述數字、引用、數量值或者按照utf編碼的字元串值。
表
-
表是由多個無符號整數或者其他表構成的符合數據類型,都由”_info”結尾。
-
表用於描述有層次關係的複合結構的數據,整個Class文件實際上就是一張表。
ClassFile {
u4 magic;
u2 minor_version;
u2 major_version;
u2 constant_pool_count;
cp_info constant_pool[constant_pool_count-1];
u2 access_flags;
u2 this_class;
u2 super_class;
u2 interfaces_count;
u2 interfaces[interfaces_count];
u2 fields_count;
field_info fields[fields_count];
u2 methods_count;
method_info methods[methods_count];
u2 attributes_count;
attribute_info attributes[attributes_count];
}
編輯器用16進位打開類文件
0 1 2 3 4 5 6 7 8 9 A B C D E F
ca fe ba be 00 00 00 34 00 3f 0a 00 0a 00 2b 08
00 2c 09 00 0d 00 2d 06 40 59 00 00 00 00 00 00
09 00 0d 00 2e 09 00 2f 00 30 08 00 31 0a 00 32....
magic
類文件第一個數據為u4,我們查看16進位文件前4個字元是cafebabe
,它用來確定這個文件是否為一個能被虛擬機接受的Class文件。
minor_version、major_version
u4後的兩個u2,即00 00 00 34用來代表jdk的主次版本。
常量池
常量池是Class文件結構中與其他項目關聯最多的數據類型,也是佔用Class文件空間最大的數據,也是Class文件中第一個出現的表類型數據項目。
存放類型
存放類型包含:
- 字面量:文本字元串、聲明為final的常量值等。
- 符號引用:類和介面的全限定名、欄位的名稱和描述符、方法的名稱和描述符。不會保存它們最終的資訊,因為這是無意義的,它們不經過運行期轉換的話得不到真正的入口地址,也就無法被虛擬機使用。
類載入的時機
類從被載入到虛擬機記憶體開始,到卸載出記憶體為止,它的整個生命周期包括以下 7 個階段:載入、驗證、準備、解析、初始化、使用、卸載
載入、驗證、準備、初始化和卸載這 5 個階段的順序是確定的,類的載入過程必須按照這種順序按部就班地開始(注意是「開始」,而不是「進行」或「完成」),而解析階段則不一定:它在某些情況下可以在初始化後再開始,這是為了支援 Java 語言的運行時綁定(多態)。
對類進行初始化的情況
虛擬機規範嚴格規定了有且只有5鍾情況必須立即對類進行初始化:
-
使用 new、getstatic、putstatic、或invokestatic這四條位元組碼命令時,後三個命令分別代表對類的靜態變數進行操作,調用類的靜態方法。生成這四條指令最常見的場景為:new一個對象的時候、讀取或者賦值給類的靜態變數的時候(被final修飾的除外,因為已經在編譯期把結果放入了常量池)、以及調用一個靜態方法的時候。
-
反射調用類的時候,如果類未被初始化需要進行初始化
-
當實例化某類時,其父類沒被初始化,需要初始化父類
-
當虛擬機啟動時,用戶指定的執行的主類(包含main方法的類),虛擬機會先初始化這個主類
-
當使用 JDK 1.7 的動態語言支援時,如果一個 java.lang.invoke.MethodHandle 實例最後的解析結果為 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,並且這個方法句柄所對應的類還沒初始化,則需要先觸發其初始化。
這 5 種場景稱為對一個類進行主動引用(有且只有這五種才可以觸發類的初始化),除此之外,其它所有引用類的方式都不會觸發初始化,稱為被動引用。
被動引用反例
/**
* 被動引用 Demo1:
* 通過子類引用父類的靜態欄位,不會導致子類初始化。
*/
class SuperClass {
static {
System.out.println("SuperClass init!");
}
public static int value = 123;
}
class SubClass extends SuperClass {
static {
System.out.println("SubClass init!");
}
}
public class NotInitialization {
public static void main(String[] args) {
System.out.println(SubClass.value);
// SuperClass init!
}
}
對於靜態欄位,只有直接定義這個欄位的類才會被初始化,因此通過其子類來引用父類中定義的靜態欄位,只會觸發父類的初始化而不會觸發子類的初始化。
/**
* 被動引用 Demo2:
* 通過數組定義來引用類,不會觸發此類的初始化。
*/
public class NotInitialization {
public static void main(String[] args) {
SuperClass[] superClasses = new SuperClass[10];
}
}
new數組對象時並不會觸發SuperClass
類的初始化,而是在這段程式碼里觸發一個名為Lorg.fenixsoft.classloading.SuperClass
的類初始化,他直接繼承自Object
類,由虛擬機來產生和觸發。
/**
* 被動引用 Demo3:
* 常量在編譯階段會存入調用類的常量池中,本質上並沒有直接引用到定義常量的類,因此不會觸發定義常量的類的初始化。
*/
class ConstClass {
static {
System.out.println("ConstClass init!");
}
public static final String HELLO_ZHIYIN = "Hello ZhiYin";
}
public class NotInitialization {
public static void main(String[] args) {
System.out.println(ConstClass.HELLO_ZHIYIN);
}
}
JVM在編譯期進行了傳播優化,將ConstClass類中的常量放入了NotInitialization的常量池中,事實上這個常量已經和ConstClass類沒有了聯繫,不會觸發初始化。
類載入的過程
類載入過程包括 5 個階段:載入、驗證、準備、解析和初始化。
載入
「載入」是「類載入」過程的第一步,在載入階段,虛擬機需要完成以下三件事情:
-
通過一個類的全限定名(com.zhiyin.TestClass)來獲取定義此類的二進位位元組流
-
將這個位元組流所代表的靜態存儲結構轉化為方法區的運行時數據結構
-
在記憶體中(HostSpot在方法區)生成一個代表該類的
java.lang.Class
對象,作為方法區這個類的各種數據的訪問入口
獲取二進位位元組流
對於 Class 文件,虛擬機沒有指明要從哪裡獲取、怎樣獲取。除了直接從編譯好的 .class 文件中讀取,還有以下幾種方式:
- 從 zip 包中讀取,如 jar、war等
- 從網路中獲取,如 Applet
- 通過動態代理技術生成代理類的二進位位元組流
- 由 JSP 文件生成對應的 Class 類
- 從資料庫中讀取,如 有些中間件伺服器可以選擇把程式安裝到資料庫中來完成程式程式碼在集群間的分發。
「數組類」與「非數組類」載入情況的不同
- 非數組類由載入器來進行載入
- 數組類由於沒有位元組流,由jvm直接創建,如果數組中的對象是引用類,遞歸採用載入器進行載入
注意事項
- 虛擬機規範未規定 Class 對象的存儲位置,對於 HotSpot 虛擬機而言,Class 對象比較特殊,它雖然是對象,但存放在方法區中。
- 載入階段與連接階段的部分內容交叉進行,載入階段尚未完成,連接階段可能已經開始了。但這兩個階段的開始時間仍然保持著固定的先後順序。
驗證
驗證意義
驗證是連接階段的第一步,這一階段的目的是為了確保Class文件的位元組流中包含的資訊符合當前虛擬機的要求,並且不會危害虛擬機自身的安全。
驗證過程
-
文件格式驗證:第一階段是驗證位元組流是否符合Class文件的規範
- 是否以
0xCAFEBABE
開頭 - 主次版本號是否能被當前版本虛擬機處理
- 常量池中常量是否有不被支援的類型
- 指向常量的各種索引值中是否有指向不存在的常量或不符合類型的常量
- Class文件中各個部分是否有被刪除或額外添加的內容等….
- 是否以
-
元數據驗證:第二階段是對位元組碼描述的資訊進行語義分析,保證符合Java語言要求
- 這個類是否有父類(除了Object之外都應該有父類)
- 這個類是否繼承了不允許被繼承的類(final類)
- 如果不是抽象類是否實現了父類或介面中要求被實現的方法
- 類中的欄位和方法是否與父類發生矛盾(同名同參函數等)….
-
位元組碼驗證:本階段是驗證過程中最複雜的一個階段,是對方法體進行語義分析,保證方法在運行時不會出現危害虛擬機的事件。
-
符號引用驗證:最後一個階段的驗證時發生在虛擬機將符號引用轉化為直接引用的時候。這個動作在連接的第三階段——解析階段中發生,校驗以下內容:
- 符號引用中通過字元串描述的全限定名是否能找到對應的類
- 在指定類中是否存在合法的欄位、方法描述符
- 檢查符號引用中的類、欄位、方法是否可被當前類訪問(private、protected、public、default)
符號引用驗證如果沒有通過,會拋出一個
java.lang.IncompatibleClassChangeError
異常的子類,如常見的java.lang.NoSuchFieldError、java.lang.NoSuchMethodError
等
注意事項
對於虛擬機的類載入機制而言,驗證是一個很重要的、但不是必須的(因為對程式運行期無影響)一個階段,如果運行的全部程式碼(包括自己編寫的以及第三方包中的程式碼)都已經被反覆使用和驗證過,那麼在實施階段就可以使用-Xverify:none
來關閉大部分類的驗證過程,以縮短虛擬機類載入的時間
準備
準備階段是正式為類變數(被 static修飾的變數)分配記憶體並設置類變數初始值(通常為零值,引用類型為null)的階段,這些變數所使用的記憶體將在方法去區中進行分配。如下語句中:
public static int value = 666;
value
變數在準備階段之後初始值變為0而不是666,變為666的過程是在初始化階段進行。
上面說到通常情況下是零值,特殊情況為該變數同時被final修飾,是常量。
public static final int value = 666;
編譯時value就會生成ConstantValue屬性(定義為常量),在準備階段虛擬機就會依據ConstantValue的設置將value賦值為666.
解析
解析階段是虛擬機將常量池內的符號引用替換為直接引用的過程。
class二進位位元組流中的引用關係都是符號引用沒有真正的意義,解析之後將會變成直接指向目標的指針。
初始化
類初始化階段是類載入過程的最後一步,是執行類構造器方法的過程。
類構造器方法是由編譯器自動收集類中的所有類變數的賦值動作和靜態語句塊(static {} 塊)中的語句合併產生的,編譯器收集的順序是由語句在源文件中出現的順序所決定的。
靜態語句塊中只能訪問定義在靜態語句塊之前的變數,定義在它之後的變數,在前面的靜態語句塊中可以賦值,但不能訪問。如下方程式碼所示:
public class Test {
static {
i = 0; // 給變數賦值可以正常編譯通過
System.out.println(i); // 這句編譯器會提示「非法向前引用」
}
static int i = 1;
}
類構造器方法不需要顯式調用父類構造器,虛擬機會保證在子類的類構造器方法執行之前,父類的類構造器方法方法已經執行完畢。
由於父類的類構造器方法方法先執行,意味著父類中定義的靜態語句塊要優先於子類的變數賦值操作。如下方程式碼所示:
static class Parent {
public static int A = 1;
static {
A = 2;
}
}
static class Sub extends Parent {
public static int B = A;
}
public static void main(String[] args) {
System.out.println(Sub.B); // 輸出 2
}
類構造器方法不是必需的,如果一個類沒有靜態語句塊,也沒有對類變數的賦值操作,那麼編譯器可以不為這個類生成它。
介面中不能使用靜態程式碼塊,但介面也需要通過類構造器方法為介面中定義的靜態成員變數顯式初始化。但介面與類不同,介面的類構造方法不需要先執行父類的類構造方法方法,只有當父介面中定義的變數使用時,父介面才會初始化。
虛擬機會保證一個類的類構造方法在多執行緒環境中被正確加鎖、同步。如果多個執行緒同時去初始化一個類,那麼只會有一個執行緒去執行這個類的類構造方法。
參考《深入理解Java虛擬機》、Jvm官方規範