不是單例的單例——巧用ClassLoader

PPPHUANG發表於2023-05-15

本文透過如何將一個單例類例項化兩次的案例,用程式碼實踐來引入 Java 類載入器相關的概念與工作機制。理解並熟練掌握相關知識之後可以擴寬解決問題的思路,另闢蹊徑,達到目的。

背景

單例模式是最常用的設計模式之一。其目的是保證一個類在程式中僅有一個例項,並提供一個它的全域性訪問方式。那什麼場景下一個程式裡需要單例類的兩個物件呢?很明顯這破壞了單例模式的設計初衷。

這裡舉例一個我司的特殊場景:

RPC 的呼叫規範是每個業務叢集裡只能有一個呼叫方,如果一個業務節點已經例項化了一個客戶端,就無法再例項化另一個。這個規範的目的是讓一個叢集統一個呼叫方,方便服務資料的收集、展示、告警等操作。

一個專案有多個叢集,多個專案組維護,各個叢集都有一個共同特點,需要呼叫相同的 RPC 服務。如果嚴格按照上述 RPC 規範的話,每一個叢集都需要申請一個自己呼叫方,每一個呼叫方都申請相同的 RPC 服務。這樣做完全沒有問題,只是相同的工作會被各個叢集都做一遍,並且生成了多個 RPC 的呼叫方。

最終方案是將相同的邏輯程式碼打包成一個公用 jar 包,然後其他叢集引入這個包就能解決我們上述的問題。這麼做的話就碰到了 RPC 規範中的約束問題,jar 包裡的公用邏輯會呼叫 RPC 服務,那麼勢必會有一個 RPC 的公用呼叫方。我們的業務程式碼裡也會有自己業務需要呼叫的其他 RPC 服務,這個呼叫方和 jar 包裡的呼叫方就衝突了,只能有一個呼叫方會被成功初始化,另一個則會報錯。這個場景是不是就要例項化兩個單例模式的物件呢。

有相關經驗的讀者可能會想到,能不能把各個叢集中相同的工作抽取出來,做成一個類似閘道器的叢集,然後各個叢集再來呼叫這個公用叢集,這樣同一個工作也不會被做多遍,RPC 的呼叫方也被整合成了一個。這個方案也是很好的,考慮到一些客觀因素,最終並沒有選擇這種方式。

例項化兩個單例類

我們假設下述單例類程式碼是 RPC 的呼叫 Client:

public class RPCClient {
  	private static BaseClient baseClient;
    private volatile static RPCClient instance;
  
  	static {
        baseClient = BaseClient.getBaseClient();
    }
  
    private RPCClient() {
       System.out.println("構造 Client");
    }
    public String callRpc() {
        return "callRpc success";
    }
    public static RPCClient getClient() {
        if (instance == null) {
            synchronized (RPCClient.class) {
                if (instance == null) {
                    instance = new RPCClient();
                }
            }
        }
        return instance;
    }
}
public class BaseClient {
  ...
  private BaseClient() {
      System.out.println("構造 BaseClient");
  }
  ...
}

這個單例 Client 有一點點不同,就是有一個靜態屬性 baseClient,BaseClient 也是一個簡單的單例類,構造方法裡有一些列印操作,方便後續觀察。baseClient 屬性透過靜態程式碼塊來賦值。

我們可以想一想,有什麼辦法可以將這個單例的 Client 類例項化兩個物件出來?

無所不能的反射大法

最容易想到的就是利用反射獲取構造方法,來規避單例類私有化構造方法的約束來例項化:

Constructor<?> declaredConstructor = RPCClient.class.getDeclaredConstructor();
declaredConstructor.setAccessible(true);
Object rpcClient = declaredConstructor.newInstance();
Method sayHi = rpcClient.getClass().getMethod("callRpc");
Object invoke = sayHi.invoke(rpcClient);
//執行輸出
//構造 Client
//callBaseRpc successcallRpc success

