深入 Java 類載入全流程,值得你收藏

a_wei發表於2020-02-04

先測試一番,全對的就走人

//題目一
class Parent1{
    public static String parent1 = "hello parent1";
    static { System.out.println("Parent1 靜態程式碼塊"); }
}
class Children1 extends Parent1{
    public static String children1 = "hello children1";
    static {System.out.println("Children1 靜態程式碼塊");}
}
//----------------------------------------------------------------
//題目二
class GrandParent2{
    static { System.out.println("GrandParent2靜態程式碼塊"); }
}
class Parent2 extends GrandParent2{
    public static String parent2="hello parent2";
    static{ System.out.println("Parent2 靜態程式碼塊");}
}
class Children2 extends Parent2{
    public static String children2 ="hello children2";
    static{ System.out.println("Children2 靜態程式碼塊");}
}
//----------------------------------------------------------------
//題目三
class GrandParent3{
    static { System.out.println("GrandParent3靜態程式碼塊"); }
}
class Parent3 extends GrandParent3{
    public final static String parent3="hello parent3";
    static{ System.out.println("Parent3 靜態程式碼塊");}
}
class Children3 extends Parent3{
    public static String children3 ="hello children3";
    static{ System.out.println("Children3 靜態程式碼塊");}
}
//測試
public class ClassLoaderTest {
    public static void main(String[] args) {
        //測試一的輸出
        System.out.println(Children1.children1);
        System.out.println("-------------------------------");
        //測試二的輸出
        System.out.println(Children2.parent2);
        System.out.println("--------------------------------");
        //測試三的輸出
        System.out.println(Children3.parent3);
    }
    //你認為輸出什麼呢
}
答案如下

Parent1 靜態程式碼塊
Children1 靜態程式碼塊
hello children1


GrandParent2靜態程式碼塊
Parent2 靜態程式碼塊
hello parent2


hello parent3

如果看清到這裡,你的回答和結果一致,那麼你真的懂了,可以轉載給他人了,如果出乎你的意料,請認真看完。

什麼是類載入(或者初始化)

Java原始碼經過編譯之後轉換成class檔案,在系統執行期間當需要某個類的時候,如果記憶體中還沒該class檔案,那麼JVM需要對這個類的class檔案進行載入,連線,初始化,JVM通常會連續完成這三步,這個過程叫做類的載入或者初始化, 類從磁碟載入到記憶體必須經歷這三個階段的。

重點是:類的載入都是在程式執行期間完成的,這提供了無限可能,意味著你可以在某個階段對類的位元組碼進行修改,JVM也確實提供了這樣的功能。

類的載入並不是物件的建立,類的載入是在為物件建立前做一些資訊準備。

類的生命週期

我們明白了什麼是類的載入,那麼從類的載入到最後類的解除安裝成為類在JVM中的宣告週期,這個生命週期總共包含了七個階段:我畫一張圖,如下,我們逐個分析一下類的生命週期的每一步。

深入Java類載入全流程,值得你收藏

這是類的生命週期的,但它不總是按照這個固定的流程進行的,我們先知道這個就行,後面再說。

載入

類的載入指的是把class檔案從磁碟讀入記憶體中,將其放入後設資料區域並且建立一個Class物件,放入堆中,Class物件是類載入的最終產品,Class物件並不是new出來的物件。

後設資料區域儲存的資訊

  1. 這個型別的完整有效名
  2. 這個型別的直接父類完整有效名
  3. 這個型別的修飾符(public final abstract等)
  4. 這個型別的直接介面的列表

Class物件中包含的如下資訊,這也是我們能夠通過Class物件獲取類的很多資訊的原因

  1. 類的方法程式碼,方法名,欄位等
  2. 類的返回值
  3. 類的訪問許可權

載入class檔案有很多種方式,可以從磁碟上讀取,可以從網路上讀取,可以從zip等歸檔檔案中讀取,可以從資料庫中讀取

驗證

驗證的目的是驗證class檔案的正確性,是否能夠被當前JVM虛擬機器執行,主要包含了一些部分驗證,驗證非常重要,但不是必須的(正常情況下都是正確的)
檔案格式驗證:比如JDK8載入的是JDK6下編譯的class檔案,這肯定不行。
後設資料驗證:確保位元組碼描述資訊符合Java語言規範的要求,你理解為校驗外殼,比如類中是否實現了介面的所有方法。
位元組碼驗證:確定程式語義執行是合法的,校驗內在,校驗方法體,防止位元組碼執行過程中危害JVM虛擬機器。
符合引用驗證:其對類自身以外(常量池中的各種符號引用)的資訊進行匹配性校驗,比如:符號引用中的類、欄位、方法的訪問性是否可被當前類訪問,通過全限定名,是否能找到對應的類。

