1. 程式人生 > >探秘類加載器和類加載機制

探秘類加載器和類加載機制

實例 驗證 字符 只需要 路徑名 lean shell 繼承關系 判斷

在面向對象編程實踐中,我們通過眾多的類來組織一個復雜的系統,這些類之間相互關聯、調用使他們的關系形成了一個復雜緊密的網絡。當系統啟動時,出於性能、資源利用多方面的考慮,我們不可能要求 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 無涯苦作舟」,與君共勉。

探秘類加載器和類加載機制