探祕類載入器和類載入機制

長卿changqing發表於2019-02-20

在物件導向程式設計實踐中,我們通過眾多的類來組織一個複雜的系統,這些類之間相互關聯、呼叫使他們的關係形成了一個複雜緊密的網路。當系統啟動時,出於效能、資源利用多方面的考慮,我們不可能要求 JVM 一次性將全部的類都載入完成,而是隻載入能夠支援系統順利啟動和執行的類和資源即可。那麼在系統執行過程中如果需要使用未在啟動時載入的類或資源時該怎麼辦呢?這就要靠類載入器來完成了。

什麼是類載入器

類載入器(ClassLoader)就是在系統執行過程中動態的將位元組碼檔案載入到 JVM 中的工具,基於這個工具的整套類載入流程,我們稱作類載入機制。我們在 IDE 中編寫的都是原始碼檔案,以字尾名 .java 的檔案形式存在於磁碟上,通過編譯後生成字尾名 .class 的位元組碼檔案,ClassLoader 載入的就是這些位元組碼檔案。

有哪些類載入器

Java 預設提供了三個 ClassLoader,分別是 AppClassLoader、ExtClassLoader、BootStrapClassLoader,依次後者分別是前者的「父載入器」。父載入器不是「父類」,三者之間沒有繼承關係,只是因為類載入的流程使三者之間形成了父子關係,下文會詳細講述。

BootStrapClassLoader

BootStrapClassLoader 也叫「根載入器」,它是脫離 Java 語言,使用 C/C++ 編寫的類載入器,所以當你嘗試使用 ExtClassLoader 的例項呼叫 getParent() 方法獲取其父載入器時會得到一個 null 值。

// 返回一個 AppClassLoader 的例項
ClassLoader appClassLoader = this.getClass().getClassLoader();
// 返回一個 ExtClassLoader 的例項
ClassLoader extClassLoader = appClassLoader.getParent();
// 返回 null,因為 BootStrapClassLoader 是 C/C++ 編寫的,無法在 Java 中獲得其例項
ClassLoader bootstrapClassLoader = extClassLoader.getParent();
複製程式碼

根載入器會預設載入系統變數 sun.boot.class.path 指定的類庫(jar 檔案和 .class 檔案),預設是 $JRE_HOME/lib 下的類庫,如 rt.jar、resources.jar 等,具體可以輸出該環境變數的值來檢視。

String bootClassPath = System.getProperty("sun.boot.class.path");
String[] paths = bootClassPath.split(":");
for (String path : paths) {
    System.out.println(path);
}

// output
// /Library/Java/JavaVirtualMachines/jdk1.8.0_151.jdk/Contents/Home/jre/lib/resources.jar
// /Library/Java/JavaVirtualMachines/jdk1.8.0_151.jdk/Contents/Home/jre/lib/rt.jar
// /Library/Java/JavaVirtualMachines/jdk1.8.0_151.jdk/Contents/Home/jre/lib/sunrsasign.jar
// /Library/Java/JavaVirtualMachines/jdk1.8.0_151.jdk/Contents/Home/jre/lib/jsse.jar
// /Library/Java/JavaVirtualMachines/jdk1.8.0_151.jdk/Contents/Home/jre/lib/jce.jar
// /Library/Java/JavaVirtualMachines/jdk1.8.0_151.jdk/Contents/Home/jre/lib/charsets.jar
// /Library/Java/JavaVirtualMachines/jdk1.8.0_151.jdk/Contents/Home/jre/lib/jfr.jar
// /Library/Java/JavaVirtualMachines/jdk1.8.0_151.jdk/Contents/Home/jre/classes
複製程式碼

除了載入這些預設的類庫外,也可以使用 JVM 引數 -Xbootclasspath/a 來追加額外需要讓根載入器載入的類庫。比如我們自定義一個 com.ganpengyu.boot.DateUtils 類來讓根載入器載入。

package com.ganpengyu.boot;

import java.text.SimpleDateFormat;
import java.util.Date;

public class DateUtils {
    public static void printNow() {
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        System.out.println(sdf.format(new Date()));
    }
}
複製程式碼

我們將其製作成一個名為 gpy-boot 的 jar 包放到 /Users/yu/Desktop/lib 下,然後寫一個測試類去嘗試載入 DateUtils。

public class Test {
    public static void main(String[] args) throws Exception {
        Class<?> clz = Class.forName("com.ganpengyu.boot.DateUtils");
        ClassLoader loader = clz.getClassLoader();
        System.out.println(loader == null);
    }
}
複製程式碼

執行這個測試類:

java -Xbootclasspath/a:/Users/yu/Desktop/lib/gpy-boot.jar -cp /Users/yu/Desktop/lib/gpy-boot.jar:. Test
複製程式碼

可以看到輸出為 true,也就是說載入 com.ganpengyu.boot.DateUtils 的類載入器在 Java 中無法獲得其引用,而任何類都必須通過類載入器載入才能被使用,所以推斷出這個類是被 BootStrapClassLoader 載入的,也證明了 -Xbootclasspath/a 引數確實可以追加需要被根載入器額外載入的類庫。

總之,對於 BootStrapClassLoader 這個根載入器我們需要知道三點:

  1. 根載入器使用 C/C++ 編寫,我們無法在 Java 中獲得其例項
  2. 根載入器預設載入系統變數 sun.boot.class.path 指定的類庫
  3. 可以使用 -Xbootclasspath/a 引數追加根載入器的預設載入類庫

ExtClassLoader

ExtClassLoader 也叫「擴充套件類載入器」,它是一個使用 Java 實現的類載入器(sun.misc.Launcher.ExtClassLoader),用於載入系統所需要的擴充套件類庫。預設載入系統變數 java.ext.dirs 指定位置下的類庫,通常是 $JRE_HOME/lib/ext 目錄下的類庫。

public static void main(String[] args) {
    String extClassPath = System.getProperty("java.ext.dirs");
    String[] paths = extClassPath.split(":");
    for (String path : paths) {
        System.out.println(path);
    }
}

// output
// /Users/leon/Library/Java/Extensions
// /Library/Java/JavaVirtualMachines/jdk1.8.0_151.jdk/Contents/Home/jre/lib/ext
// /Library/Java/Extensions
// /Network/Library/Java/Extensions
// /System/Library/Java/Extensions
// /usr/lib/java
複製程式碼

我們可以在啟動時修改java.ext.dirs 變數的值來修改擴充套件類載入器的預設類庫載入目錄,但通常並不建議這樣做。如果我們真的有需要擴充套件類載入器在啟動時載入的類庫,可以將其放置在預設的載入目錄下。總之,對於 ExtClassLoader 這個擴充套件類載入器我們需要知道兩點:

  1. 擴充套件類載入器是使用 Java 實現的類載入器,我們可以在程式中獲得它的例項並使用。
  2. 通常不建議修改java.ext.dirs 引數的值來修改預設載入目錄,如有需要,可以將要載入的類庫放到這個預設目錄下。

AppClassLoader

AppClassLoader 也叫「應用類載入器」,它和 ExtClassLoader 一樣,也是使用 Java 實現的類載入器(sun.misc.Launcher.AppClassLoader)。它的作用是載入應用程式 classpath 下所有的類庫。這是我們最常打交道的類載入器,我們在程式中呼叫的很多 getClassLoader() 方法返回的都是它的例項。在我們自定義類載入器時如果沒有特別指定,那麼我們自定義的類載入器的預設父載入器也是這個應用類載入器。總之,對於 AppClassLoader 這個應用類載入器我們需要知道兩點:

  1. 應用類載入器是使用 Java 實現的類載入器,負責載入應用程式 classpath 下的類庫。
  2. 應用類載入器是和我們最常打交道的類載入器。
  3. 沒有特別指定的情況下,自定義類載入器的父載入器就是應用類載入器。

自定義類載入器

除了上述三種 Java 預設提供的類載入器外,我們還可以通過繼承 java.lang.ClassLoader 來自定義一個類載入器。如果在建立自定義類載入器時沒有指定父載入器,那麼預設使用 AppClassLoader 作為父載入器。關於自定義類載入器的建立和使用,我們會在後面的章節詳細講解。

類載入器的啟動順序

上文已經提到過 BootStrapClassLoader 是一個使用 C/C++ 編寫的類載入器,它已經嵌入到了 JVM 的核心之中。當 JVM 啟動時,BootStrapClassLoader 也會隨之啟動並載入核心類庫。當核心類庫載入完成後,BootStrapClassLoader 會建立 ExtClassLoader 和 AppClassLoader 的例項,兩個 Java 實現的類載入器將會載入自己負責路徑下的類庫,這個過程我們可以在 sun.misc.Launcher 中窺見。

ExtClassLoader 的建立過程

我們將 Launcher 類的構造方法原始碼精簡展示如下:

public Launcher() {
    // 建立 ExtClassLoader
    Launcher.ExtClassLoader var1;
    try {
        var1 = Launcher.ExtClassLoader.getExtClassLoader();
    } catch (IOException var10) {
        throw new InternalError("Could not create extension class loader", var10);
    }
	// 建立 AppClassLoader
    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);
    // 建立 SecurityManager

}
複製程式碼

可以看到當 Launcher 被初始化時就會依次建立 ExtClassLoader 和 AppClassLoader。我們進入 getExtClassLoader() 方法並跟蹤建立流程,發現這裡又呼叫了 ExtClassLoader 的構造方法,在這個構造方法裡呼叫了父類的構造方法,這便是 ExtClassLoader 建立的關鍵步驟,注意這裡傳入父類構造器的第二個引數為 null。接著我們去檢視這個父類構造方法,它位於 java.net.URLClassLoader 類中:

URLClassLoader(URL[] urls, ClassLoader parent,
                          URLStreamHandlerFactory factory)
複製程式碼

通過這個構造方法的簽名和註釋我們可以明確的知道,第二個引數 parent 表示的是當前要建立的類載入器的父載入器。結合前面我們提到的 ExtClassLoader 的父載入器是 JVM 核心中 C/C++ 開發的 BootStrapClassLoader,且無法在 Java 中獲得這個類載入器的引用,同時每個類載入器又必然有一個父載入器,我們可以反證出,ExtClassLoader 的父載入器就是 BootStrapClassLoader。

AppClassLoader 的建立過程

理清了 ExtClassLoader 的建立過程,我們來看 AppClassLoader 的建立過程就清晰很多了。跟蹤 getAppClassLoader() 方法的呼叫過程,可以看到這個方法本身將 ExtClassLoader 的例項作為引數傳入,最後還是呼叫了 java.net.URLClassLoader 的構造方法,將 ExtClassLoader 的例項作為父構造器 parent 引數值傳入。所以這裡我們又可以確定,AppClassLoader 的父構造器就是 ExtClassLoader。

怎麼載入一個類

將一個 .class 位元組碼檔案載入到 JVM 中成為一個 java.lang.Class 例項需要載入這個類的類載入器及其所有的父級載入器共同參與完成,這主要是遵循「雙親委派原則」。

雙親委派

當我們要載入一個應用程式 classpath 下的自定義類時,AppClassLoader 會首先檢視自己是否已經載入過這個類,如果已經載入過則直接返回類的例項,否則將載入任務委託給自己的父載入器 ExtClassLoader。同樣,ExtClassLoader 也會先檢視自己是否已經載入過這個類,如果已經載入過則直接返回類的例項,否則將載入任務委託給自己的父載入器 BootStrapClassLoader。