上述程式碼透過反射來獲取私有化的構造方法,然後透過這個構造方法來例項化物件。這樣確實能生成單例 RPCClient 的第二個物件。觀察程式碼執行的輸出能發現,透過反射生成的這個物件 rpcClient 確實是一個新物件,因為輸出裡有 RPCClient 構造方法的列印輸出。但是並沒有列印 BaseClient 這個物件的構造方法裡的輸出。rpcClient 這個物件裡的 baseClient 永遠都是隻用一個,因為 baseClient 在靜態程式碼塊裡賦值的,並且 BaseClient 又是一個單例類。這樣,我們反射生成的物件與非反射生成的物件就不是完全隔離的。

上述的簡單 Demo 裡,使用反射好像都不太能夠生成兩個完全隔離的單例客戶端。一個複雜的 RPC Client 類可遠沒有這麼簡單,Client 類裡還有很多依賴的類,依賴的類裡也會依賴其他類,其中不乏各種單例類。透過反射的方法好像行不太通。那還有什麼方法能達到目的呢?

自定義類載入器

另一個方法是用一個自定義的類載入器來載入 RPCClient 類並例項化。業務程式碼預設使用的是 AppClassLoader 類載入器,這個類載入器來載入 RPCClient 類並例項化第一個 Client 物件,我們自定義的類載入器會載入並例項化第二個 Client 物件。那麼在一個 JVM 程式裡就存在了兩個 RPCClient 物件了。這兩個物件會不會存在上述反射中沒有完全隔離的問題呢?

答案是不會。類載入是有傳遞性的,當一個類被載入時,這個類依賴的類如果需要載入,使用的類載入器就是當前類的類載入器。我們使用自定義類載入器載入 RPCClient 時,RPCClient 依賴的類也會被自定義載入器載入。這樣依賴類也會被完全隔離,也就沒有在上述反射中存在的 baseClient 屬性還是同一個物件的情況。

自定義類載入器程式碼如下:

public class MyClassLoader extends ClassLoader{
    @Override
    public Class<?> loadClass(String name) {
      //透過 findLoadedClass 判斷是否已經被載入 (下文會補充)
      Class<?> loadedClass = findLoadedClass(name);
      //如果已載入返回已載入的類
      if (loadedClass != null) {
          return loadedClass;
      }
      //透過類名獲取類檔案
      String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";
      InputStream resourceAsStream = getClass().getResourceAsStream(fileName);
      //如果查詢不到檔案 則委託父類載入器實現 這裡的父載入器就是 AppClassLoader 
      if (resourceAsStream == null) {
          return super.loadClass(name);
      }
      //讀取檔案 並載入類
      byte[] bytes = new byte[resourceAsStream.available()];
      resourceAsStream.read(bytes);
      return defineClass(name, bytes, 0, bytes.length);
   }
}

測試程式碼如下:

//例項化自定義類載入器
MyClassLoader myClassLoader = new MyClassLoader();
//獲取當前執行緒的 ContextClassLoader 備用
ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
//設定當前執行緒的 ContextClassLoader 為例項化的自定義類載入器(這麼做的原因下文會補充)
Thread.currentThread().setContextClassLoader(myClassLoader);
//透過自定義類載入器載入 RPCClient
Class<?> rpcClientCls = myClassLoader.loadClass("com.ppphuang.demo.classloader.single.RPCClient");
//將當前執行緒的 ContextClassLoader 還原為初始的 contextClassLoader
Thread.currentThread().setContextClassLoader(contextClassLoader);
//透過反射獲取該類的 getClient 方法
Method getInstance = rpcClientCls.getMethod("getClient");
getInstance.setAccessible(true);
//呼叫 getClient 方法獲取單例物件
Object rpcClient = getInstance.invoke(rpcClientCls);
//獲取 callRpc 方法
Method callRpc = rpcClientCls.getMethod("callRpc");
//呼叫 callRpc 方法
Object callRpcMsg = callRpc.invoke(rpcClient);
System.out.println(callRpcMsg);
//執行輸出
//構造 BaseClient
//構造 Client
//callBaseRpc successcallRpc success

