一 基礎原理:第03講:大廠面試題:從覆蓋 JDK 的類開始掌握類的加載機制

本課時我們主要從覆蓋 JDK 的類開始講解 JVM 的類加載機制。其實,JVM 的類加載機制和 Java 的類加載機制類似,但 JVM 的類加載過程稍有些複雜。

前面課時我們講到,JVM 通過加載 .class 文件,能夠將其中的字節碼解析成操作系統機器碼。那這些文件是怎麼加載進來的呢?又有哪些約定?接下來我們就詳細介紹 JVM 的類加載機制,同時介紹三個實際的應用場景。

我們首先看幾個面試題。

  • 我們能夠通過一定的手段,覆蓋 HashMap 類的實現麼?
  • 有哪些地方打破了 Java 的類加載機制?
  • 如何加載一個遠程的 .class 文件?怎樣加密 .class 文件?

關於類加載,很多同學都知道雙親委派機制,但這明顯不夠。面試官可能要你講出幾個能打破這個機制的例子,這個時候不要慌。上面幾個問題,是我在接觸的一些比較高級的面試場景中,遇到的一些問法。在平常的工作中,也有大量的相關應用,我們會理論聯繫實踐綜合分析這些問題。

類加載過程

現實中並不是說,我把一個文件修改成 .class 後綴,就能夠被 JVM 識別。類的加載過程非常複雜,主要有這幾個過程:加載、驗證、準備、解析、初始化。這些術語很多地方都出現過,我們不需要死記硬背,而應該要了解它背後的原理和要做的事情。

如圖所示。大多數情況下,類會按照圖中給出的順序進行加載。下面我們就來分別介紹下這個過程。

加載

加載的主要作用是將外部的 .class 文件,加載到 Java 的方法區內,你可以回顧一下我們在上一課時講的內存區域圖。加載階段主要是找到並加載類的二進制數據,比如從 jar 包裏或者 war 包裏找到它們。

驗證

肯定不能任何 .class 文件都能加載,那樣太不安全了,容易受到惡意代碼的攻擊。驗證階段在虛擬機整個類加載過程中佔了很大一部分,不符合規範的將拋出 java.lang.VerifyError 錯誤。像一些低版本的 JVM,是無法加載一些高版本的類庫的,就是在這個階段完成的。

準備

從這部分開始,將爲一些類變量分配內存,並將其初始化爲默認值。此時,實例對象還沒有分配內存,所以這些動作是在方法區上進行的。

我們順便看一道面試題。下面兩段代碼,code-snippet 1 將會輸出 0,而 code-snippet 2 將無法通過編譯。

code-snippet 1:
     public class A {
         static int a ;
         public static void main(String[] args) {
             System.out.println(a);
         }
     }
 code-snippet 2:
 public class A {
     public static void main(String[] args) {
         int a ;
         System.out.println(a);
     }
 }

爲什麼會有這種區別呢?

這是因爲局部變量不像類變量那樣存在準備階段。類變量有兩次賦初始值的過程,一次在準備階段,賦予初始值(也可以是指定值);另外一次在初始化階段,賦予程序員定義的值。

因此,即使程序員沒有爲類變量賦值也沒有關係,它仍然有一個默認的初始值。但局部變量就不一樣了,如果沒有給它賦初始值,是不能使用的。

解析

解析在類加載中是非常非常重要的一環,是將符號引用替換爲直接引用的過程。這句話非常的拗口,其實理解起來也非常的簡單。

符號引用是一種定義,可以是任何字面上的含義,而直接引用就是直接指向目標的指針、相對偏移量。

直接引用的對象都存在於內存中,你可以把通訊錄裏的女友手機號碼,類比爲符號引用,把面對面和你喫飯的人,類比爲直接引用。

解析階段負責把整個類激活,串成一個可以找到彼此的網,過程不可謂不重要。那這個階段都做了哪些工作呢?大體可以分爲:

  • 類或接口的解析
  • 類方法解析
  • 接口方法解析
  • 字段解析

我們來看幾個經常發生的異常,就與這個階段有關。

  • java.lang.NoSuchFieldError 根據繼承關係從下往上,找不到相關字段時的報錯。
  • java.lang.IllegalAccessError 字段或者方法,訪問權限不具備時的錯誤。
  • java.lang.NoSuchMethodError 找不到相關方法時的錯誤。

解析過程保證了相互引用的完整性,把繼承與組合推進到運行時。

初始化

如果前面的流程一切順利的話,接下來該初始化成員變量了,到了這一步,才真正開始執行一些字節碼。

接下來是另一道面試題,你可以猜想一下,下面的代碼,會輸出什麼?

public class A {
     static int a = 0 ;
     static {
         a = 1;
         b = 1;
     }
     static int b = 0;
 
     public static void main(String[] args) {
         System.out.println(a);
         System.out.println(b);
     }
 }

結果是 1 0。a 和 b 唯一的區別就是它們的 static 代碼塊的位置。

這就引出一個規則:static 語句塊,只能訪問到定義在 static 語句塊之前的變量。所以下面的代碼是無法通過編譯的。

static {
         b = b + 1;
 }
 static int b = 0;

我們再來看第二個規則:JVM 會保證在子類的初始化方法執行之前,父類的初始化方法已經執行完畢。

所以,JVM 第一個被執行的類初始化方法一定是 java.lang.Object。另外,也意味着父類中定義的 static 語句塊要優先於子類的。

<cinit>與<init>

說到這裏,不得不再說一個面試題:<cinit> 方法和 <init> 方法有什麼區別?

主要是爲了讓你弄明白類的初始化和對象的初始化之間的差別。

public class A {
     static {
         System.out.println("1");
     }
     public A(){
         System.out.println("2");
         }
     }
 
     public class B extends A {
         static{
         System.out.println("a");
     }
     public B(){
         System.out.println("b");
     }
 
     public static void main(String[] args){
         A ab = new B();
         ab = new B();
     }
 }

先公佈下答案:

1
a
2
b
2
b

你可以看下這張圖。其中 static 字段和 static 代碼塊,是屬於類的,在類的加載的初始化階段就已經被執行。類信息會被存放在方法區,在同一個類加載器下,這些信息有一份就夠了,所以上面的 static 代碼塊只會執行一次,它對應的是 <cinit> 方法。

而對象初始化就不一樣了。通常,我們在 new 一個新對象的時候,都會調用它的構造方法,就是 <init>,用來初始化對象的屬性。每次新建對象的時候,都會執行。

所以,上面代碼的 static 代碼塊只會執行一次,對象的構造方法執行兩次。再加上繼承關係的先後原則,不難分析出正確結果。

類加載器

整個類加載過程任務非常繁重,雖然這活兒很累,但總得有人幹。類加載器做的就是上面 5 個步驟的事。

如果你在項目代碼裏,寫一個 java.lang 的包,然後改寫 String 類的一些行爲,編譯後,發現並不能生效。JRE 的類當然不能輕易被覆蓋,否則會被別有用心的人利用,這就太危險了。

那類加載器是如何保證這個過程的安全性呢?其實,它是有着嚴格的等級制度的。

幾個類加載器

首先,我們介紹幾個不同等級的類加載器。

  • Bootstrap ClassLoader

這是加載器中的大 Boss,任何類的加載行爲,都要經它過問。它的作用是加載核心類庫,也就是 rt.jar、resources.jar、charsets.jar 等。當然這些 jar 包的路徑是可以指定的,-Xbootclasspath 參數可以完成指定操作。

這個加載器是 C++ 編寫的,隨着 JVM 啓動。

  • Extention ClassLoader

擴展類加載器,主要用於加載 lib/ext 目錄下的 jar 包和 .class 文件。同樣的,通過系統變量 java.ext.dirs 可以指定這個目錄。

這個加載器是個 Java 類,繼承自 URLClassLoader。

  • App ClassLoader

這是我們寫的 Java 類的默認加載器,有時候也叫作 System ClassLoader。一般用來加載 classpath 下的其他所有 jar 包和 .class 文件,我們寫的代碼,會首先嚐試使用這個類加載器進行加載。

  • Custom ClassLoader

自定義加載器,支持一些個性化的擴展功能。

雙親委派機制

關於雙親委派機制的問題面試中經常會被問到,你可能已經倒背如流了。

雙親委派機制的意思是除了頂層的啓動類加載器以外,其餘的類加載器,在加載之前,都會委派給它的父加載器進行加載。這樣一層層向上傳遞,直到祖先們都無法勝任,它纔會真正的加載。

打個比方。有一個家族,都是一些聽話的孩子。孫子想要買一塊棒棒糖,最終都要經過爺爺過問,如果力所能及,爺爺就直接幫孫子買了。

但你有沒有想過,“類加載的雙親委派機制,雙親在哪裏?明明都是單親?”

我們還是用一張圖來講解。可以看到,除了啓動類加載器,每一個加載器都有一個parent,並沒有所謂的雙親。但是由於翻譯的問題,這個叫法已經非常普遍了,一定要注意背後的差別。

我們可以翻閱 JDK 代碼的 ClassLoader#loadClass 方法,來看一下具體的加載過程。和我們描述的一樣,它首先使用 parent 嘗試進行類加載,parent 失敗後才輪到自己。同時,我們也注意到,這個方法是可以被覆蓋的,也就是雙親委派機制並不一定生效。

這個模型的好處在於 Java 類有了一種優先級的層次劃分關係。比如 Object 類,這個毫無疑問應該交給最上層的加載器進行加載,即使是你覆蓋了它,最終也是由系統默認的加載器進行加載的。

如果沒有雙親委派模型,就會出現很多個不同的 Object 類,應用程序會一片混亂。

一些自定義加載器

下面我們就來聊一聊可以打破雙親委派機制的一些案例。爲了支持一些自定義加載類多功能的需求,Java 設計者其實已經作出了一些妥協。

案例一:tomcat

tomcat 通過 war 包進行應用的發佈,它其實是違反了雙親委派機制原則的。簡單看一下 tomcat 類加載器的層次結構。

對於一些需要加載的非基礎類,會由一個叫作 WebAppClassLoader 的類加載器優先加載。等它加載不到的時候,再交給上層的 ClassLoader 進行加載。這個加載器用來隔絕不同應用的 .class 文件,比如你的兩個應用,可能會依賴同一個第三方的不同版本,它們是相互沒有影響的。

如何在同一個 JVM 裏,運行着不兼容的兩個版本,當然是需要自定義加載器才能完成的事。

那麼 tomcat 是怎麼打破雙親委派機制的呢?可以看圖中的 WebAppClassLoader,它加載自己目錄下的 .class 文件,並不會傳遞給父類的加載器。但是,它卻可以使用 SharedClassLoader 所加載的類,實現了共享和分離的功能。

但是你自己寫一個 ArrayList,放在應用目錄裏,tomcat 依然不會加載。它只是自定義的加載器順序不同,但對於頂層來說,還是一樣的。

案例二:SPI

Java 中有一個 SPI 機制,全稱是 Service Provider Interface,是 Java 提供的一套用來被第三方實現或者擴展的 API,它可以用來啓用框架擴展和替換組件。

這個說法可能比較晦澀,但是拿我們常用的數據庫驅動加載來說,就比較好理解了。在使用 JDBC 寫程序之前,通常會調用下面這行代碼,用於加載所需要的驅動類。

Class.forName("com.mysql.jdbc.Driver")

這只是一種初始化模式,通過 static 代碼塊顯式地聲明瞭驅動對象,然後把這些信息,保存到底層的一個 List 中。這種方式我們不做過多的介紹,因爲這明顯就是一個接口編程的思路,沒什麼好奇怪的。

但是你會發現,即使刪除了 Class.forName 這一行代碼,也能加載到正確的驅動類,什麼都不需要做,非常的神奇,它是怎麼做到的呢?

我們翻開 MySQL 的驅動代碼,發現了一個奇怪的文件。之所以能夠發生這樣神奇的事情,就是在這裏實現的。

路徑:

mysql-connector-java-8.0.15.jar!/META-INF/services/java.sql.Driver

裏面的內容是:

com.mysql.cj.jdbc.Driver

通過在 META-INF/services 目錄下,創建一個以接口全限定名爲命名的文件(內容爲實現類的全限定名),即可自動加載這一種實現,這就是 SPI。

SPI 實際上是“基於接口的編程+策略模式+配置文件”組合實現的動態加載機制,主要使用 java.util.ServiceLoader 類進行動態裝載。

這種方式,同樣打破了雙親委派的機制。

DriverManager 類和 ServiceLoader 類都是屬於 rt.jar 的。它們的類加載器是 Bootstrap ClassLoader,也就是最上層的那個。而具體的數據庫驅動,卻屬於業務代碼,這個啓動類加載器是無法加載的。這就比較尷尬了,雖然凡事都要祖先過問,但祖先沒有能力去做這件事情,怎麼辦?

我們可以一步步跟蹤代碼,來看一下這個過程。

//part1:DriverManager::loadInitialDrivers
//jdk1.8 之後,變成了lazy的ensureDriversInitialized
...
ServiceLoader <Driver> loadedDrivers = ServiceLoader.load(Driver.class);
Iterator<Driver> driversIterator = loadedDrivers.iterator();
...

//part2:ServiceLoader::load
public static <T> ServiceLoader<T> load(Class<T> service) {
    ClassLoader cl = Thread.currentThread().getContextClassLoader();
    return ServiceLoader.load(service, cl);
}