BootStrapClassLoader 收到類載入任務時,會首先檢查自己是否已經載入過這個類,如果已經載入則直接返回類的例項,否則在自己負責的載入路徑下搜尋這個類並嘗試載入。如果找到了這個類,則執行載入任務並返回類例項,否則將載入任務交給 ExtClassLoader 去執行。

ExtClassLoader 同樣也在自己負責的載入路徑下搜尋這個類並嘗試載入。如果找到了這個類,則執行載入任務並返回類例項,否則將載入任務交給 AppClassLoader 去執行。

由於自己的父載入器 ExtClassLoader 和 BootStrapClassLoader 都沒能成功載入到這個類,所以最後由 AppClassLoader 來嘗試載入。同樣,AppClassLoader 會在 classpath 下所有的類庫中查詢這個類並嘗試載入。如果最後還是沒有找到這個類,則丟擲 ClassNotFoundException 異常。

綜上,當類載入器要載入一個類時,如果自己曾經沒有載入過這個類,則層層向上委託給父載入器嘗試載入。對於 AppClassLoader 而言,它上面有 ExtClassLoader 和 BootStrapClassLoader,所以我們稱作「雙親委派」。但是如果我們是使用自定義類載入器來載入類,且這個自定義類載入器的預設父載入器是 AppClassLoader 時,它上面就有三個父載入器,這時再說「雙親」就不太合適了。當然,理解了載入一個類的整個流程,這些名字就無關痛癢了。

為什麼需要雙親委派機制

「雙親委派機制」最大的好處是避免自定義類和核心類庫衝突。比如我們大量使用的 java.lang.String 類,如果我們自己寫的一個 String 類被載入成功,那對於應用系統來說完全是毀滅性的破壞。我們可以嘗試著寫一個自定義的 String 類,將其包也設定為 java.lang

package java.lang;

public class String {

    private int n;

    public String(int n) {
        this.n = n;
    }

    public String toLowerCase() {
        return new String(this.n + 100);
    }

}
複製程式碼

我們將其製作成一個 jar 包,命名為 thief-jdk,然後寫一個測試類嘗試載入 java.lang.String 並使用接收一個 int 型別引數的構造方法建立例項。

import java.lang.reflect.Constructor;

public class Test {

    public static void main(String[] args) throws Exception {
        Class<?> clz = Class.forName("java.lang.String");
        System.out.println(clz.getClassLoader() == null);
        Constructor<?> c = clz.getConstructor(int.class);
        String str = (String) c.newInstance(5);
        str.toLowerCase();
    }
}
複製程式碼

執行測試程式

java -cp /Users/yu/Desktop/lib/thief/thief-jdk.jar:. Test
複製程式碼

程式丟擲 NoSuchMethodException 異常,因為 JVM 不能夠載入我們自定義的 java.lang.String,而是從 BootStrapClassLoader 的快取中返回了核心類庫中的 java.lang.String 的例項,且核心類庫中的 String 沒有接收 int 型別引數的構造方法。同時我們也看到 Class 例項的類載入器是 null,這也說明了我們拿到的 java.lang.String 的例項確實是由 BootStrapClassLoader 載入的。

總之,「雙親委派」機制的作用就是確保類的唯一性,最直接的例子就是避免我們自定義類和核心類庫衝突。

JVM 怎麼判斷兩個類是相同的

「雙親委派」機制用來保證類的唯一性,那麼 JVM 通過什麼條件來判斷唯一性呢?其實很簡單,只要兩個類的全路徑名稱一致,且都是同一個類載入器載入,那麼就判斷這兩個類是相同的。如果同一份位元組碼被不同的兩個類載入器載入,那麼它們就不會被 JVM 判斷為同一個類。

Person 類

public class Person {
    private Person p;
    public void setPerson(Object obj) {
        this.p = (Person) obj;
    }
}
複製程式碼

setPerson(Object obj) 方法接收一個物件,並將其強制轉換為 Person 型別賦值給變數 p。

測試類