透過測試程式碼的輸出可以看到,RPCClient BaseClient 這兩個類構造方法裡的列印都輸出了,那就說明透過自定義類載入器例項化的兩個物件都執行了構造方法。自然就跟直接呼叫 RPCClient.getClient() 生成的物件是完全隔離開的。

你可以透過程式碼註釋,來理解一下測試程式碼的執行過程。

如果看到這裡你還有一些疑問的話,我們再鞏固一下類載入器相關的知識。

類與類載入器

預設類載入

在 Java 中有三個預設的類載入器:

BootstrapClassLoader

載入 Java 核心庫(JAVA_HOME/jre/lib/rt.jar 或 sun.boot.class.path 路徑下的內容)。用於提供 JVM 自身需要的類。由 C++ 載入,用如下程式碼去獲取的話會顯示為 null:

System.out.println(String.class.getClassLoader());
ExtClassLoader

Java 語言編寫,從 java.ext.dirs 系統屬性所指定的目錄中載入類,或從 JDK 的安裝目錄 jre/lib/ext 子目錄下載入類。如果使用者建立 的 jar 放在此目錄下,也會自動由 ExtClassLoader 載入。

System.out.println(com.sun.crypto.provider.DESedeKeyFactory.class.getClassLoader());
AppClassLoader

它負責載入環境變數 classpath 或系統屬性 java.class.path 指定路徑下的類,應用程式中預設是系統類載入器。

System.out.println(ClassLoader.getSystemClassLoader());

如果我們沒有特殊指定類載入器的話,JVM 程式中所有需要的類都會由上述三個類載入來完成載入。

每個 Class 物件的內部都有一個 classLoader 欄位來標識自己是由哪個 ClassLoader 載入的:

class Class<T> {
  private final ClassLoader classLoader;
}

你可以這樣來獲取某個類的 ClassLoader:

System.out.println(obj.getClass().getClassLoader());

不同類載入器的影響

兩個類相同的前提是類的載入器也相同,不同類載入器載入同一個 Class 也是不一樣的 Class,會影響 equals、instanceof 的運算結果。

下面的程式碼展示了不同類載入器對類判等的影響,為了減少程式碼篇幅,程式碼省略了異常處理:

public class ClassLoaderTest {
    public static void main(String[] args) {
        ClassLoader myClassLoader = new ClassLoader() {
            @Override
            public Class<?> loadClass(String name) {
                Class<?> loadedClass = findLoadedClass(name);
                if (loadedClass != null) return loadedClass;
                String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";
                InputStream resourceAsStream = getClass().getResourceAsStream(fileName);
                if (resourceAsStream == null) {
                    return super.loadClass(name);
                }
                byte[] bytes = new byte[resourceAsStream.available()];
                resourceAsStream.read(bytes);
                return defineClass(name, bytes, 0, bytes.length);
            }
        };
        Object obj = myClassLoader.loadClass("ClassLoaderTest").newInstance();
        System.out.println(obj.getClass().getClassLoader());
        System.out.println(com.ppphuang.demo.classloader.ClassLoaderTest.class.getClassLoader());
        System.out.println(obj instanceof ClassLoaderTest);
    }
}
//輸出如下:
//com.ppphuang.demo.classloader.ClassLoaderTest$1@7a07c5b4
//sun.misc.Launcher$AppClassLoader@18b4aac2
//false

上述程式碼自定義了一個類載入器 myClassLoader,用 myClassLoader 載入的 ClassLoaderTest 類例項化出的物件與 AppClassLoader 載入的 ClassLoaderTest 類做 instanceof 運算,最終輸出的介面是 false。由此可以判斷出不同載入器載入同一個類,這兩個類也是不相同的。

因為不同類載入器的載入的類是不同的,所以我們可以在一個 JVM 裡透過自定義類載入器來將一個單例類例項化兩次。

ClassLoader 傳遞性

程式在執行過程中,遇到了一個未知的類,它會選擇哪個 ClassLoader 來載入它呢?

虛擬機器的策略是使用呼叫者 Class 物件的 ClassLoader 來載入當前未知的類。就是在遇到這個未知的類時,虛擬機器肯定正在執行一個方法呼叫(靜態方法或者例項方法),這個方法寫在哪個類,那這個類就是呼叫者 Class 物件。前面我們提到每個 Class 物件裡面都有一個 classLoader 屬性記錄了當前的類是由誰來載入的。

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

ClassLoaderTest classLoaderTest = new ClassLoaderTest();
System.out.println(classLoaderTest.getClass().getClassLoader());
//sun.misc.Launcher$AppClassLoader@18b4aac2

如果我們使用一個自定義類載入器載入一個類,那麼這個類裡依賴的類也會由這個類載入來負責載入:

Object obj = myClassLoader.loadClass("com.ppphuang.demo.classloader.ClassLoaderTest").newInstance();

因為類載入器的傳遞性,依賴類的載入器也會使用當前類的載入器,當我們利用自定義類載入器來將一個單例類例項化兩次的時候,能保證兩個單例物件是完全隔離。

雙親委派模型

當一個類載入器需要載入一個類時,自己並不會立即去載入,而是首先委派給父類載入器去載入,父類載入器載入不了再給父類的父類去載入,一層一層向上委託,直到頂層載入器(BootstrapClassLoader),如果父類載入器無法載入那麼類加器才會自己去載入。

findLoadedClass

當一個類被父載入器載入了,子載入器再次載入這個類的時候,還需要向父載入器委託嗎?

我們先把問題細化一下:

  1. AClassLoader 的父載入器為 BClassLoader,BClassLoader 的父載入器為 CClassLoader,當 AClassLoader 呼叫 loadClass() 載入類,並最終由 CClassLoader 載入的類,到底算誰載入的?

  2. 後續 AClassLoader 再載入相同類時,是否能直接從 AClassLoader 的 findLoadedClass0() 中找到該類並返回,還是說再走一次雙親委派最終從 CClassLoader 的 findLoadedClass0() 中找到該類並返回?

JVM 裡有一個資料結構叫做 SystemDictonary,這個結構主要就是用來檢索我們常說的類資訊,其實也就是 private native final Class<?> findLoadedClass0(String name) 方法的邏輯。

這些類資訊對應的結構是 klass,對 SystemDictonary 的理解,可以理解為一個雜湊表,key 是類載入器物件 + 類的名字,value是指向 klass 的地址。當我們任意一個類載入器去正常載入類的時候,就會到這個 SystemDictonary 中去查詢,看是否有這麼一個 klass 可以返回,如果有就返回它,否則就會去建立一個新的並放到結構裡。

這裡面還涉及兩個小概念,初始類載入器、定義類載入器。

上述類載入問題中,AClassLoader 載入類的時候會委託給 BClassLoader 來載入,BClassLoader 載入類的時候會委託給 CClassLoader 來載入,當 AClassLoader 呼叫 loadClass() 載入類,並最終由 CClassLoader 載入,那麼我們稱 CClassLoader 為該類的定義類載入器,AClassLoader 和 BClassLoader 為該類的初始類載入器。在這個過程中,AClassLoader、BClassLoader 和 CClassLoader 都會在 SystemDictonary 生成記錄。那麼後續 C 的子載入器(AClassLoader 和 BClassLoader)載入相同類時,就能在自己 findLoadedClass0() 中找到該類,不必再向上委託。

雙親委派的目的

  1. 防止重複載入類。在 JVM 中,要唯一確定一個物件,是由類載入器和全類名兩者共同確定的,考慮到各層級的類載入器之間仍然由重疊的類資源載入區域,透過向上拋的方式可以避免一個類被多個不同的類載入器載入,從而形成重複載入。

  2. 防止系統 API 被篡改。例如讀者定義了一個名為 java.lang.Integer 的類,而該類在核心庫中也存在,借用雙親委派的機制,我們就能有效防止該自定義的同名類被載入,從而保護了平臺的安全性。

