JVM的藝術—類載入器篇(三)

雕爺的架構之路發表於2020-12-11

JVM的藝術—類載入器篇(三)

引言

今天我們繼續來深入的剖析類載入器的內容。上篇文章我們講解了類載入器的雙親委託模型、全盤委託機制、以及類載入器雙親委託模型的優點、缺點等內容,沒看過的小夥伴請加關注。在公眾號內可以找到,jvm的藝術連載篇。歡迎各位小夥伴兒的持續關注,同時也感謝各位讀者一直以來的支援,本人會一直堅持原創、獨立創作,給各位讀者帶來真正的、實用的乾貨。也會把文章寫的通俗易懂,從人的思維、從程式設計師的思維中,不斷的改善寫作技巧。爭取讓每個人都能花最少的學習成本,讀懂最好的文章。謝謝。

由於被一些私事耽誤了,文章已經大概有一個月的時間沒有更新了,在這裡給大家真誠的道個歉,上一篇文章,我們提到了執行緒上下文類載入器,當時舉了一個例子說來說明,類載入器雙親委託模型的弊端。今天我們首先來說明白執行緒上下文類載入這個東西到底是什麼,為什麼會有這個東西的出現,它幫我們到底解決了什麼問題。接下來我們一點點的來分析。從案例入手。

正式介紹執行緒的上下文類載入器之前需要介紹一些理論性的東東

當前類載入器(Current ClassLoader):每一個類都會使用自己的類載入器(既載入自身的類載入器)來去載入其它類(指的是所依賴的類),如果ClassX引用了ClassY,那麼ClassX的類載入器就會載入ClassY(前提是ClassY尚未被載入)。

執行緒上下文類載入器(Context ClassLoader):執行緒上下文類載入器是從JDK1.2開始引入的,類Thread中的getContextClassLoader()setContextClassLoader(ClassLoader cl)分別用來獲取和設定上下文類載入器。如果沒有通過setContextClassLoader(ClassLoader cl)進行設定的話,執行緒將繼承其父執行緒的上下文類載入器。

Java應用執行時初始執行緒的上下文類載入器是系統類載入器

為什麼使用執行緒上下文類載入?

為什麼使用執行緒上下文類載入?上篇文章我也簡單的提到了。執行緒上下文類載入的設計初衷,原因就在於我們JAVA語言的SPI機制,我又提供了一張圖,希望下面這張圖可以全面的闡述上下文類載入器的含義。

執行緒上下文類載入器的重要性

我們在使用JDBC運算元據庫時會如下進行編寫:

Class.forName("com.mysql.driver.Driver");

Connection conn = Driver.getConnection();

Statement st = conn.getStatement();

JDBC是一個標準,這就說明使用到的Connection和Statement都是內建在JDK當中的標準,都是抽象介面,而且是位於rt.jar中,其實現肯定是由不同的資料庫廠商來實現,那麼問題就來了:這些標準都是由根類載入器所載入的,但是具體的實現是由具體的廠商來做的,那肯定是需要將廠商的jar放到工程的classpath當中來進行使用,很顯然廠商的這些類是沒辦法由啟動類載入器去載入,會由應用類載入器去載入,而根據“父類載入器所載入的類或介面是看不到子類載入器所載入的類或介面,而子類載入器所載入的類或介面是能夠看到父類載入器載入的類或介面的”這一原則,那麼會導致這樣一個局面:比如說java.sql包下面的某個類會由啟動類載入器去載入,該類有可能會要訪問具體的實現類,但具體實現類是由應用類載入器所載入的,java.sql類載入器是根據看不到具體實現類載入器所載入的類的,這就是基於雙親委託模型所出現的一個非常致命的問題,這種問題不僅是在JDBC中會出現,在JNDI、xml解析等SPI(Service Provider Interface)場景下都會出現的
所以這裡總結一下:父ClassLoader可以使用當前執行緒Thread.currentThread().getContextLoader()所指定的ClassLoader載入的類,這就改變了父ClassLoader不能使用子ClassLoader或者其它沒有直接父子關係的ClassLoader載入的類的情況,既改變了雙親委託模型。執行緒上下文類載入器就是當前執行緒的Current ClassLoader。在雙親委託模型下,類載入是由下至上的,既下層的類載入器會委託上層進行載入。但是對於SPI來說,有些介面是Java核心庫所提供的,而Java核心庫是由啟動類載入器來載入的,而這些介面的實現卻來自於不同的jar包(廠商提供)。Java的啟動類載入器是不會載入其它來源的jar包,這樣傳統的雙親委託模型就無法滿足SPI的要求。而通過給當前執行緒設定上下文類載入器,就可以由設定的上下文類載入器來實現對於介面實現類的載入。

