面試必時必問的JVM 類加載機制詳解

前言

本次帶來 JVM 的另一塊重要內容,類加載機制,不廢話,直接開懟。

正文

1、類加載的過程。

類從被加載到虛擬機內存中開始,到卸載出內存為止,它的整個生命周期包括:加載、驗證、準備、解析、初始化、使用和卸載7個階段。其中驗證、準備、解析3個部分統稱為連接。

1)加載

“類加載”過程的一個階段,在加載階段,虛擬機需要完成以下3件事情:

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

2)驗證

連接階段的第一步,這一階段的目的是為瞭確保 Class 文件的字節流中包含的信息符合當前虛擬機的要求,並且不會危害虛擬機自身的安全。從整體上看,驗證階段大致上會完成下面4個階段的檢驗動作:文件格式驗證、元數據驗證、字節碼驗證、符號引用驗證。

3)準備

該階段是正式為類變量(static修飾的變量)分配內存並設置類變量初始值的階段,這些變量所使用的內存都將在方法區中進行分配。這裡所說的初始值“通常情況”下是數據類型的零值,下表列出瞭Java中所有基本數據類型的零值。

4)解析

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

5)初始化

到瞭初始化階段,才真正開始執行類中定義的Java程序代碼。在準備階段,變量已經賦過一次系統要求的初始零值,而在初始化階段,則會根據程序員通過程序制定的主觀計劃去初始化類變量和其他資源。

我們也可以從另外一種更直接的形式來表達:初始化階段是執行類構造器<clinit>()方法的過程。<clinit>() 不是程序員在 Java 代碼中直接編寫的方法,而是由 Javac 編譯器自動生成的。

<clinit>() 方法是由編譯器自動收集類中的所有類變量的賦值動作和靜態語句塊(static{}塊)中的語句合並產生的,編譯器收集的順序是由語句在源文件中出現的順序所決定的,靜態語句塊中隻能訪問到定義在靜態語句塊之前的變量,定義在它之後的變量,在前面的靜態語句塊可以賦值,但是不能訪問。

2、Java 虛擬機中有哪些類加載器?

從 Java 虛擬機的角度來講,隻存在兩種不同的類加載器:

一種是啟動類加載器(Bootstrap ClassLoader),這個類加載器使用C++語言實現,是虛擬機自身的一部分;

另一種就是所有其他的類加載器,這些類加載器都由Java語言實現,獨立於虛擬機外部,並且全都繼承自抽象類java.lang.ClassLoader。

從Java開發人員的角度來看,絕大部分Java程序都會使用到以下3種系統提供的類加載器。

1)啟動類加載器(Bootstrap ClassLoader):

這個類加載器負責將存放在<JAVA_HOME>\lib目錄中的,或者被-Xbootclasspath參數所指定的路徑中的,並且是虛擬機識別的(僅按照文件名識別,如rt.jar,名字不符合的類庫即使放在lib目錄中也不會被加載)類庫加載到虛擬機內存中。

2)擴展類加載器(Extension ClassLoader):

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

3)應用程序類加載器(Application ClassLoader):

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

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

3、什麼是雙親委派模型?

如果一個類加載器收到瞭類加載的請求,它首先不會自己去嘗試加載這個類,而是把這個請求委派給父類加載器去完成,每一個層次的類加載器都是如此,因此所有的加載請求最終都應該傳送到頂層的啟動類加載器中,隻有當父加載器反饋自己無法完成這個加載請求(它的搜索范圍中沒有找到所需的類)時,子加載器才會嘗試自己去加載。

類加載的源碼如下:

protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // 1、檢查請求的類是否已經被加載過瞭
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                long t0 = System.nanoTime();
                try {
                    // 2、將類加載請求先委托給父類加載器
                    if (parent != null) {
                        // 父類加載器不為空時,委托給父類加載進行加載
                        c = parent.loadClass(name, false);
                    } else {
                        // 父類加載器為空,則代表當前是Bootstrap,從Bootstrap中加載類
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // 如果父類加載器拋出ClassNotFoundException
                    // 說明父類加載器無法完成加載請求
                }
                if (c == null) {
                    // 3、在父類加載器無法加載的時候,再調用本身的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;
        }
    }

4、為什麼使用雙親委派模式?

1)使用雙親委派模型來組織類加載器之間的關系,有一個顯而易見的好處就是 Java 類隨著它的類加載器一起具備瞭一種帶有優先級的層次關系。

2)如果沒有使用雙親委派模型,由各個類加載器自行去加載的話,如果用戶自己編寫瞭一個java.lang.Object 的類,並放在程序的 ClassPath 中,那系統中將會出現多個不同的 Object 類,Java 類型體系中最基礎的行為也就無法保證,應用程序也將會變得一片混亂。

5、有哪些場景破壞瞭雙親委派模型?

目前比較常見的場景主要有:

1)線程上下文類加載器,典型的:JDBC 使用線程上下文類加載器加載 Driver 實現類

2)Tomcat 的多 Web 應用程序

3)OSGI 實現模塊化熱部署

6、為什麼要破壞雙親委派模型?

原因其實很簡單,就是使用雙親委派模型無法滿足需求瞭,因此隻能破壞它,這邊以面試常問的 Tomcat 為例。

我們知道 Tomcat 容器可以同時部署多個 Web 應用程序,多個 Web 應用程序很容易存在依賴同一個 jar 包,但是版本不一樣的情況。例如應用1和應用2都依賴瞭 spring ,應用1使用的 3.2.* 版本,而應用2使用的是 4.3.* 版本。

如果遵循雙親委派模型,這個時候使用哪個版本瞭?

其實使用哪個版本都不行,很容易出現兼容性問題。因此,Tomcat 隻能選擇破壞雙親委派模型。