準備(重點)

驗證完成之後,JVM就開始為類變數(靜態變數) 分配記憶體,設定初始化值, 記住兩點

  1. 不會為成員變數分配記憶體的。
  2. 初始化值是指JVM預設的指,不是程式中指定的值。

看如下程式碼,你就明白了:

//類變數,初始化值是 null, 不是123
public static String s1 = "123"
//成員變數
public String s2 = "456"

但有一個特殊,如果一個類變數是final修飾的常量,那麼在準備階段就會被賦值為程式中指定的值,如下程式碼,初始值是123

//初始值是123,不是null
public static final String s1 = "123"

為什麼會這樣呢?兩行程式碼的區別在於final,final在Java中代表著不可變,不能賦值了之後重新賦值,所以一開始就必須賦值為使用者想要的預設值,而不是Java語言的預設值。而不是final修時的變數有可能在之後發生變化,所以就先賦值為Java語言的預設值。

解析

解析階段主要是將常量池中的符號引用轉換為直接引用,解析動作主要包含類或介面、欄位、類方法、介面方法、方法型別、方法控制程式碼和呼叫點限定符7類符號引用。

符號引用包括什麼呢?

  1. 類和方法的全限定名
  2. 欄位的名稱和描述符
  3. 方法的名稱和描述符,

直接引用是是什麼呢?一個指向目標的指標地址或者控制程式碼。
舉個例子如下:

// 123 是一個符號引用,123所對應的記憶體中的地址是一個直接引用。
public static final String s1 = "123"

常量池是什麼呢?,常量池包含好多種,字串常量池,class常量池,執行時常量池,這裡指的是class常量池。我們寫的每一個Java類被編譯後,就會形成一份class檔案,class檔案中除了包含類的版本、欄位、方法、介面等描述資訊外,還有一項資訊就是常量池,用於存放編譯器生成的各種字面量和符號引用,每個class檔案都有一個class常量池。

比如解析階段,找不到某個欄位就丟擲NoSuchFieldError,同理NoSuchMethodError

初始化(重點)

初始化階段使用者定義的Java程式碼才會真正開始執行,一般來說當首次主動使用某個類的時候就會對該類初始化,初始化某個類時也會初始化這個類的父類,這裡的首次主動使用,大家要理解清楚了,第二次使用時不會初始化的。類的初始化其實就是執行類構造器的過程,這個不是我們程式碼定義的構造方法。

下面列舉了JVM初始化類的時機:

  1. 建立物件時(比如:new Person())
  2. 訪問類變數時
  3. 呼叫類的靜態方法時
  4. 反射載入某個類是(Class.forName("....."))
  5. Java虛擬機器啟動時被標明為啟動類的類(單測時),Main方法的類。

初始化時類變數會被賦予真正的值,也就是開發人員在程式碼中定義的值,也會執行靜態程式碼塊。

JVM初始化類的步驟:

  1. 若該類還沒有被載入和連線,則程式先載入並連線該類
  2. 若該類的父類還沒有初始化,則先初始化該類的夫類
  3. 若該類中有靜態程式碼塊,則系統依次執行這些程式碼塊

上面提到了首次主動使用時初始化類,那麼就有被動使用,被動使用是什麼意思呢?比如說通過子類引用父類的靜態欄位,那麼子類會初始化嗎?答案是不會的,所以下面測試的子類的靜態程式碼塊是不會執行的。

class Parent4{
    public final static String parent4="hello parent4";
}

class Children4 extends Parent4{
    static{ System.out.println("Children4 靜態程式碼塊");}
}
public class ClassLoaderTest {
    public static void main(String[] args) {
        //測試四的輸出
        System.out.println(Children4.parent4);
    }
}

再說一個點解析時有提到常量池的概念,在經過初始化後,類就被載入到記憶體中去了,這個時候jvm就會將class常量池中的內容存放到執行時常量池中,執行時常量池也是每個類都有一個。在解析階段,會把符號引用替換為直接引用,解析的過程會去查詢字串常量池,以保證執行時常量池所引用的字串與字串常量池中是一致的

