三、類的加載篇——類的加載過程


按照Java虛擬機規范,從class文件到加載到內存中的類,到類卸載出內存為止,它的整個生命周期包括如下7個階段:

 

 從程序中類的使用過程看: 

 

  

一、過程一:Loading(加載)階段

1、做了哪些事?

在Java中數據類型分為基本數據類型和引用數據類型。基本數據類型由虛擬機預先定義引用數據類型則需要進行類的加載

所謂加載,就是將Java類的字節碼文件加載到機器內存中,並在內存中構建出Java類的原型——類模板對象

加載完成的操作

加載階段,簡言之,查找並加載類的二進制數據,生成Class的實例。在加載類時,Java虛擬機必須完成以下3件事情:

  • 通過類的全名,獲取類的二進制數據流。
  • 解析類的二進制數據流為方法區內的數據結構(Java類模型)
  • 創建java.lang.Class類的實例,表示該類型。作為方法區這個類的各種數據的訪問入口

2、什么是類模板對象?

類模板對象,其實就是Java類在JVM內存中的一個快照,JVM將從字節碼文件中解析出的常量池、類字段、類方法等信息存儲到類模板中,這樣JVM在運行期便能通過類模板而獲取Java類中的任意信息,能夠對Java類的成員變量進行遍歷,也能進行Java方法的調用。

反射的機制即基於這一基礎。如果JVM沒有將Java類的聲明信息存儲起來,則JVM在運行期也無法反射。

類模板的位置

加載的類在JVM中創建相應的類結構,類結構會存儲在方法區(JDK1.8之前:永久代;JDK1.8及之后:元空間)

3、二進制流的獲取方式有哪些?

對於類的二進制數據流,虛擬機可以通過多種途徑產生或獲得。(只要所讀取的字節碼符合JVM規范即可)

  • 虛擬機可能通過文件系統讀入一個class后綴的文件(最常見)
  • 讀入jar、zip等歸檔數據包,提取類文件
  • 事先存放在數據庫中的類的二進制數據
  • 使用類似於HTTP之類的協議通過網絡進行加載
  • 在運行時生成一段Class的二進制信息等

在獲取到類的二進制信息后,Java虛擬機就會處理這些數據,並最終轉為一個java.lang.Class的實例。

如果輸入數據不是ClassFile的結構,則會拋出ClassFormatError。(比如如果不是cafebabe開頭,就會拋出ClassFormatError)

4、Class實例的位置在哪?

類將.class文件加載至元空間后,會在堆中創建一個Java.lang.Class對象,用來封裝類位於方法區內的數據結構,該Class對象是在加載類的過程中創建的,每個類都對應有一個Class類型的對象。(instanceKlass → mirror : Class的實例)

外部可以通過訪問代表Order類的Class對象來獲取Order的類數據結構

 

5、數組類的加載

創建數組類的情況稍微有些特殊,因為數組類本身並不是由類加載器負責創建,而是由JVM在運行時根據需要而直接創建的,但數組的元素類型仍然需要依靠類加載器去創建。創建數組類(下述簡稱A)的過程:

  1. 如果數組的元素類型是引用類型,那么就遵循定義的加載過程遞歸加載和創建數組A的元素類型;

  2. JVM使用指定的元素類型和數組維度來創建新的數組類。

  3. 如果數組的元素類型是引用類型,數組類的可訪問性就由元素類型的可訪問性決定。否則數組類的可訪問性將被缺省定義為public

 

二、過程二:Linking(鏈接)階段

1、驗證Verification

當類加載到系統后,就開始鏈接操作,驗證是鏈接操作的第一步。它的目的是保證加載的字節碼是合法、合理並符合規范的

主要包括四種驗證,文件格式驗證,元數據驗證,字節碼驗證,符號引用驗證。

驗證的步驟比較復雜,實際要驗證的項目也很繁多,大體上Java虛擬機需要做以下檢查,如圖所示:

2、准備Preparation

簡言之,為類變量分配內存,並將其初始化為默認值。Java虛擬機為各類型變量默認的初始值如表所示:

類型 byte short int long float double char boolean reference
默認初始值 (byte)0 (short)0 0 0L 0.0f 0.0 \u0000 false null