7、如何破壞雙親委派模型?

破壞雙親委派模型的思路都比較類似,這邊以面試中常問到的 Tomcat 為例。

其實原理非常簡單,我們可以看到上面的類加載方法源碼(loadClass)的方法修飾符是 protected,因此我們隻需以下幾步就能破壞雙親委派模型。

1)繼承 ClassLoader,Tomcat 中的 WebappClassLoader 繼承 ClassLoader 的子類 URLClassLoader。

2)重寫 loadClass 方法,實現自己的邏輯,不要每次都先委托給父類加載,例如可以先在本地加載,這樣就破壞瞭雙親委派模型瞭。

8、Tomcat 的類加載器?

Tomcat 的類加載器如下圖所示:

1)Bootstrap ClassLoader:可以看到上圖中缺少瞭 Extension ClassLoader,在 Tomcat 中 Extension ClassLoader 被集成到瞭 Bootstrap ClassLoader 裡面。

2)System ClassLoader 就是 Application ClassLoader:Tomcat 中的系統類加載器不會加載 CLASSPATH 環境變量的內容,而是從以下資源庫構建 System 類加載器。

  • $CATALINA_HOME/bin/bootstrap.jar,包含用於初始化Tomcat服務器的 main() 方法,以及它所依賴的類加載器實現類。
  • $CATALINA_BASE/bin/tomcat-juli.jar 或 $CATALINA_HOME/bin/tomcat-juli.jar,日志實現類。
  • 如果 $CATALINA_BASE/bin 中存在 tomcat-juli.jar,則使用它來代替 $CATALINA_HOME/bin中的那個。
  • $CATALINA_HOME/bin/commons-daemon.jar

3)Common ClassLoader:從名字也看出來來瞭,主要包含一些通用的類,這些類對 Tomcat 內部類和所有 Web 應用程序都可見。

該類加載器搜索的位置由 $CATALINA_BASE/conf/catalina.properties 中的 common.loader 屬性定義,默認設置將按照順序搜索以下位置。

  • $CATALINA_BASE/lib 中未打包的類和資源
  • $CATALINA_BASE/lib 目錄下的JAR 文件
  • $CATALINA_HOME/lib 中未打包的類和資源
  • $CATALINA_HOME/lib 目錄下的JAR文件

4)WebappX ClassLoader:Tomcat 為每個部署的 Web 應用程序創建一個單獨的類加載器,這樣保證瞭不同應用之間是隔離的,類和資源對其他 Web 應用是不可見的。加載的路徑如下:

  • Web應用的 /WEB-INF/classes 目錄下的所有未打包的類和資源
  • Web應用的 /WEB-INF/lib 目錄下的 JAR 文件中的類和資源

9、Tomcat 的類加載過程?

Tomcat 的類加載過程,也就是 WebappClassLoaderBase#loadClass 的邏輯如下。

1)首先本地緩存 resourceEntries,如果已經被加載過則直接返回緩存中的數據。

2)檢查 JVM 是否已經加載過該類,如果是則直接返回。

3) 檢查要加載的類是否是 Java SE 的類,如果是則使用 BootStrap 類加載器加載該類,以防止 webapp 的類覆蓋瞭 Java SE 的類。

例如你寫瞭一個 java.lang.String 類,放在當前應用的 /WEB-INF/classes 中,如果沒有此步驟的保證,那麼之後項目中使用的 String 類都是你自己定義的,而不是 rt.jar 下面的,可能會導致很多隱患。

4)針對委托屬性 delegate 顯示設置為 true、或者一些特殊的類(javax、org 包下的部分類),使用雙親委派模式加載,隻有很少部分使用雙親委派模型來加載。

5)嘗試從本地加載類,如果步驟5中加載失敗也會走到本步驟,這邊打破瞭雙親委派模型,優先從本地進行加載。

7)走到這,代表步驟6加載失敗,如果之前不是使用雙親委派模式,則在這邊會委托給父類加載器來嘗試加載。

8)走到這邊代表所有的嘗試都加載失敗,拋出 ClassNotFoundException。

10、JDBC 使用線程上下文類加載器的原理

JDBC 功能相關的基礎類是由 Java 統一定義的,在 rt.jar 裡面,例如 DriverManager,也就是由 Bootstrap ClassLoader 來加載,而 JDBC 的實現類是在各廠商的實現 jar 包裡,例如 MySQL 是在 mysql-connector-java 裡,oracle、sqlserver 也會有各自的實現 jar。

此時需要 JDBC 的基礎類調用其他廠商實現並部署在應用程序的 ClassPath 下的 JDBC 服務提供接口(SPI,Service Provider Interface)的代碼。當類A調用類B時,此時類B是由類A的類加載器來負責加載,而 JDBC 的基礎類都是由 Bootstrap ClassLoader 來加載,但是 Bootstrap ClassLoader 是不認識也不會去加載這些廠商實現的代碼的。

因此,Java 提供瞭線程上下文類加載器,允許通過 Thread#setContextClassLoader/Thread#getContextClassLoader() 來設置和獲取當前線程的上下文類加載器。如果創建線程時沒有設置,則會繼承父線程的,如果在應用程序的全局范圍內都沒有設置過的話,那這個類加載器默認就是應用程序類加載器(Application ClassLoader)。

綜上,JDBC 可以通過線程上下文類加載器,來實現父類加載器“委托”子類加載器完成類加載的行為,這個就明顯不遵守雙親委派模型瞭,不過這也是雙親委派模型自身的缺陷導致的。

總結

本篇文章就到這裡瞭,希望能給你帶來幫助,也希望您能夠多多關註WalkonNet的更多內容!

推薦閱讀: