一文帶你深扒ClassLoader核心,揭開它的神祕面紗!

我沒有三顆心臟發表於2020-08-28

  • 「MoreThanJava」 宣揚的是 「學習,不止 CODE」
  • 如果覺得 「不錯」 的朋友,歡迎 「關注 + 留言 + 分享」,文末有完整的獲取連結,您的支援是我前進的最大的動力!

前言

ClassLoader 可以說是 Java 最為神祕的功能之一了,好像大家都知道怎麼回事兒 (雙親委派模型好像都都能說得出來...),又都說不清楚具體是怎麼一回事 (為什麼需要需要有什麼實際用途就很模糊了...)

今天,我們就來深度扒一扒,揭開它神祕的面紗!

Part 1. 類載入是做什麼的?

首先,我們知道,Java 為了實現 「一次編譯,到處執行」 的目標,採用了一種特別的方案:先 編譯與任何具體及其環境及作業系統環境無關的中間程式碼(也就是 .class 位元組碼檔案),然後交由各個平臺特定的 Java 直譯器(也就是 JVM)來負責 解釋 執行。

ClassLoader (顧名思義就是類載入器) 就是那個把位元組碼交給 JVM 的搬運工 (載入進記憶體)。它負責將 位元組碼形式 的 Class 轉換成 JVM 中 記憶體形式 的 Class 物件。

位元組碼可以是來自於磁碟上的 .class 檔案,也可以是 jar 包裡的 *.class,甚至是來自遠端伺服器提供的位元組流。位元組碼的本質其實就是一個有特定複雜格式的位元組陣列 byte[] (從後面解析 ClassLoader 類中的方法時更能體會)

另外,類載入器不光可以把 Class 載入到 JVM 之中並解析成 JVM 統一要求的物件格式,還有一個重要的作用就是 審查每個類應該由誰載入

而且,這些 Java 類不會一次全部載入到記憶體,而是在應用程式需要時載入,這也是需要類載入器的地方。

Part 2. ClassLoader 類結構分析

以下就是 ClassLoader 的主要方法了:

  • defineClass() 用於將 byte 位元組流解析成 JVM 能夠識別的 Class 物件。有了這個方法意味著我們不僅可以通過 .class 檔案例項化物件,還可以通過其他方式例項化物件,例如通過網路接收到一個類的位元組碼。

    (注意,如果直接呼叫這個方法生成類的 Class 物件,這個類的 Class 物件還沒有 resolve,JVM 會在這個物件真正例項化時才呼叫 resolveClass() 進行連結)

  • findClass() 通常和 defineClass() 一起使用,我們需要直接覆蓋 ClassLoader 父類的 findClass() 方法來實現類的載入規則,從而取得要載入類的位元組碼。(以下是 ClassLoader 原始碼)

    protected Class<?> findClass(String name) throws ClassNotFoundException {
      throw new ClassNotFoundException(name);
    }
    

    如果你不想重新定義載入類的規則,也沒有複雜的處理邏輯,只想在執行時能夠載入自己制定的一個類,那麼你可以用 this.getClass().getClassLoader().loadClass("class") 呼叫 ClassLoader 的 loadClass() 方法來獲取這個類的 Class 物件,這個 loadClass() 還有過載方法,你同樣可以決定再什麼時候解析這個類。

  • loadClass() 用於接受一個全類名,然後返回一個 Class 型別的物件。(該方法原始碼蘊含了著名的雙親委派模型)

  • resolveClass() 用於對 Class 進行 連結,也就是把單一的 Class 加入到有繼承關係的類樹中。如果你想在類被載入到 JVM 中時就被連結(Link),那麼可以在呼叫 defineClass() 之後緊接著呼叫一個 resolveClass() 方法,當然你也可以選擇讓 JVM 來解決什麼時候才連結這個類(通常是真正被實例項化的時候)。

ClassLoader 是個抽象類,它還有很多子類,如果我們要實現自己的 ClassLoader,一般都會繼承 URLClassLoader 這個子類,因為這個類已經幫我們實現了大部分工作。

例如,我們來看一下 java.net.URLClassLoader.findClass() 方法的實現:

// 入參為 Class 的 binary name,如 java.lang.String
protected Class<?> findClass(final String name) throws ClassNotFoundException {
    // 以上程式碼省略
  
    // 通過 binary name 生成包路徑,如 java.lang.String -> java/lang/String.class
    String path = name.replace('.', '/').concat(".class");
    // 根據包路徑,找到該 Class 的檔案資源
    Resource res = ucp.getResource(path, false);
    if (res != null) {
        try {
           // 呼叫 defineClass 生成 java.lang.Class 物件
            return defineClass(name, res);
        } catch (IOException e) {
            throw new ClassNotFoundException(name, e);
        }
    } else {
        return null;
    }
  
    // 以下程式碼省略
}

Part 3. Java 類載入流程詳解

以下就是 ClassLoader 載入一個 class 檔案到 JVM 時需要經過的步驟。

事實上,我們每一次在 IDEA 中點選執行時,IDE 都會預設替我們執行以下的命令:

  • javac Xxxx.java ➡️ 找到原始檔中的 public class,再找 public class 引用的其他類,Java 編譯器會根據每一個類生成一個位元組碼檔案;
  • java Xxxx ➡️ 找到檔案中的唯一主類 public class,並根據 public static 關鍵字找到跟主類關聯可執行的 main 方法 (這也是為什麼 main 方法需要被定義為 public static void 的原因了——我們需要在類沒有載入時訪問),開始執行。

在真正的執行 main 方法之前,JVM 需要 載入、連結 以及 初始化 上述的 Xxxx 類。

第一步:載入(Loading)

這一步是讀取到類檔案產生的二進位制流(findClass()),並轉換為特定的資料結構(defineClass()),初步校驗 cafe babe 魔法數 (二進位制中前四個位元組為 0xCAFEBABE 用來標識該檔案是 Java 檔案,這是很多軟體的做法,比如 zip壓縮檔案、常量池、檔案長度、是否有父類等,然後在 Java 中建立對應類的 java.lang.Class 例項,類中儲存的各部分資訊也需要對應放入 執行時資料區 中(例如靜態變數、類資訊等放入方法區)。

以下是一個 Class 檔案具有的基本結構的簡單圖示:

如果對 Class 檔案更多細節感興趣的可以進一步閱讀:https://juejin.im/post/6844904199617003528

這裡我們可能會有一個疑問,為什麼 JVM 允許還沒有進行驗證、準備和解析的類資訊放入方法區呢?

答案是載入階段和連結階段的部分動作(比如一部分位元組碼檔案格式驗證動作)是 交叉進行 的,也就是說 載入階段還沒完成,連結階段可能已經開始了。但這些夾雜在載入階段的動作(驗證檔案格式等)仍然屬於連結操作。

第二步:連結(Linking)

Link 階段包括驗證、準備、解析三個步驟。下面?我們來詳細說說。

驗證:確保被載入的類的正確性

驗證是連線階段的第一步,這一階段的目的是 為了確保 Class 檔案的位元組流中包含的資訊符合當前虛擬機器的要求,並且不會危害虛擬機器自身的安全。驗證階段大致會完成 4 個階段的檢驗動作:

  • 檔案格式驗證: 驗證位元組流是否符合 Class 檔案格式的規範;例如:是否以 0xCAFEBABE 開頭、主次版本號是否在當前虛擬機器的處理範圍之內、常量池中的常量是否有不被支援的型別。
  • 後設資料驗證: 對位元組碼描述的資訊進行語義分析(注意:對比 javac 編譯階段的語義分析),以保證其描述的資訊符合 Java 語言規範的要求;例如:這個類是否有父類,除了 java.lang.Object 之外。
  • 位元組碼驗證: 通過資料流和控制流分析,確定程式語義是合法的、符合邏輯的。
  • 符號引用驗證: 確保解析動作能正確執行。

驗證階段是非常重要的,但不是必須的,它對程式執行期沒有影響,如果所引用的類經過反覆驗證,那麼可以考慮採用 -Xverifynone 引數來關閉大部分的類驗證措施,以縮短虛擬機器類載入的時間。

準備:為類的靜態變數分配記憶體,並將其初始化為預設值

準備階段是正式為類變數分配記憶體並設定類變數初始值的階段,這些記憶體都將在 方法區 中分配。對於該階段有以下幾點需要注意:

  • 1️⃣ 這時候進行記憶體分配的 僅包括類變數(static),而不包括例項變數,例項變數會在物件例項化時隨著物件一塊分配在 Java 堆中。

  • 2️⃣ 這裡所設定的 初始值通常情況下是資料型別預設的零值(如 00Lnullfalse等),而不是被在 Java 程式碼中被顯式地賦予的值。

  • 3️⃣ 如果類欄位的欄位屬性表中存在 ConstantValue 屬性,即 同時被 finalstatic 修飾,那麼在準備階段變數 value 就會被初始化為 ConstValue 屬性所指定的值。

➡️ 例如,假設這裡有一個類變數 public static int value = 666;,在準備階段時初始值是 0 而不是 666,在 初始化階段 才會被真正賦值為 666

➡️ 假設是一個靜態類變數 public static final int value = 666;,則再準備階段 JVM 就已經賦值為 666 了。

解析:把類中的符號引用轉換為直接引用(重要)

解析階段是虛擬機器將常量池內的 符號引用 替換為 直接引用 的過程,解析動作主要針對類或介面、欄位、類方法、介面方法、方法型別、方法控制程式碼和呼叫點限定符 7 類符號引用進行。

➡️ 符號引用 的作用是在編譯的過程中,JVM 並不知道引用的具體地址,所以用符號引用進行代替,而在解析階段將會將這個符號引用轉換為真正的記憶體地址。

➡️ 直接引用 可以理解為指向 類、變數、方法 的指標,指向 例項 的指標和一個 間接定位 到物件的物件控制程式碼。

為了理解?上面兩種概念的區別,來看一個實際的例子吧:

public class Tester {

    public static void main(String[] args) {
        String str = "關注【我沒有三顆心臟】,關注更多精彩";
        System.out.println(str);
    }
}

我們先在該類同級目錄下執行 javac Tester 編譯成 .class 檔案然後再利用 javap -verbose Tester 檢視類的詳細資訊 (為了節省篇幅只擷取了 main 方法反編譯後的程式碼)

// 上面是類的詳細資訊省略...
{
	// .....
  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=2, args_size=1
         0: ldc           #7                  // String 關注【我沒有三顆心臟】,關注更多精彩
         2: astore_1
         3: getstatic     #9                  // Field java/lang/System.out:Ljava/io/PrintStream;
         6: aload_1
         7: invokevirtual #15                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        10: return
      LineNumberTable:
        line 4: 0
        line 5: 3
        line 6: 10
}
SourceFile: "Tester.java"

可以看到,上面?定義的 str 變數在編譯階段會被解析稱為 符號引用,符號引用的標誌是 astore_<n>,這裡就是 astore_1

store_1的含義是將運算元棧頂的 關注【我沒有三顆心臟】,關注更多精彩 儲存回索引為 1 的區域性變數表中,此時訪問變數 str 就會讀取區域性變數表索引值為 1 中的資料。所以區域性變數 str 就是一個符號引用。

再來看另外一個例子:

public class Tester {

    public static void main(String[] args) {
        System.out.println("關注【我沒有三顆心臟】,關注更多精彩");
    }
}

這一段程式碼反編譯之後得到如下的程式碼:

// 上面是類的詳細資訊省略...
{
  // ......
  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #7                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #13                 // String 關注【我沒有三顆心臟】,關注更多精彩
         5: invokevirtual #15                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 4: 0
        line 5: 8
}
SourceFile: "Tester.java"

我們可以看到這裡直接使用了 ldc 指令將 關注【我沒有三顆心臟】,關注更多精彩 推送到了棧,緊接著就是呼叫指令 invokevirtual,並沒有將字串存入區域性變數表中,這裡的字串就是一個 直接引用

第三步:初始化(Initialization)

初始化,為類的靜態變數賦予正確的初始值,JVM 負責對類進行初始化,主要對類變數進行初始化。在 Java 中對類變數進行初始值設定有兩種方式:

  • 1️⃣ 宣告類變數是指定初始值;
  • 2️⃣ 使用靜態程式碼塊為類變數指定初始值;

JVM 初始化步驟:

  • 1️⃣ 假如這個類還沒有被載入和連線,則程式先載入並連線該類
  • 2️⃣ 假如該類的直接父類還沒有被初始化,則先初始化其直接父類
  • 3️⃣ 假如類中有初始化語句,則系統依次執行這些初始化語句

類初始化時機:只有當對類的主動使用的時候才會導致類的初始化,類的主動使用包括以下幾種:

  • 建立類的例項,也就是 new 的方式
  • 訪問某個類或介面的靜態變數,或者對該靜態變數賦值
  • 呼叫類的靜態方法
  • 反射(如 Class.forName("com.wmyskxz.Tester")
  • 初始化某個類的子類,則其父類也會被初始化
  • Java 虛擬機器啟動時被標明為啟動類的類,直接使用 java.exe 命令來執行某個主類
  • 使用 JDK 7 新加入的動態語言支援時,如果一個 java.lang.invoke.MethodHanlde 例項最後的解析結果為 REF_getstaticREF_putstaticREF_invokeStaticREF_newInvokeSpecial 四種型別的方法控制程式碼時,都需要先初始化該控制程式碼對應的類
  • 介面中定義了 JDK 8 新加入的預設方法(default修飾符),實現類在初始化之前需要先初始化其介面

Part 4. 深入理解雙親委派模型

我們在上面?已經瞭解了一個類是如何被載入進 JVM 的——依靠類載入器——在 Java 語言中自帶有三個類載入器:

  • Bootstrap ClassLoader 最頂層的載入類,主要載入 核心類庫%JRE_HOME%\lib 下的rt.jarresources.jarcharsets.jarclass 等。
  • Extention ClassLoader 擴充套件的類載入器,載入目錄 %JRE_HOME%\lib\ext 目錄下的 jar 包和 class 檔案。
  • Appclass Loader 也稱為 SystemAppClass 載入當前應用的 classpath 的所有類。

我們可以通過一個簡單的例子來簡單瞭解 Java 中這些自帶的類載入器:

public class PrintClassLoader {

    public static void main(String[] args) {
        printClassLoaders();
    }

    public static void printClassLoaders() {
        System.out.println("Classloader of this class:"
            + PrintClassLoader.class.getClassLoader());
        System.out.println("Classloader of Logging:"
            + com.sun.javafx.util.Logging.class.getClassLoader());
        System.out.println("Classloader of ArrayList:"
            + java.util.ArrayList.class.getClassLoader());
    }
}

上方程式列印輸出如下:

Classloader of this class:sun.misc.Launcher$AppClassLoader@18b4aac2
Classloader of Logging:sun.misc.Launcher$ExtClassLoader@60e53b93
Classloader of ArrayList:null

如我們所見,這裡分別對應三種不同型別的類載入器:AppClassLoader、ExtClassLoader 和 BootstrapClassLoader(顯示為 null)。

一個很好的問題是:Java 類是由 java.lang.ClassLoader 例項載入的,但類載入器本身也是類,那麼誰來載入類載入器呢?

我們假裝不知道,先來跟著原始碼一步一步來看。

先來看看 Java 虛擬機器入口程式碼

在 JDK 原始碼 sun.misc.Launcher 中,蘊含了 Java 虛擬機器的入口方法:

public class Launcher {
    private static Launcher launcher = new Launcher();
    private static String bootClassPath =
        System.getProperty("sun.boot.class.path");

    public static Launcher getLauncher() {
        return launcher;
    }

    private ClassLoader loader;

    public Launcher() {
        // Create the extension class loader
        ClassLoader extcl;
        try {
            extcl = ExtClassLoader.getExtClassLoader();
        } catch (IOException e) {
            throw new InternalError(
                "Could not create extension class loader", e);
        }

        // Now create the class loader to use to launch the application
        try {
            loader = AppClassLoader.getAppClassLoader(extcl);
        } catch (IOException e) {
            throw new InternalError(
                "Could not create application class loader", e);
        }

        // 設定 AppClassLoader 為執行緒上下文類載入器,這個文章後面部分講解
        Thread.currentThread().setContextClassLoader(loader);
    }
    /*
     * Returns the class loader used to launch the main application.
     */
    public ClassLoader getClassLoader() {
        return loader;
    }
    /*
     * The class loader used for loading installed extensions.
     */
    static class ExtClassLoader extends URLClassLoader {}
		/**
     * The class loader used for loading from java.class.path.
     * runs in a restricted security context.
     */
    static class AppClassLoader extends URLClassLoader {}
}

原始碼有精簡,但是我們可以得到以下資訊:

1️⃣ Launcher 初始化了 ExtClassLoader 和 AppClassLoader。

2️⃣ Launcher 沒有看到 Bootstrap ClassLoader 的影子,但是有一個叫做 bootClassPath 的變數,大膽一猜就是 Bootstrap ClassLoader 載入的 jar 包的路徑。

(ps: 可以自己嘗試輸出一下 System.getProperty("sun.boot.class.path") 的內容,它正好對應了 JDK 目錄 libclasses 目錄下的 jar 包——也就是通常你配置環境變數時設定的 %JAVA_HOME/lib 的目錄了——同樣的方式你也可以看看 Ext 和 App 的原始碼)

3️⃣ ExtClassLoader 和 AppClassLoader 都繼承自 URLClassLoader,進一步檢視 ClassLoader 的繼承樹,傳說中的雙親委派模型也並沒有出現。(甚至看不到 Bootstrap ClassLoader 的影子,Ext 也沒有直接繼承自 App 類載入器)

ClassLoader 繼承樹

(⚠️注意,這裡可以明確看到每一個 ClassLoader 都有一個 parent 變數,用於標識自己的父類,下面?詳細說)

4️⃣ 注意以下程式碼:

ClassLoader extcl;
        
extcl = ExtClassLoader.getExtClassLoader();

loader = AppClassLoader.getAppClassLoader(extcl);

分別跟蹤檢視到這兩個 ClassLoader 初始化時的程式碼:

// 一直追蹤到最頂層的 ClassLoader 定義,構造器的第二個引數標識了類載入器的父類
private ClassLoader(Void unused, ClassLoader parent) {
  this.parent = parent;
  // 程式碼省略.....
}
// Ext 設定自己的父類為 null
public ExtClassLoader(File[] var1) throws IOException {
  super(getExtURLs(var1), (ClassLoader)null, Launcher.factory);
  SharedSecrets.getJavaNetAccess().getURLClassPath(this).initLookupCache(this);
}
// 手動把 Ext 設定為 App 的 parent(這裡的 var2 是傳進來的 extc1)
AppClassLoader(URL[] var1, ClassLoader var2) {
  super(var1, var2, Launcher.factory);
  this.ucp.initLookupCache(this);
}

由此,我們得到了這樣一個類載入器的關係圖:

類載入器的父類都來自哪裡?

奇怪,為什麼 ExtClassLoader 的 parent 明明是 null,我們卻一般地認為 Bootstrap ClassLoader 才是 ExtClassLoader 的父載入器呢?

答案的一部分就藏在 java.lang.ClassLoader.loadClass() 方法裡面:(這也就是著名的「雙親委派模型」現場了)

protected Class<?> loadClass(String name, boolean resolve)
  throws ClassNotFoundException
{
  synchronized (getClassLoadingLock(name)) {
    // 首先檢查是否已經載入過了
    Class<?> c = findLoadedClass(name);
    if (c == null) {
      long t0 = System.nanoTime();
      try {
        if (parent != null) {
					// 父載入器不為空則呼叫父載入器的 loadClass 方法
          c = parent.loadClass(name, false);
        } else {
          // 父載入器為空則呼叫 Bootstrap ClassLoader
          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();
        // 父載入器沒有找到,則呼叫 findclass
        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()
      resolveClass(c);
    }
    return c;
  }
}

程式碼邏輯很好地解釋了雙親委派的原理。

1️⃣ 當前 ClassLoader 首先從 自己已經載入的類中查詢是否此類已經載入,如果已經載入則直接返回原來已經載入的類。(每個類載入器都有自己的載入快取,當一個類被載入了以後就會放入快取,等下次載入的時候就可以直接返回了。)

2️⃣ 當前 ClassLoader 的快取中沒有找到被載入的類的時候,委託父類載入器去載入,父類載入器採用同樣的策略,首先檢視自己的快取,然後委託父類的父類去載入,一直到 Bootstrap ClassLoader。(當所有的父類載入器都沒有載入的時候,再由當前的類載入器載入,並將其放入它自己的快取中,以便下次有載入請求的時候直接返回。)

所以,答案的另一部分是因為最高一層的類載入器 Bootstrap 是通過 C/C++ 實現的,並不存在於 JVM 體系內 (不是一個 Java 類,沒辦法直接表示為 ExtClassLoader 的父載入器),所以輸出為 null

(我們可以很輕易跟蹤到 findBootstrapClass() 方法被 native 修飾:private native Class<?> findBootstrapClass(String name);

➡️ OK,我們理解了為什麼 ExtClassLoader 的父載入器為什麼是表示為 null 的 Bootstrap 載入器,那我們 自己實現的 ClassLoader 父載入器應該是誰呢?

觀察一下 ClassLoader 的原始碼就知道了:

protected ClassLoader(ClassLoader parent) {
    this(checkCreateClassLoader(), parent);
}
protected ClassLoader() {
    this(checkCreateClassLoader(), getSystemClassLoader());
}

類載入器的 parent 的賦值是在 ClassLoader 物件的構造方法中,它有兩個情況:

1️⃣ 由外部類建立 ClassLoader 時直接指定一個 ClassLoader 為 parent

2️⃣ 由 getSystemClassLoader() 方法生成,也就是在 sun.misc.Laucher 通過 getClassLoader() 獲取,也就是 AppClassLoader。直白的說,一個 ClassLoader 建立時如果沒有指定 parent,那麼它的 parent 預設就是 AppClassLoader。(建議去看一下原始碼)

為什麼這樣設計呢?

簡單來說,主要是為了 安全性,避免使用者自己編寫的類動態替換 Java 的一些核心類,比如 String,同時也 避免了重複載入,因為 JVM 中區分不同類,不僅僅是根據類名,相同的 class 檔案被不同的 ClassLoader 載入就是不同的兩個類,如果相互轉型的話會拋 java.lang.ClassCaseException

如果我們要實現自己的類載入器,不管你是直接實現抽象類 ClassLoader,還是繼承 URLClassLoader 類,或者其他子類,它的父載入器都是 AppClassLoader。

因為不管呼叫哪個父類構造器,建立的物件都必須最終呼叫 getSystemClassLoader() 作為父載入器 (我們已經從上面?的原始碼中看到了)。而該方法最終獲取到的正是 AppClassLoader (別稱 SystemClassLoader)

這也就是我們熟知的最終的雙親委派模型了。

Part 5. 實現自己的類載入器

什麼情況下需要自定義類載入器

在學習了類載入器的實現機制之後,我們知道了雙親委派模型並非強制模型,使用者可以自定義類載入器,在什麼情況下需要自定義類載入器呢?

1️⃣ 隔離載入類。在某些框架內進行中介軟體與應用的模組隔離,把類載入器到不同的環境。比如,阿里內某容器框架通過自定義類載入器確保應用中依賴的 jar 包不會影響到中介軟體執行時使用的 jar 包。

2️⃣ 修改類載入方式。類的載入模型並非強制,除了 Bootstrap 外,其他的載入並非一定要引入,或者根據實際情況在某個時間點進行按需的動態載入。

3️⃣ 擴充套件載入源。比如從資料庫、網路,甚至是電視機頂盒進行載入。(下面?我們會編寫一個從網路載入類的例子)

4️⃣ 防止原始碼洩露。Java 程式碼容易被編譯和篡改,可以進行編譯加密。那麼類載入器也需要自定義,還原加密的位元組碼。

一個常規的例子

實現一個自定義的類載入器比較簡單:繼承 ClassLoader,重寫 findClass() 方法,呼叫 defineClass() 方法,就差不多行了。

Tester.java

我們先來編寫一個測試用的類檔案:

public class Tester {

    public void say() {
        System.out.println("關注【我沒有三顆心臟】,解鎖更多精彩!");
    }
}

在同級目錄下執行 javac Tester.java 命令,並把編譯後的 Tester.class 放到指定的目錄下(我這邊為了方便就放在桌面上啦 /Users/wmyskxz/Desktop

MyClassLoader.java

我們編寫自定義 ClassLoader 程式碼:

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;

public class MyClassLoader extends ClassLoader {

    private final String mLibPath;

    public MyClassLoader(String path) {
        // TODO Auto-generated constructor stub
        mLibPath = path;
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        // TODO Auto-generated method stub

        String fileName = getFileName(name);

        File file = new File(mLibPath, fileName);

        try {
            FileInputStream is = new FileInputStream(file);

            ByteArrayOutputStream bos = new ByteArrayOutputStream();
            int len = 0;
            try {
                while ((len = is.read()) != -1) {
                    bos.write(len);
                }
            } catch (IOException e) {
                e.printStackTrace();
            }

            byte[] data = bos.toByteArray();
            is.close();
            bos.close();

            return defineClass(name, data, 0, data.length);

        } catch (IOException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }

        return super.findClass(name);
    }

    // 獲取要載入的 class 檔名
    private String getFileName(String name) {
        // TODO Auto-generated method stub
        int index = name.lastIndexOf('.');
        if (index == -1) {
            return name + ".class";
        } else {
            return name.substring(index + 1) + ".class";
        }
    }
}

我們在 findClass() 方法中定義了查詢 class 的方法,然後資料通過 defineClass() 生成了 Class 物件。

ClassLoaderTester 測試類

我們需要刪除剛才在專案目錄建立的 Tester.java 和編譯後的 Tester.class 檔案來觀察效果:

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;

public class ClassLoaderTester {

    public static void main(String[] args) {
        // 建立自定義的 ClassLoader 物件
        MyClassLoader myClassLoader = new MyClassLoader("/Users/wmyskxz/Desktop");
        try {
            // 載入class檔案
            Class<?> c = myClassLoader.loadClass("Tester");

            if(c != null){
                try {
                    Object obj = c.newInstance();
                    Method method = c.getDeclaredMethod("say",null);
                    //通過反射呼叫Test類的say方法
                    method.invoke(obj, null);
                } catch (InstantiationException | IllegalAccessException
                    | NoSuchMethodException
                    | SecurityException |
                    IllegalArgumentException |
                    InvocationTargetException e) {
                    // TODO Auto-generated catch block
                    e.printStackTrace();
                }
            }
        } catch (ClassNotFoundException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }
    }
}

執行測試,正常輸出:

關注【我沒有三顆心臟】,解鎖更多精彩!

加密解密類載入器

突破了 JDK 系統內建載入路徑的限制之後,我們就可以編寫自定義的 ClassLoader。你完全可以按照自己的意願進行業務的定製,將 ClassLoader 玩出花樣來。

例如,一個加密解密的類載入器。(不涉及完整程式碼,我們可以來說一下思路和關鍵程式碼)

首先,在編譯之後的位元組碼檔案中動一動手腳,例如,給檔案每一個 byte 異或一個數字 2:(這就算是模擬加密過程)

File file = new File(path);
try {
  FileInputStream fis = new FileInputStream(file);
  FileOutputStream fos = new FileOutputStream(path+"en");
  int b = 0;
  int b1 = 0;
  try {
    while((b = fis.read()) != -1){
      // 每一個 byte 異或一個數字 2
      fos.write(b ^ 2);
    }
    fos.close();
    fis.close();
  } catch (IOException e) {
    // TODO Auto-generated catch block
    e.printStackTrace();
  }
} catch (FileNotFoundException e) {
  // TODO Auto-generated catch block
  e.printStackTrace();
}

然後我們再在 findClass() 中自己解密:

File file = new File(mLibPath,fileName);

try {
  FileInputStream is = new FileInputStream(file);

  ByteArrayOutputStream bos = new ByteArrayOutputStream();
  int len = 0;
  byte b = 0;
  try {
    while ((len = is.read()) != -1) {
      // 將資料異或一個數字 2 進行解密
      b = (byte) (len ^ 2);
      bos.write(b);
    }
  } catch (IOException e) {
    e.printStackTrace();
  }

  byte[] data = bos.toByteArray();
  is.close();
  bos.close();

  return defineClass(name,data,0,data.length);

} catch (IOException e) {
  // TODO Auto-generated catch block
  e.printStackTrace();
}

(程式碼幾乎與上面?一個例子等同,所以只說一下思路和完整程式碼)

網路類載入器

其實非常類似,也不做過多講解,直接上程式碼:

import java.io.ByteArrayOutputStream;  
import java.io.InputStream;  
import java.net.URL;  
  
public class NetworkClassLoader extends ClassLoader {  
  
    private String rootUrl;  
  
    public NetworkClassLoader(String rootUrl) {  
        // 指定URL  
        this.rootUrl = rootUrl;  
    }  
  
    // 獲取類的位元組碼  
    @Override  
    protected Class<?> findClass(String name) throws ClassNotFoundException {  
        byte[] classData = getClassData(name);  
        if (classData == null) {  
            throw new ClassNotFoundException();  
        } else {  
            return defineClass(name, classData, 0, classData.length);  
        }  
    }  
  
    private byte[] getClassData(String className) {  
        // 從網路上讀取的類的位元組  
        String path = classNameToPath(className);  
        try {  
            URL url = new URL(path);  
            InputStream ins = url.openStream();  
            ByteArrayOutputStream baos = new ByteArrayOutputStream();  
            int bufferSize = 4096;  
            byte[] buffer = new byte[bufferSize];  
            int bytesNumRead = 0;  
            // 讀取類檔案的位元組  
            while ((bytesNumRead = ins.read(buffer)) != -1) {  
                baos.write(buffer, 0, bytesNumRead);  
            }  
            return baos.toByteArray();  
        } catch (Exception e) {  
            e.printStackTrace();  
        }  
        return null;  
    }  
  
    private String classNameToPath(String className) {  
        // 得到類檔案的URL  
        return rootUrl + "/"  
                + className.replace('.', '/') + ".class";  
    }  
}  

(程式碼來自:https://blog.csdn.net/justloveyou_/article/details/72217806)

Part 6. 必要的擴充套件閱讀

學習到這裡,我們對 ClassLoader 已經不再陌生了,但是仍然有一些必要的知識點需要去掌握 (限於篇幅和能力這裡不擴充套件了),希望您能認真閱讀以下的材料:(可能排版上面層次不齊,但內容都是有質量的,並用 ♨️ 標註了更加重點一些的內容)

1️⃣ ♨️能不能自己寫一個類叫 java.lang.System 或者 java.lang.String - https://blog.csdn.net/tang9140/article/details/42738433

2️⃣ 深入理解 Java 之 JVM 啟動流程 - https://cloud.tencent.com/developer/article/1038435

3️⃣ ♨️真正理解執行緒上下文類載入器(多案例分析) - https://blog.csdn.net/yangcheng33/article/details/52631940

4️⃣ ♨️曹工雜談:Java 類載入器還會死鎖?這是什麼情況? - https://www.cnblogs.com/grey-wolf/p/11378747.html#_label2

5️⃣ 謹防JDK8重複類定義造成的記憶體洩漏 - https://segmentfault.com/a/1190000022837543

7️⃣ ♨️Tomcat 類載入器的實現 - https://juejin.im/post/6844903945496690695

8️⃣ ♨️Spring 中的類載入機制 - https://www.shuzhiduo.com/A/gVdnwgAlzW/

參考資料

  1. 《深入分析 Java Web 技術內幕》 | 許令波 著
  2. Java 類載入機制分析 - https://www.jianshu.com/p/3615403c7c84
  3. Class 檔案解析實戰 - https://juejin.im/post/6844904199617003528
  4. 圖文兼備看懂類載入機制的各個階段,就差你了! - https://juejin.im/post/6844904119258316814
  5. Java面試知識點解析(三)——JVM篇 - https://www.wmyskxz.com/2018/05/16/java-mian-shi-zhi-shi-dian-jie-xi-san-jvm-pian/
  6. 一看你就懂,超詳細Java中的ClassLoader詳解 - https://blog.csdn.net/briblue/article/details/54973413
  • 本文已收錄至我的 Github 程式設計師成長系列 【More Than Java】,學習,不止 Code,歡迎 star:https://github.com/wmyskxz/MoreThanJava
  • 個人公眾號 :wmyskxz,個人獨立域名部落格:wmyskxz.com,堅持原創輸出,下方掃碼關注,2020,與您共同成長!

非常感謝各位人才能 看到這裡,如果覺得本篇文章寫得不錯,覺得 「我沒有三顆心臟」有點東西 的話,求點贊,求關注,求分享,求留言!

創作不易,各位的支援和認可,就是我創作的最大動力,我們下篇文章見!

相關文章