天天看點

老大難的 Java ClassLoader,到了該徹底了解它的時候了

老大難的 Java ClassLoader,到了該徹底了解它的時候了

ClassLoader 是 Java 屆最為神秘的技術之一,無數人被它傷透了腦筋,摸不清門道究竟在哪裡。網上的文章也是一篇又一篇,經過本人的親自鑒定,絕大部分内容都是在誤導别人。本文我帶讀者徹底吃透 ClassLoader,以後其它的相關文章你們可以不必再細看了。

ClassLoader 做什麼的?

顧名思義,它是用來加載 Class 的。它負責将 Class 的位元組碼形式轉換成記憶體形式的 Class 對象。位元組碼可以來自于磁盤檔案 *.class,也可以是 jar 包裡的 *.class,也可以來自遠端伺服器提供的位元組流,位元組碼的本質就是一個位元組數組 []byte,它有特定的複雜的内部格式。

老大難的 Java ClassLoader,到了該徹底了解它的時候了

圖檔

有很多位元組碼加密技術就是依靠定制 ClassLoader 來實作的。先使用工具對位元組碼檔案進行加密,運作時使用定制的 ClassLoader 先解密檔案内容再加載這些解密後的位元組碼。

每個 Class 對象的内部都有一個 classLoader 字段來辨別自己是由哪個 ClassLoader 加載的。

class Class<T> {

...
private final ClassLoader classLoader;

...

}
           

延遲加載

JVM 運作并不是一次性加載所需要的全部類的,它是按需加載,也就是延遲加載。程式在運作的過程中會逐漸遇到很多不認識的新類,這時候就會調用 ClassLoader 來加載這些類。加載完成後就會将 Class 對象存在 ClassLoader 裡面,下次就不需要重新加載了。

比如你在調用某個類的靜态方法時,首先這個類肯定是需要被加載的,但是并不會觸及這個類的執行個體字段,那麼執行個體字段的類别 Class 就可以暫時不必去加載,但是它可能會加載靜态字段相關的類别,因為靜态方法會通路靜态字段。而執行個體字段的類别需要等到你執行個體化對象的時候才可能會加載。

各司其職

JVM 運作執行個體中會存在多個 ClassLoader,不同的 ClassLoader 會從不同的地方加載位元組碼檔案。它可以從不同的檔案目錄加載,也可以從不同的 jar 檔案中加載,也可以從網絡上不同的服務位址來加載。

JVM 中内置了三個重要的 ClassLoader,分别是 BootstrapClassLoader、ExtensionClassLoader 和 AppClassLoader。

BootstrapClassLoader 負責加載 JVM 運作時核心類,這些類位于 JAVA_HOME/lib/rt.jar 檔案中,我們常用内置庫 java.xxx.* 都在裡面,比如 java.util.*、java.io.*、java.nio.*、java.lang.* 等等。這個 ClassLoader 比較特殊,它是由 C 代碼實作的,我們将它稱之為「根加載器」。

