jvm位元組碼和類載入機制

海向發表於2020-07-01

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檔案的位元組流中包含的資訊符合當前虛擬機器的要求,並且不會危害虛擬機器自身的安全。

驗證過程

  1. 檔案格式驗證:第一階段是驗證位元組流是否符合Class檔案的規範

    • 是否以0xCAFEBABE開頭
    • 主次版本號是否能被當前版本虛擬機器處理
    • 常量池中常量是否有不被支援的型別
    • 指向常量的各種索引值中是否有指向不存在的常量或不符合型別的常量
    • Class檔案中各個部分是否有被刪除或額外新增的內容等....
  2. 後設資料驗證:第二階段是對位元組碼描述的資訊進行語義分析,保證符合Java語言要求

    • 這個類是否有父類(除了Object之外都應該有父類)
    • 這個類是否繼承了不允許被繼承的類(final類)
    • 如果不是抽象類是否實現了父類或介面中要求被實現的方法
    • 類中的欄位和方法是否與父類發生矛盾(同名同參函式等)....
  3. 位元組碼驗證:本階段是驗證過程中最複雜的一個階段,是對方法體進行語義分析,保證方法在執行時不會出現危害虛擬機器的事件。

  4. 符號引用驗證:最後一個階段的驗證時發生在虛擬機器將符號引用轉化為直接引用的時候。這個動作在連線的第三階段——解析階段中發生,校驗以下內容:

    • 符號引用中通過字串描述的全限定名是否能找到對應的類
    • 在指定類中是否存在合法的欄位、方法描述符
    • 檢查符號引用中的類、欄位、方法是否可被當前類訪問(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官方規範

相關文章