注意:
Java並不支持boolean類型,對於boolean類型,內部實現是int,由於int的默認值是0,故對應的,boolean的默認值就是false.

  • 這里不包含基本數據類型的字段用static final修飾的情況,因為final在編譯的時候就會分配了,准備階段會顯式賦值
  • 注意這里不會為實例變量分配初始化,類變量會分配在方法區中,而實例變量是會隨着對象一起分配到Java堆中。
  • 在這個階段並不會像初始化階段中那樣會有初始化或者代碼被執行。

3、解析Resolution

簡言之,將類、接口、字段和方法的符號引用轉為直接引用

  1. 符號引用 :符號引用以一組符號來描述所引用的目標。符號引用可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可,符號引用和虛擬機的布局無關。個人理解為:在編譯的時候一個每個java類都會被編譯成一個class文件,但在編譯的時候虛擬機並不知道所引用類的地址,多以就用符號引用來代替,而在這個解析階段就是為了把這個符號引用轉化成為真正的地址的階段。
  2. 直接引用 :直接引用和虛擬機的布局是相關的,不同的虛擬機對於相同的符號引用所翻譯出來的直接引用一般是不同的。如果有了直接引用,那么直接引用的目標一定被加載到了內存中。

    直接引用可以是: 
    直接指向目標的指針。(個人理解為:指向對象,類變量和類方法的指針)
    相對偏移量。(指向實例的變量,方法的指針)
    一個間接定位到對象的句柄。 

所謂解析就是將符號引用轉為直接引用,也就是得到類、字段、方法在內存中的指針或者偏移量。因此,可以說,如果直接引用存在,那么可以肯定系統中存在該類、方法或者字段。但只存在符號引用,不能確定系統中一定存在該結構。

不過Java虛擬機規范並沒有明確要求解析階段一定要按照順序執行。在HotSpot VM中,加載、驗證、准備和初始化會按照順序有條不紊地執行,但鏈接階段中的解析操作往往會伴隨着JVM在執行完初始化之后再執行

 

三、過程三:Initialization(初始化)階段

初始化階段,簡言之,為類的靜態變量賦予正確的初始值

1、子類加載前先加載父類?

在加載一個類之前,虛擬機總是會試圖加載該類的父類,因此父類的總是在子類之前被調用。也就是說,父類的static塊優先級高於子類

2、哪些類不會生成<clinit>方法?

  1. 一個類中並沒有聲明任何的類變量,也沒有靜態代碼塊時。
  2. 一個類中聲明類變量,但是沒有明確使用類變量的初始化語句以及靜態代碼塊來執行初始化操作時。
  3. 一個類中包含static final修飾的基本數據類型的字段,這些類字段初始化語句采用編譯時常量表達式。
    public class test {
        //場景1:對於非靜態的字段,不管是否進行了顯式賦值,都不會生成<clinit>()方法
        public int num = 1;
        //場景2:靜態的字段,沒有顯式的賦值,不會生成<cLinit>()方法
        public static int num1;
        //場景3:比如對於聲明為static final的基本數據類型的字段,不管是否進行了顯式賦值,都不會生成<clinit>()方法
        public static final int num2 = 1;
    }

3、static與final的搭配問題

使用static + final修飾的成員變量,稱為:全局常量。

什么時候在鏈接階段的准備環節:給此全局常量附的值是字面量或常量,不涉及到方法或構造器的調用。

除此之外,都是在初始化環節賦值的

    public class test {     
        public static int a = 1;    //初始化階段賦值
      
        public static final int INT_CONSTANT = 10;  //鏈接階段的准備環節賦值

        public static final Integer INTEGER_CONSTANT1 = Integer.valueOf(100);   //初始化階段賦值
      
        public static Integer INTEGER_CONSTANT2 = Integer.valueOf(1000);    //初始化階段賦值
      
        public static final String s0 = "helloworld0";  //鏈接階段的准備環節賦值
       
        public static final String s1 = new String("helloworld1");  //初始化階段賦值

        public static String s2 = "helloworld2";    //鏈接階段的准備環節賦值

        public static final int NUM = 2;    //鏈接階段的准備環節賦值
     
        public static final int NUM1 = new Random().nextInt(10);    //初始化階段賦值
    }

