JVM 雙親委派模型及 SPI 實現分析

語言: CN / TW / HK

雙親委派模型

雙親委派模型的工作機制是:當類載入器接收到類載入的請求時,它不會自己去嘗試載入這個類,而是把這個請求委派給父載入器去完成,只有當父類載入器反饋自己無法完成這個載入請求時,子載入器才會嘗試自己去載入類。

ClassLoader#loadClass(String, boolean)

關於雙親委派機制,我們可以通過 JDK 中 CLassLoader 類的 loadClass 方法來一看究竟。

public class ClassLoader {
    protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException{
        synchronized (getClassLoadingLock(name)) {
            // First, check if the class has already been loaded
            // 首先,檢查該類是否已經被當前類載入器載入,若當前類載入未載入過該類,呼叫父類的載入類方法去載入該類(如果父類為null的話交給啟動類載入器載入)
            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) {
                    // ClassNotFoundException thrown if class not found
                    // from the non-null parent class loader
                }
    
                if (c == null) {
                    // If still not found, then invoke findClass in order
                    // to find the class.
                    // 如果父類未完成載入,使用當前類載入器去載入該類
                    long t1 = System.nanoTime();
                    c = findClass(name);
    
                    // this is the defining class loader; record the stats
                    sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    sun.misc.PerfCounter.getFindClasses().increment();
                }
            }
            if (resolve) {
                // 連結指定的類
                resolveClass(c);
            }
            return c;
        }
    }
}
複製程式碼

參照上面的程式碼,雙親委派機制有如下的流程:

  1. 當類載入器接收到類載入的請求時,首先檢查該類是否已經被當前類載入器載入
  2. 若該類未被載入過,當前類載入器會將載入請求委託給父類載入器去完成
  3. 若當前類載入器的父類載入器(或父類的父類……向上遞迴)為 null,會委託啟動類載入器完成載入
  4. 若父類載入器無法完成類的載入,當前類載入器才會去嘗試載入該類

類載入器分類

我們知道在 JVM 中預定義的類載入器有3種:啟動類載入器(Bootstrap ClassLoader)、擴充套件類載入器(Extension ClassLoader)、應用類/系統類載入器(App/System ClassLoader),另外還有一種是使用者自定義的類載入器。

首先來介紹一下上述三種類載入器的職能:

  1. 啟動類載入器Bootstrap ClassLoader

    • 啟動類載入器作為所有類載入器的"老祖宗",是由C++實現的,不繼承於java.lang.ClassLoader類。它在虛擬機器啟動時會由虛擬機器的一段C++程式碼進行載入,所以它沒有父類載入器,在載入完成後,它會負責去載入擴充套件類載入器和應用類載入器。
    • 啟動類載入器用於載入 Java 的核心類——位於<JAVA_HOME>\lib中,或者被-Xbootclasspath引數所指定的路徑中,並且是虛擬機器能夠識別的類庫(僅按照檔名識別,如rt.jar、tools.jar,名字不符合的類庫即使放在lib目錄中也不會被載入)。
  2. 拓展類載入器Extension ClassLoader

    • 拓展類載入器繼承於java.lang.ClassLoader類,它的父類載入器是啟動類載入器,而啟動類載入器在 Java 中的顯示就是 null。

    引自 jdk1.8 ClassLoader#getParent() 方法的註釋,這個方法是用於獲取類載入器的父類載入器: Returns the parent class loader for delegation. Some implementations may use null to represent the bootstrap class loader. This method will return null in such implementations if this class loader's parent is the bootstrap class loader.

    • 拓展類載入器負責載入<JAVA_HOME>\lib\ext目錄中的,或者被java.ext.dirs系統變數所指定的路徑的所有類。
    • 需要注意的是擴充套件類載入器僅支援載入被打包為.jar格式的位元組碼檔案。
  3. 應用類/系統類載入器App/System ClassLoader

    • 應用類載入器繼承於java.lang.ClassLoader類,它的父類載入器是擴充套件類載入器。
    • 應用類載入器負責載入使用者類路徑classpath上所指定的類庫。
    • 如果應用程式中沒有自定義的類載入器,一般情況下應用類載入器就是程式中預設的類載入器。
  4. 自定義類載入器Custom ClassLoader

    • 自定義類載入器繼承於java.lang.ClassLoader類,它的父類載入器是應用類載入器。
    • 這是普某戶籍自定義的類載入器,可載入指定路徑的位元組碼檔案。

雙親委派模型的好處

  1. 基於雙親委派模型規定的這種帶有優先順序的層次性關係,虛擬機器執行程式時就能夠避免類的重複載入。當父類類載入器已經載入過類時,如果再有該類的載入請求傳遞到子類類載入器,子類類載入器執行loadClass方法,然後委託給父類類載入器嘗試載入該類,但是父類類載入器執行Class<?> c = findLoadedClass(name);檢查該類是否已經被載入過這一階段就會檢查到該類已經被載入過,直接返回該類,而不會再次載入此類。
  2. 雙親委派模型能夠避免核心類篡改。一般我們描述的核心類是rt.jar、tools.jar這些由啟動類載入器載入的類,這些類庫在日常開發中被廣泛運用,如果被篡改,後果將不堪設想。假設我們自定義了一個java.lang.Integer類,與好處1一樣的流程,當載入類的請求傳遞到啟動類載入器時,啟動類載入器執行findLoadedClass(String)方法發現java.lang.Integer已經被載入過,然後直接返回該類,載入該類的請求結束。雖然避免核心類被篡改這一點的原因與避免類的重複載入一致,但這還是能夠作為雙親委派模型的好處之一的。

雙親委派模型的不足

這裡所說的不足也可以理解為打破雙親委派模型,當雙親委派模型不滿足使用者需求時,自然是由於其不足之處,也就促使使用者將其打破。這裡描述的也就是打破雙親委派模型的三種方式。

  1. 由於歷史原因(ClassLoader類在 JDK1.0 時就已經存在,而雙親委派模型是在 JDK1.2 之後才引入的),在未引入雙親委派模型時,使用者自定義的類載入器需要繼承java.lang.ClassLoader類並重寫loadClass()方法,因為虛擬機器在載入類時會呼叫ClassLoader#loadClassInternal(String),而這個方法(原始碼如下)會呼叫自定義類載入重寫的loadClass()方法。而在引入雙親委派模型後,ClassLoader#loadClass方法實際就是雙親委派模型的實現,如果重寫了此方法,相當於打破了雙親委派模型。為了讓使用者自定義的類載入器也遵從雙親委派模型, JDK 新增了findClass方法,用於實現自定義的類載入邏輯。

    class ClassLoader {
        // This method is invoked by the virtual machine to load a class.
        private Class<?> loadClassInternal(String name) throws ClassNotFoundException{
            // For backward compatibility, explicitly lock on 'this' when
            // the current class loader is not parallel capable.
            if (parallelLockMap == null) {
                synchronized (this) {
                     return loadClass(name);
                }
            } else {
                return loadClass(name);
            }
        }
        // 其餘方法省略......
    }
    複製程式碼
  2. 由於雙親委派模型規定的層次性關係,導致子類類載入器載入的類能訪問父類類載入器載入的類,而父類類載入器載入的類無法訪問子類類載入器載入的類。為了讓上層類載入器載入的類能夠訪問下層類載入器載入的類,或者說讓父類類載入器委託子類類載入器完成載入請求,JDK 引入了執行緒上下文類載入器,藉由它來打破雙親委派模型的屏障。

  3. 當用戶需要程式的動態性,比如程式碼熱替換、模組熱部署等時,雙親委派模型就不再適用,類載入器會發展為更為複雜的網狀結構。

執行緒上下文類載入器

上面說到雙親委派模型的不足時提到了執行緒上下文類載入器Thread Context ClassLoader,執行緒上下文類載入器是定義在Thread類中的一個ClassLoader型別的私有成員變數,它指向了當前執行緒的類載入器。上文已經提到執行緒上下文類載入能夠讓父類類載入器委託子類類載入器完成載入請求,那麼這是如何實現的呢?下面就來討論一下。

SPI 在 JDBC 中的應用

我們知道 Java 提供了一些 SPI(Service Provider Interface) 介面,它允許服務商編寫具體的程式碼邏輯來完成該介面的功能。但是 Java 提供的 SPI 介面是在核心類庫中,由啟動類載入器載入的,廠商實現的具體邏輯程式碼是在 classpath 中,是由應用類載入器載入的,而啟動類載入器載入的類無法訪問應用類載入器載入的類,也就是說啟動類載入器無法找到 SPI 實現類,單單依靠雙親委派模型就無法實現 SPI 的功能了,所以執行緒上下文類載入器應運而生。

在 Java 提供的 SPI 中我們最常用的可能就屬 JDBC 了,下面我們就以 JDBC 為例來看一下執行緒上下文類載入器如何打破雙親委派模型。

回憶一下以前使用 JDBC 的場景,我們需要建立驅動,然後建立連線,就像下面的程式碼這樣:

public class ThreadContextClassLoaderDemoOfJdbc {

    public static void main(String[] args) throws Exception {
        // 載入 Driver 的實現類
        Class.forName("com.mysql.jdbc.Driver");
        // 建立連線
        Connection conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/mysql", "root", "admin");
    }
}
複製程式碼

