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文件的字節流中包含的信息符合當前虛擬機的要求,並且不會危害虛擬機自身的安全。

驗證過程

  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官方規范


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM