JVM系列[2]-Java類加載機制

上一篇博文Java類的生命週期概要性地介紹了一個類從「出生」到「凋亡」的各個生命階段。今天,筆者就跟你們聊聊其中很是重要的一個環節:類的加載過程。Java虛擬機中類加載的過程具體包含加載驗證準備解析初始化這5個階段,各階段涉及的細節較多,在上一篇博文中都有簡短介紹,本文將主要介紹加載階段,其中包括加載方式、加載時機、類加載器原理、實例分析等內容。java

前言

在具體介紹類加載機制以前,咱們先來看下網上關於理解類加載機制的經典題目:spring

public class Singleton {
    private static Singleton singleton = new Singleton();
    public static int counter1;
    public static int counter2 = 0;
    
    private Singleton() {
        counter1++;
        counter2++;
    }
    
    public static Singleton getSingleton() {
        return singleton;
    }
}
// 打印靜態屬性的值
public class TestSingleton{
    public static void main(String[] args) {
        Singleton singleton = Singleton.getSingleton();
        System.out.println("counter1=" + singleton.counter1);
        System.out.println("counter2=" + singleton.counter2);
    }
}
// 輸出結果:
>>> counter1=1
>>> counter2=0

關於爲何counter2=0,這裏就不具體解釋了,只是想說下它考覈了那幾個點:編程

  1. 類加載過程的5個階段前後順序:準備階段在初始化以前
  2. 準備階段和初始化階段各自作的事情
  3. 靜態初始化的細節:前後順序

什麼是類的加載

言歸正傳,咱們先從類加載的定義提及,一句話概述,緩存

虛擬機將class文件中的二進制數據流加載到JVM運行時數據區的方法區內,並進行驗證準備解析初始化等動做後,在內存中建立java.lang.class對象,做爲對方法區中該類數據結構的訪問入口。tomcat

這裏有幾點要解釋下,class文件是指符合class文件格式的二進制數據流,也就是咱們常說的字節碼文件,它是咱們與JVM約定的格式協議,只要是符合class文件格式的二進制流,均可被JVM加載,這也是JVM跨平臺的基礎;另外,java.lang.class對象只是說在內存建立,並無明確規定是否在Java堆中,對於Hotspot虛擬機,是存放在方法區的。安全

加載方式

類的加載方式分爲兩種:隱式加載和顯式加載。服務器

隱式加載

實際就是不用咱們代碼主動聲明,而是JVM在適當的時機自動加載類。好比主動引用某個類時,會自動觸發類加載和初始化階段。數據結構

顯式加載

則一般是指經過代碼的方式顯式加載指定類,常見如下幾種:架構

經過Class.forName()加載指定類。對於forName(String className)方法,默認會執行靜態初始化,但若是使用另外一個重載函數forName(String name, boolean initialize, ClassLoader loader),其實是能夠經過initialize來控制是否執行靜態初始化併發

經過ClassLoader.loadClass()加載指定類,這種方式僅僅是將.class加載到JVM,並不會執行靜態初始化塊,這個等後面談到類加載器的職責時會再強調這一點

關於Class.forName()是否執行靜態初始化,經過源碼就能一目瞭然:

public static Class<?> forName(String className)	// 執行初始化,由於initialize爲true
            throws ClassNotFoundException {
    Class<?> caller = Reflection.getCallerClass();
    return forName0(className, true, ClassLoader.getClassLoader(caller), caller);
}
...
public static Class<?> forName(String name, boolean initialize,
                               ClassLoader loader)  // 可控的,經過initialize來指定初始化與否
    throws ClassNotFoundException
{
    ...
    return forName0(name, initialize, loader, caller);
}

加載時機

類加載的第一個階段——加載階段具體何時開始,虛擬機規範並未指明,由具體的虛擬機實現決定,可分爲預加載和運行時加載兩種時機:

  1. 預加載:對於JDK中的經常使用基礎庫——JAVA_HOME/lib下的rt.jar,它包含了咱們最經常使用的class,如java.lang.*java.util.*等,在虛擬機啓動時會提早加載,這樣用到時就省去了加載耗時,能加快訪問速度。
  2. 運行時加載:大多數類好比用戶代碼,都是在類第一次被使用時才加載的,也就是常說的惰性加載,這麼作的比較直觀的緣由大概是節省內存吧。

加載原理

loadClass源碼

先上代碼(JDK1.7源碼):

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();
                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
                }

                if (c == null) {
                    // If still not found, then invoke findClass in order
                    // to find the class.
                    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;
        }
    }

loadClass是類加載機制中最核心的代碼,這段代碼基本闡述瞭如下最核心的兩點:

緩存機制

findLoadedClass(name),首先第一步就檢查這個name表示的類是否已被某個類加載器實例加載過,若已加載,則直接返回已加載的c,不然才繼續下面的委派等邏輯。即JVM層會對已加載的類進行緩存,那具體是怎麼緩存的的呢?在JVM實現中,有個相似於HashTable的數據結構叫作SystemDictionary,對已加載的類信息進行緩存,緩存的key是類加載器實例+類的全限定名,value則是指向表示類信息的klass數據結構。這也是爲何咱們常說,JVM中加載類的類加載器實例加上類的全限定名,這二者一塊兒才能惟一肯定一個類。每一個類加載器實例就至關因而一個獨立的類命名空間,對於兩個不一樣類加載器實例加載的類,即使名稱相同,也是兩個徹底不一樣的類。

雙親委派

對於新加載的類,緩存沒命中後走雙親委派邏輯——當parent存在時,會先委派給parent進行loadClass,而後parent.loadClass內部又會進行一樣的向上委派,直至parentnull,委派給根加載器。也就是說委派請求會一直向上傳遞,直到頂層的引導類加載器,而後再統一用ClassNotFoundException異常的方式逐層向下回傳,直到某一層classLoader在其搜索範圍內找到並加載該類;當parent不存在時,即沒有父類加載器,此時直接委派給頂層加載器——BootstrapClassLoader

從這裏能夠看到雙親委派結構中,類加載器之間的父子層級關係並非經過繼承來實現,而是經過組合的方式即子類加載器持有parent代理以指向父類加載器來實現的。

要點

  1. 因爲委派是單向的,處於子類加載器層級的類,能夠訪問父類加載器層級的類,反過來不行
  2. 各執其責,各層級的類加載器只負責加載本層級下的類。實現方式:各層級類加載器有本身的加載路徑,路徑隔離,互不可見。URLClassLoaderucp屬性瞭解下~
  3. ClassNotFoundException——父子類加載器之間的協議。只有當父類加載器拋出此異常時,加載請求才會向下層傳遞,其餘異常不認!
  4. 上下層的這種優先級進一步保證了Java程序的穩定性,對於JDK庫中核心的類不會由於用戶誤定義的同名類而致使被覆蓋。

類加載器

仍是給個定義吧:

經過一個類的全限定名來獲取描述此類的二進制字節流的代碼模塊

經典的三層加載器結構:

three_level_classloaders

一、啓動類加載器(或稱爲引導類加載器):只負責加載<JAVA_HOME>/lib目錄中的,或是啓動參數-Xbootclasspath所指定路徑中的特定名稱類庫。該加載器由C++實現,對Java程序不可見,對於自定義加載器,如果未指定parent,則會委派該加載器進行加載。

二、擴展類加載器:負責加載<JAVA_HOME>/lib/ext目錄中的,或是java.ext.dirs系統變量所指定的路徑下全部類庫。該加載器由sum.misc.Launcher$ExtClassLoader實現,可直接使用。

三、應用程序類加載器(或稱爲系統類加載器):負責加載用戶類路徑ClassPath中全部類庫。該加載器由sum.misc.Launcher$AppClassLoader實現,可由ClassLoader.getSystemClassLoader()方法得到。

要點

ExtClassLoaderAppClassLoader都是繼承自URLClassLoader,各自負責的加載路徑都是保存在ucp屬性中,這個看源碼就能得知。

三次「破壞」

雙親委派並非一個強制性約束模型,畢竟它自身也有侷限性。不管是歷史代碼層面、SPI設計問題、仍是新的熱部署需求,都不可避免地會違背該原則,累計有三次「破壞」。

可覆蓋的loadClass方法

經過ClassLoader的源碼可知,雙親委派的實現細節都在loadClass方法中,而該方法是一個protected的,意味着子類能夠覆蓋該方法,從而可繞過雙親委派邏輯。雙親委派模型是在JDK1.2以後才被引入,在此以前的JDK1.0,已有部分用戶經過繼承ClassLoader重寫了loadClass邏輯,這使得後面引入的雙親委派邏輯在這些用戶程序中不起做用。

爲了向前兼容,ClassLoader新增了findClass方法,提倡用戶將本身的類加載邏輯放入findClass中,而不要再去覆蓋loadClass方法。

自身缺陷,沒法支持SPI

雙親委派的層次優先級就決定了用戶代碼和JDK基礎類之間的不對等性,即只能用戶代碼調用基礎類,反之不行。對於SPI之類的設計,好比已經成爲Java標準服務的JNDI,其接口代碼是在基礎類中,而具體的實現代碼則是在用戶Classpath下,在雙親委派的限制下,JNDI沒法調用實現層代碼。

開個後門——引入線程上下文類加載器(Thread Context ClassLoader),該加載器可經過java.lang.Thread.setContextClassLoader()進行設置,若建立線程時未設置,則從父線程繼承;若應用程序的全局範圍都未設置過,則默認設置爲應用程序類加載器,這個可在Launcher的源碼中找到答案。

有了這個,JNDI服務就可以使用該加載器去加載所需的SPI代碼。其餘相似的SPI設計也是這種方式,如JDBC、JCE、JAXB、JBI等。

程序動態性的需求,即熱部署

模塊化熱部署,在生產環境中顯得尤其有吸引力,就像咱們的計算機外設同樣,不用重啓,可隨時更換鼠標、U盤等。 OSGi已經成爲業界事實上的Java模塊化標準,此時類加載器再也不是雙親委派中的樹狀層次,而是複雜的網狀結構。

類加載器實例分析

Tomcat——雙親委派的最佳實踐者

一般Web服務器須要解決幾個基本問題:

  1. 同一個服務器上,部署兩個及以上的Web應用程序,各自使用的Java類庫能夠相互隔離。
  2. 多個Web應用程序,共享所使用的部分Java類庫。好比都用到了一樣版本的spring,共享一份,不管是本地磁盤,仍是Web服務器內存(主要是方法區),都是不錯的節省。
  3. 保證服務器自身安全不受部署的Web應用程序影響,這跟前面談到的雙親委派保證Java程序穩定性是一個道理。
  4. 支持JSP的話,須要支持HotSwap功能。

爲了應對以上基本問題,主流的Java Web服務器都會提供多個Classpath存放類庫。對於Tomcat,其目錄結構劃分爲如下4組:

  1. /common目錄,存放的類庫被Tomcat和全部的Web應用程序共享。
  2. /server目錄,僅被Tomcat使用,其餘Web應用程序不可見。
  3. /shared目錄,可被全部Web應用程序共享,但對Tomcat不可見。
  4. /WebApp/WEB-INF目錄,僅被所屬的Web應用程序使用,對Tomcat和其餘Web應用程序不可見。

跟以上目錄對應的,是Tomcat經典的雙親委派類加載器架構:

tomcat_classloaders

上圖中,CommonClassLoader、CatalinaClassLoader、SharedClassLoader和WebAppClassLoader分別負責/common/*/server/*/shared/*/WebApp/WEB-INF/*目錄下的Java類庫加載,其中WebApp類加載器和Jsp類加載器一般會存在多個實例,每個Web應用程序對應一個WebAppClassLoader,每個JSP文件對應一個Jsp類加載器。

OSGi——勇於突破

OSGi(Open Service Gateway Initiative)是OSGi聯盟制定的一個基於Java語言的動態模塊化規範,其最著名的應用案例就是Eclipse IDE,它是Eclipse強大插件體系的基礎。

OSGi中的每一個模塊稱爲Bundle,一個Bundle能夠聲明它所依賴的Java Package(經過Import-Package描述),也能夠聲明它容許導出發佈的Java Package(經過Export-Package描述)。Bundle之間的依賴關係爲平級依賴,Bundle類加載器之間只有規則,沒有固定的委派關係。假設存在BundleA、BundleB和BundleC,

BundleA:聲明發布了packageA,依賴了java.*的包 BundleB:聲明依賴了packageA和packageC,同時也依賴了java.*的包 BundleC:聲明發布了packageC,依賴了packageA

一個簡單的OSGi類加載器架構示例以下:

osgi_classloaders

上圖的這種網狀架構帶來了更好的靈活性,但同時也可能產生許多新的隱患。好比Bundle之間的循環依賴,在高併發場景下致使加載死鎖。

總結

本文以一個關於類加載的編程題爲切入點,闡述了類加載階段的具體細節,包括加載方式、加載時機、加載原理,以及雙親委派的優劣點。並以具體的類加載器實例Tomcat和OSGi爲例,簡單分析了類加載器在實踐過程當中的多種選擇。

同步更新到原文

相關文章
相關標籤/搜索