import java.lang.reflect.Method;
public class Test {
    public static void main(String[] args) {
        CustomClassLoader classLoader1 = new CustomClassLoader("/Users/yu/Desktop/lib");
        CustomClassLoader classLoader2 = new CustomClassLoader("/Users/yu/Desktop/lib");
        try {
            Class c1 = classLoader1.findClass("Person");
            Object instance1 = c1.newInstance();

            Class c2 = classLoader2.findClass("Person");
            Object instance2 = c2.newInstance();

            Method method = c1.getDeclaredMethod("setPerson", Object.class);
            method.invoke(instance1, instance2);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

複製程式碼

CustomClassLoader 是一個自定義的類載入器,它將位元組碼檔案載入為字元陣列,然後呼叫 ClassLoader 的 defineClass() 方法建立類的例項,後文會詳細講解怎麼自定義類載入器。在測試類中,我們建立了兩個類載入器的例項,讓他們分別去載入同一份位元組碼檔案,即 Person 類的位元組碼。然後在例項一上呼叫 setPerson() 方法將例項二傳入,將例項二強制轉型為例項一。

執行程式會看到 JVM 丟擲了 ClassCastException 異常,異常資訊為 Person cannot be cast to Person。從這我們就可以知道,同一份位元組碼檔案,如果使用的類載入器不同,那麼 JVM 就會判斷他們是不同的型別。

全盤負責

「全盤負責」是類載入的另一個原則。它的意思是如果類 A 是被類載入器 X 載入的,那麼在沒有顯示指定別的類載入器的情況下,類 A 引用的其他所有類都由類載入器 X 負責載入,載入過程遵循「雙親委派」原則。我們編寫兩個類來驗證「全盤負責」原則。

Worker 類

package com.ganpengyu.full;

import com.ganpengyu.boot.DateUtils;

public class Worker {

    public Worker() {
    }
    public void say() {
        DateUtils dateUtils = new DateUtils();
        System.out.println(dateUtils.getClass().getClassLoader() == null);
        dateUtils.printNow();
    }
}

複製程式碼

DateUtils 類

package com.ganpengyu.boot;

import java.text.SimpleDateFormat;
import java.util.Date;

public class DateUtils {

    public void printNow() {
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        System.out.println(sdf.format(new Date()));
    }
}
複製程式碼

測試類

import com.ganpengyu.full.Worker;
import java.lang.reflect.Constructor;
public class Test {
    public static void main(String[] args) throws Exception {
        Class<?> clz = Class.forName("com.ganpengyu.full.Worker");
        System.out.println(clz.getClassLoader() == null);
        Worker worker = (Worker) clz.newInstance();
        worker.say();
    }
}
複製程式碼

執行測試類

java -Xbootclasspath/a:/Users/yu/Desktop/lib/worker.jar Test
複製程式碼

執行結果

true
true
2018-09-16 22:34:43
複製程式碼

我們將 Worker 類和 DateUtils 類製作成名為worker 的 jar 包,將其設定為由根載入器載入,這樣 Worker 類就必然是被根載入器載入的。然後在 Worker 類的 say() 方法中初始化了 DateUtils 類,然後判斷 DateUtils 類是否由根載入器載入。從執行結果看到,Worker 和其引用的 DateUtils 類都被跟載入器載入,符合類載入的「全盤委託」原則。

「全盤委託」原則實際是為「雙親委派」原則提供了保證。如果不遵守「全盤委託」原則,那麼同一份位元組碼可能會被 JVM 載入出多個不同的例項,這就會導致應用系統中對該類引用的混亂,具體可以參考上文「JVM 怎麼判斷兩個類是相同的」這一節的示例。

自定義類載入器

除了使用 JVM 預定義的三種類載入器外,Java 還允許我們自定義類載入器以讓我們系統的類載入方式更靈活。要自定義類載入器非常簡單,通常只需要三個步驟:

  1. 繼承 java.lang.ClassLoader 類,讓 JVM 知道這是一個類載入器
  2. 重寫 findClass(String name) 方法,告訴 JVM 在使用這個類載入器時應該按什麼方式去尋找 .class 檔案
  3. 呼叫 defineClass(String name, byte[] b, int off, int len) 方法,讓 JVM 載入上一步讀取的 .class 檔案
import java.io.*;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;

public class CustomClassLoader extends ClassLoader {
    private String classpath;
    
    public CustomClassLoader(String classpath) {
        this.classpath = classpath;
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        String classFilePath = getClassFilePath(name);
        byte[] classData = readClassFile(classFilePath);
        return defineClass(name, classData, 0, classData.length);
    }

    public String getClassFilePath(String name) {
        if (name.lastIndexOf(".") == -1) {
            return classpath + "/" + name + ".class";
        } else {
            name = name.replace(".", "/");
            return classpath + "/" + name + ".class";
        }
    }

    public byte[] readClassFile(String filepath) {
        Path path = Paths.get(filepath);
        if (!Files.exists(path)) {
            return null;
        }
        try {
            return Files.readAllBytes(path);
        } catch (IOException e) {
            throw new RuntimeException("Can not read class file into byte array");
        }
    }

    public static void main(String[] args) {
        CustomClassLoader loader = new CustomClassLoader("/Users/leon/Desktop/lib");
        try {
            Class<?> clz = loader.loadClass("com.ganpengyu.demo.Person");
            System.out.println(clz.getClassLoader().toString());

            Constructor<?> c = clz.getConstructor(String.class);
            Object instance = c.newInstance("Leon");
            Method method = clz.getDeclaredMethod("say", null);
            method.invoke(instance, null);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}
複製程式碼

示例中我們通過繼承 java.lang.ClassLoader 建立了一個自定義類載入器,通過構造方法指定這個類載入器的類路徑(classpath)。重寫 findClass(String name) 方法自定義類載入的方式,其中 getClassFilePath(String filepath) 方法和 readClassFile(String filepath) 方法用於找到指定的 .class 檔案並載入成一個字元陣列。最後呼叫 defineClass(String name, byte[] b, int off, int len) 方法完成類的載入。

main() 方法中我們測試載入了一個 Person 類,通過 loadClass(String name) 方法載入一個 Person 類。我們自定義的 findClass(String name) 方法,就是在這裡面呼叫的,我們把這個方法精簡展示如下:

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) {
                    c = parent.loadClass(name, false);
                } else {
                    // 所有父載入器都無法載入,使用根載入器嘗試載入
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {}
            if (c == null) {
                // 所有父載入器和根載入器都無法載入
                // 使用自定義的 findClass() 方法查詢 .class 檔案
                c = findClass(name);
            }
        }
        return c;
    }
}
複製程式碼

可以看到 loadClass(String name) 方法內部是遵循「雙親委派」機制來完成類的載入。在「雙親」都沒能成功載入類的情況下才呼叫我們自定義的 findClass(String name) 方法查詢目標類執行載入。

為什麼需要自定義類載入器

自定義類載入器的用處有很多,這裡簡單列舉一些常見的場景。

  1. 從任意位置載入類。JVM 預定義的三個類載入器都被限定了自己的類路徑,我們可以通過自定義類載入器去載入其他任意位置的類。
  2. 解密類檔案。比如我們可以對編譯後的類檔案進行加密,然後通過自定義類載入器進行解密。當然這種方法實際並沒有太大的用處,因為自定義的類載入器也可以被反編譯。
  3. 支援更靈活的記憶體管理。我們可以使用自定義類載入器在執行時解除安裝已載入的類,從而更高效的利用記憶體。

就這樣吧

類載入器是 Java 中非常核心的技術,本文僅對類載入器進行了較為粗淺的分析,如果需要深入更底層則需要我們開啟 JVM 的原始碼進行研讀。「Java 有路勤為徑,JVM 無涯苦作舟」,與君共勉。

相關文章