在面向對象編程實踐中,我們通過眾多的類來組織一個複雜的系統,這些類之間相互關聯、調用使他們的關係形成了一個複雜緊密的網路。當系統啟動時,出於性能、資源利用多方面的考慮,我們不可能要求 JVM 一次性將全部的類都載入完成,而是只載入能夠支持系統順利啟動和運行的類和資源即可。那麼在系統運行過程中如果需要 ...
在面向對象編程實踐中,我們通過眾多的類來組織一個複雜的系統,這些類之間相互關聯、調用使他們的關係形成了一個複雜緊密的網路。當系統啟動時,出於性能、資源利用多方面的考慮,我們不可能要求 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 這個根載入器我們需要知道三點:
- 根載入器使用 C/C++ 編寫,我們無法在 Java 中獲得其實例
- 根載入器預設載入系統變數
sun.boot.class.path
指定的類庫 - 可以使用
-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 這個擴展類載入器我們需要知道兩點:
- 擴展類載入器是使用 Java 實現的類載入器,我們可以在程式中獲得它的實例並使用。
- 通常不建議修改
java.ext.dirs
參數的值來修改預設載入目錄,如有需要,可以將要載入的類庫放到這個預設目錄下。
AppClassLoader
AppClassLoader 也叫「應用類載入器」,它和 ExtClassLoader 一樣,也是使用 Java 實現的類載入器(sun.misc.Launcher.AppClassLoader
)。它的作用是載入應用程式 classpath
下所有的類庫。這是我們最常打交道的類載入器,我們在程式中調用的很多 getClassLoader()
方法返回的都是它的實例。在我們自定義類載入器時如果沒有特別指定,那麼我們自定義的類載入器的預設父載入器也是這個應用類載入器。總之,對於 AppClassLoader 這個應用類載入器我們需要知道兩點:
- 應用類載入器是使用 Java 實現的類載入器,負責載入應用程式
classpath
下的類庫。 - 應用類載入器是和我們最常打交道的類載入器。
- 沒有特別指定的情況下,自定義類載入器的父載入器就是應用類載入器。
自定義類載入器
除了上述三種 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 還允許我們自定義類載入器以讓我們系統的類載入方式更靈活。要自定義類載入器非常簡單,通常只需要三個步驟:
- 繼承
java.lang.ClassLoader
類,讓 JVM 知道這是一個類載入器 - 重寫
findClass(String name)
方法,告訴 JVM 在使用這個類載入器時應該按什麼方式去尋找.class
文件 - 調用
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)
方法查找目標類執行載入。
為什麼需要自定義類載入器
自定義類載入器的用處有很多,這裡簡單列舉一些常見的場景。
- 從任意位置載入類。JVM 預定義的三個類載入器都被限定了自己的類路徑,我們可以通過自定義類載入器去載入其他任意位置的類。
- 解密類文件。比如我們可以對編譯後的類文件進行加密,然後通過自定義類載入器進行解密。當然這種方法實際並沒有太大的用處,因為自定義的類載入器也可以被反編譯。
- 支持更靈活的記憶體管理。我們可以使用自定義類載入器在運行時卸載已載入的類,從而更高效的利用記憶體。
就這樣吧
類載入器是 Java 中非常核心的技術,本文僅對類載入器進行了較為粗淺的分析,如果需要深入更底層則需要我們打開 JVM 的源碼進行研讀。「Java 有路勤為徑,JVM 無涯苦作舟」,與君共勉。