Java虛擬機(六):類加載機制

大家都知道,我們編寫的Java類經(jīng)過編譯器編譯后會生成class文件,class文件描述了類的各種信息,最終都要加載到內(nèi)存中才能運行使用,那虛擬機是如何加載這些class文件的呢?加載又有哪些過程呢?是否程序一啟動就把所有的類都加載到內(nèi)存中呢?下面我們就來討論這些問題。

上面說到Java類經(jīng)過編譯器編譯后會生成class文件,這個說法在現(xiàn)在可能有些不準確了,更準確的說法應(yīng)該是“Java類經(jīng)過前端編譯器編譯后會生成class文件”,因為現(xiàn)在的Java會有一個JIT機制,JIT屬于后端編譯,JIT編譯器會在Java程序運行期間將“熱點代碼”編譯成機器碼,以提高運行效率。本文所提到的編譯都是前端編譯,不涉及后端編譯,關(guān)于后端編譯,會在下一篇文章中詳細討論。

1 什么是類加載

編寫好Java代碼經(jīng)Java編譯器(Javac)編譯之后會生成class字節(jié)碼文件,這是我們從剛開始學(xué)習(xí)Java就知道的事。實際上,這僅僅是Java程序運行的第一步,JVM還必須在運行時將字節(jié)碼載入到內(nèi)中,然后驗證、分析字節(jié)碼文件,并執(zhí)行相應(yīng)的指令,最后該class文件對應(yīng)的Java類才能被使用,這就是類加載機制。

那為什么會有這個類加載機制呢?學(xué)過C++的朋友應(yīng)該知道,C++程序要運行大體有編譯和鏈接兩個過程,這樣的好處是運行時效率非常高,不需要在做額外的操作,但大型的C++程序的編譯速度會慢得令人發(fā)指。Java就不這樣干,它會先編譯源代碼成字節(jié)碼,然后在運行時動態(tài)的將字節(jié)碼加載到內(nèi)存中,這樣的效果是大大降低了編譯速度和啟動速度(我們發(fā)現(xiàn)即使大型的Java程序,編譯和啟動過程都不會太慢),只有需要用到某個類的時候才會將其字節(jié)碼加載到內(nèi)存中,但運行時效率就會受到影響(不過隨著JIT技術(shù)的成熟,這個方面的性能問題已經(jīng)得到了很大的改善),這是Java程序運行時性能不如C++程序的一方面原因。

2 類加載過程

上圖是Java類的生命周期,從加載到卸載。我們主要關(guān)注的是加載、驗證、準備、解析和初始化5個階段,使用和卸載暫不討論。這些階段都是交叉運行的,例如加載階段可能還沒完成,驗證就已經(jīng)開始了,這有點像流水線作業(yè)一樣。Java虛擬機沒有明確規(guī)定什么時候應(yīng)該開始加載一個類,但規(guī)定了什么時候應(yīng)該開始初始化一個類,而加載的開始必須要發(fā)生在初始化開始之前(但初始化開始并不就一定需要等待加載階段結(jié)束)。虛擬機規(guī)定了如下5種情況必須立即對類進行初始化:

  1. 遇到new、getstatic、putstatic和invokestatic這四條指令時,如果該類沒有進行過初始化,就必須先觸發(fā)其初始化操作。
  2. 使用java.util.reflect包的方法對類進行反射調(diào)用的時候,如果該類沒有進行過初始化,就必須先觸發(fā)其初始化操作。
  3. 當初始化一個類時,如果其父類沒有進行過初始化,就先觸發(fā)其父類的初始化。
  4. 當虛擬機啟動時,會先啟動用戶指定的主類(包含main方法的類)。
  5. 當使用動態(tài)代理相關(guān)技術(shù)時,如果一個java.lang.invoke.MethodHandle實例最后的解析結(jié)果是REF_getStatic、REF_pubSttic、REF_invokeStatic的方法句柄,并且這個方法所對應(yīng)的類沒有進行過初始化,那么必須先觸發(fā)其初始化。

“有且僅有”上述5種情況才會觸發(fā)初始化,這5中情況的行為被稱作“主動引用”,其他引用類的方式都不會觸發(fā)初始化,被稱作“被動引用”。只有根據(jù)這5種情況來判斷類是否初始化才是正確的,根據(jù)其他的諸如“經(jīng)驗法則”等會很容易出錯,所以正確理解5種情況所要表達的意義才是關(guān)鍵。