JDK 1.2 之後引入雙親委派的方式來實現類載入器的層次呼叫,以儘可能保證 JDK 的系統 API 不會被使用者定義的類載入器所破壞,但一些使用場景會打破這個慣例來實現必要的功能。

破壞雙親委派模型

Thread Context ClassLoader

在介紹破壞雙親委派模型之前,我們先了解一下 Thread Context ClassLoader(執行緒上下文類載入器)。

JVM 中經常需要呼叫由其他廠商實現並部署在應用程式的 ClassPath 下的 JNDI 服務提供者介面 (Servicepovider iotertace, SPD) 的程式碼,現在問題來了,啟動類載入器是絕不可能認識、載入這些程式碼的,那該怎麼辦?
為瞭解決這個困境,Java 的設計團隊只好引入了一個不太優雅的設計:執行緒上下文類加裁器 ( Thread Context ClassLoader)。這個類載入器可以透過 java.lang.Thread 類的 setContextClassLoader 方法進行設定,如果建立執行緒時還未設定,它將會從父執行緒中繼承一個,如果在應用程式的全域性範圍內都沒有設定過的話,那這個類載入器預設就是 AppClassLoader。
有了執行緒上下文類載入器,程式就可以做一些 “舞弊”的事情了。JNDI 服務使用這個執行緒上下文類載入器去載入所需的 SPI 服務程式碼,這是一種父類載入器去請求子類載入器完成類載入的行為,這種行為實際上是打通了雙親委派模型的層次結構來逆向使用類載入器,已經違背了雙親委派模型的一般性原則,但也是無可奈何的事情。Java 中涉及 SPI 的載入基本上都採用這種方式來完成的。

可以透過如下的程式碼來獲取當前執行緒的 ContextClassLoader :

ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();

我們在前面測試程式碼中將 Thread Context ClassLoader 也設定為自定義載入器,目的是避免自定義載入器載入的類裡面使用了 Thread Context ClassLoader(預設是 AppClassLoader),導致物件沒有完全完全隔離,這也是自定義載入器的常用原則之一。在自定義載入器載入完成之後也要將 Thread Context ClassLoader 復原:

//例項化自定義類載入器
MyClassLoader myClassLoader = new MyClassLoader();
//獲取當前執行緒的 ContextClassLoader 備用
ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
//設定當前執行緒的 ContextClassLoader 為例項化的自定義類載入器(這麼做的原因下文會補充)
Thread.currentThread().setContextClassLoader(myClassLoader);
//透過自定義類載入器載入 RPCClient
Class<?> rpcClientCls = myClassLoader.loadClass("com.ppphuang.demo.classloader.single.RPCClient");
//將當前執行緒的 ContextClassLoader 還原為初始的 contextClassLoader
Thread.currentThread().setContextClassLoader(contextClassLoader);

Tomcat類載入模型

提到破壞雙親委派模型就必須要提到 Tomcat,部署在一個 Tomcat 中的每個應用程式都會有一個獨一無二的 webapp classloader,他們互相隔離不受彼此的影響。除了互相隔離的類載入器,Tomcat 中還有共享的類載入器,大家可以去檢視一下相關的檔案,還是很值得我們借鑑學習的。

看到這裡再回頭來理解上文自定義類載入器例項化單例類的程式碼,應該就很好理解了。

總結

本文透過如何將一個單例類例項化兩次的案例,用程式碼實踐來引入 Java 類載入器相關的概念與工作機制。理解並熟練掌握相關知識之後可以擴寬解決問題的思路,另闢蹊徑,達到目的。

參考

https://blog.csdn.net/qq_43369986/article/details/117048340

https://blog.csdn.net/qq_40378034/article/details/119973663

https://blog.csdn.net/J080624/article/details/84835493

公眾號:DailyHappy 一位後端寫碼師,一位黑暗料理製造者。

相關文章