4、<clinit>會發生死鎖嗎

對應<clinit>()方法的調用,也就是類的初始化,虛擬機會在內部確保其多線程環境中的安全性。

虛擬機會保證一個類的<clinit>()方法在多線程環境中被正確地加鎖、同步,如果多個線程同時去初始化一個類,那么只會有一個線程去執行這個類的<clinit>()方法,其他線程都需要阻塞等待,直到活動線程執行<clinit>()方法完畢。

正是因為函數<clinit>()帶鎖線程安全的,因此,如果在一個類的<clinit>()方法中有耗時很長的操作,就可能造成多個線程阻塞,引發死鎖。並且這種死鎖是很難發現的,因為看起來它們並沒有可用的鎖信息。

死鎖demo:

  • loadA線程加載了staticA,staticA加載了staticB
  • loadB線程加載了staticB,staticB加載了staticA
  • staticA和staticB都有1s的阻塞,所以當staticA想要加載staticB的時候,staticB已經被loadB線程先加載
  • 所以loadA線程需要等待staticB,而loadB線程也要等待staticA
  • 由此出現了類的交叉加載行為,繼而出現了類的死鎖行為
  • 程序呈現出僵持狀態,輸出語句不會打印,相當於程序進入阻塞狀態,出現了事實上的死鎖
class StaticA {
    static {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
        }
        try {
            Class.forName("StaticB");
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
        System.out.println("StaticA init OK");
    }
}

class StaticB {
    static {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
        }
        try {
            Class.forName("StaticA");
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
        System.out.println("StaticB init OK");
    }
}

/**
 * 死鎖舉例
 */
public class StaticDeadLockMain extends Thread {
    private char flag;

    public StaticDeadLockMain(char flag) {
        this.flag = flag;
        this.setName("Thread" + flag);
    }

    @Override
    public void run() {
        try {
            Class.forName("Static" + flag);
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
        System.out.println(getName() + " over");
    }

    public static void main(String[] args) throws InterruptedException {
        StaticDeadLockMain loadA = new StaticDeadLockMain('A');
        loadA.start();
        StaticDeadLockMain loadB = new StaticDeadLockMain('B');
        loadB.start();
    }
}

5、類的初始化情況:主動使用vs被動使用

(1)主動使用

主動使用 = 哪些情況會發出類的加載、類加載時機

Class只有在必須要首次使用的時候才會被裝載,Java虛擬機不會無條件地裝載Class類型。Java虛擬機規定,一個類或接口在初次使用前,必須要進行初始化。這里指的“使用”,是指主動使用。

  1. 當創建一個類的實例時,比如使用new關鍵字,或者通過反射、克隆、反序列化。

  2. 當調用類的靜態方法時,即當使用了字節碼invokestatic指令。

  3. 當使用類、接口的靜態字段時(final修飾特殊考慮),比如,使用getstatic或者putstatic指令。(對應訪問變量、賦值變量操作)

  4. 當使用java.lang.reflect包中的方法反射類的方法時。比如:Class.forName("com.atguigu.java.Test")

  5. 當初始化子類時,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化。

  6. 如果一個接口定義了default方法,那么直接實現或者間接實現該接口的類的初始化,該接口要在其之前被初始化

  7. 當虛擬機啟動時,用戶需要指定一個要執行的主類(包含main()方法的那個類),虛擬機會先初始化這個主類。

  8. 當初次調用 MethodHandle實例時,初始化該MethodHandle指向的方法所在的類。(涉及解析REF_getStatic、REF_putStatic、REF_invokeStatic方法句柄對應的類)

注意:只有當程序首次使用特定接口的靜態字段時,才會導致該接口的初始化

(2)被動使用

除了以上的情況屬於主動使用,其他的情況均屬於被動使用。被動使用不會引起類的初始化。

也就是說:並不是在代碼中出現的類,就一定會被加載或者初始化。如果不符合主動使用的條件,類就不會初始化。