下面以JDBC的這種SPI場景用圖來更具體的描述一下:

很明顯JDBC會去引用JDBCImpl的具體廠商的實現,而JDBC標準是由根類載入器所載入,那對於具體實現廠商的類也會用根類載入器去載入,而由於它們是處於工程中的classPath當中,由系統類載入器去載入,很顯然是沒辦法由根類載入器去載入的,為了解決這個問題,執行緒的上下文類載入器就發揮作用了。

分析:
由上面的理論可知:Java應用執行時初始執行緒的上下文類載入器是系統類載入器

那思考一下:為什麼預設的執行緒上下文類載入器就是系統類載入器呢?肯定是在某個地方給設定了,其實它是在Launcher中進行設定的,如下:

1、執行緒上下文類載入器的一般使用模式(獲取 - 使用 - 還原)
  ClassLoader classLoader = Thread.currentThread().getContextClassLoader();//獲取
        try{
            ClassLoader targetTccl = xxx;//要設定的上下文類記載器
            Thread.currentThread().setContextClassLoader(targetTccl);//設定
            myMethod();//使用
        } finally {
            Thread.currentThread().setContextClassLoader(classLoader);//還原
        }
2、如果一個類由類載入器A載入,那麼這個類的依賴類也是由相同的類載入器載入的(如果該依賴類之前沒有被載入過的話),ContextClassLoader的作用就是為破壞Java的類載入委託機制。
3、當高層提供了統一的介面讓低層來實現,同時又要在高層載入(或例項化)低層的類時,就必須要通過執行緒上下文類載入器來幫助高層的ClassLoader找到並載入該類。
Thread.currentThread().getContextClassLoader();//獲取

Thread.currentThread().setContextClassLoader(targetTccl);//設定

至此執行緒上下文類載入器就介紹到這裡。

類載入的過程

其實一個類從載入到使用是要經歷很多個過程的,下面我們來詳細的說說,一個類從載入到初始化的這個過程,然而還有哪些坑不為人知。

下面給出一張圖:

固定的類載入執行順序: 載入 驗證 準備 初始化 解除安裝 的執行順序是一定的 為什麼解析過程沒有在這個執行順序中?(接下來分析)

什麼時候觸發類載入不一定,但是類的初始化如下四種情況就要求一定初始化。 但是初始化之前 就一定會執行 載入 驗證 準備 三個階段

觸發類載入的過程(由初始化過程引起的類載入)

1):使用new 關鍵字 獲取一個靜態屬性 設定一個靜態屬性 呼叫一個靜態方法。

​ int myValue = SuperClass.value;會導致父類初始化,但是不會導致子類初始化

​ SuperClass.Value = 3 ; 會導致父類初始化,不會導致子類初始化。

​ SubClass.staticMethod(); 先初始化父類 在初始化子類

​ SubClass sc = new SubClass(); 先初始化父類 再初始化子類

2):使用反射的時候,若發現類還沒有初始化,就會進行初始化

​ Class clazz = Class.forName("com.hnnd.classloader.SubClass");

3):在初始化一個類的時,若發現其父類沒有初始化,就會先初始化父類

​ SubClass.staticMethod(); 先初始化父類 在初始化子類

4):啟動虛擬機器的時候,需要載入包含main方法的類.

 class SuperClass{
    	public static int value = 5;
    	
    	static {
    		
    		System.out.println("Superclass ...... init........");
    	}
    }
    
    class SubClass extends SuperClass {
    	
    	static {
    		System.out.println("subClass********************init");
    	}
    	
    	public static void staticMethod(){
    		System.out.println("superclass value"+SubClass.value);
    	}
    }

