Java虛擬機(JVM)之類的加載過程詳解

java程序在對某個類進行引用、使用時,就會開始對該類進行加載,比如直接使用類加載器進行顯式加載、創建該類的對象、使用該類的類變量等情況。類的加載是通過java虛擬機的類加載子系統完成的。類的加載主要分爲三個階段。

類的加載步驟

在這裏插入圖片描述

  1. 類加載子系統負責從文件系統或者網絡上加載class文件,class文件在文件開頭會有特定的文件標誌。
  2. ClassLoader只負責class文件的加載,至於他是否可以運行,則有執行引擎決定。
  3. 加載的類信息存放在一塊稱爲方法區的內存空間中,除了類信息外,方法區還會存放運行時常量池信息,還可能包括字符串字面量和數字字面量(這部分常量信息是Class文件中常量池部分的內存映射)。

加載Load階段

這個階段主要分爲三步,由類加載器ClassLoader負責執行。

  1. 通過一個類的全限定名或者定義此類的二進制字節流。
  2. 將這些字節流中所代表的靜態存儲結構轉化爲方法區中的運行時數據結構。
  3. 在內存中生成一個代表這個字節碼文件的java.lang.Class對象,作爲方法區這個類的各種數據訪問入口。

字節碼文件的來源:

  • 從本地文件系統中直接加載。
  • 通過網絡獲取,比如Web Applet應用。
  • 從打包中獲取。jar、war等。
  • 運行時計算生成,比如動態代理技術。

鏈接階段Linking

鏈接階段又分爲三個階段。

驗證

目的在於確保Class文件的字節流中包含信息符合當前虛擬機要求,保證被加載類的正確性,不會危害到虛擬機自身安全。主要包括4種驗證:文件格式驗證、元數據驗證、字節碼驗證、符號引用驗證。

準備階段

該階段爲類變量分配內存並設置該類變量的默認初始值,比如對象引入設置爲null,int型設置爲0。這裏不會包含使用final修飾的static,因爲final在編譯時就會分配內存了,準備階段會顯示初始化。這裏也不會爲實例變量分配內存以及初始化,類變量會分配在方法區中,而實例變量會隨着對象一起分配在java堆中。

解析階段

將常量池內的符號引用轉換爲直接引用的過程。
關於符號引用和直接引用可以參考:
https://blog.csdn.net/mp252119282/article/details/82988504

事實上,解析操作往往會伴隨着類在執行初始化完成之後再執行。

初始化階段

初始化階段執行類初始化方法clinit方法的過程。此方法不需要定義,是javac編譯器自動收集類中所有類變量的賦值動作和靜態代碼塊中的語句合併而來。
也就是說這個階段會對類變量進行初始化和對靜態代碼塊內的代碼執行。

demo

public class TestInit {

    private static int name = 5;
    static {
        name = 6;
    }

    public static void main(String[] args) {
        System.out.println(name);
    }
}

使用Idea的jclasslib插件對該文件反編譯後的結構進行查看如下圖。
在這裏插入圖片描述
clinit方法中字節碼指令的意思是先把靜態屬性name賦值5,此時對應的是類中的name定義時賦值的代碼。然後在把靜態屬性name賦值6,此時對應的是類中靜態代碼塊的代碼。

構造器方法中的指令是按照語句在源文件中出現的位置順序執行。

public class TestInit {

    private static int name = 5;
    static {
        name = 6;
        value = 7;
    }

    private static int value = 6;

    public static void main(String[] args) {
        System.out.println(value);
    }
}

程序結果輸出value的值爲6,原因是value的內存分配以及默認值初始化在準備階段已經執行了,而靜態代碼塊是在初始化階段執行,所以就算value定義的位置在靜態代碼塊之後,程序依然不會報錯,此時clinit方法的順序是先把name賦值5、把name賦值6、把value賦值7、把value賦值6,按順序由上往下執行,所以最終結果爲6。
在這裏插入圖片描述
jclasslib反編譯後如下圖:
在這裏插入圖片描述

若該類具有父類,JVM會保證父類的clinit方法會在子類的clinit方法執行之前完全執行結束。
驗證:

public class Father {

    static {
        System.out.println("Father執行開始");
        for (int i = 0;i==0;){

        }
        System.out.println("Father執行結束");
    }
}

public class Son extends  Father{
    private static int i = 0;
    static {
        System.out.println("Son執行開始");

        System.out.println("Son執行結束");
    }

    public static void main(String[] args) {
        System.out.println(i);
    }
}

上面的代碼,在父類的靜態代碼塊裏面添加了一個死循環,不然父類初始化執行結束。
執行結果:
在這裏插入圖片描述
只輸出了一個,子類clinit一值沒有執行。因爲父類clinit沒有執行完成。

JVM保證一個類的clinit方法在多線程環境下會被同步加鎖,也就是同一時間只有一個線程能夠執行該方法,並且該clinit方法只會被執行一次。

驗證:

public class TestInit {

    private static int name = 5;
    static {
        name = 6;
        value = 7;
    }

    private static int value = 6;

    public static void main(String[] args) {
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                Inner inner = new Inner();
            }
        },"t1");
        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
                Inner inner = new Inner();

            }
        },"t2");
        t1.start();
        t2.start();
    }

    static class Inner{

        static {
            System.out.println(Thread.currentThread().getName() + "執行Inner類的初始化");
            //死循環
            for (int i = 0;i == 0;);
        }
    }
}

上面代碼使用兩個線程來加載Inner類。
結果:
在這裏插入圖片描述
只有線程t1進入了初始化,線程t2被同步鎖阻塞在外面,實際上線程t2執行Inner類的初始化,因爲類只會加載一次,所以也只會初始化一次。

當類中沒有要初始化的代碼,也就是說類中沒有定義靜態代碼塊,並且靜態變量都是使用默認初始化,不顯式初始化或者根本沒有靜態變量時,編譯器不會生成clinit方法。


public class TestInit1 {

    private static int i;
}

結果:clinit不存在。
在這裏插入圖片描述

類加載完成。

特別說明

init方法實際上是我們類的對象構造方法,如果類中沒有顯示定義,JVM就會爲我們默認生成一個無參構造方法,這也就你可以看到上面的截圖中都是由一個init方法。

public class TestInit1 {

    private int i;

    public TestInit1(int i){
        this.i = i;
    }
    public TestInit1(){
        i = 5;
    }
}

上面代碼定義了兩個構造方法。
在這裏插入圖片描述
兩個init方法。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章