JVM那點事-虛擬機類加載機制

在Class文件中描述了各種信息,最終都需要加載到虛擬機中才能運行和使用,而虛擬機是如何加載這些Class文件呢?Class文件信息進入到虛擬機后會發生什么變化?

虛擬機把描述類的數據從Class文件[加載]到內存,并對數據進行[驗證]、[準備]、[解析]、[初始化],最終形成可以被虛擬機直接[使用]的Java類型,這就是虛擬機的類加載機制。

1. 類加載的生命周期

類從被加載到虛擬機內存中開始,到被卸載出內存為止,他的生命周期:

  1. 加載(Loading):將class文件讀取到內存,并在內存中生成class對象;
  2. 驗證(Verification):確保class文件內容符合虛擬機要求;
  3. 準備(Preparation):為靜態變量分配內存并初始化;
  4. 解析(Resolution):將常量池內的符號引用替換為直接引用;
  5. 初始化(Initialization):根據程序制定的主觀計劃去初始化類變量和其他資源。
  6. 使用(Using)
  7. 卸載(Unloading)

而其中的驗證、準備、解析三個部分統稱為連接(Linking)。

加載、驗證準備初始化、卸載這5個階段順序是確定的,類加載過程中必須按照這個順序執行。而解析階段則不一致。解析階段,在某些情況下,可以在初始化階段之后再開始,這是為了支持Java語言的運行時綁定(也稱為動態綁定)

2. 類加載的過程

Java虛擬機中類加載的全過程,也就是加載、驗證準備解析、初始化這5個階段執行的具體動作。

2.1 加載

需要類加載器的參與。將class文件讀取到內存中。(用戶可自定義類加載器,參與加載過程)。

在加載階段,虛擬機需要完成:

  1. 通過(類加載器)將一個類的全限定名來獲取定義此類的二進制字節流
  2. 將這個字節流所代表的靜態存儲結構轉化為方法區的運行時數據結構。
  3. 在內存中生成一個代表這個類得java.lang.Class對象,作為方法區這個類的各種數據的訪問入口。

注意:虛擬機規范的這三點要求其實都不算具體。例如通過一個類的全限定名來獲取定義此類的二進制字節流??梢酝ㄟ^:

  • 從ZIP包中讀取,最終成為日后JAR、EAR、WAR格式的基礎。
  • 從網絡中獲取,這種場景最典型的的應用就是Applet
  • 運行時計算生產,這種場景使用最多的就是動態代理技術。在java.lang.reflect.Proxy中,就是用了ProxyGenerator.generateProxyClass來為特定接口生成形式為*$Proxy的代理類的二進制字節流。
  • 由其他文件生成,典型場景是JSP應用,即由JSP文件生成對應的Class類。

加載階段完成后,虛擬機外部的二進制字節流就按照虛擬機所需的格式存儲在方法區之中。方法區中的數據存儲格式由虛擬機實現自行定義。然后在內存中實例一個java.lang.Class類的對象(不一定是在堆上呢。對于HotSpot虛擬機來說,Class對象比較特殊,雖然是對象,但是存放在方法區里面。)這個對象將作為程序訪問方法區中這些類型數據的外部接口。

2.2 驗證

驗證階段是連接階段Linking的第一步,這一階段的目的是為了確保Class文件的字節流中包含信息符合當前虛擬機的要求,并且不會危害虛擬機自身的安全。

對于虛擬機的類加載來說,驗證階段是一個非常重要的,但不是一定必要(因為對程序運行期沒有影響)的階段。如果所運行的全部代碼都已經被反復使用和驗證過,那么在實施階段就可以考慮使用-Xverify:none參數來關閉大部分的類驗證措施,以縮短虛擬機類加載時間,

2.3 準備

準備階段是正式為類變量分配內存并設置類變量初始值的階段,這些變量所使用的內存都將在方法區中進行分配。

  • 這時候進行內存分配的僅包括類變量即static變量,而不包括實例變量,實例變量將在對象實例化時隨著對象一起分配在Java堆里。

  • 這里說的初始值“通常情況”下是數據類型的零值。假設一個類變量的定義:public static int value=123,那變量value準備階段過后的初始值是0而不是123。因為這時候尚未開始執行任何Java方法,而把value賦值給123的動作是在初始化階段才會被執行的。

  • 但是public static final int value=123在準備階段,屬性就會被賦予用戶希望的值。

2.4 解析階段

解析階段是虛擬機將常量池內的符號引用替換為直接引用的過程。
解析動作主要針對類或接口、字段、類方法、接口方法、方法類型、方法句柄和調用點限定符7類符號引用進行的。

2.5 初始化階段(重點)

類初始化階段是類加載過程的最后一步,前面的類加載過程中,除了加載階段用戶應用程序可以通過自定義類加載器參與之外。其余動作完成由虛擬機主導和控制。到了初始化階段,才真正開始執行類中定義的Java程序代碼(或者說是字節碼)。

準備階段,變量已經賦過一次系統要求的初始值,在初始化階段,則根據程序員通過程序制定的主觀計劃去初始化類變量和其他資源。

初始化的順序:

無父類的情況下:

  1. 靜態成員變量、靜態代碼塊(按出現的順序)
  2. 成員變量、代碼塊(按出現的順序)
  3. 構造方法

有父類的情況下:

  1. 父類靜態成員,父類靜態代碼塊
  2. 子類靜態成員,子類靜態代碼塊
  3. 父類成員變量,父類代碼塊
  4. 父類構造方法
  5. 子類靜態成員,子類代碼塊
  6. 子類構造方法

還有一個小坑,就是重寫方法:

下面請看這道面試題最終會輸出什么?

父類:

public class Feb {
    private String name="feb";
    public Feb(){
        tellMQ();
        tellRedis();
    }
    public void tellMQ(){
        System.out.println("The Feb MQ name is "+name);
    }
    public void tellRedis(){
        System.out.println("The Feb Redis name is "+name);
    }
}

子類:

public class Sub extends Feb{
    private String name="sub";
    public Sub(){
        super();
        tellMQ();
        tellRedis();
    }
    public void tellMQ(){
        System.out.println("The Sub MQ name is "+name);
    }
    public void tellRedis(){
        System.out.println("The Sub Redis name is "+name);
    }
    //執行該方法后,會輸出什么數據
    public static void main(String[] args) {
        Feb object=new Sub(); //動態綁定
    }
}

由于類的初始化過程:
父類的常量->父類的構造方法->子類的常量->子類的構造函數

類的加載時機中我們知道,JVM嚴格規范了5種情況需要立即對類進行初始化。此時滿足:初始化一個類的時候,如果發現父類還未初始化,則需先觸發父類初始化,于是初始化父類的變量后,執行父類的構造方法時。由于發生了多態(動態綁定)于是在子類中找到的了該方法。執行子類的方法。
由于子類的name屬性沒有初始化(什么?父類有name,但是父類的訪問修飾符是private,子類不能繼承呀),所以最后返回的是null。

執行結果

類初始化的條件

什么時候對類進行加載Loading,JVM并沒有強制約束。但是對于初始化Initialization,JVM嚴格規定了5種情況下必須立即對類進行初始化(而加載、驗證準備自然要在此前開始)。

  1. 遇到new、getstaticputstatic、invokestatic這4條字節碼指令時,如果類沒有進行初始化,那么就需要先觸發其初始化。(咳咳,說人話...)生成這4條指令最常見java代碼就是:new一個對象、讀取或者設置一個類的靜態字段(需要注意:被final修飾的常量在編譯期把結果放入常量池。那么不會創建對象)的時候,以及調用一個類的靜態方法的時候。
  2. 使用java.lang.reflect包的方法對類進行反射調用的時候,如果類沒有初始化,則需要先觸發其初始化。
  3. 當初始化一個類的時候,如果發現其父類還沒有初始化,則需要先觸發初始化。
  4. 虛擬機啟動時,用戶需要指定一個要執行的主類(包含main方法的類),虛擬機就會先初始化這個主類。
  5. 當使用JDK1.7的動態語言支持時,如果一個java.lang.invoke.MethodHandle實例的最后解析結果是REF_getstaticREF-putstatic、REF_invokeStatic的方法句柄,并且這個方法的句柄所對應的類沒有進行初始化,則需要先觸發其初始化。

這5種場景中的行為被稱為對一個類進行主動引用,除此之外,所以引用類的方式都不會觸發初始化,稱為被動引用。

  • 例如,使用子類引用父類的靜態字段時,不會導致子類初始化。
  • 通過數組定義來引用類,不會觸發此類的初始化。
  • final修飾的常量,會在編譯期就存入調用類的常量池中。本質上并沒有直接引用定義常量的類,因此不會觸發定義常量的類的初始化。

注意:當一個類在初始化時,要求其父類全部都已經初始化,但是一個接口初始化時,并不要求其父接口全部完成初始化,只有在真正使用到父接口的時候(如:引用接口中定義的常量)才會初始化。

類加載的時機

在Java語言里面,類型的加載、連接(驗證,準備,解析)和初始化過程都是程序運行期完成的。這種策略雖然令類加載時稍微增加一些性能開銷,但是會為Java應用程序提供了高度的靈活性。Java里面天生可以動態擴展語言的特性就是依賴運行期動態加載和動態連接這個特點實現的。

舉幾個栗子:

  • (多態)編寫一個面向接口的應用程序,可以等到運行時在指定其實際的實現類;
  • 用戶可以通過Java預定義的和自定義的類加載器,讓一個本地的應用程序可以在運行時從網絡或其他地方加載一個二進制流作為程序代碼的一部分,這種組裝應用程序的方式目前已廣泛應用于Java程序中。

3. 類加載器

虛擬機將類加載階段中的“通過一個類的全限定名來獲取描述此類的二進制字節流”這個動作放到java虛擬機外部去實現,以便讓應用程序自己決定如何獲取所需要的類,實現這個動作的代碼模塊被稱為“類加載器”。

類加載器雖然是只用于實現類的加載動作,但它在java程序中起到的作用遠遠不限于類加載階段

/測試類加載器
public class ClassLoaderTest {
    public static void main(String[] args) {

        //定義類加載器(局部內部類)
        ClassLoader myLoader = new ClassLoader() {
            @Override
            public Class<?> loadClass(String name) throws ClassNotFoundException {
                //獲取文件名.class格式的
                try {
                    String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";
                    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類!");
                }
            }
        };
        try {
            //同一個Class文件,被不同的類加載器所加載
            Object obj = myLoader.loadClass("com.ms.ClassLoaderTest").newInstance();
            System.out.println(obj.getClass());
            //那么這兩個類必定不相等
            System.out.println(obj instanceof com.ms.ClassLoaderTest);
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        } catch (InstantiationException e) {
            e.printStackTrace();
        }
    }
}

對于任意一個類,都需要由加載它的類加載器這個類本身一同確立其在Java虛擬機中的唯一性

比較兩個類是否“相等”,只有在這兩個類是由同一個類加載器加載的前提下才有意義,否則,即使這兩個類來源于同一個Class文件,被同一個虛擬機加載,只要加載它們的類加載器不同,那么這兩個類就必定不相等。

返回結果

3.1 雙親委派模型

從虛擬機角度來說,只存在兩種不同的類加載器。一種是啟動類加載器(Bootstrap ClassLoader),這個類加載器是使用C++實現的。是虛擬機自身的一部分;另一種就是所有其他的類加載器,這些類加載器都是由Java語言實現,獨立于虛擬機外部,并且全部繼承自抽象類java.lang.ClassLoader

從java開發人員的角度來看,類加載器還可以劃分得更細致一些,絕大部分java程序都會使用以下3種系統提供的類加載器。

  • 啟動類加載器(Bootstrap ClassLoader):負責將存放在<JAVA_HOME>\lib目錄中的,或者被-Xbootclasspath參數所指定的路徑中的,并且被java虛擬機識別的(僅按照文件名識別,如rt.jar,名字不符合即使放在lib目錄下也不會被加載)類庫加載到虛擬機內存。啟動類加載器無法被Java程序直接引用,用戶在編寫自定義加載器時,如果需要把類加載請求委派給引導類加載器,那可以直接使用null代替即可。

  • 擴展類加載器(Extension ClassLoader):這個類加載器由sun.misc.Launcher$ExtClassLoader實現,它負責加載<JAVA_HOME>\lib\ext目錄中的,或者被java.ext.dirs系統變量所指定的路徑中的所有類庫,開發者可以直接使用擴展類加載器。

  • 應用程序類加載器(Application ClassLoader):這個類加載器由sun.misc.Launcher$AppClassLoader實現,這個類加載器是ClassLoader中的getSystemClassLoader方法的返回值,所以一般也稱為系統類加載器,他負責加載用戶類路徑(ClassPath)上所指定的類庫,開發者可以直接使用這個類加載器,如果應用程序中沒有自定義過自己的類加載器,一般情況下這個就是程序中默認的類加載器。

我們的應用程序都是由這3種類加載器相互配合進行加載的,如果有必要,還可以加入自己定義的類加載器,這些類加載器之間的關系一般是:

類加載器雙親委派模型

上圖展示的類加載器之間的層次關系,被稱為類加載器的雙親指派模型(Parents Delegation Model)。雙親委派模型要求除了頂層的啟動類加載器外,其余的類加載器都應當有自己的父類加載器。這里類加載器之間的父子關系一般不會以繼承的關系來實現,而是都是以組合關系來復用父加載器的代碼。

雙親委派模型的工作過程:
如果一個類加載器收到類加載的請求,他首先不會自己去嘗試加載這個類,而是把這個請求委派給父加載器去完成,每一個層次的類加載器都是如此,因此所有的加載請求最終都應該傳送到頂層的啟動類加載器(Bootstrap ClassLoader)中,只有父加載器反饋自己無法完成這個加載請求(即:他的搜索范圍中沒有找到所需的類)時,子加載器才會嘗試自己去加載。

小胖說:對于類加載請求,都會委派自己的上級加載器,若上級加載器不能加載,則子加載器才會嘗試。

雙親委派模型的優點:
使用雙親委派模型來組織類加載器之間的關系,有一個顯而易見的好處就是Java類隨著它的類加載器一起具備一種帶有優先級的層次關系。例如java.lang.Object,他存放在rt.jar之中,無論哪一個類加載器需要加載這個類,最終都會委派給處于模型最頂端的啟動類加載器進行加載,因此Object類在程序的各種類加載器環境中都是同一個類。

如果不使用雙親委派模型,由各個類加載器自行去加載的話,如果用戶自己編寫了一個稱為java.lang.Object的類,并放在程序的ClassPath中,那系統中將會出現多個不同的Object類。

雙親委派模型源碼分析:

  protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // 首先,檢查class類是否被加載
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                long t0 = System.nanoTime();
                try {
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    } else {
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // 如果父加載器拋出ClassNotFoundException異常
                    // 那種說明父加載器無法完成加載請求
                }

                if (c == null) {
                    // 在父加載器沒有找到的情況下,調用本身的findClass()進行加載
                    long t1 = System.nanoTime();
                    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;
        }
    }

3.2 破壞雙親委派模型

上面所說的雙親委派模型并不是一個強制性的約束模型,而是Java設計者推薦給開發者的類加載器的實現方式。

雙親委派模型三次被破壞的情況:

  1. 雙親委派模型是JDK 1.2才加入的,而類加載器和抽象類的java.lang.ClassLoader則在JDK 1.0就存在,為了兼容已經存在的用戶自定義類加載器的實現代碼JDK 1.2之后的java.lang.ClassLoader添加了一個新的protected方法findClass()。
    JDK 1.2之前,用戶繼承java.lang.ClassLoader的唯一目的就是為了重寫loadClass()方法。因為虛擬機在進行類加載的時候,會調用加載器的私有方法loadClassInternal(),而這個方法的唯一邏輯就是去調用自己的loadClass()。我們在上面看到loadClass()的代碼。雙親委派的具體邏輯就實現在這個方法之中。
    JDK 1.2之后,提倡用戶將自己的類加載器邏輯寫在findClass()方法中,那么在loadClass()方法的邏輯里如果父類加載失敗,則會調用自己的findClass()方法來完成加載。這樣就保證新寫出來的類加載器符合雙親委派模型。

  2. 雙親委派很好的解決了各個類加載器的基礎類的統一問題(越基礎的類由越上層的加載器進行加載),基礎類之所以稱為“基礎”。是因為他們總是作為被用戶代碼調用的API但是集成類又要回調回用戶的代碼,那該怎么辦?

為了解決這個問題,Java設計團隊引入了不太優雅的設計:線程上下文加載器(Thread Context ClassLoader。這個類加載器可以通過java.lang.Thread類的setContextClassLoader()方法進行設置,如果創建線程時還沒有設置類處理器,線程將繼承其父線程的上下文加載器,如果在應用程序的全局范圍內都沒有設置過的話,那么這個類加載器默認就是應用程序類加載器。
有了線程上下文類加載器,就打破雙親委派模型的層次結構逆向使用類加載器了。即:父類加載器請求子類加載器完成類加載的動作。


是不是沒搞懂線程上下文加載器到底有啥用吧?

Java提供了很多服務提供者接口(Service Provider Interface SPI)允許第三方為這些接口提供服務。

SPI的實現一般是由應用加載器Application ClassLoader加載的,導致啟動類加載器Bootstrap ClassLoader無法找到SPI的實現類,因為它只加載Java核心庫。那么Bootstrap Classloader所加載的代碼需要反過來委托左邊的UserClassLoad加載數據的時候,就需要設置線程的上下文加載器。
在SPI接口的代碼中使用線程上下文加載器,就可以成功的加載到SPI實現的類。大部分的java application服務器(jboss,tomcat)也是采用contextClassLoader來處理java服務。還有一些熱部署hot swap特性的框架,也使用了線程上下文加載器。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容