2.1 加載階段

加載階段,主要完成以下3個事情:

  1. 通過一個類的全限定類名來獲取該類對應(yīng)的二進制字節(jié)流。
  2. 將這個字節(jié)流轉(zhuǎn)換成方法區(qū)的運行時數(shù)據(jù)結(jié)構(gòu)。
  3. 在內(nèi)存中生成代表這個類的class對象,作為方法區(qū)這個類的各種數(shù)據(jù)訪問的入口。

這里的二進制字節(jié)流并不一定就是class文件,只要是二進制字節(jié)流就行,也沒有規(guī)定該字節(jié)流從哪獲取,所以其實獲取字節(jié)流的方式有很多,例如從壓縮包中獲取、網(wǎng)絡(luò)傳輸通道中獲取,運行時生成(動態(tài)代理等技術(shù))等。加載階段是類加載整個過程中唯一可以由開發(fā)人員掌控的,開發(fā)人員可以通過重寫Classloader的loadClass()方法來更改加載的方式,但最好要遵循雙親委派模型。

數(shù)組類和普通類的加載階段有一些差別。數(shù)組類是由虛擬機直接創(chuàng)建的,而不是由類加載器創(chuàng)建的,但數(shù)組類和加載器仍然有密切的關(guān)系。如果數(shù)組的元素類型是引用類型,那么就根據(jù)普通類的加載規(guī)則去加載該類,數(shù)組類將與加載該類的加載器建立唯一關(guān)系標識,如果數(shù)組元素類似引用類型,那么數(shù)組類將與bootstrap加載器建立唯一關(guān)系標識。

加載完成之后,會在方法區(qū)中生成一個代表該類的Class對象,class對象雖然是對象,但確實是存儲在方法區(qū)里,這算是一個特例,主要目的應(yīng)該是方便直接在方法區(qū)里訪問Class對象。

2.2 驗證

驗證和加載階段是交叉運行的,即加載階段可能剛剛開始加載字節(jié)流,驗證階段就開始對字節(jié)流進行驗證了,但驗證開始時機仍然發(fā)生在加載開始之后。

Java號稱是一門安全的語言,所以驗證階段就顯得尤為重要,在驗證階段中,虛擬機會驗證字節(jié)碼是否符號虛擬機規(guī)范,是否存在惡意的字節(jié)碼指令,邏輯是否符合Java語言規(guī)范等。如果驗證失敗,虛擬機應(yīng)該拋出一個java.lang.VerifyError異?;蛘咂渥宇惒⑼V诡惣虞d過程。驗證階段大致分為4個校驗動作:文件格式驗證、元數(shù)據(jù)驗證、字節(jié)碼驗證、符號引用驗證。

2.2.1 文件格式驗證

文件格式驗證即驗證字節(jié)流是否符合Class文件的格式規(guī)范,例如開頭的CAFEBABE魔數(shù),版本號、常量池等各種信息的先后順序、有UTF-8要求的字符串是否滿足UTF-8字符編碼等等。這個階段的操作目標是字節(jié)流,只有通過了這個階段的驗證,并將字節(jié)流描述的類存儲到方法區(qū)中,才能進行后面的驗證,因為后面的幾個驗證動作都是基于方法區(qū)的數(shù)據(jù)結(jié)構(gòu)來做的。

2.2.2 元數(shù)據(jù)驗證

這個階段就是對字節(jié)碼描述的信息做語義分析,驗證字節(jié)碼是否符合Java語言的規(guī)范,例如這個類是否有父類,這個類是否被設(shè)置成不可繼承的類,如果該類不是抽象類且實現(xiàn)了接口,是否實現(xiàn)了接口的抽象方法等等。這里還要說一下,Java語言規(guī)范和Java虛擬機規(guī)范是兩碼事,不能一概而論。

2.2.3 字節(jié)碼驗證

這個階段主要進行的是用數(shù)據(jù)流和控制流分析程序語義是否合法,保證被校驗的類不會做出危害虛擬機的事情。該階段是很復(fù)雜的,也是比較耗時的,所以后期的Java虛擬機對整個步驟做了一些優(yōu)化,使用“StackMapTable”屬性來保存本地變量表和操作數(shù)等信息,當需要進行字節(jié)碼驗證的時候,就直接驗證“StackMapTable”里的信息即可,大大減少了驗證時間。

2.2.4 符號引用驗證

這個階段會嚴重符號引用是否正確,符號引用是否正確的判斷依據(jù)主要有以下幾個:

  • 是否能通過描述符號引用的全限定類名字符串找到對應(yīng)的類。

  • 在指定的類中是否存在符合方法的字段描述符以及簡單名稱所描述的方法和字段。

  • 符號引用中的類、字段、方法的訪問性是否可以被當前類訪問。

    .......

只有完成了符號引用驗證,后續(xù)在解析階段將符號引用轉(zhuǎn)換成直接引用的時候才可能成功,如果驗證失敗,將會拋出java.lang.NoSuchMethodError、java.lang.NoSuchFieldError等異常。

對于類加載機制來說,驗證階段雖然是非常重要的,但并不是必須的,如果要運行的代碼已經(jīng)經(jīng)歷過反復(fù)驗證和使用,那么就可以省略掉驗證這個階段,從而降低類加載的時間。

2.3 準備

準備階段是為類變量分配內(nèi)存并初始化的階段,這些變量所使用內(nèi)存都是方法區(qū)內(nèi)存,需要注意的是,類變量和實例變量是不同的,準備階段僅包括類變量(static修飾的)的內(nèi)存分配和初始化。初始化是給變量賦予對應(yīng)類型的“零值”,這里的“零值”并不是特點的數(shù)字0,對于數(shù)字類型來說確實是0或者0.0,對于布爾型變量來說是false,引用類型是null等,下面這個表格給出了各種類型的“零”值:

即使用戶在聲明的時候并同時賦值,也不會馬上按照程序員的意愿進行操作,如下所示:

public class Main {
    private static int a = 123;   
}

這里a在僅僅會被賦值成0,而不是123。但有一個例外,就是常量!常量會直接根據(jù)程序員的意愿進行操作:

public class Main {
    private static final int a = 123;
}

a被final修飾了,所以他是一個常量,在準備階段,虛擬機會根據(jù)常量值做賦值操作,即準備階段完成后,a的值是123而不是0。

2.4 解析

解析過程的作用是將符號引用轉(zhuǎn)換成虛擬機可以直接使用的直接引用。在之前的文章中,有不少地方提到過符號引用,但一直沒有詳細解釋什么是符號引用,在此就詳細介紹一下吧:

  • 符號引用。符號引用可以是任何形式的字面值常量,只要能在使用時無歧義的定位到目標即可。在HotSpot虛擬機中,是以字符串的形式存在的,而且往往是一組字符串。這組字符串所代表的可能是某個類、某個接口等,無論代表的是什么,只要能唯一的定位到目標,那就是一個正確的符號引用。關(guān)于符號引用的更多,推薦看看知乎上這個問題:JVM里的符號引用如何存儲。
  • 直接引用。直接引用可以是指向目標的指針、相對偏移量或者一個能間接定位到目標的句柄等,直接引用和虛擬機的內(nèi)存布局是有關(guān)的,同一個符號引用在不同的虛擬機里翻譯處理的直接引用往往不相同,如果成功將符號引用轉(zhuǎn)換成直接引用了,那么直接引用的目標肯定是已經(jīng)存在于內(nèi)存中的。

解析階段的對象不僅僅是類,還包括接口、方法、字段等。因為要訪問一個接口或者方法、字段都需要有一個直接引用,而直接引用又是由符號引用轉(zhuǎn)換而成的。關(guān)于解析更加詳細的內(nèi)容建議細節(jié)看看《深入理解Java虛擬機》的7.3.4節(jié)內(nèi)容。

2.5 初始化

初始化階段是執(zhí)行類構(gòu)造器<clinit>()方法的過程。需要注意的是這里的<clinit>()方法不包括實例構(gòu)造器,實例構(gòu)造器屬于<init>()方法,換句話說,這里不會執(zhí)行實例構(gòu)造器或者初始化代碼塊里的內(nèi)容。這是因為這里的初始化階段還屬于類加載的過程,沒有涉及到實例化的過程,實例構(gòu)造器或者初始化代碼塊的代碼會在類被實例化成對象的時候執(zhí)行。

<clinit>()方法由靜態(tài)變量的賦值語句和static代碼塊里的語句構(gòu)成,出現(xiàn)在前的語句在合并后仍然出現(xiàn)在前,即順序保持源碼中的順序。有一個比較奇怪的現(xiàn)象,在我們編寫源碼的時候,static塊里能對聲明在后面的變量做賦值操作,只是不能訪問。

<clinit>()方法不需要顯式的調(diào)用父類的<clinit>()方法(實例構(gòu)造器需要顯示的調(diào)用,只是大多數(shù)時候,編譯器會幫我們在第一行添上了),虛擬機會保證在調(diào)用子類的<clinit>()方法之前調(diào)用父類的<clinit>()方法?;谶@個機制,父類的靜態(tài)變量的賦值操作優(yōu)先于子類的靜態(tài)變量賦值。

<clinit>()方法并不是必須的,如果一個類沒有任何靜態(tài)變量和靜態(tài)塊,那么虛擬機就不會為該類生成<clinit>()方法。還有就是雖然接口不能定義靜態(tài)塊,但可以定義靜態(tài)變量,所以接口也是可能有<clinit>(),但和類的<clinit>()不同,接口執(zhí)行<clinit>()方法之前不需要執(zhí)行父接口的<clinit>()方法,只有在父接口中定義的變量被使用時,才會調(diào)用父接口的<clinit>方法。

虛擬機還會保證<clinit>方法是線程安全的,即多個線程同時要去執(zhí)行<clinit>()方法也僅僅有一個方法能真正執(zhí)行,其他線程會被阻塞,當能執(zhí)行<clinit>()方法的線程執(zhí)行完畢之后,其他線程被喚醒,但不會再去嘗試執(zhí)行<clinit>方法,這避免了重復(fù)執(zhí)行<clinit>()。我們可以寫一些代碼嘗試一下:

public class ClinitTest {

    static class Test {
        private static int a = 32;

        static {
            a = 42;
            System.out.println(Thread.currentThread().getName() + "execute static");

        }
    }


    public static void main(String[] args) throws InterruptedException {
        Runnable r = () -> {
            System.out.println(Thread.currentThread().getName() + " started");
            Test test = new Test();
            System.out.println(Thread.currentThread().getName() + "end");
        };
        Thread thread1 = new Thread(r);
        Thread thread2 = new Thread(r);
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
    }
}

結(jié)果如下所示:

Thread-0 started
Thread-1 started
Thread-1execute static
Thread-1end
Thread-0end

可見,<clinit>()方法只被執(zhí)行了一次,符合我們上面說到的規(guī)則。

3 類加載器

類加載器是這么一個東西:可以通過一個類的全限定類名獲取描述該類的二進制字節(jié)流的代碼模塊。有了上面的分析,我們知道這其實是“加載”階段的一個步驟,虛擬機設(shè)計團隊之所以單獨將其抽離出來,是為了方便應(yīng)用程序自己決定如何獲取需要的字節(jié)流。

我們可以通過重寫ClassLoader的loadClass()方法來改變加載類的方式,如下代碼所示:

public class ClassLoaderTest {
    public static void main(String[] args) throws ClassNotFoundException, IllegalAccessException, InstantiationException {

        ClassLoader classLoader = new ClassLoader() {
            @Override
            public Class<?> loadClass(String name) throws ClassNotFoundException {
                String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";
                try {
                    InputStream is = getClass().getResourceAsStream(fileName);
                    if (is == null) {
                        return super.loadClass(name);
                    }
                    byte[] bytes = new byte[is.available()];
                    is.read(bytes);
                    return defineClass(name, bytes, 0, bytes.length);
                } catch (IOException e) {
                    throw new ClassNotFoundException();
                }
            }
        };

        Class<?> clz = classLoader.loadClass("top.yeonon.ch11.ClassLoaderTest");
        System.out.println(clz);
        System.out.println(clz.newInstance() instanceof ClassLoaderTest);
    }
}

代碼重寫了loadClass方法,只是簡單的通過class文件來獲取。其中最后一行代碼的返回結(jié)果是false,為什么呢?因為我們用自己重寫了loadClass()方法的classLoader來加載類,這里獲取到的類和虛擬機加載類是不一樣的,即使它們是同一個類。那為什么會不一樣呢?因為類加載器不一樣,現(xiàn)在虛擬機中有兩個ClassLoaderTest類,一個是由系統(tǒng)的類加載器加載的(更準確的應(yīng)該是ApplicationClassLoader),一個是由我們自己實現(xiàn)的classloader加載,虛擬機判斷兩個類是否是同一個類不僅僅是通過他們的全限定類名來判斷,還通過加載他們的類加載器是否一樣來判斷,只有滿足上述兩個條件,虛擬機才會認為兩個類是相同的。

既然講到了ApplicationClassLoader,接下來就討論一下雙親委派模型。

3.1 雙親委派模型

在JDK8及以下的版本中(JDK9之后有不小的改動),默認的有三種類加載器:

  1. BootStrap ClassLoader(引導(dǎo)類加載器)
  2. Extension ClassLoader(擴展類加載器)
  3. Application ClassLoader(應(yīng)用類加載器)

引導(dǎo)類加載器負責(zé)加載$JAVA_HOME/lib下的,或者被-Xbootclasspath參數(shù)指定的路徑下的,并且是虛擬機識別的(有些類即使在上述兩個路徑下,也不會被加載)類。這個類加載器是由C++實現(xiàn)的(HotSpot虛擬機),所以使用Java代碼無法獲取,只會返回null,當我們需要將類加載委托給它時,用null代替接口。

擴展類加載器負責(zé)加載$JAVA_HOME/lib/ext目錄,或者被java.ext.dirs系統(tǒng)變量指定的路徑下的類。這個類加載器是由Java語言實現(xiàn)的,用戶可以直接使用該類加載器。

應(yīng)用類加載器負責(zé)加載classpath中指定的路徑中的類,一般我們編寫的Java類都是由這個類加載的。

有了上述三個概念,我們就可以看看雙親委派模型的定義了:如果一個類加載器收到了類加載請求,它不會自己馬上去嘗試加載這個類,而是將這個請求委托給父類加載器完成,如果父類加載器上面還有父類加載器,那么會繼續(xù)將委托向上提交,直到引導(dǎo)類加載器,如果引導(dǎo)類加載器無法加載這個類,就會將請求往下傳,只要中途有一個類加載器加載成功了,就不會繼續(xù)往下走了。

為了理解這個過程,舉個例子。假設(shè)我們現(xiàn)在編寫了一個top.yeonon.Test類,當需要加載這個類的時候,如果沒有其他類加載器,默認就先將請求發(fā)送到Application ClassLoader,Application ClassLoader有父類加載器Extension ClassLoader,所以它就將請求發(fā)送到Extension ClassLoader,Extension ClassLoader也同理,最終請求達到最頂層的BootStrap ClassLoader,BootStrap ClassLoader發(fā)現(xiàn)top.yeonon.Test這個類自己不能加載,然后將請求原路返回,到Extension ClassLoader的時候,Extension ClassLoader發(fā)現(xiàn)自己也不能加載,然后再回到Application ClassLoader,這時候沒地方去了,Application ClassLoader才會嘗試去加載該類,如果加載成功(該類確實在classpath路徑下),那么就完成了類加載,如果加載失敗,就會拋出異常。下面是雙親委派模型的示意圖:

那為什么Java要搞這么一套雙親委派模型呢?為了保證安全,試想一下,假設(shè)我們現(xiàn)在編寫了一個java.lang.String類,在這類里加入了一些惡意代碼,如果沒有雙親委派模型,這個類就會直接被Application ClassLoader加載,當用戶使用String類的時候,就會用到這個含有惡意代碼的類,從而造成應(yīng)用程序崩潰或者重要信息泄露。

4 小結(jié)

本文介紹了什么是類加載、類加載過程已經(jīng)類加載器和雙親委派模型,類加載是一個比較獨特的特性,這個機制使得Java程序更加安全、高效,理解類加載過程也有助于解決各種由于類加載導(dǎo)致的問題。

5 參考資料

《深入理解Java虛擬機》

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容