  1. 當訪問一個靜態字段時,只有真正聲明這個字段的類才會被初始化。
    • 當通過子類引用父類的靜態變量,不會導致子類初始化
  2. 通過數組定義類引用,不會觸發此類的初始化
  3. 引用常量不會觸發此類或接口的初始化。因為常量在鏈接階段就已經被顯式賦值了。
  4. 調用ClassLoader類的loadClass()方法加載一個類,並不是對類的主動使用,不會導致類的初始化。

 

四、過程四:類的Using使用

任何一個類型在使用之前都必須經歷過完整的加載、鏈接和初始化3個類加載步驟。一旦一個類型成功經歷過這3個步驟之后,便“萬事俱備,只欠東風”,就等着開發者使用了。

開發人員可以在程序中訪問和調用它的靜態類成員信息(比如:靜態字段、靜態方法),或者使用new關鍵字為其創建對象實例。

例:加載一個類時,以Order類為例:

  • 方法區存放Order類模板數據/對象
  • 堆空間中創建一個Order類的Class實例,這個實例指向了方法區中的類模板對象
  • 棧中(棧幀的局部變量表中)中聲明了一個class對象,class對象指向了堆空間中的Class實例
  • Order的對象實例存放在堆中

 

五、過程五:類的Unloading(卸載)

1、類、類的加載器、類的實例之間的引用關系

在類加載器的內部實現中,用一個Java集合來存放所加載類的引用。另一方面,一個Class對象總是會引用它的類加載器,調用Class對象的getClassLoader()方法,就能獲得它的類加載器。由此可見,代表某個類的Class實例與其類的加載器之間為雙向關聯關系。

一個類的實例總是引用代表這個類的Class對象。在0bject類中定義了getClass()方法,這個方法返回代表對象所屬類的Class對象的引用。此外,所有的Java類都有一個靜態屬性class,它引用代表這個類的Class對象。

2、類什么情況會被卸載?

當Sample類被加載、鏈接和初始化后,它的生命周期就開始了。當代表Sample類的Class對象不再被引用,即不可觸及時,Class對象就會結束生命周期,Sample類在方法區內的數據也會被卸載,從而結束Sample類的生命周期。

一個類何時結束生命周期,取決於代表它的Class對象何時結束生命周期

3、類卸載在實際生產中的情況如何?

  1. 啟動類加載器加載的類型在整個運行期間是不可能被卸載的(jvm和jls規范)
  2. 被系統類加載器和擴展類加載器加載的類型在運行期間不太可能被卸載,因為系統類加載器實例或者擴展類的實例基本上在整個運行期間總能直接或者間接的訪問的到,其達到 unreachable的可能性極小。
  3. 被開發者自定義的類加載器實例加載的類型只有在很簡單的上下文環境中才能被卸載,而且一般還要借助於強制調用虛擬機的垃圾收集功能才可以做到。可以預想,稍微復雜點的應用場景中(比如:很多時候用戶在開發自定義類加載器實例的時候采用緩存的策略以提高系統性能),被加載的類型在運行期間也是幾乎不太可能被卸載的(至少卸載的時間是不確定的)。

綜合以上三點,一個已經加載的類型被卸載的幾率很小至少被卸載的時間是不確定的。同時我們可以看的出來,開發者在開發代碼時候,不應該對虛擬機的類型卸載做任何假設的前提下,來實現系統中的特定功能。

4、方法區的垃圾回收

方法區的垃圾收集主要回收兩部分內容:常量池中廢棄的常量和不再使用的類型

HotSpot虛擬機對常量池的回收策略是很明確的,只要常量池中的常量沒有被任何地方引用,就可以被回收。

判定一個常量是否“廢棄”還是相對簡單,而要判定一個類型是否屬於“不再被使用的類”的條件就比較苛刻了。需要同時滿足下面三個條件:

  • 該類所有的實例都已經被回收。也就是Java堆中不存在該類及其任何派生子類的實例。
  • 加載該類的類加載器已經被回收。這個條件除非是經過精心設計的可替換類加載器的場景,如OSGi、JSP的重加載等,否則通常是很難達成的。
  • 該類對應的java.lang.Class對象沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。

Java虛擬機被允許對滿足上述三個條件的無用類進行回收,這里說的僅僅是“被允許”,而並不是和對象一樣,沒有引用了就必然會回收。

 


免責聲明!

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



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