JVM-類載入

//*發表於2021-07-19

上文講到一個.java檔案是如何變成一個.class檔案以及Class檔案的組成,在Class檔案中描述的各類資訊,最終都需要載入到虛擬機器中之後才能被執行和使用。那麼一個.class檔案是如何載入到虛擬機器中使用的呢?它是通過類載入器通過類載入的過程實現的。一個類的載入過程分為載入、驗證、準備、解析、初始化、使用、銷燬,JVM通過類載入器實現完成載入這一步驟,類載入器又分為BootStrapClassLorder、ExtensionClassLoader、ApplicationClassLoader、自定義類載入器。

一、類載入

一個型別從被載入到虛擬機器記憶體中開始,到解除安裝出記憶體為止,它的整個生命週期將會經歷載入、驗證、準備、解析、初始化、使用、解除安裝七個階段,其中驗證、準備、解析三個部分統稱為連線。

需要注意的是載入、驗證、準備、初始化和解除安裝這五個階段的順序是確定的,型別的載入過程必須按照這種順序按部就班地開始,而解析階段則不一定:它在某些情況下可以在初始化階段之後再開始,這是為了支援Java語言的執行時繫結特性(也稱為動態繫結或晚期繫結)。

何時會進行類載入

1、遇到new、getstatic、putstatic或invokestatic這四條位元組碼指令時,如果型別沒有進行過初始化,則需要先觸發其初始化階段(而載入、驗證、準備自然需要在此之前開始)
new:建立類例項 使用new關鍵字例項化物件的時候
getstatic、putstatic訪問類的域和類例項域 讀取或設定一個型別的靜態欄位
invokestatic 呼叫命名類中的靜態方法
2、使用java.lang.reflect包的方法對型別進行反射呼叫的時候,如果型別沒有進行過初始化,則需要先觸發其初始化。
3、當初始化類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化。
4、當虛擬機器啟動時,使用者需要指定一個要執行的主類(包含main()方法的那個類),虛擬機器會先初始化這個主類。
... 等等
介面的載入過程與類載入過程稍有不同 編譯器仍然會為介面生成“()”類構造器,用於初始化介面中所定義的成員變數 但是一個介面在初始化時,並不要求其父介面全部都完成了初始化,只有在真正使用到父介面的時候(如引用介面中定義的常量)才會初始化。

1.載入:

載入,是指查詢位元組流,並且據此建立類或者介面的過程。載入階段既可以使用Java虛擬機器裡內建的引導類載入器來完成,也可以由使用者自定義的類載入器去完成,開發人員通過定義自己的類載入器去控制位元組流的獲取方式(重寫一個類載入器的findClass()或loadClass()方法),實現根據自己的想法來賦予應用程式獲取執行程式碼的動態性。
但是對於陣列類而言,情況就有所不同,陣列類本身不通過類載入器建立,它是由Java虛擬機器直接在記憶體中動態構造出來的,因為陣列它並沒有對應的位元組流,由Java虛擬機器直接生成的。

在載入階段,Java虛擬機器需要完成以下三件事情
1、通過一個類的全限定名來獲取定義此類的二進位制位元組流。這裡不一定是class檔案 可以從ZIP壓縮包、JAR、EAR、WAR等格式中讀取,可以從網路中獲取,也可以執行時獲取,也就是動態代理技術。
2、將這個位元組流所代表的靜態儲存結構轉化為方法區的執行時資料結構。
3、在記憶體中生成一個代表這個類的java.lang.Class物件,作為方法區這個類的各種資料的訪問入口。

載入一個類的時候會去先載入其父類,而且會有懶載入的機制(就是用到的時候才去載入這個類)。

public class Test_2 {
    public static void main(String[] args) {
        System.out.println(B.a);
    }
}
class A{
    public static  String a = "str";

    static {
        System.out.println("AAAAAA");
    }
}
class B extends A{
    static {
        a+="aaa";
        System.out.println("BBBBB");
    }
}

輸出 AAAAAA str

為什麼B類沒有被載入?

因為JVM會先判斷是否載入,才會有初始化的動作。
JVM又是懶載入 只有用到的時候才會去載入,所以JVM判斷只要載入A就可以了,B的內部沒有任何東西被使用,所以B並沒有載入。
JVM載入類是採用懶載入,用到的時候再去載入,一些根類是採用預載入,一開始就會載入進虛擬機器裡,比如String Interge常用的。

2.驗證:

驗證階段大致上會完成下面四個階段的檢驗動作:檔案格式驗證、後設資料驗證、位元組碼驗證和符號引用驗證。
1、檔案格式驗證:包含驗證是否以魔數0xCAFEBABE開頭。主、次版本號是否在當前Java虛擬機器接受範圍之內等資訊的驗證後設資料驗證:這個類是否有父類(除了java.lang.Object之外,所有的類都應當有父類)這個類的父類是否繼承了不允許被繼承的類(被final修飾的類)等資訊的驗證 側重點是驗證描述的資訊符合《Java語言規範》的要求
2、位元組碼驗證:對類的方法體(Class檔案中的Code屬性)進行校驗分析,保證被校驗類的方法在執行時不會做出危害虛擬機器安全的行為,
3、符號引用驗證:最後一個階段的校驗行為發生在虛擬機器將符號引用轉化為直接引用的時候,這個轉化動作將在連線的第三階段——解析階段中發生。符號引用中通過字串描述的全限定名是否能找到對應的類等資訊的驗證
需要注意的是驗證階段對於虛擬機器的類載入機制來說,是一個非常重要的、但卻不是必須要執行的階段 可以使用-Xverify:none引數來關閉大部分的類驗證措施,以縮短虛擬機器類載入的時間。

3.準備:

準備階段是正式為類中定義的變數(即靜態變數,被static修飾的變數)分配記憶體並設定類變數初始值的階段
需要注意的是這時候進行記憶體分配的僅包括類變數,而不包括例項變數,例項變數將會在物件例項化時隨著物件一起分配在Java堆中
public static int value = 123; 執行完之後變成了 value = 0;
把value賦值為123的動作要到類的初始化階段才會被執行
public static final int value = 123; 準備階段執行完之後變成了 value = 123;
如果類欄位的欄位屬性表中存在ConstantValue屬性,那在準備階段變數值就會被初始化為ConstantValue屬性所指定的初始值,

總結:
1、final static修飾的在準備階段直接分配記憶體並賦值了
2、static修飾的是在準備階段進行分配記憶體會初始化賦一個預設值 初始化階段的()進行初始化賦值的
3、非靜態的是在初始化階段的中分配記憶體並賦值的

4.解析:

解析階段是Java虛擬機器將常量池內的符號引用替換為直接引用的過程。java檔案通過編譯之後會變成符號引用
類似這樣的 #7.#28 這樣的為符號引用 在16進位制檔案裡就是 0A 00 07 00 1C
解析階段就是把#7這樣的符號引用變成直接引用

1.符號引用:符號引用以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能夠無歧義定位到目標即可。
你比如說某個方法的符號引用,

如:“java/io/PrintStream.println:(Ljava/lang/String;)V”。裡面有類的資訊,方法名,方法引數等資訊。
 #1=Methodref   #9.#33 這樣的為符號引用

2.直接引用:直接引用可以是直接指向目標的指標、相對偏移量或是一個能間接定位到目標的控制程式碼。
0x123123123123 這樣的地址為直接引用(不指向常量池 而是直接指向記憶體地址)

為什麼會有符號引用?

因為在類沒有載入的時候,也不能確保其呼叫的資源被載入,更何況還有可能呼叫自身的方法或者欄位,就算能確保,其呼叫的資源也不會每次在程式啟動時,都載入在同一個地址。簡而言之,在編譯階段,位元組碼檔案根本不知道這些資源在哪,所以根本沒辦法使用直接引用,於是只能使用符號引用代替,而類載入過程中的解析也只是解析一部分,只對類載入時可以確定的符號引用進行解析。比如父類、介面、靜態欄位、呼叫的靜態方法等(靜態連結)。還有一部分,比如方法中的區域性變數、例項欄位等在程式執行期間完成的;也就是使用前才去解析它(動態連結)。

《Java虛擬機器規範》之中並未規定解析階段發生的具體時間,虛擬機器實現可以根據需要來自行判斷,到底是在類被載入器載入時就對常量池中的符號引用進行解析(靜態連結),還是等到一個符號引用將要被使用前才去解析它(動態連結)。對同一個符號引用進行多次解析請求是很常見的事情,除invokedynamic指令以外,虛擬機器實現可以對第一次解析的結果進行快取。因為invokedynamic指令的目的本來就是用於動態語言支援,這裡“動態”的含義是指必須等到程式實際執行到這條指令時,解析動作才能進行。相對地,其餘可觸發解析的指令都是“靜態”的,可以在剛剛完成載入階段,還沒有開始執行程式碼時就提前進行解析。Java有了Lambda表示式和介面的預設方法,它們在底層呼叫時就會用到invokedynamic指令.

5.初始化:

除了在載入階段使用者應用程式可以通過自定義類載入器的方式區域性參與外,其餘動作都完全由Java虛擬機器來主導控制。直到初始化階段,Java虛擬機器才真正開始執行類中編寫的Java程式程式碼,將主導權移交給應用程式。

進行準備階段時,變數已經賦過一次系統要求的初始零值,而在初始化階段,則會根據程式設計師通過程式編碼制定的主觀計劃去初始化類變數和其他資源。初始化階段就是執行類構造器clinit()方法的過程。clinit()並不是程式設計師在Java程式碼中直接編寫的方法,它是Javac編譯器的自動生成物。
1、clinit()方法是由編譯器自動收集類中的所有類變數(靜態變數)的賦值動作和靜態語句塊(static{}塊)中的語句合併產生的,編譯器收集的順序是由語句在原始檔中出現的順序決定的clinit()方法與類的建構函式(即在虛擬機器視角中的例項構造器init()方法)不同,它不需要顯式地呼叫父類構造器,Java虛擬機器會保證在子類的clinit()方法執行前,父類的clinit()方法已經執行完畢。clinit()方法對於類或介面來說並不是必需的,如果一個類中沒有靜態語句塊,也沒有對變數的賦值操作,那麼編譯器可以不為這個類生成()方法。
2、init()物件構造時用以初始化物件的,構造器以及非靜態初始化塊中的程式碼。

