如何實現Java類隔離加載?
Java 開發中,如果不同的 jar 包依賴了某些通用 jar 包的版本不一樣,運行時就會因為加載的類跟預期不符合導致報錯。如何避免這種情況呢?本文通過分析 jar 包產生沖突的原因及類隔離的實現原理,分享兩種實現自定義類加載器的方法。
一 什么是類隔離技術
只要你 Java 代碼寫的足夠多,就一定會出現這種情況:系統新引入了一個中間件的 jar 包,編譯的時候一切正常,一運行就報錯:java.lang.NoSuchMethodError,然后就哼哧哼哧的開始找解決方法,最后在幾百個依賴包里面找的眼睛都快瞎了才找到沖突的 jar,把問題解決之后就開始吐槽中間件為啥搞那么多不同版本的 jar,寫代碼五分鐘,排包排了一整天。
上面這種情況就是 Java 開發過程中常見的情況,原因也很簡單,不同 jar 包依賴了某些通用 jar 包(如日志組件)的版本不一樣,編譯的時候沒問題,到了運行時就會因為加載的類跟預期不符合導致報錯。舉個例子:A 和 B 分別依賴了 C 的 v1 和 v2 版本,v2 版本的 Log 類比 v1 版本新增了 error 方法,現在工程里面同時引入了 A、B 兩個 jar 包,以及 C 的 v0.1、v0.2 版本,打包的時候 maven 只能選擇一個 C 的版本,假設選擇了 v1 版本。到了運行的時候,默認情況下一個項目的所有類都是用同一個類加載器加載的,所以不管你依賴了多少個版本的 C,最終只會有一個版本的 C 被加載到 JVM 中。當 B 要去訪問 Log.error,就會發現 Log 壓根就沒有 error 方法,然后就拋異常java.lang.NoSuchMethodError。這就是類沖突的一個典型案例。
類沖突的問題如果版本是向下兼容的其實很好解決,把低版本的排除掉就完事了。但要是遇到版本不向下兼容的那就陷入了“救媽媽還是救女朋友”的兩難處境了。
為了避免兩難選擇,有人就提出了類隔離技術來解決類沖突的問題。類隔離的原理也很簡單,就是讓每個模塊使用獨立的類加載器來加載,這樣不同模塊之間的依賴就不會互相影響。如下圖所示,不同的模塊用不同的類加載器加載。為什么這樣做就能解決類沖突呢?這里用到了 Java 的一個機制:不同類加載器加載的類在 JVM 看來是兩個不同的類,因為在 JVM 中一個類的唯一標識是 類加載器+類名。通過這種方式我們就能夠同時加載 C 的兩個不同版本的類,即使它類名是一樣的。注意,這里類加載器指的是類加載器的實例,并不是一定要定義兩個不同類加載器,例如圖中的 PluginClassLoaderA 和 PluginClassLoaderB 可以是同一個類加載器的不同實例。
二 如何實現類隔離
前面我們提到類隔離就是讓不同模塊的 jar 包用不同的類加載器加載,要做到這一點,就需要讓 JVM 能夠使用自定義的類加載器加載我們寫的類以及其關聯的類。
那么如何實現呢?一個很簡單的做法就是 JVM 提供一個全局類加載器的設置接口,這樣我們直接替換全局類加載器就行了,但是這樣無法解決多個自定義類加載器同時存在的問題。
實際上 JVM 提供了一種非常簡單有效的方式,我把它稱為類加載傳導規則:JVM 會選擇當前類的類加載器來加載所有該類的引用的類。例如我們定義了 TestA 和 TestB 兩個類,TestA 會引用 TestB,只要我們使用自定義的類加載器加載 TestA,那么在運行時,當 TestA 調用到 TestB 的時候,TestB 也會被 JVM 使用 TestA 的類加載器加載。依此類推,只要是 TestA 及其引用類關聯的所有 jar 包的類都會被自定義類加載器加載。通過這種方式,我們只要讓模塊的 main 方法類使用不同的類加載器加載,那么每個模塊的都會使用 main 方法類的類加載器加載的,這樣就能讓多個模塊分別使用不同類加載器。這也是 OSGi 和 SofaArk 能夠實現類隔離的核心原理。
了解了類隔離的實現原理之后,我們從重寫類加載器開始進行實操。要實現自己的類加載器,首先讓自定義的類加載器繼承 java.lang.ClassLoader,然后重寫類加載的方法,這里我們有兩個選擇,一個是重寫 findClass(String name),一個是重寫 loadClass(String name)。那么到底應該選擇哪個?這兩者有什么區別?
下面我們分別嘗試重寫這兩個方法來實現自定義類加載器。
1 重寫 findClass
首先我們定義兩個類,TestA 會打印自己的類加載器,然后調用 TestB 打印它的類加載器,我們預期是實現重寫了 findClass 方法的類加載器 MyClassLoaderParentFirst 能夠在加載了 TestA 之后,讓 TestB 也自動由 MyClassLoaderParentFirst 來進行加載。
- public class TestA {
- public static void main(String[] args) {
- TestA testA = new TestA();
- testA.hello();
- }
- public void hello() {
- System.out.println("TestA: " + this.getClass().getClassLoader());
- TestB testB = new TestB();
- testB.hello();
- }
- }
- public class TestB {
- public void hello() {
- System.out.println("TestB: " + this.getClass().getClassLoader());
- }
- }
然后重寫一下 findClass 方法,這個方法先根據文件路徑加載 class 文件,然后調用 defineClass 獲取 Class 對象。
- public class MyClassLoaderParentFirst extends ClassLoader{
- private Map<String, String> classPathMap = new HashMap<>();
- public MyClassLoaderParentFirst() {
- classPathMap.put("com.java.loader.TestA", "/Users/hansong/IdeaProjects/OhMyJava/CodeRepository/target/classes/com/java/loader/TestA.class");
- classPathMap.put("com.java.loader.TestB", "/Users/hansong/IdeaProjects/OhMyJava/CodeRepository/target/classes/com/java/loader/TestB.class");
- }
- // 重寫了 findClass 方法
- @Override
- public Class<?> findClass(String name) throws ClassNotFoundException {
- String classPath = classPathMap.get(name);
- File file = new File(classPath);
- if (!file.exists()) {
- throw new ClassNotFoundException();
- }
- byte[] classBytes = getClassData(file);
- if (classBytes == null || classBytes.length == 0) {
- throw new ClassNotFoundException();
- }
- return defineClass(classBytes, 0, classBytes.length);
- }
- private byte[] getClassData(File file) {
- try (InputStream ins = new FileInputStream(file); ByteArrayOutputStream baos = new
- ByteArrayOutputStream()) {
- byte[] buffer = new byte[4096];
- int bytesNumRead = 0;
- while ((bytesNumRead = ins.read(buffer)) != -1) {
- baos.write(buffer, 0, bytesNumRead);
- }
- return baos.toByteArray();
- } catch (FileNotFoundException e) {
- e.printStackTrace();
- } catch (IOException e) {
- e.printStackTrace();
- }
- return new byte[] {};
- }
- }
最后寫一個 main 方法調用自定義的類加載器加載 TestA,然后通過反射調用 TestA 的 main 方法打印類加載器的信息。
- public class MyTest {
- public static void main(String[] args) throws Exception {
- MyClassLoaderParentFirst myClassLoaderParentFirst = new MyClassLoaderParentFirst();
- Class testAClass = myClassLoaderParentFirst.findClass("com.java.loader.TestA");
- Method mainMethod = testAClass.getDeclaredMethod("main", String[].class);
- mainMethod.invoke(null, new Object[]{args});
- }
執行的結果如下:
- TestA: com.java.loader.MyClassLoaderParentFirst@1d44bcfa
- TestB: sun.misc.Launcher$AppClassLoader@18b4aac2
執行的結果并沒有如我們期待,TestA 確實是 MyClassLoaderParentFirst 加載的,但是 TestB 還是 AppClassLoader 加載的。這是為什么呢?
要回答這個問題,首先是要了解一個類加載的規則:JVM 在觸發類加載時調用的是 ClassLoader.loadClass 方法。這個方法的實現了雙親委派:
- 委托給父加載器查詢
- 如果父加載器查詢不到,就調用 findClass 方法進行加載
明白了這個規則之后,執行的結果的原因就找到了:JVM 確實使用了MyClassLoaderParentFirst 來加載 TestB,但是因為雙親委派的機制,TestB 被委托給了 MyClassLoaderParentFirst 的父加載器 AppClassLoader 進行加載。
你可能還好奇,為什么 MyClassLoaderParentFirst 的父加載器是 AppClassLoader?因為我們定義的 main 方法類默認情況下都是由 JDK 自帶的 AppClassLoader 加載的,根據類加載傳導規則,main 類引用的 MyClassLoaderParentFirst 也是由加載了 main 類的AppClassLoader 來加載。由于 MyClassLoaderParentFirst 的父類是 ClassLoader,ClassLoader 的默認構造方法會自動設置父加載器的值為 AppClassLoader。
- protected ClassLoader() {
- this(checkCreateClassLoader(), getSystemClassLoader());
- }
2 重寫 loadClass
由于重寫 findClass 方法會受到雙親委派機制的影響導致 TestB 被 AppClassLoader 加載,不符合類隔離的目標,所以我們只能重寫 loadClass 方法來破壞雙親委派機制。代碼如下所示:
- public class MyClassLoaderCustom extends ClassLoader {
- private ClassLoader jdkClassLoader;
- private Map<String, String> classPathMap = new HashMap<>();
- public MyClassLoaderCustom(ClassLoader jdkClassLoader) {
- this.jdkClassLoader = jdkClassLoader;
- classPathMap.put("com.java.loader.TestA", "/Users/hansong/IdeaProjects/OhMyJava/CodeRepository/target/classes/com/java/loader/TestA.class");
- classPathMap.put("com.java.loader.TestB", "/Users/hansong/IdeaProjects/OhMyJava/CodeRepository/target/classes/com/java/loader/TestB.class");
- }
- @Override
- protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
- Class result = null;
- try {
- //這里要使用 JDK 的類加載器加載 java.lang 包里面的類
- result = jdkClassLoader.loadClass(name);
- } catch (Exception e) {
- //忽略
- }
- if (result != null) {
- return result;
- }
- String classPath = classPathMap.get(name);
- File file = new File(classPath);
- if (!file.exists()) {
- throw new ClassNotFoundException();
- }
- byte[] classBytes = getClassData(file);
- if (classBytes == null || classBytes.length == 0) {
- throw new ClassNotFoundException();
- }
- return defineClass(classBytes, 0, classBytes.length);
- }
- private byte[] getClassData(File file) { //省略 }
- }
這里注意一點,我們重寫了 loadClass 方法也就是意味著所有類包括 java.lang 包里面的類都會通過 MyClassLoaderCustom 進行加載,但類隔離的目標不包括這部分 JDK 自帶的類,所以我們用 ExtClassLoader 來加載 JDK 的類,相關的代碼就是:result = jdkClassLoader.loadClass(name);
測試代碼如下:
- public class MyTest {
- public static void main(String[] args) throws Exception {
- //這里取AppClassLoader的父加載器也就是ExtClassLoader作為MyClassLoaderCustom的jdkClassLoader
- MyClassLoaderCustom myClassLoaderCustom = new MyClassLoaderCustom(Thread.currentThread().getContextClassLoader().getParent());
- Class testAClass = myClassLoaderCustom.loadClass("com.java.loader.TestA");
- Method mainMethod = testAClass.getDeclaredMethod("main", String[].class);
- mainMethod.invoke(null, new Object[]{args});
- }
- }
執行結果如下:
- TestA: com.java.loader.MyClassLoaderCustom@1d44bcfa
- TestB: com.java.loader.MyClassLoaderCustom@1d44bcfa
可以看到,通過重寫了 loadClass 方法,我們成功的讓 TestB 也使用MyClassLoaderCustom 加載到了 JVM 中。
三 總結
類隔離技術是為了解決依賴沖突而誕生的,它通過自定義類加載器破壞雙親委派機制,然后利用類加載傳導規則實現了不同模塊的類隔離。
參考資料
深入探討 Java 類加載器(https://www.ibm.com/developerworks/cn/java/j-lo-classloader/index.html)