下面我們對類的載入、連線、初始化這幾個過程逐一的解釋:

1:載入

1.1)根據全類名獲取到對應類的位元組碼流(位元組流的來源 class 檔案,網路檔案,還有反射的Proxygeneraotor.generaotorProxyClass)

1.2)把位元組流中的靜態資料結構載入到方法區中的執行時資料結構

1.3)在記憶體中生成java.lang.Class物件,可以通過該物件來操作方法區中的資料結構(通過反射)

2:驗證

檔案格式的驗證: 驗證class檔案開頭的0XCAFFBASE 開頭

​ 驗證主次版本號是否在當前的虛擬機器的範圍之類

​ 檢測jvm不支援的常量型別

後設資料的校驗:

​ 驗證本類是否有父類

​ 驗證是否繼承了不允許繼承的類(final)修飾的類

​ 驗證本類不是抽象類的時候,是否實現了所有的介面和父類的介面

位元組碼驗證:驗證跳轉指令跳轉到 方法以外的指令.

​ 驗證型別轉換是否為有效的, 比如子類物件賦值父類的引用是可以的,但是把父類物件賦值給子類引用是危險的

​ 總而言之:位元組碼驗證通過,並不能說明該位元組碼一定沒有問題,但是位元組碼驗證不通過。那麼該位元組碼檔案一定是有問題:。

符號引用的驗證(發生在解析的過程中):

通過字串描述的全類名是否能找到對應的類。

指定類中是否包含欄位描述符,以及簡單的欄位和方法名稱。

3:準備:為類變數分配記憶體以及設定初始值。

​ 比如public static int value = 123;

​ 在準備的過程中 value=0 而不是123 ,當執行類的初始化的方法的時候,value=123

​ 若是一個靜態常量

​ public static final int value = 9; 那麼在準備的過程中value為9.

4:解析 :把符號引用替換成直接引用

​ 符號引用分類:

​ CONSTANT_Class_info 類或者介面的符號引用

​ CONSTANT_Fieldref_info 欄位的符號引用

​ CONSTANT_Methodref_info 方法的符號引用

​ CONSTANT_intfaceMethodref_info- 介面中方法的符號引用

​ CONSTANT_NameAndType_info 子類或者方法的符號引用.

​ CONSTANT_MethodHandle_Info 方法控制程式碼

​ CONSTANT_InvokeDynamic_Info 動態呼叫

直接引用:

​ 指向物件的指標

​ 相對偏移量

​ 操作控制程式碼

5:初始化:類的初始化時類載入的最後一步:執行類的構造器,為所有的類變數進行賦值(編譯器生成CLInit<>)

​ 類構造器是什麼?: 類構造器是編譯器按照Java原始檔總類變數和靜態程式碼塊出現的順序來決定

​ 靜態語句只能訪問定義在靜態語句之前的類變數,在其後的靜態變數能賦值 但是不能訪問。

​ 父類中的靜態程式碼塊優先於子類靜態程式碼塊執行。

​ 若類中沒有靜態程式碼塊也沒有靜態類變數的話,那麼編譯器就不會生成 Clint<>類構造器的方法。

public class TestClassInit {
	public static void main(String[] args) {
		System.out.println(SubClass.sub_before_v);
	}
}

class SubClass extends SuperClass{
	public static int sub_before_v = 5;
	static {
		sub_before_v = 10;
		System.out.println("subclass init.......");
		sub_after_v=0;
		//拋錯,static程式碼塊中的程式碼只能賦值後面的類變數 但是不能訪問。
		sub_before_v = sub_after_v;
	}
	public static int sub_after_v = 10;
}

class SuperClass {
	public static int super_before_v = 5;
	static{
		System.out.println("superclass init......");
	}
	public static int super_after_v = 10;
}

下面我們通過一系列的案例來說驗證上面所說的。先做個小的總結。

類的初始化需要對類進行主動使用,下面總結了幾點,都可以看做是對類的主動使用:

1:建立類的例項。

2:訪問某個類或者介面中的靜態變數,或者對其賦值。

3:訪問某個類的靜態方法。

4:反射。

5:初始化一個類的子類。

6:包含main方法的類。

7:jdk1.7開始提供動態語言的支援。

除了以上7種情況,都是被動使用,都不會導致類被初始化。

根據以上結論,我們來寫幾個案例,針對每種情況進行一下證明。

結論一:

靜態常量初始化過程是,在jvm連線之後,靜態常量的初始化,是由呼叫這個靜態常量方法所在的類的常量池中被儲存,此時,被呼叫的靜態常量所在的類的class檔案就可以被刪除,即使被刪除,該常量依然有效。呼叫某個類的靜態常量不能初始化該類。

程式碼:

package com.jdyun.jvm001;

public class TestClass03 {

    public static void main(String[] args) {

        System.out.println(Pet1.a);
    }
}

class Pet1{

    public static final int a = 10;

    static {
        System.out.println("我是Pet1,我被初始化了");
    }
}

執行結果:
"C:\Program Files\Java\jdk-11.0.2\bin\java.exe" "-javaagent:C:\Program Files\JetBrains\IntelliJ IDEA 2019.2\lib\idea_rt.jar=64451:C:\Program Files\JetBrains\IntelliJ IDEA 2019.2\bin" -Dfile.encoding=UTF-8 -classpath G:\jdyun-jvm\out\production\jdyun-jvm com.jdyun.jvm001.TestClass03
10

Process finished with exit code 0

從上面這個案例可知,一個類呼叫另一個類的常量不會導致一個類的初始化。

結論二:

  • 此處宣告的靜態常量,按照之前的理解是靜態常量被呼叫不會初始化該靜態常量所在的類
  • 但是此處當靜態常量的值是一個引用型別的時候,這個時候該靜態常量所在的類就會被初始化
  • 故此會先列印我被初始化了,然後在列印a的隨機值

程式碼:

package com.jdyun.jvm001;

import java.util.UUID;

public class TestClass03 {

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

    }
}

class Pet1{
    public static final String a = UUID.randomUUID().toString();

    static{
        System.out.println("我被初始化了");
    }
}

執行結果:
    
"C:\Program Files\Java\jdk-11.0.2\bin\java.exe" "-javaagent:C:\Program Files\JetBrains\IntelliJ IDEA 2019.2\lib\idea_rt.jar=50237:C:\Program Files\JetBrains\IntelliJ IDEA 2019.2\bin" -Dfile.encoding=UTF-8 -classpath G:\jdyun-jvm\out\production\jdyun-jvm com.jdyun.jvm001.TestClass03
    
我被初始化了
e5b56749-5a97-405f-9fe9-dfe4211bc0ce

Process finished with exit code 0

結論三:

靜態變數初始化與靜態常量初始化不同,靜態變數初始化是在初始化階段被賦予真實的值比如int a = 2,那麼2會被真正的賦值給a。

如果某個類呼叫了該類的靜態變數,那麼靜態變數所在的類就會被視為被主動呼叫了。那麼該類就會被初始化。

該類如果有靜態程式碼塊兒那麼靜態程式碼塊兒的優先順序高於靜態變數。

如果該靜態變數所在的類中有父類,那麼會優先初始化父類。

package com.jdyun.jvm001;

import java.util.Random;
import java.util.UUID;

public class TestClass03 {

    public static void main(String[] args) {

        System.out.println(Dog3.a);

    }
}

class Dog3 extends Pet1{

    public static final  int a = new Random().nextInt();

    static {
        System.out.println("我是Pet1,我是父類,我被最先載入了");
    }

}

class Pet1{

    static{
        System.out.println("我被初始化了");
    }
}

執行結果:
    
"C:\Program Files\Java\jdk-11.0.2\bin\java.exe" "-javaagent:C:\Program Files\JetBrains\IntelliJ IDEA 2019.2\lib\idea_rt.jar=64951:C:\Program Files\JetBrains\IntelliJ IDEA 2019.2\bin" -Dfile.encoding=UTF-8 -classpath G:\jdyun-jvm\out\production\jdyun-jvm com.jdyun.jvm001.TestClass03
    											     
