1. 程式人生 > 程式設計 >通過原始碼,例項詳解java類載入機制

通過原始碼,例項詳解java類載入機制

之前的文章中,介紹了class的位元組碼靜態結構,這些類需要jvm載入到其在記憶體中分配的執行時資料區才會生效,這個過程包含:載入 -> 連結 -> 初始化 幾個階段,其中連結階段又有驗證 -> 準備 -> 解析三個部分,接下來我會用三篇文章分別詳細介紹這三個階段,本文先介紹jvm類的載入以及雙親委派模型的概念。

注意:類載入包含了從位元組碼流到jvm方法區java.lang.Class物件建立並初始化整個過程,而本文介紹的類的載入只是其中一個階段

執行時資料區

本文內容基於hotspot jvm,執行時Class物件就儲存在Method Area中

類載入時機

什麼時候開始載入一個類的位元組碼呢?對此jvm規範並沒有給出明確定義,但是jvm規範明確規定了類初始化的時機,根據類的載入發生在其初始化之前,可以反推出類其載入的觸發條件。

注:本文的類是泛指,還包括介面等

jvm規範定義了有且僅有5種情況下如果類還沒有初始化,會觸發類的初始化:

  • 虛擬機器器啟動所指定的主類(包含main方法的類)先被載入並初始化
  • 初始化一個字類的時候,遞迴初始化其父類
  • 執行new,getstatic,putstatic,invokestatic指令時
  • 通過反射呼叫使用類時
  • java.lang.invoke.MethodHandle例項解析結果為REF_getStatic
    ,REF_putStatic,REF_invokeStatic方法控制程式碼時

上面幾種情況都很好理解,當前類引用了某個類並且使用了它,自然需要初始化,也自然要載入它,可以通過-XX:+TraceClassLoading檢視載入的類。關於初始化,以後的文章會單獨詳細介紹

類載入器

類的載入就是把一個類的位元組碼靜態結構通過jvm載入,並建立一個對應的java.lang.Class物件,儲存在自己的執行時方法區記憶體空間,此後,這個類的資料便通過這個Class物件來訪問,包括其類field,方法等。

位元組碼不僅是侷限於本地檔案系統中的檔案,也可能是在記憶體中(動態生成),網路上,壓縮包(jar,war)等,而類載入器的職責就是從這些地方載入位元組碼到jvm中。

類載入器按其實現可以分為兩類:引導類載入器(Bootstrap Class Loader),使用者類載入器(User-defined Class Loader)

  • 引導類載入器:載入$JAVA_HOME/jre/lib/下核心類庫,如rt.jar,hotspot jvm中由C++實現

  • 使用者類載入器:所有使用者類載入器都繼承了java.lang.ClassLoader抽象類,sun提供了兩個使用者類載入器,我們也可以定義自己的類載入器

    • 擴充套件類載入器(ExtClassLoader):sun.misc.Launcher$ExtClassLoader,負責載入$JAVA_HOME/jre/lib/ext下的一些擴充套件類

    • 應用類載入器(AppClassLoader):可由ClassLoader.getSystemClassLoader()方法獲得,也稱系統類載入器,負責載入使用者(classpath中)定義的類。

    • 自定義類載入器(Custom ClassLoader):使用者也可以定義自己的類載入器,實現一些定製的功能

關於類載入器補充幾點:

  1. 使用者類載入器都實現了 java.lang.ClassLoader抽象類,該類又個private final ClassLoader parent欄位表示一個載入器的父載入器(設計模式中推薦使用這種組合的方式來代替繼承),這是實現雙親委派模型的關鍵。
  2. 引導類載入器之載入jre lib目錄(或-Xbootclasspath指定)下的類,並且只識別特定檔名如rt.jar,所以不會載入使用者的類
  3. 對於陣列,並不存在陣列型別的位元組碼錶示形式,它由jvm負責建立,一般在碰到newarray指令進行初始化時,如果陣列的元素型別不是基本型別(如int[]),而是引用型別(如Integer[]),則會先載入基本型別,這可能由引導類載入器或使用者類載入器載入,具體看引用型別是什麼。
  4. jvm會快取已載入過的類,並設定載入相應類的載入器,見下文
  5. 一個類和載入它的類載入器(定義類載入器)共同確定一個類的唯一性

下面通過例項來看一下:

/**
 * 自定義類載入器,重寫loadClass,優先在當前目錄載入
 */
public class MyClassLoader extends ClassLoader {
    @Override
    public Class<?> loadClass(String name) throws ClassNotFoundException {
        try {
            InputStream is = new FileInputStream("./" + name + ".class");
            byte[] data = new byte[is.available()];
            is.read(data);
            return defineClass(name,data,0,data.length);
        } catch (IOException e) {
            return super.loadClass(name);
        }
    }
}
複製程式碼
public class Callee {
    public Callee() {
        System.out.println("Callee class loaded by " + this.getClass().getClassLoader().getClass().getName());
    }
}
複製程式碼
/**
 * Run: javac MyClassLoader.java Callee.java Test.java && java Test
 * /
public class Test {
    public static void main(String[] args) throws Exception {
        ClassLoader myClassLoader = new MyClassLoader();

        Class<?> calleeClass = myClassLoader.loadClass("Callee");

        //輸出:calleeClass == Callee.class ? false
        System.out.println("calleeClass == Callee.class ? " + (calleeClass == Callee.class));

        //輸出:Callee class loaded by sun.misc.Launcher$AppClassLoader
        Callee.class.newInstance();
        //輸出:Callee class loaded by MyClassLoader
        Object calleeObj = calleeClass.newInstance();

    }
}
複製程式碼

可以看出,雖然是同一個類Callee,但由於是不同類載入器載入,所以Class例項並不是同一個。

雙親委派模型

所謂雙親委派模型是指一個類載入器在載入某個類時,首先把委派給父載入器去載入,父載入器又委派給它的父載入器載入,如此頂層的引導類載入器為止,如果其父載入器在其搜尋範圍沒有找到相應類,則嘗試自己載入。

從雙親委派模型的定義可以看出,它要求每個載入器都有一個父載入器,如果某個類載入器的父載入器為null,則搜尋引導類載入器是否載入過它要載入的類。

可以看出首先接收載入請求的類載入器並不一定真正載入類,可能由它的父載入器完成載入,接收載入請求的類載入器叫做初始類載入器(initiating loader),而完成載入的類載入器叫做定義類載入器(defining loader),初始類載入器和定義類載入器可能相同也可能不同。

如果兩個類:D引用了C,L1作為D的定義類載入器,在解析D時會去載入C,這個載入請求由L1接收,假設C由另一個載入器L2載入,則L1最終將載入請求委託給L2,L1就稱為C的初始載入器,L2是C的定義類載入器。

下面看看ClassLoader怎麼實現雙親委派載入的:

protected Class<?> loadClass(String name,boolean resolve)
        throws ClassNotFoundException
    {
    	 // 一個類的載入是放在程式碼同步塊裡邊的,所以不會有同一個類載入多次
        synchronized (getClassLoadingLock(name)) {
            // 首先檢查該類是否已載入過
            Class<?> c = findLoadedClass(name);
            // 如果快取中沒有找到,則按雙親委派模型載入
            if (c == null) {
                try {
                    if (parent != null) {
                    	// 如果父載入器不為null,則代理給父載入器載入
                    	// 父載入器在自己搜尋範圍內找不到該類,則丟擲ClassNotFoundException
                        c = parent.loadClass(name,false);
                    } else {
                    	// 如果父載入器為null,則從引導類載入器載入過的類中
                    	// 找是否載入過此類,找不到返回null
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // 存在父載入器但父載入器沒有找到要載入的類觸發此異常
                    // 只捕獲不處理,交給字載入器自身去載入
                }

                if (c == null) {
                    // 如果從父載入器到頂層載入器(引導類載入器)都找不到此類,則自己來載入
                    c = findClass(name);
                }
            }
            
            // 如果resolve指定為true,則立即進入連結階段
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }
複製程式碼

通過原始碼可以看出,所有的類都優先委派給父載入器載入,如果父載入器無法載入,則自己來載入,邏輯很簡單,這樣做的好處是不用層次的類交給不同的載入器去載入,如java.lang.Integer最終都是由Bootstrap ClassLoader來載入的,這樣只會有一個相同類被載入。

再來說說裡邊呼叫的幾個方法:

  • getClassLoadingLock
protected Object getClassLoadingLock(String className) {
    Object lock = this;
    if (parallelLockMap != null) {
        Object newLock = new Object();
        lock = parallelLockMap.putIfAbsent(className,newLock);
        if (lock == null) {
            lock = newLock;
        }
    }
    return lock;
}
複製程式碼

該方法很簡單,parallelLockMap是一個ConcurrentHashMap<String,Object> map物件,如果當前classloader註冊為可並行載入的,則為每一個類名維護一個鎖物件供synchronized使用,可並行載入不同類,否則以當前classloader作為鎖物件,只能序列載入。

  • findBootstrapClassOrNull
private Class<?> findBootstrapClassOrNull(String name)
{
   if (!checkName(name)) 
   		return null;
   
   return findBootstrapClass(name);
}
複製程式碼
private native Class<?> findBootstrapClass(String name);
複製程式碼

findBootstrapClass是jvm原生實現,查詢Bootstrap ClassLoader已載入的類,沒有則返回null

  • findClss
protected Class<?> findClass(String name) throws ClassNotFoundException {
    throw new ClassNotFoundException(name);
}
複製程式碼

findClass交給子載入器實現,我們一般重寫該方法來實現自己的類載入器,這樣實現的類載入器也符合雙親委派模型。當然,雙親委派的邏輯都是在loadClass實現的,可以自己重寫loadClass來打破雙親委派邏輯。

自定義類載入器:

/**
 * Run: javac MyClassLoader.java Callee.java Test.java && java Test
 */
public class MyClassLoader extends ClassLoader {

    @Override
    public Class<?> findClass(String name) throws ClassNotFoundException {
        try {
            InputStream is = new FileInputStream("./" + name + ".class");
            byte[] data = new byte[is.available()];
            is.read(data);
            return defineClass(name,data.length);
        } catch (IOException e) {
            return super.loadClass(name);
        }
    }
}

public static void main(String[] args) throws Exception {
    ClassLoader myClassLoader = new MyClassLoader();
    Class<?> callerClass = myClassLoader.loadClass("Callee");
    // 輸出:Callee class loaded by sun.misc.Launcher$AppClassLoader
    callerClass.newInstance();
}
複製程式碼

可以看出,只需吧前面的示例方法名改為findClass就可以了,而且可以看到是由應用類載入器負責載入的(預設父載入器是AppClassLoader),符合雙親委派模型。

再來做個實驗:

// 讓自定義類載入器載入/tmp目錄下的類
InputStream is = new FileInputStream("/tmp/" + name + ".class");
複製程式碼

把剛編譯的Callee.class移動至/tmp下(注意:當前目錄不要也保留一份):

 mv Callee.class /tmp
複製程式碼

再次編譯執行:

javac MyClassLoader.java && java Test
複製程式碼

結果:

Callee class loaded by MyClassLoader
複製程式碼

Callee變成由自定義類載入器載入了,因為向上委託時都找不到該類,自定義載入器findClass方法起了作用。

再來做個有趣的實驗:

定義一個類Caller裡邊呼叫了Callee

public class Caller {
    public Caller() {
        System.out.println("Caller class loaded by " + this.getClass().getClassLoader().getClass().getName());
        Callee callee = new Callee();
    }
}
複製程式碼

修改Test.java,載入Caller

Class<?> callerClass = myClassLoader.loadClass("Caller");
複製程式碼

再次編譯執行:

javac MyClassLoader.java Caller.java Test.java
mv Callee.class /tmp # 保證當前目錄下沒有Callee.class,/tmp下有
java Test
複製程式碼

為什麼/tmp下有Callee.class但沒有載入到呢?其實很好理解:輸出第一句看出AppClassLoader載入了Caller.class,作為它的定義類載入器,當Caller中使用了Callee需要載入Callee.class的時候,AppClassLoader就會作為Callee.class的初始載入器去載入它,根據雙親委派模型,最後AppClassLoader呼叫自己的findClass嘗試自己載入,classpath下沒有這個類,肯定找不到~

這個例子還可以看出:真正去載入類的類載入器(呼叫findClass方法)找不到類丟擲ClassNotFoundException,此異常被封裝成NoClassDefFoundError丟擲給使用的地方(初始類載入器),這種錯誤很常見。

反雙親委派模型

雙親委派模型很好的解決了載入類統一的問題,類載入都是由子載入器向上委派給父載入器載入,這樣載入的類具有層次,但如果在父載入器載入的類中又要呼叫子載入器載入的類怎麼辦呢?

比如兩個載入器L1,L2(L1 extends L2),L2載入了類A,類A中使用了類B(類B在L1搜尋範圍內,應由L1載入),則L2作為類B的初始載入器並向上委託父載入器載入,最終,父載入器載入失敗,L2嘗試自己載入。可以想象,L2在自己搜尋範圍也找不到類B,最終載入失敗。

要解決這個問題就要適當打破雙親委派模型的限制:

  • Thread Context Class Loader

執行緒上下文載入器, 最典型的應用場景就是SPI技術,像JDBC,JNDI,JAXP等,介面規範都是由java核心類庫來定義的,而規範的具體實現則是由不同廠商提供的,要在類庫程式碼中呼叫使用者程式碼時,就需通過執行緒上下文載入器來完成了。

可以通過Thread物件的setContextClassLoader方法設定當前執行緒上下文載入器,如果沒有設定,則從父執行緒繼承,如果父執行緒也沒有設定過,那麼就取應用類載入器(AppClassLoader)作為執行緒上下文載入器。

//ServiceLoader.java
public static <S> ServiceLoader<S> load(Class<S> service) {
    ClassLoader cl = Thread.currentThread().getContextClassLoader();
    return ServiceLoader.load(service,cl);
}

private ServiceLoader(Class<S> svc,ClassLoader cl) {
    service = Objects.requireNonNull(svc,"Service interface cannot be null");
    loader = (cl == null) ? ClassLoader.getSystemClassLoader() : cl;
    acc = (System.getSecurityManager() != null) ? AccessController.getContext() : null;
    reload();
}
複製程式碼
  • tomcat類載入機制

tomcat採用不同的類載入機制,主要為瞭解決兩個問題:

  1. 公共的類庫(如servlet-api.jar)需要共享
  2. 不同的應用可以依賴同一類的不同版本,不同應用相互隔離,互不影響

瞭解了目的,再來看看tomcat採取了那種措施:

本文不打算介紹原始碼,以後我會寫一個tomcat原始碼系列

注:不同的jvm實現不太相同,這裡的Bootstrap泛指hotspot中的Bootstrap和ExtClassloader

這是tomcat6之前的架構,common,Server(Catalina),Shared分別載入tomcat /common,/server,/shared 下的類,不過現在的版本(tomcat9)中如果配置了server.loader,shared.loader依然適用。

如果沒有配置,tomcat依然建立commonLoader,catalinaLoader,sharedLoader三個類載入器(都是common類載入器例項,載入/lib目錄下的類),所以一般架構如下:

現在再來看:

  1. common載入器遵循雙親載入模型,基本類庫不重複
  2. WebappX載入器對應每個應用一個,載入/WEB-INF/classes,/WEB-INF/lib/*下的類,應用級別隔離

問題完美解決,WebappX載入順序:

  1. 先交給Bootstrap loader載入
  2. 在應用/WEB-INF/classes下查詢載入
  3. 在應用/WEB-INF/lib/*.jar下查詢載入
  4. 轉交給系統類載入器載入(classpath)
  5. 交給Common 類載入器載入(/lib)

可以看到/class,/lib目錄下類載入優先順序高於系統類載入器和common類載入器。

可以配置<Loader delegate="true"/>強行讓其按雙親委派模型載入

原文地址:原文

往期內容:

詳解位元組碼(class)檔案

讀取class檔案

歡迎關注!