ExtensionClassLoader 負責加載 JVM 擴充類,比如 swing 系列、内置的 js 引擎、xml 解析器 等等,這些庫名通常以 javax 開頭,它們的 jar 包位于 JAVA_HOME/lib/ext/*.jar 中,有很多 jar 包。

AppClassLoader 才是直接面向我們使用者的加載器,它會加載 Classpath 環境變量裡定義的路徑中的 jar 包和目錄。我們自己編寫的代碼以及使用的第三方 jar 包通常都是由它來加載的。

那些位于網絡上靜态檔案伺服器提供的 jar 包和 class檔案,jdk 内置了一個 URLClassLoader,使用者隻需要傳遞規範的網絡路徑給構造器,就可以使用 URLClassLoader 來加載遠端類庫了。URLClassLoader 不但可以加載遠端類庫,還可以加載本地路徑的類庫,取決于構造器中不同的位址形式。ExtensionClassLoader 和 AppClassLoader 都是 URLClassLoader 的子類,它們都是從本地檔案系統裡加載類庫。

AppClassLoader 可以由 ClassLoader 類提供的靜态方法 getSystemClassLoader() 得到,它就是我們所說的「系統類加載器」,我們使用者平時編寫的類代碼通常都是由它加載的。當我們的 main 方法執行的時候,這第一個使用者類的加載器就是 AppClassLoader。

ClassLoader 傳遞性

程式在運作過程中,遇到了一個未知的類,它會選擇哪個 ClassLoader 來加載它呢?虛拟機的政策是使用調用者 Class 對象的 ClassLoader 來加載目前未知的類。何為調用者 Class 對象?就是在遇到這個未知的類時,虛拟機肯定正在運作一個方法調用(靜态方法或者執行個體方法),這個方法挂在哪個類上面,那這個類就是調用者 Class 對象。前面我們提到每個 Class 對象裡面都有一個 classLoader 屬性記錄了目前的類是由誰來加載的。

因為 ClassLoader 的傳遞性,所有延遲加載的類都會由初始調用 main 方法的這個 ClassLoader 全全負責,它就是 AppClassLoader。

雙親委派

前面我們提到 AppClassLoader 隻負責加載 Classpath 下面的類庫,如果遇到沒有加載的系統類庫怎麼辦,AppClassLoader 必須将系統類庫的加載工作交給 BootstrapClassLoader 和 ExtensionClassLoader 來做,這就是我們常說的「雙親委派」。

老大難的 Java ClassLoader,到了該徹底了解它的時候了

AppClassLoader 在加載一個未知的類名時,它并不是立即去搜尋 Classpath,它會首先将這個類名稱交給 ExtensionClassLoader 來加載,如果 ExtensionClassLoader 可以加載,那麼 AppClassLoader 就不用麻煩了。否則它就會搜尋 Classpath 。

而 ExtensionClassLoader 在加載一個未知的類名時,它也并不是立即搜尋 ext 路徑,它會首先将類名稱交給 BootstrapClassLoader 來加載,如果 BootstrapClassLoader 可以加載,那麼 ExtensionClassLoader 也就不用麻煩了。否則它就會搜尋 ext 路徑下的 jar 包。

這三個 ClassLoader 之間形成了級聯的父子關系,每個 ClassLoader 都很懶,盡量把工作交給父親做,父親幹不了了自己才會幹。每個 ClassLoader 對象内部都會有一個 parent 屬性指向它的父加載器。

class ClassLoader {

...
private final ClassLoader parent;

...

}
           

值得注意的是圖中的 ExtensionClassLoader 的 parent 指針畫了虛線,這是因為它的 parent 的值是 null,當 parent 字段是 null 時就表示它的父加載器是「根加載器」。如果某個 Class 對象的 classLoader 屬性值是 null,那麼就表示這個類也是「根加載器」加載的。

Class.forName

當我們在使用 jdbc 驅動時,經常會使用 Class.forName 方法來動态加載驅動類。

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

其原理是 mysql 驅動的 Driver 類裡有一個靜态代碼塊,它會在 Driver 類被加載的時候執行。這個靜态代碼塊會将 mysql 驅動執行個體注冊到全局的 jdbc 驅動管理器裡。

class Driver {
static {
try {

java.sql.DriverManager.registerDriver(new Driver());

} catch (SQLException E) {
throw new RuntimeException("Can't register driver!");

}

}

...

}
           

forName 方法同樣也是使用調用者 Class 對象的 ClassLoader 來加載目标類。不過 forName 還提供了多參數版本,可以指定使用哪個 ClassLoader 來加載

Class<?> forName(String name, boolean initialize, ClassLoader cl)
           

通過這種形式的 forName 方法可以突破内置加載器的限制,通過使用自定類加載器允許我們自由加載其它任意來源的類庫。根據 ClassLoader 的傳遞性,目标類庫傳遞引用到的其它類庫也将會使用自定義加載器加載。

自定義加載器

ClassLoader 裡面有三個重要的方法 loadClass()、findClass() 和 defineClass()。

loadClass() 方法是加載目标類的入口,它首先會查找目前 ClassLoader 以及它的雙親裡面是否已經加載了目标類,如果沒有找到就會讓雙親嘗試加載,如果雙親都加載不了,就會調用 findClass() 讓自定義加載器自己來加載目标類。ClassLoader 的 findClass() 方法是需要子類來覆寫的,不同的加載器将使用不同的邏輯來擷取目标類的位元組碼。拿到這個位元組碼之後再調用 defineClass() 方法将位元組碼轉換成 Class 對象。下面我使用僞代碼表示一下基本過程

class ClassLoader {

// 加載入口,定義了雙親委派規則
Class loadClass(String name) {
// 是否已經加載了

Class t = this.findFromLoaded(name);
if(t == null) {
// 交給雙親

t = this.parent.loadClass(name)

}
if(t == null) {
// 雙親都不行,隻能靠自己了

t = this.findClass(name);

}
return t;

}

// 交給子類自己去實作
Class findClass(String name) {
throw ClassNotFoundException();

}

// 組裝Class對象
Class defineClass(byte[] code, String name) {
return buildClassFromCode(code, name);

}

}

class CustomClassLoader extends ClassLoader {

Class findClass(String name) {
// 尋找位元組碼
byte[] code = findCodeFromSomewhere(name);
// 組裝Class對象
return this.defineClass(code, name);

}

}
           

自定義類加載器不易破壞雙親委派規則,不要輕易覆寫 loadClass 方法。否則可能會導緻自定義加載器無法加載内置的核心類庫。在使用自定義加載器時,要明确好它的父加載器是誰,将父加載器通過子類的構造器傳入。如果父類加載器是 null,那就表示父加載器是「根加載器」。

// ClassLoader 構造器
protected ClassLoader(String name, ClassLoader parent);
           

雙親委派規則可能會變成三親委派,四親委派,取決于你使用的父加載器是誰,它會一直遞歸委派到根加載器。

Class.forName vs ClassLoader.loadClass

這兩個方法都可以用來加載目标類,它們之間有一個小小的差別,那就是 Class.forName() 方法可以擷取原生類型的 Class,而 ClassLoader.loadClass() 則會報錯。

Class<?> x = Class.forName("[I");

System.out.println(x);


x = ClassLoader.getSystemClassLoader().loadClass("[I");

System.out.println(x);


---------------------
class [I


Exception in thread "main" java.lang.ClassNotFoundException: [I

...
           

鑽石依賴

項目管理上有一個著名的概念叫着「鑽石依賴」,是指軟體依賴導緻同一個軟體包的兩個版本需要共存而不能沖突。

老大難的 Java ClassLoader,到了該徹底了解它的時候了

我們平時使用的 maven 是這樣解決鑽石依賴的,它會從多個沖突的版本中選擇一個來使用,如果不同的版本之間相容性很糟糕,那麼程式将無法正常編譯運作。Maven 這種形式叫「扁平化」依賴管理。

使用 ClassLoader 可以解決鑽石依賴問題。不同版本的軟體包使用不同的 ClassLoader 來加載,位于不同 ClassLoader 中名稱一樣的類實際上是不同的類。下面讓我們使用 URLClassLoader 來嘗試一個簡單的例子,它預設的父加載器是 AppClassLoader

$ cat ~/source/jcl/v1/Dep.java
public class Dep {
public void print() {

System.out.println("v1");

}

}


$ cat ~/source/jcl/v2/Dep.java
public class Dep {
public void print() {

System.out.println("v1");

}

}


$ cat ~/source/jcl/Test.java
public class Test {
public static void main(String[] args) throws Exception {

String v1dir = "file:///Users/qianwp/source/jcl/v1/";

String v2dir = "file:///Users/qianwp/source/jcl/v2/";

URLClassLoader v1 = new URLClassLoader(new URL[]{new URL(v1dir)});

URLClassLoader v2 = new URLClassLoader(new URL[]{new URL(v2dir)});


Class<?> depv1Class = v1.loadClass("Dep");

Object depv1 = depv1Class.getConstructor().newInstance();

depv1Class.getMethod("print").invoke(depv1);


Class<?> depv2Class = v2.loadClass("Dep");

Object depv2 = depv2Class.getConstructor().newInstance();

depv2Class.getMethod("print").invoke(depv2);


System.out.println(depv1Class.equals(depv2Class));

}

}
           

在運作之前,我們需要對依賴的類庫進行編譯

$ cd ~/source/jcl/v1
$ javac Dep.java
$ cd ~/source/jcl/v2
$ javac Dep.java
$ cd ~/source/jcl
$ javac Test.java
$ java Test

v1

v2

false
           

在這個例子中如果兩個 URLClassLoader 指向的路徑是一樣的,下面這個表達式還是 false,因為即使是同樣的位元組碼用不同的 ClassLoader 加載出來的類都不能算同一個類

depv1Class.equals(depv2Class)
           

我們還可以讓兩個不同版本的 Dep 類實作同一個接口,這樣可以避免使用反射的方式來調用 Dep 類裡面的方法。

Class<?> depv1Class = v1.loadClass("Dep");

IPrint depv1 = (IPrint)depv1Class.getConstructor().newInstance();

depv1.print()
           

ClassLoader 固然可以解決依賴沖突問題,不過它也限制了不同軟體包的操作界面必須使用反射或接口的方式進行動态調用。Maven 沒有這種限制,它依賴于虛拟機的預設懶惰加載政策,運作過程中如果沒有顯示使用定制的 ClassLoader,那麼從頭到尾都是在使用 AppClassLoader,而不同版本的同名類必須使用不同的 ClassLoader 加載,是以 Maven 不能完美解決鑽石依賴。

如果你想知道有沒有開源的包管理工具可以解決鑽石依賴的,我推薦你了解一下 sofa-ark,它是螞蟻金服開源的輕量級類隔離架構。

分工與合作

這裡我們重新了解一下 ClassLoader 的意義,它相當于類的命名空間,起到了類隔離的作用。位于同一個 ClassLoader 裡面的類名是唯一的,不同的 ClassLoader 可以持有同名的類。ClassLoader 是類名稱的容器,是類的沙箱。

老大難的 Java ClassLoader,到了該徹底了解它的時候了

不同的 ClassLoader 之間也會有合作,它們之間的合作是通過 parent 屬性和雙親委派機制來完成的。parent 具有更高的加載優先級。除此之外,parent 還表達了一種共享關系,當多個子 ClassLoader 共享同一個 parent 時,那麼這個 parent 裡面包含的類可以認為是所有子 ClassLoader 共享的。這也是為什麼 BootstrapClassLoader 被所有的類加載器視為祖先加載器,JVM 核心類庫自然應該被共享。

Thread.contextClassLoader

如果你稍微閱讀過 Thread 的源代碼,你會在它的執行個體字段中發現有一個字段非常特别

class Thread {

...
private ClassLoader contextClassLoader;

public ClassLoader getContextClassLoader() {
return contextClassLoader;

}

public void setContextClassLoader(ClassLoader cl) {
this.contextClassLoader = cl;

}

...

}
           

contextClassLoader「線程上下文類加載器」,這究竟是什麼東西?

首先 contextClassLoader 是那種需要顯示使用的類加載器,如果你沒有顯示使用它,也就永遠不會在任何地方用到它。你可以使用下面這種方式來顯示使用它

Thread.currentThread().getContextClassLoader().loadClass(name);
           

這意味着如果你使用 forName(string name) 方法加載目标類,它不會自動使用 contextClassLoader。那些因為代碼上的依賴關系而懶惰加載的類也不會自動使用 contextClassLoader來加載。

其次線程的 contextClassLoader 是從父線程那裡繼承過來的,所謂父線程就是建立了目前線程的線程。程式啟動時的 main 線程的 contextClassLoader 就是 AppClassLoader。這意味着如果沒有人工去設定,那麼所有的線程的 contextClassLoader 都是 AppClassLoader。

那這個 contextClassLoader 究竟是做什麼用的?我們要使用前面提到了類加載器分工與合作的原理來解釋它的用途。

它可以做到跨線程共享類,隻要它們共享同一個 contextClassLoader。父子線程之間會自動傳遞 contextClassLoader,是以共享起來将是自動化的。

如果不同的線程使用不同的 contextClassLoader,那麼不同的線程使用的類就可以隔離開來。

如果我們對業務進行劃分,不同的業務使用不同的線程池,線程池内部共享同一個 contextClassLoader,線程池之間使用不同的 contextClassLoader,就可以很好的起到隔離保護的作用,避免類版本沖突。

如果我們不去定制 contextClassLoader,那麼所有的線程将會預設使用 AppClassLoader,所有的類都将會是共享的。

線程的 contextClassLoader 使用場合比較罕見,如果上面的邏輯晦澀難懂也不必過于計較。

JDK9 增加了子產品功能之後對類加載器的結構設計做了一定程度的修改,不過類加載器的原理還是類似的,作為類的容器,它起到類隔離的作用,同時還需要依靠雙親委派機制來建立不同的類加載器之間的合作關系。

原文釋出時間為: 2018-12-02

本文作者:老錢

本文來自雲栖社群合作夥伴“

碼洞

”,了解相關資訊可以關注“

”。