通過代碼你可以發現 Java 玩了個魔術,它把當前的類加載器,設置成了線程的上下文類加載器。那麼,對於一個剛剛啓動的應用程序來說,它當前的加載器是誰呢?也就是說,啓動 main 方法的那個加載器,到底是哪一個?

所以我們繼續跟蹤代碼。找到 Launcher 類,就是 jre 中用於啓動入口函數 main 的類。我們在 Launcher 中找到以下代碼。

public Launcher() {
 Launcher.ExtClassLoader var1;
 try {
     var1 = Launcher.ExtClassLoader.getExtClassLoader();
 } catch (IOException var10) {
     throw new InternalError("Could not create extension class loader", var10);
 }
 
 try {
     this.loader = Launcher.AppClassLoader.getAppClassLoader(var1);
 } catch (IOException var9) {
     throw new InternalError("Could not create application class loader", var9);
 }
 Thread.currentThread().setContextClassLoader(this.loader);
 ...
 }

到此爲止,事情就比較明朗了,當前線程上下文的類加載器,是應用程序類加載器。使用它來加載第三方驅動,是沒有什麼問題的。

我們之所以花大量的篇幅來介紹這個過程,第一,可以讓你更好的看到一個打破規則的案例。第二,這個問題面試時出現的機率也是比較高的,你需要好好理解。

案例三:OSGi

OSGi 曾經非常流行,Eclipse 就使用 OSGi 作爲插件系統的基礎。OSGi 是服務平臺的規範,旨在用於需要長運行時間、動態更新和對運行環境破壞最小的系統。

OSGi 規範定義了很多關於包生命週期,以及基礎架構和綁定包的交互方式。這些規則,通過使用特殊 Java 類加載器來強制執行,比較霸道。

比如,在一般 Java 應用程序中,classpath 中的所有類都對所有其他類可見,這是毋庸置疑的。但是,OSGi 類加載器基於 OSGi 規範和每個綁定包的 manifest.mf 文件中指定的選項,來限制這些類的交互,這就讓編程風格變得非常的怪異。但我們不難想象,這種與直覺相違背的加載方式,肯定是由專用的類加載器來實現的。

隨着 jigsaw 的發展(旨在爲 Java SE 平臺設計、實現一個標準的模塊系統),我個人認爲,現在的 OSGi,意義已經不是很大了。OSGi 是一個龐大的話題,你只需要知道,有這麼一個複雜的東西,實現了模塊化,每個模塊可以獨立安裝、啓動、停止、卸載,就可以了。

不過,如果你有機會接觸相關方面的工作,也許會不由的發出感嘆:原來 Java 的類加載器,可以玩出這麼多花樣。

如何替換 JDK 的類

讓我們回到本課時開始的問題,如何替換 JDK 中的類?比如,我們現在就拿 HashMap爲例。

當 Java 的原生 API 不能滿足需求時,比如我們要修改 HashMap 類,就必須要使用到 Java 的 endorsed 技術。我們需要將自己的 HashMap 類,打包成一個 jar 包,然後放到 -Djava.endorsed.dirs 指定的目錄中。注意類名和包名,應該和 JDK 自帶的是一樣的。但是,java.lang 包下面的類除外,因爲這些都是特殊保護的。

因爲我們上面提到的雙親委派機制,是無法直接在應用中替換 JDK 的原生類的。但是,有時候又不得不進行一下增強、替換,比如你想要調試一段代碼,或者比 Java 團隊早發現了一個 Bug。所以,Java 提供了 endorsed 技術,用於替換這些類。這個目錄下的 jar 包,會比 rt.jar 中的文件,優先級更高,可以被最先加載到。

小結

通過本課時的學習我們可以瞭解到,一個 Java 類的加載,經過了加載、驗證、準備、解析、初始化幾個過程,每一個過程都劃清了各自負責的事情。

接下來,我們瞭解到 Java 自帶的三個類加載器。同時瞭解到,main 方法的線程上下文加載器,其實是 Application ClassLoader。

一般情況下,類加載是遵循雙親委派機制的。我們也認識到,這個雙親,很有問題。通過 3 個案例的學習和介紹,可以看到有很多打破這個規則的情況。類加載器通過開放的 API,讓加載過程更加靈活。

Java 的類加載器是非常重要的知識點,也是面試常考的知識點,本課時提供了多個面試題,你可以實際操作體驗一下。

所以我們在課時開始時的第三個問題就很簡單了,無論是遠程存儲字節碼,還是將字節碼進行加密,這都是業務需求。要做這些,我們實現一個新的類加載器就可以了。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章