在 JDK1.6 以後可以不用寫Class.forName("com.mysql.jdbc.Driver");,程式碼依舊能正常執行。這是因為自帶的 jdbc4.0 版本已支援 SPI 服務載入機制,只要服務商的實現類在 classpath 路徑中,Java 程式會主動且自動去載入符合 SPI 規範的具體的驅動實現類,驅動的全限定類名在META-INF.services檔案中。

所以,讓我們把目光聚焦於建立連線的語句,這裡呼叫了DriverManager類的靜態方法getConnection。在呼叫此方法前,根據類載入機制的初始化時機,呼叫類的靜態方法會觸發類的初始化,當DriverManager類被初始化時,會執行它的靜態程式碼塊。

public class DriverManager {

    static {
        loadInitialDrivers();
        println("JDBC DriverManager initialized");
    }

    private static void loadInitialDrivers() {
        String drivers;
        // 省略程式碼:首先讀取系統屬性 jdbc.drivers
        
        // 通過 SPI 載入 classpath 中的驅動類
        AccessController.doPrivileged(new PrivilegedAction<Void>() {
            public Void run() {
                // ServiceLoader 類是 SPI 載入的工具類
                ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
                Iterator<Driver> driversIterator = loadedDrivers.iterator();
                try{
                    while(driversIterator.hasNext()) {
                        driversIterator.next();
                    }
                } catch(Throwable t) {
                // Do nothing
                }
                return null;
            }
        });
        // 省略程式碼:使用應用類載入器繼續載入系統屬性 jdbc.drivers 中的驅動類
    }

}
複製程式碼

從上面的程式碼中可以看到,程式時通過呼叫ServiceLoader類來完成自動載入 classpath 路徑中具體的所有廠商實現驅動的。

public final class ServiceLoader<S> implements Iterable<S>{
    public static <S> ServiceLoader<S> load(Class<S> service) {
        // 獲取當前執行緒的執行緒上下文類載入器 AppClassLoader,用於載入 classpath 中的具體實現類
        ClassLoader cl = Thread.currentThread().getContextClassLoader();
        return ServiceLoader.load(service, cl);
    }
}
複製程式碼

通過跟蹤程式碼,不難看出ServiceLoader#load(Class)方法建立了一個LazyIterator類同時返回了一個ServiceLoader物件,前者是一個懶載入的迭代器,同時它也是後者的一個成員變數,當對迭代器進行遍歷時,就觸發了目標介面實現類的載入。

private class LazyIterator implements Iterator<S> {

    public S next() {
        if (acc == null) {
            return nextService();
        } else {
            PrivilegedAction<S> action = new PrivilegedAction<S>() {
                public S run() { return nextService(); }
            };
            return AccessController.doPrivileged(action, acc);
        }
    }
    
    private S nextService() {
        if (!hasNextService())
            throw new NoSuchElementException();
        String cn = nextName;
        nextName = null;
        Class<?> c = null;
        try {
            // 不用寫 Class.forName("com.mysql.jdbc.Driver"); 的原因就是在此處會自動呼叫這個方法
            c = Class.forName(cn, false, loader);
        } catch (ClassNotFoundException x) {
            fail(service, "Provider " + cn + " not found");
        }
        if (!service.isAssignableFrom(c)) {
            fail(service, "Provider " + cn  + " not a subtype");
        }
        try {
            S p = service.cast(c.newInstance());
            providers.put(cn, p);
            return p;
        } catch (Throwable x) {
            fail(service, "Provider " + cn + " could not be instantiated", x);
        }
        throw new Error();          // This cannot happen
    }

}
複製程式碼

上面這段程式碼中的c = Class.forName(cn, false, loader);就進行了類的載入,而這裡傳入的引數 cn 是全路徑類名,false 是指不進行初始化,loader 則是指定完成 cn 類載入的類載入器。在這裡的 loader 變數,通過程式碼跟蹤,可以發現它最終是在ServiceLoader.load(Class, ClassLoader);方法中指定的——loader = (cl == null) ? ClassLoader.getSystemClassLoader() : cl;,而此處的 cl 變數就是呼叫DriverManager類靜態方法的執行緒上下文類載入器,即應用類載入器。

也就是說,通過DriverManager類的靜態方法,實現了由ServiceLoader類觸發載入位於 classpath 的廠商實現的驅動類。我們知道ServiceLoader類位於 java.util 包中,是由啟動類載入器載入的,而由啟動類載入器載入的類竟然實現了"委派"應用類載入器去載入驅動類,這無疑是與雙親委派機制相悖的。而實現這個功能的,就是執行緒上下文類載入器。