上面還有一個關鍵字一般來說,那麼不一般呢?類載入器並不需要等到某個類被首次主動使用時再載入它,JVM規範允許類載入器在預料某個類將要被使用時就預先載入它.

使用

使用就比較簡單了,JVM初始化完成後,就開始按照順尋執行使用者程式碼了。

解除安裝

類解除安裝有個前提,就是class的引用是空的,要麼程式中手動置為空,要麼程式退出時JVM銷燬class物件,然後JVM退出。只要class引用不存在,那麼這個類就可以回收了。

你自己可以試驗一下,寫一個classload類載入器,寫一個Test測試類,實際測試一下,我的測試程式碼如下:

public class ClassTest {
    public static void main(String[] args){
        ClassLoaderMy classLoader = new ClassLoaderMy();
        classLoader.setRoot("D:\\github\\java_common\\target\\classes\\");
        Class clazz = classLoader.findClass("jvm.Test類中有一個靜態程式碼塊。");
        Object obj = clazz.newInstance();
        System.out.println("1:"+clazz.hashCode());
        obj=null;
        System.out.println("2:"+clazz.hashCode());
        classLoader = null;
        System.out.println("3:"+clazz.hashCode());
        clazz = null;

        System.out.println("此時 obj classloader clazz 都為空了");

        classLoader = new ClassLoaderMy();
        classLoader.setRoot("D:\\github\\java_common\\target\\classes\\");
        clazz = classLoader.findClass("jvm.Test");
        System.out.println("4:"+clazz.hashCode());
        obj = clazz.newInstance();
    }
    //列印結果如下,看之前你猜一猜。Test類中有一個靜態程式碼塊。
}

初始化了
1:1775282465
2:1775282465
3:1775282465
此時 obj classloader clazz 都為空了
4:1267032364
初始化了

最終結果你會發現,前三個hashcode的值是一樣的,第四個的值發生了變化,說明class檔案被解除安裝了後重新載入生成了新的class物件,否則,同一個物件的hashcode是不會發生變化的,而且Test類的靜態程式碼塊執行了兩遍,完整程式碼地址如下:

https://github.com/sunpengwei1992/java_common/tree/master/src/jvm

我畫了一張圖,方便大家更好的理解,如下,當左邊的三個變數都指向為null時,最右邊的後設資料區域的代表Class物件的Test二進位制資料就會被解除安裝,當下次使用時就會被重新載入,初始化等。

深入Java類載入全流程,值得你收藏

但是,注意了 由JVM自帶的類載入器載入的類,在JVM生命週期中,始終不會被解除安裝,
JVM自帶的類載入器包括根類載入器,擴充套件類載入器,系統類載入器,這些回頭單聊。

解密測試題目

接下來我們聊一聊一開始的測試題,其實看到這裡,想必大家都明白了吧,還是說一說。

第一個不用講了,都會。

第二題:子類Children2,父類Parent2, 祖父類GrandParent2,我們通過Chidlren2列印父類Parent2的靜態變數,類載入時,發現有父類存在,逐層往上載入,那麼Parent2和GrandParent2都會被載入,所以Parent2和GrandParent2的靜態程式碼塊都會被執行,而Children2就不會被載入了,因為不符合首次主動使用的條件。

第三題:同樣的道理,只是Parent3和GrandParent3的靜態程式碼塊為什麼沒執行呢,因為Parent3的靜態變數是final型別的,在準備階段就已經完成了,不需要再逐層往上載入了.

提一下介面的載入

當一個類在初始化時,要求其父類全部都已經初始化過了,但是一個介面在初始化時,並不要求其父介面全部都完成了初始化,當真正用到父介面的時候才會載入該介面,如下程式碼,執行main方法,Parent5介面是不會被載入的,parent5變數也是不會被初始化的。

interface Parent5{
    public final static  String parent5 = "hello parent5";
}
interface Children5 extends Parent5{
    public final static String children5 = "hello children5";
}
public static void main(String[] args) {
    System.out.println(Children5.children5);
}

表格整理一下流程

深入Java類載入全流程,值得你收藏

深入Java類載入全流程,值得你收藏

本作品採用《CC 協議》,轉載必須註明作者和本文連結

那小子阿偉

相關文章