二、類載入器

  • BootStrapClassLorder 根類(啟動類)載入器: 它用來載入 Java 的核心類,是用原生程式碼來實現的,並不繼承自 java.lang.ClassLoader(負責載入$JAVA_HOME中jre/lib/rt.jar裡所有的class,由C++實現,不是ClassLoader子類。
  • ExtensionClassLoader 擴充套件類載入器:它負責載入JRE的擴充套件目錄,lib/ext或者由java.ext.dirs系統屬性指定的目錄中的JAR包的類。由Java語言實現,父類載入器為null。
  • ApplicationClassLoader 系統類(應用程式類)載入器:如果沒有特別指定,則使用者自定義的類載入器都以此類載入器作為父載入器。由Java語言實現,父類載入器為ExtClassLoader。
  • 自定義類載入器 自定義類載入器只需要繼承java.lang.ClassLoader類 用於載入自己定義目錄下的類 其父類載入器預設為ApplicationClassLoader。
boot 類載入器是載入 jre/lib
ext  類載入器是載入 jre/ext/lib
app  類載入器是載入 classpath
classpath是我們程式執行時候列印的目錄 classpath 也就是我們程式設計師自己寫的程式碼

/Library/Java/JavaVirtualMachines/jdk1.8.0_251.jdk/Contents/Home/bin/java -javaagent:/Applications/IntelliJ IDEA.app/Contents/lib/idea_rt.jar=55758:/Applications/IntelliJ IDEA.app/Contents/bin -Dfile.encoding=UTF-8 
-classpath /Library/Java/JavaVirtualMachines/jdk1.8.0_251.jdk/Contents/Home/jre/lib/charsets.jar:/Library/Java/JavaVirtualMachines/jdk1.8.0_251.jdk/Contents/Home/jre/lib/deploy.jar:/Library/Java/JavaVirtualMachines/jdk1.8.0_251.jdk/Contents/Home/jre/lib/ext/cldrdata.jar:/Library/Java/JavaVirtualMachines/jdk1.8.0_251.jdk/Contents/Home/jre/lib/ext/dnsns.jar: 
...

Main函式所在的類是用什麼類載入器載入的?
    App
    jvm要去載入main函式所在的類
    boot -> Ext -> App

1. 類載入器初始化過程:

  • C++呼叫java程式碼建立JVM啟動器 例項sun.misc.Launcher 該類由引導類載入器負責載入其它類載入器 sun.misc.Launcher.getLauncher()
  • Launcher.getLauncher()方法裡做的事情就是初始化ExtensionClassLoader和ApplicationClassLoader 並把他們的關係構造好 ApplicationClassLoader的父類載入器是ExtensionClassLoader
  • 父類載入器要注意 不是繼承關係 只是父類載入器 他們繼承的類都是ClassLoader
    ExtClassLoader AppClassLoader 都是Launcher類裡的內部類 他們都是繼承URLClassLoader(最終繼承的都是ClassLoader)
public class Launcher {
    ...
    static class ExtClassLoader extends URLClassLoader {...}
    static class AppClassLoader extends URLClassLoader {...}
    ...
}

public class URLClassLoader extends SecureClassLoader implements Closeable  {...}
public class SecureClassLoader extends ClassLoader  {...}

// 檢視類載入器和父載入器
public class Test_14 {
    public static void main(String[] args) {
        System.out.println(String.class.getClassLoader());
        System.out.println(DESKeyFactory.class.getClassLoader());
        System.out.println(Test_14.class.getClassLoader());

        System.out.println("*************************");
        ClassLoader appClassLoader = ClassLoader.getSystemClassLoader();
        ClassLoader extClassLoader = appClassLoader.getParent();
        ClassLoader bootStrapClassLoader = extClassLoader.getParent();
        System.out.println("appClassLoader父類載入器是:" + extClassLoader);
        System.out.println("extClassLoader父類載入器是:" + bootStrapClassLoader);
    }
}

null   // String的類載入器是引導類載入器 列印null說明了它不是Java類實現的 是C++實現的 所以獲取不到
sun.misc.Launcher$ExtClassLoader@eed1f14
sun.misc.Launcher$AppClassLoader@14dad5dc
*************************
appClassLoader父類載入器是:sun.misc.Launcher$ExtClassLoader@eed1f14
extClassLoader父類載入器是:null

2.不同的類載入器載入一個類不相等是為什麼?

類載入器載入的類的儲存檔案空間不一樣 boot類載入器有一塊記憶體 Ext類載入器也有一塊記憶體 App載入器也有一塊記憶體 自定義的類載入器也有一塊記憶體

3.什麼是雙親委派(就是向上委派)

參考:https://mp.weixin.qq.com/s/E5ZwfpOLqGRK3ZtcsaXAuw

雙親委派機制,其工作原理的是,如果一個類載入器收到了類載入請求,它並不會自己先去載入,而是把這個請求委託給父類的載入器去執行,如果父類載入器還存在其父類載入器,則進一步向上委託,依次遞迴,
請求最終將到達頂層的啟動類載入器,如果父類載入器可以完成類載入任務,就成功返回,倘若父類載入器無法完成此載入任務,子載入器才會嘗試自己去載入,這就是雙親委派模式,
即每個兒子都很懶,每次有活就丟給父親去幹,直到父親說這件事我也幹不了時,兒子自己才想辦法去完成。

// 雙親委派的程式碼實現邏輯
classLoader1.loadClass("");
點選loaderClass進去
 try {
    if (parent != null) {
        c = parent.loadClass(name, false);
    } else {
        c = findBootstrapClassOrNull(name);
    }
} catch (ClassNotFoundException e) {
    // ClassNotFoundException thrown if class not found
    // from the non-null parent class loader
}

4.雙親委派的作用?

1、效率:通過這種層級關可以避免類的重複載入,當父親已經載入了該類時,就沒有必要子ClassLoader再載入一次
2、安全:java核心api中定義型別不會被隨意替換,假設通過網路傳遞一個名為java.lang.Integer的類,通過雙親委託模式傳遞到啟動類載入器,而啟動類載入器在核心Java API發現這個名字的類,發現該類已被載入,並不會重新載入網路傳遞的過來的java.lang.Integer,而直接返回已載入過的Integer.class,這樣便可以防止核心API庫被隨意篡改。
3、提供了擴充套件性: 比如加密 class檔案可以反編譯 不安全 我們可以對class檔案進行加密 用自定義的類載入器進行載入

// 證明了一個載入類重複載入一個類只會載入一次
public class Test_3 extends ClassLoader {
    public static void main(String[] args) throws ClassNotFoundException {
        Test_3 classLoader1 = new Test_3();
        Class<?> class1 = classLoader1.loadClass("com.leetcode.JVM.Test_3");
        System.out.println(class1.hashCode());

        Test_3 classLoader2 = new Test_3();
        Class<?> class2 = classLoader2.loadClass("com.leetcode.JVM.Test_3");
        System.out.println(class2.hashCode());

        System.out.println("*******************************");
        System.out.println(class1==class2);
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        System.out.println("ClassLoader");
        return null;
    }
}

791452441
791452441
*******************************
true
System.out.println(class1==class2); 輸出了true為什麼?
HashCode相等 證明了一個載入類重複載入一個類只會載入一次

5.雙親委派的侷限性

1、無法做到不委派
2、無法做到向下委派

6.怎麼打破雙向委派

1、自定義載入器去實現 extends ClassLoader 重寫loadClass不委派
2、通過執行緒上下文類載入器去載入所需的SPI服務程式碼 SPI(Service Provider Interface) SPI是通過向下委派打破雙親委派 是一種服務發現機制。它通過在ClassPath路徑下的META-INF/services資料夾查詢檔案,自動載入檔案裡所定義的類。這一機制為很多框架擴充套件提供了可能,比如在Dubbo、JDBC中都使用到了SPI機制

public class User {
    public void sout() {
        System.out.println("____User");
    }
}
// 在E盤建立一層檔案目錄對應com.leetcode.JVM
// 把User.class檔案放到建立的目錄資料夾裡
public class Test_15 extends ClassLoader {
    public static void main(String[] args) throws Exception {
        Test_15 test = new Test_15("E:/log");
        Class clazz = test.loadClass("com.leetcode.JVM.User", false);
        Object object = clazz.newInstance();
        Method method = clazz.getDeclaredMethod("sout", null);
        method.invoke(object, null);
        System.out.println(clazz.getClassLoader().getClass().getName());
    }
    private String classPath;
    
    Test_15(String name) {
        this.classPath = name;
    }

    private byte[] lodeByte(String name) throws Exception {
        name = name.replaceAll("\\.","/");
        FileInputStream file = new FileInputStream(classPath + "/" + name + ".class");
        int available = file.available();
        byte[] data = new byte[available];
        file.read(data);
        file.close();
        return data;
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        try {
            byte[] data = lodeByte(name);
            return defineClass(name, data, 0, data.length);
        } catch (Exception e) {
            e.printStackTrace();
            throw new ClassNotFoundException();
        }
    }

    @Override
    protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
        synchronized (getClassLoadingLock(name)) {
            // First, check if the class has already been loaded
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                long t0 = System.nanoTime();

                // If still not found, then invoke findClass in order
                // to find the class.
                long t1 = System.nanoTime();
                // 簡單處理 只有這個路徑下的才使用自己的類載入器
                // 不然無法載入父類Object類 會報錯
                // java類中的核心包都是不允許使用自己的類載入器去載入的(java.lang包下的) 因為沙箱安全機制
                if (!name.startsWith("com.leetcode.JVM")) {
                  c = this.getParent().loadClass(name);
                } else {
                  c = findClass(name);
                }
                // this is the defining class loader; record the stats
                sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                sun.misc.PerfCounter.getFindClasses().increment();
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }
}

7.Tomcat和com.mysql.jdbc.Driver打破雙親委派機制

1、Tomcat中使用自定義的類載入器去載入 不向上委託載入 但公共使用的類還是使用雙親委派。一個web容器可能需要部署兩個應用程式,不同的應用程式可能會依賴同一個第三方類庫的
不同版本,不能要求同一個類庫在同一個伺服器只有一份,因此要保證每個應用程式的類庫都是
獨立的,保證相互隔離
2、mysql.jdbc.Driver使用SPI機制去載入 向下委託載入。 因為在某些情況下父類載入器需要委託子類載入器去載入class檔案 以Driver介面為例,由於Driver介面定義在jdk當中的,而其實現由各個資料庫的服務商來提供 DriverManager由啟動類載入器載入,只能記載JAVA_HOME的lib下檔案,而其實現是由服務商提供的,由系統類載入器載入,這個時候就需要啟動類載入器來委託子類來載入Driver實現,從而破壞了雙親委派。

8.沙箱安全和全盤負責委託機制

防止打破雙親委派修改系統類保護核心庫類String Interge等,全盤負責委託機制,指的是當一個ClassLoader裝載一個類時,除非顯示的使用另外一個ClassLoader,該類所依賴及引用的類也由這個類的ClassLoader載入。

相關文章