我被初始化了
我是Pet1,我是父類,我被最先載入了
-1203457101

Process finished with exit code 0

結論四:

驗證初始化次數,只會被初始化一次。

package com.jdyun.jvm001;


import java.util.Arrays;
import java.util.List;
import java.util.Random;
import java.util.UUID;

public class MyTest02  extends  ClassLoader{

    public static void main(String[] args) throws ClassNotFoundException {
        //1,驗證初始化次數
        for(int i=0;i<50;i++){
            Test01 test01 = new Test01();
        }

    }
}

class Test01{

    static{
        System.out.println("我被初始化了");
    }
}

執行結果:
    
"C:\Program Files\Java\jdk-11.0.2\bin\java.exe" "-javaagent:C:\Program Files\JetBrains\IntelliJ IDEA 2019.2\lib\idea_rt.jar=65340:C:\Program Files\JetBrains\IntelliJ IDEA 2019.2\bin" -Dfile.encoding=UTF-8 -classpath G:\jdyun-jvm\out\production\jdyun-jvm com.jdyun.jvm001.MyTest02
我被初始化了

Process finished with exit code 0

結論五:

介面的初始化,子介面的初始化不會導致父介面的初始化,如果可以導致父介面的初始化,那麼Test01類中的靜態程式碼塊兒就會被列印。很顯然結果來看,Test01

中的靜態程式碼塊兒沒有被列印,所以,介面的初始化中,子介面的初始化,不會導致父介面的初始化。

package com.jdyun.jvm001;


import java.util.Arrays;
import java.util.List;
import java.util.Random;
import java.util.UUID;

public class MyTest02  extends  ClassLoader{

    public static void main(String[] args) throws ClassNotFoundException {

        //2,介面初始化,子介面的初始化不會導致父介面的初始化
        System.out.println(MyChild.b);
       /* System.out.println(MyParent.test01);
        System.out.println(MyChild.test001);*/

        //3,反射初始化類
        //Class.forName("com.jdyun.jvm001.Test01");

        //4,建立陣列不會導致類的初始化
        //Test01[] test01 = new Test01[1];

        //5,靜態變數賦值
        //System.out.println(MyChild.b);

        //Class clesses = String.class;


    }
}

class Test01{

    static{
        System.out.println("Test01被初始化了");
    }
}

interface MyParent{

    Test01 test01 = new Test01();

    public static final String a="5";

}

interface MyChild extends MyParent {

    public static  Integer b= UUID.randomUUID().hashCode();


}
"C:\Program  Files\Java\jdk-11.0. 2\bin\java.exe" "-javaagent:C:\Program Files\JetBrains\IntelliJ IDEA 2019.2\lib\idea_rt.jar=49632:C:\Program Files\JetBrains\IntelliJ IDEA 2019.2\bin" -Dfile.encoding=UTF-8 -classpath G:\jdyun-jvm\out\production\jdyun-jvm com.jdyun.jvm001.MyTest02
-221561202

Process finished with exit code 0

結論六:

建立一個陣列,不會導致類的初始化。

package com.jdyun.jvm001;


import java.util.Arrays;
import java.util.List;
import java.util.Random;
import java.util.UUID;

public class MyTest02  extends  ClassLoader{

    public static void main(String[] args) throws ClassNotFoundException {

        //4,建立陣列不會導致類的初始化
        Test01[] test01 = new Test01[1];
    }
}

class Test01{

    static{
        System.out.println("Test01被初始化了");
    }
}

執行結果:

"C:\Program Files\Java\jdk-11.0.2\bin\java.exe" "-javaagent:C:\Program Files\JetBrains\IntelliJ IDEA 2019.2\lib\idea_rt.jar=50058:C:\Program Files\JetBrains\IntelliJ IDEA 2019.2\bin" -Dfile.encoding=UTF-8 -classpath G:\jdyun-jvm\out\production\jdyun-jvm com.jdyun.jvm001.MyTest02

Process finished with exit code 0

結論七:

此處宣告的靜態常量,按照之前的理解是靜態常量被呼叫不會初始化該靜態常量所在的類
但是此處當靜態常量的值是一個引用型別的時候,這個時候該靜態常量所在的類就會被初始化
故此會先列印我被初始化了,然後在列印a的隨機值

package com.jdyun.jvm07;

import java.util.Random;
import java.util.UUID;

/**
 * 此處宣告的靜態常量,按照之前的理解是靜態常量被呼叫不會初始化該靜態常量所在的類
 * 但是此處當靜態常量的值是一個引用型別的時候,這個時候該靜態常量所在的類就會被初始化
 * 故此會先列印我被初始化了,然後在列印a的隨機值
 */
public class Test  {

    public static void main(String[] args) {

        System.out.println(Pet.a);

    }
}

class Pet{

    public static final String a = UUID.randomUUID().toString();

    static{

        System.out.println("我被初始化了");
    }
}
執行結果:
"C:\Program Files\Java\jdk-11.0.2\bin\java.exe" "-javaagent:C:\Program Files\JetBrains\IntelliJ IDEA 2019.2\lib\idea_rt.jar=50995:C:\Program Files\JetBrains\IntelliJ IDEA 2019.2\bin" -Dfile.encoding=UTF-8 -classpath G:\jdyun-jvm\out\production\jdyun-jvm com.jdyun.jvm07.Test
我被初始化了
3febaad7-90fe-4d7f-be1c-62b70b9f41cc

Process finished with exit code 0

結論八:

對子介面靜態常量呼叫時,父介面沒有被載入也並沒有被初始化。當我們有兩個介面,父子介面,然後在子介面中宣告一個靜態變數,此時對子介面中的靜態變數進行主動呼叫,此時父介面沒有被初始化,也沒有被載入。(刪除父介面中的class)

package com.jdyun.jvm8;

import java.util.Random;

public class Test {

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

}

interface MyParent{

    public static final String a="5";

}

interface MyChild extends MyParent{

    public static  Integer b= 1;
}

執行結果:
    
"C:\Program Files\Java\jdk-11.0.2\bin\java.exe" "-javaagent:C:\Program Files\JetBrains\IntelliJ IDEA 2019.2\lib\idea_rt.jar=51297:C:\Program Files\JetBrains\IntelliJ IDEA 2019.2\bin" -Dfile.encoding=UTF-8 -classpath G:\jdyun-jvm\out\production\jdyun-jvm com.jdyun.jvm8.Test
1

Process finished with exit code 0

結論九:

介面中的變數賦予引用初始值會初始化子介面。

public class Test {

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

}

interface MyParent{

    public static String a=5;

}

interface MyChild extends  MyParent{

    Integer b= new Random().nextInt(2);
}

小結:

1,假如這個類還沒有被載入和連結就先進行載入和連結。

2,假如類存在直接引用父類,並且這個父類還沒有被初始化,就先初始化父類。

3,如果類中存在初始化語句,就依次執行這些初始化語句。

名稱空間相關結論總結:

1:同一個名稱空間下的Class物件相同(hasCode相同),不同名稱空間下不同。

2:同一個類載入器載入的類處於一個名稱空間。

3:不同的類載入器例項載入的類名稱空間不同。

4:每一個類載入器都有自己的名稱空間。

5:子類載入器載入的類能見父類載入器載入的類。

6:父類載入器不可見子類類載入載入的類。

至此:jvm藝術類載入器篇就說這麼多,如果jvm的藝術三篇文章,各位小夥兒伴都看懂了。並且掌握了。那麼恭喜你,至少在面試的時候,考類載入器應該不會丟分。後面的文章還是針對jvm的。將會開啟一個新的篇章。主要針對,jvm的記憶體模型、物件模型、以及jvm的堆疊、調優、垃圾回收等領域進行細緻的講解。歡迎各位小夥伴兒持續關注更新。也感謝大家一直以來的支援和關注。筆者會繼續努力,深度學習並且拿出高質量的文章來回饋廣大的讀者。謝謝!!!

更多內容請關注我的公眾號:奇客時間

相關文章