本文通過如何將一個單例類實體化兩次的案例,用代碼實踐來引入 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
當一個類被父加載器加載了,子加載器再次加載這個類的時候,還需要向父加載器委托嗎?
我們先把問題細化一下:
-
AClassLoader 的父加載器為 BClassLoader,BClassLoader 的父加載器為 CClassLoader,當 AClassLoader 呼叫 loadClass() 加載類,并最終由 CClassLoader 加載的類,到底算誰加載的?
-
后續 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() 中找到該類,不必再向上委托,
雙親委派的目的
-
防止重復加載類,在 JVM 中,要唯一確定一個物件,是由類加載器和全類名兩者共同確定的,考慮到各層級的類加載器之間仍然由重疊的類資源加載區域,通過向上拋的方式可以避免一個類被多個不同的類加載器加載,從而形成重復加載,
-
防止系統 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 一位后端寫碼師,一位黑暗料理制造者,
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/552467.html
標籤:Java
上一篇:SpringBoot 使用 Sa-Token 完成注解鑒權功能
下一篇:返回列表
