深入理解Java類載入器(ClassLoader)

shinezejian發表於2017-06-26
【版權申明】未經博主同意,謝絕轉載!(請尊重原創,博主保留追究權)

本篇博文主要是探討類載入器,同時在本篇中列舉的原始碼都基於Java8版本,不同的版本可能有些許差異。主要內容如下

類載入的機制的層次結構

每個編寫的".java"擴充名類檔案都儲存著需要執行的程式邏輯,這些".java"檔案經過Java編譯器編譯成擴充名為".class"的檔案,".class"檔案中儲存著Java程式碼經轉換後的虛擬機器指令,當需要使用某個類時,虛擬機器將會載入它的".class"檔案,並建立對應的class物件,將class檔案載入到虛擬機器的記憶體,這個過程稱為類載入,這裡我們需要了解一下類載入的過程,如下:

  • 載入:類載入過程的一個階段:通過一個類的完全限定查詢此類位元組碼檔案,並利用位元組碼檔案建立一個Class物件

  • 驗證:目的在於確保Class檔案的位元組流中包含資訊符合當前虛擬機器要求,不會危害虛擬機器自身安全。主要包括四種驗證,檔案格式驗證,後設資料驗證,位元組碼驗證,符號引用驗證。

  • 準備:為類變數(即static修飾的欄位變數)分配記憶體並且設定該類變數的初始值即0(如static int i=5;這裡只將i初始化為0,至於5的值將在初始化時賦值),這裡不包含用final修飾的static,因為final在編譯的時候就會分配了,注意這裡不會為例項變數分配初始化,類變數會分配在方法區中,而例項變數是會隨著物件一起分配到Java堆中。

  • 解析:主要將常量池中的符號引用替換為直接引用的過程。符號引用就是一組符號來描述目標,可以是任何字面量,而直接引用就是直接指向目標的指標、相對偏移量或一個間接定位到目標的控制程式碼。有類或介面的解析,欄位解析,類方法解析,介面方法解析(這裡涉及到位元組碼變數的引用,如需更詳細瞭解,可參考《深入Java虛擬機器》)。

  • 初始化:類載入最後階段,若該類具有超類,則對其進行初始化,執行靜態初始化器和靜態初始化成員變數(如前面只初始化了預設值的static變數將會在這個階段賦值,成員變數也將被初始化)。

這便是類載入的5個過程,而類載入器的任務是根據一個類的全限定名來讀取此類的二進位制位元組流到JVM中,然後轉換為一個與目標類對應的java.lang.Class物件例項,在虛擬機器提供了3種類載入器,引導(Bootstrap)類載入器、擴充套件(Extension)類載入器、系統(System)類載入器(也稱應用類載入器),下面分別介紹

##啟動(Bootstrap)類載入器
啟動類載入器主要載入的是JVM自身需要的類,這個類載入使用C++語言實現的,是虛擬機器自身的一部分,它負責將 <JAVA_HOME>/lib路徑下的核心類庫或-Xbootclasspath引數指定的路徑下的jar包載入到記憶體中,注意必由於虛擬機器是按照檔名識別載入jar包的,如rt.jar,如果檔名不被虛擬機器識別,即使把jar包丟到lib目錄下也是沒有作用的(出於安全考慮,Bootstrap啟動類載入器只載入包名為java、javax、sun等開頭的類)。

##擴充套件(Extension)類載入器
擴充套件類載入器是指Sun公司(已被Oracle收購)實現的sun.misc.Launcher$ExtClassLoader類,由Java語言實現的,是Launcher的靜態內部類,它負責載入<JAVA_HOME>/lib/ext目錄下或者由系統變數-Djava.ext.dir指定位路徑中的類庫,開發者可以直接使用標準擴充套件類載入器。

//ExtClassLoader類中獲取路徑的程式碼
private static File[] getExtDirs() {
     //載入<JAVA_HOME>/lib/ext目錄中的類庫
     String s = System.getProperty("java.ext.dirs");
     File[] dirs;
     if (s != null) {
         StringTokenizer st =
             new StringTokenizer(s, File.pathSeparator);
         int count = st.countTokens();
         dirs = new File[count];
         for (int i = 0; i < count; i++) {
             dirs[i] = new File(st.nextToken());
         }
     } else {
         dirs = new File[0];
     }
     return dirs;
 }複製程式碼

系統(System)類載入器

也稱應用程式載入器是指 Sun公司實現的sun.misc.Launcher$AppClassLoader。它負責載入系統類路徑java -classpath-D java.class.path 指定路徑下的類庫,也就是我們經常用到的classpath路徑,開發者可以直接使用系統類載入器,一般情況下該類載入是程式中預設的類載入器,通過ClassLoader#getSystemClassLoader()方法可以獲取到該類載入器。
  在Java的日常應用程式開發中,類的載入幾乎是由上述3種類載入器相互配合執行的,在必要時,我們還可以自定義類載入器,需要注意的是,Java虛擬機器對class檔案採用的是按需載入的方式,也就是說當需要使用該類時才會將它的class檔案載入到記憶體生成class物件,而且載入某個類的class檔案時,Java虛擬機器採用的是雙親委派模式即把請求交由父類處理,它一種任務委派模式,下面我們進一步瞭解它。  

理解雙親委派模式

雙親委派模式工作原理

雙親委派模式要求除了頂層的啟動類載入器外,其餘的類載入器都應當有自己的父類載入器,請注意雙親委派模式中的父子關係並非通常所說的類繼承關係,而是採用組合關係來複用父類載入器的相關程式碼,類載入器間的關係如下:

雙親委派模式是在Java 1.2後引入的,其工作原理的是,如果一個類載入器收到了類載入請求,它並不會自己先去載入,而是把這個請求委託給父類的載入器去執行,如果父類載入器還存在其父類載入器,則進一步向上委託,依次遞迴,請求最終將到達頂層的啟動類載入器,如果父類載入器可以完成類載入任務,就成功返回,倘若父類載入器無法完成此載入任務,子載入器才會嘗試自己去載入,這就是雙親委派模式,即每個兒子都很懶,每次有活就丟給父親去幹,直到父親說這件事我也幹不了時,兒子自己想辦法去完成,這不就是傳說中的實力坑爹啊?那麼採用這種模式有啥用呢?

雙親委派模式優勢

採用雙親委派模式的是好處是Java類隨著它的類載入器一起具備了一種帶有優先順序的層次關係,通過這種層級關可以避免類的重複載入,當父親已經載入了該類時,就沒有必要子ClassLoader再載入一次。其次是考慮到安全因素,java核心api中定義型別不會被隨意替換,假設通過網路傳遞一個名為java.lang.Integer的類,通過雙親委託模式傳遞到啟動類載入器,而啟動類載入器在核心Java API發現這個名字的類,發現該類已被載入,並不會重新載入網路傳遞的過來的java.lang.Integer,而直接返回已載入過的Integer.class,這樣便可以防止核心API庫被隨意篡改。可能你會想,如果我們在classpath路徑下自定義一個名為java.lang.SingleInterge類(該類是胡編的)呢?該類並不存在java.lang中,經過雙親委託模式,傳遞到啟動類載入器中,由於父類載入器路徑下並沒有該類,所以不會載入,將反向委託給子類載入器載入,最終會通過系統類載入器載入該類。但是這樣做是不允許,因為java.lang是核心API包,需要訪問許可權,強制載入將會報出如下異常

java.lang.SecurityException: Prohibited package name: java.lang複製程式碼

所以無論如何都無法載入成功的。下面我們從程式碼層面瞭解幾個Java中定義的類載入器及其雙親委派模式的實現,它們類圖關係如下

從圖可以看出頂層的類載入器是ClassLoader類,它是一個抽象類,其後所有的類載入器都繼承自ClassLoader(不包括啟動類載入器),這裡我們主要介紹ClassLoader中幾個比較重要的方法。

  • loadClass(String)

    該方法載入指定名稱(包括包名)的二進位制型別,該方法在JDK1.2之後不再建議使用者重寫但使用者可以直接呼叫該方法,loadClass()方法是ClassLoader類自己實現的,該方法中的邏輯就是雙親委派模式的實現,其原始碼如下,loadClass(String name, boolean resolve)是一個過載方法,resolve引數代表是否生成class物件的同時進行解析相關操作。

      protected Class<?> loadClass(String name, boolean resolve)
            throws ClassNotFoundException
        {
            synchronized (getClassLoadingLock(name)) {
                // 先從快取查詢該class物件,找到就不用重新載入
                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
                        // 如果都沒有找到,則通過自定義實現的findClass去查詢並載入
                        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;
            }
        }複製程式碼

    正如loadClass方法所展示的,當類載入請求到來時,先從快取中查詢該類物件,如果存在直接返回,如果不存在則交給該類載入去的父載入器去載入,倘若沒有父載入則交給頂級啟動類載入器去載入,最後倘若仍沒有找到,則使用findClass()方法去載入(關於findClass()稍後會進一步介紹)。從loadClass實現也可以知道如果不想重新定義載入類的規則,也沒有複雜的邏輯,只想在執行時載入自己指定的類,那麼我們可以直接使用this.getClass().getClassLoder.loadClass("className"),這樣就可以直接呼叫ClassLoader的loadClass方法獲取到class物件。

  • findClass(String)
    在JDK1.2之前,在自定義類載入時,總會去繼承ClassLoader類並重寫loadClass方法,從而實現自定義的類載入類,但是在JDK1.2之後已不再建議使用者去覆蓋loadClass()方法,而是建議把自定義的類載入邏輯寫在findClass()方法中,從前面的分析可知,findClass()方法是在loadClass()方法中被呼叫的,當loadClass()方法中父載入器載入失敗後,則會呼叫自己的findClass()方法來完成類載入,這樣就可以保證自定義的類載入器也符合雙親委託模式。需要注意的是ClassLoader類中並沒有實現findClass()方法的具體程式碼邏輯,取而代之的是丟擲ClassNotFoundException異常,同時應該知道的是findClass方法通常是和defineClass方法一起使用的(稍後會分析),ClassLoader類中findClass()方法原始碼如下:

      //直接丟擲異常
      protected Class<?> findClass(String name) throws ClassNotFoundException {
              throw new ClassNotFoundException(name);
      }複製程式碼
  • defineClass(byte[] b, int off, int len)
    defineClass()方法是用來將byte位元組流解析成JVM能夠識別的Class物件(ClassLoader中已實現該方法邏輯),通過這個方法不僅能夠通過class檔案例項化class物件,也可以通過其他方式例項化class物件,如通過網路接收一個類的位元組碼,然後轉換為byte位元組流建立對應的Class物件,defineClass()方法通常與findClass()方法一起使用,一般情況下,在自定義類載入器時,會直接覆蓋ClassLoader的findClass()方法並編寫載入規則,取得要載入類的位元組碼後轉換成流,然後呼叫defineClass()方法生成類的Class物件,簡單例子如下:

      protected Class> findClass(String name) throws ClassNotFoundException {
            // 獲取類的位元組陣列
            byte[] classData = getClassData(name);  
            if (classData == null) {
                throw new ClassNotFoundException();
            } else {
                //使用defineClass生成class物件
                return defineClass(name, classData, 0, classData.length);
            }
        }複製程式碼

    需要注意的是,如果直接呼叫defineClass()方法生成類的Class物件,這個類的Class物件並沒有解析(也可以理解為連結階段,畢竟解析是連結的最後一步),其解析操作需要等待初始化階段進行。

  • resolveClass(Class≺?≻ c)
    使用該方法可以使用類的Class物件建立完成也同時被解析。前面我們說連結階段主要是對位元組碼進行驗證,為類變數分配記憶體並設定初始值同時將位元組碼檔案中的符號引用轉換為直接引用。

上述4個方法是ClassLoader類中的比較重要的方法,也是我們可能會經常用到的方法。接看SercureClassLoader擴充套件了 ClassLoader,新增了幾個與使用相關的程式碼源(對程式碼源的位置及其證照的驗證)和許可權定義類驗證(主要指對class原始碼的訪問許可權)的方法,一般我們不會直接跟這個類打交道,更多是與它的子類URLClassLoader有所關聯,前面說過,ClassLoader是一個抽象類,很多方法是空的沒有實現,比如 findClass()、findResource()等。而URLClassLoader這個實現類為這些方法提供了具體的實現,並新增了URLClassPath類協助取得Class位元組碼流等功能,在編寫自定義類載入器時,如果沒有太過於複雜的需求,可以直接繼承URLClassLoader類,這樣就可以避免自己去編寫findClass()方法及其獲取位元組碼流的方式,使自定義類載入器編寫更加簡潔,下面是URLClassLoader的類圖(利用IDEA生成的類圖)

從類圖結構看出URLClassLoader中存在一個URLClassPath類,通過這個類就可以找到要載入的位元組碼流,也就是說URLClassPath類負責找到要載入的位元組碼,再讀取成位元組流,最後通過defineClass()方法建立類的Class物件。從URLClassLoader類的結構圖可以看出其構造方法都有一個必須傳遞的引數URL[],該引數的元素是代表位元組碼檔案的路徑,換句話說在建立URLClassLoader物件時必須要指定這個類載入器的到那個目錄下找class檔案。同時也應該注意URL[]也是URLClassPath類的必傳引數,在建立URLClassPath物件時,會根據傳遞過來的URL陣列中的路徑判斷是檔案還是jar包,然後根據不同的路徑建立FileLoader或者JarLoader或預設Loader類去載入相應路徑下的class檔案,而當JVM呼叫findClass()方法時,就由這3個載入器中的一個將class檔案的位元組碼流載入到記憶體中,最後利用位元組碼流建立類的class物件。請記住,如果我們在定義類載入器時選擇繼承ClassLoader類而非URLClassLoader,必須手動編寫findclass()方法的載入邏輯以及獲取位元組碼流的邏輯。瞭解完URLClassLoader後接著看看剩餘的兩個類載入器,即擴充類載入器ExtClassLoader和系統類載入器AppClassLoader,這兩個類都繼承自URLClassLoader,是sun.misc.Launcher的靜態內部類。sun.misc.Launcher主要被系統用於啟動主應用程式,ExtClassLoader和AppClassLoader都是由sun.misc.Launcher建立的,其類主要類結構如下:

它們間的關係正如前面所闡述的那樣,同時我們發現ExtClassLoader並沒有重寫loadClass()方法,這足矣說明其遵循雙親委派模式,而AppClassLoader過載了loadCass()方法,但最終呼叫的還是父類loadClass()方法,因此依然遵守雙親委派模式,過載方法原始碼如下:

 /**
  * Override loadClass 方法,新增包許可權檢測功能
  */
 public Class loadClass(String name, boolean resolve)
     throws ClassNotFoundException
 {
     int i = name.lastIndexOf('.');
     if (i != -1) {
         SecurityManager sm = System.getSecurityManager();
         if (sm != null) {
             sm.checkPackageAccess(name.substring(0, i));
         }
     }
     //依然呼叫父類的方法
     return (super.loadClass(name, resolve));
 }複製程式碼

其實無論是ExtClassLoader還是AppClassLoader都繼承URLClassLoader類,因此它們都遵守雙親委託模型,這點是毋庸置疑的。ok~,到此我們對ClassLoader、URLClassLoader、ExtClassLoader、AppClassLoader以及Launcher類間的關係有了比較清晰的瞭解,同時對一些主要的方法也有一定的認識,這裡並沒有對這些類的原始碼進行詳細的分析,畢竟沒有那個必要,因為我們主要弄得類與類間的關係和常用的方法同時搞清楚雙親委託模式的實現過程,為編寫自定義類載入器做鋪墊就足夠了。ok~,前面出現了很多父類載入器的說法,但每個類載入器的父類到底是誰,一直沒有闡明,下面我們就通過程式碼驗證的方式來闡明這答案。

類載入器間的關係

我們進一步瞭解類載入器間的關係(並非指繼承關係),主要可以分為以下4點

  • 啟動類載入器,由C++實現,沒有父類。

  • 擴充類載入器(ExtClassLoader),由Java語言實現,父類載入器為null

  • 系統類載入器(AppClassLoader),由Java語言實現,父類載入器為ExtClassLoader

  • 自定義類載入器,父類載入器肯定為AppClassLoader。

下面我們通過程式來驗證上述闡述的觀點

/**
 * Created by zejian on 2017/6/18.
 * Blog : http://blog.csdn.net/javazejian [原文地址,請尊重原創]
 */
//自定義ClassLoader,完整程式碼稍後分析
class FileClassLoader extends  ClassLoader{
    private String rootDir;

    public FileClassLoader(String rootDir) {
        this.rootDir = rootDir;
    }
    // 編寫獲取類的位元組碼並建立class物件的邏輯
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
       //...省略邏輯程式碼
    }
    //編寫讀取位元組流的方法
    private byte[] getClassData(String className) {
        // 讀取類檔案的位元組
        //省略程式碼....
    }
}

public class ClassLoaderTest {

    public static void main(String[] args) throws ClassNotFoundException {

             FileClassLoader loader1 = new FileClassLoader(rootDir);

              System.out.println("自定義類載入器的父載入器: "+loader1.getParent());
              System.out.println("系統預設的AppClassLoader: "+ClassLoader.getSystemClassLoader());
              System.out.println("AppClassLoader的父類載入器: "+ClassLoader.getSystemClassLoader().getParent());
              System.out.println("ExtClassLoader的父類載入器: "+ClassLoader.getSystemClassLoader().getParent().getParent());

            /**
            輸出結果:
                自定義類載入器的父載入器: sun.misc.Launcher$AppClassLoader@29453f44
                系統預設的AppClassLoader: sun.misc.Launcher$AppClassLoader@29453f44
                AppClassLoader的父類載入器: sun.misc.Launcher$ExtClassLoader@6f94fa3e
                ExtClassLoader的父類載入器: null
            */

    }
}複製程式碼

程式碼中,我們自定義了一個FileClassLoader,這裡我們繼承了ClassLoader而非URLClassLoader,因此需要自己編寫findClass()方法邏輯以及載入位元組碼的邏輯,關於自定義類載入器我們稍後會分析,這裡僅需要知道FileClassLoader是自定義載入器即可,接著在main方法中,通過ClassLoader.getSystemClassLoader()獲取到系統預設類載入器,通過獲取其父類載入器及其父父類載入器,同時還獲取了自定義類載入器的父類載入器,最終輸出結果正如我們所預料的,AppClassLoader的父類載入器為ExtClassLoader,而ExtClassLoader沒有父類載入器。如果我們實現自己的類載入器,它的父載入器都只會是AppClassLoader。這裡我們不妨看看Lancher的構造器原始碼

public Launcher() {
        // 首先建立擴充類載入器
        ClassLoader extcl;
        try {
            extcl = ExtClassLoader.getExtClassLoader();
        } catch (IOException e) {
            throw new InternalError(
                "Could not create extension class loader");
        }

        // Now create the class loader to use to launch the application
        try {
            //再建立AppClassLoader並把extcl作為父載入器傳遞給AppClassLoader
            loader = AppClassLoader.getAppClassLoader(extcl);
        } catch (IOException e) {
            throw new InternalError(
                "Could not create application class loader");
        }

        //設定執行緒上下文類載入器,稍後分析
        Thread.currentThread().setContextClassLoader(loader);
//省略其他沒必要的程式碼......
        }
    }複製程式碼

顯然Lancher初始化時首先會建立ExtClassLoader類載入器,然後再建立AppClassLoader並把ExtClassLoader傳遞給它作為父類載入器,這裡還把AppClassLoader預設設定為執行緒上下文類載入器,關於執行緒上下文類載入器稍後會分析。那ExtClassLoader類載入器為什麼是null呢?看下面的原始碼建立過程就明白,在建立ExtClassLoader強制設定了其父載入器為null。

//Lancher中建立ExtClassLoader
extcl = ExtClassLoader.getExtClassLoader();

//getExtClassLoader()方法
public static ExtClassLoader getExtClassLoader() throws IOException{

  //........省略其他程式碼 
  return new ExtClassLoader(dirs);                     
  // .........
}

//構造方法
public ExtClassLoader(File[] dirs) throws IOException {
   //呼叫父類構造URLClassLoader傳遞null作為parent
   super(getExtURLs(dirs), null, factory);
}

//URLClassLoader構造
public URLClassLoader(URL[] urls, ClassLoader parent,
                          URLStreamHandlerFactory factory) {複製程式碼

顯然ExtClassLoader的父類為null,而AppClassLoader的父載入器為ExtClassLoader,所有自定義的類載入器其父載入器只會是AppClassLoader,注意這裡所指的父類並不是Java繼承關係中的那種父子關係。

類與類載入器

類與類載入器

在JVM中表示兩個class物件是否為同一個類物件存在兩個必要條件

  • 類的完整類名必須一致,包括包名。
  • 載入這個類的ClassLoader(指ClassLoader例項物件)必須相同。

也就是說,在JVM中,即使這個兩個類物件(class物件)來源同一個Class檔案,被同一個虛擬機器所載入,但只要載入它們的ClassLoader例項物件不同,那麼這兩個類物件也是不相等的,這是因為不同的ClassLoader例項物件都擁有不同的獨立的類名稱空間,所以載入的class物件也會存在不同的類名空間中,但前提是覆寫loadclass方法,從前面雙親委派模式對loadClass()方法的原始碼分析中可以知,在方法第一步會通過Class<?> c = findLoadedClass(name);從快取查詢,類名完整名稱相同則不會再次被載入,因此我們必須繞過快取查詢才能重新載入class物件。當然也可直接呼叫findClass()方法,這樣也避免從快取查詢,如下

String rootDir="/Users/zejian/Downloads/Java8_Action/src/main/java/";
//建立兩個不同的自定義類載入器例項
FileClassLoader loader1 = new FileClassLoader(rootDir);
FileClassLoader loader2 = new FileClassLoader(rootDir);
//通過findClass建立類的Class物件
Class<?> object1=loader1.findClass("com.zejian.classloader.DemoObj");
Class<?> object2=loader2.findClass("com.zejian.classloader.DemoObj");

System.out.println("findClass->obj1:"+object1.hashCode());
System.out.println("findClass->obj2:"+object2.hashCode());

/**
  * 直接呼叫findClass方法輸出結果:
  * findClass->obj1:723074861
    findClass->obj2:895328852
    生成不同的例項
  */複製程式碼

如果呼叫父類的loadClass方法,結果如下,除非重寫loadClass()方法去掉快取查詢步驟,不過現在一般都不建議重寫loadClass()方法。

//直接呼叫父類的loadClass()方法
Class<?> obj1 =loader1.loadClass("com.zejian.classloader.DemoObj");
Class<?> obj2 =loader2.loadClass("com.zejian.classloader.DemoObj");

//不同例項物件的自定義類載入器
System.out.println("loadClass->obj1:"+obj1.hashCode());
System.out.println("loadClass->obj2:"+obj2.hashCode());
//系統類載入器
System.out.println("Class->obj3:"+DemoObj.class.hashCode());

/**
* 直接呼叫loadClass方法的輸出結果,注意並沒有重寫loadClass方法
* loadClass->obj1:1872034366
  loadClass->obj2:1872034366
  Class->    obj3:1872034366
  都是同一個例項
*/複製程式碼

所以如果不從快取查詢相同完全類名的class物件,那麼只有ClassLoader的例項物件不同,同一位元組碼檔案建立的class物件自然也不會相同。

瞭解class檔案的顯示載入與隱式載入的概念

所謂class檔案的顯示載入與隱式載入的方式是指JVM載入class檔案到記憶體的方式,顯示載入指的是在程式碼中通過呼叫ClassLoader載入class物件,如直接使用Class.forName(name)this.getClass().getClassLoader().loadClass()載入class物件。而隱式載入則是不直接在程式碼中呼叫ClassLoader的方法載入class物件,而是通過虛擬機器自動載入到記憶體中,如在載入某個類的class檔案時,該類的class檔案中引用了另外一個類的物件,此時額外引用的類將通過JVM自動載入到記憶體中。在日常開發以上兩種方式一般會混合使用,這裡我們知道有這麼回事即可。

編寫自己的類載入器

通過前面的分析可知,實現自定義類載入器需要繼承ClassLoader或者URLClassLoader,繼承ClassLoader則需要自己重寫findClass()方法並編寫載入邏輯,繼承URLClassLoader則可以省去編寫findClass()方法以及class檔案載入轉換成位元組碼流的程式碼。那麼編寫自定義類載入器的意義何在呢?

  • 當class檔案不在ClassPath路徑下,預設系統類載入器無法找到該class檔案,在這種情況下我們需要實現一個自定義的ClassLoader來載入特定路徑下的class檔案生成class物件。

  • 當一個class檔案是通過網路傳輸並且可能會進行相應的加密操作時,需要先對class檔案進行相應的解密後再載入到JVM記憶體中,這種情況下也需要編寫自定義的ClassLoader並實現相應的邏輯。

  • 當需要實現熱部署功能時(一個class檔案通過不同的類載入器產生不同class物件從而實現熱部署功能),需要實現自定義ClassLoader的邏輯。

自定義File類載入器

這裡我們繼承ClassLoader實現自定義的特定路徑下的檔案類載入器並載入編譯後DemoObj.class,原始碼程式碼如下

public class DemoObj {
    @Override
    public String toString() {
        return "I am DemoObj";
    }
}複製程式碼
package com.zejian.classloader;

import java.io.*;

/**
 * Created by zejian on 2017/6/21.
 */
public class FileClassLoader extends ClassLoader {
    private String rootDir;

    public FileClassLoader(String rootDir) {
        this.rootDir = rootDir;
    }

    /**
     * 編寫findClass方法的邏輯
     * @param name
     * @return
     * @throws ClassNotFoundException
     */
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        // 獲取類的class檔案位元組陣列
        byte[] classData = getClassData(name);
        if (classData == null) {
            throw new ClassNotFoundException();
        } else {
            //直接生成class物件
            return defineClass(name, classData, 0, classData.length);
        }
    }

    /**
     * 編寫獲取class檔案並轉換為位元組碼流的邏輯
     * @param className
     * @return
     */
    private byte[] getClassData(String className) {
        // 讀取類檔案的位元組
        String path = classNameToPath(className);
        try {
            InputStream ins = new FileInputStream(path);
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            int bufferSize = 4096;
            byte[] buffer = new byte[bufferSize];
            int bytesNumRead = 0;
            // 讀取類檔案的位元組碼
            while ((bytesNumRead = ins.read(buffer)) != -1) {
                baos.write(buffer, 0, bytesNumRead);
            }
            return baos.toByteArray();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }

    /**
     * 類檔案的完全路徑
     * @param className
     * @return
     */
    private String classNameToPath(String className) {
        return rootDir + File.separatorChar
                + className.replace('.', File.separatorChar) + ".class";
    }

    public static void main(String[] args) throws ClassNotFoundException {
        String rootDir="/Users/zejian/Downloads/Java8_Action/src/main/java/";
        //建立自定義檔案類載入器
        FileClassLoader loader = new FileClassLoader(rootDir);

        try {
            //載入指定的class檔案
            Class<?> object1=loader.loadClass("com.zejian.classloader.DemoObj");
            System.out.println(object1.newInstance().toString());

            //輸出結果:I am DemoObj
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}複製程式碼

顯然我們通過getClassData()方法找到class檔案並轉換為位元組流,並重寫findClass()方法,利用defineClass()方法建立了類的class物件。在main方法中呼叫了loadClass()方法載入指定路徑下的class檔案,由於啟動類載入器、擴充類載入器以及系統類載入器都無法在其路徑下找到該類,因此最終將有自定義類載入器載入,即呼叫findClass()方法進行載入。如果繼承URLClassLoader實現,那程式碼就更簡潔了,如下:

/**
 * Created by zejian on 2017/6/21.
 */
public class FileUrlClassLoader extends URLClassLoader {

    public FileUrlClassLoader(URL[] urls, ClassLoader parent) {
        super(urls, parent);
    }

    public FileUrlClassLoader(URL[] urls) {
        super(urls);
    }

    public FileUrlClassLoader(URL[] urls, ClassLoader parent, URLStreamHandlerFactory factory) {
        super(urls, parent, factory);
    }


    public static void main(String[] args) throws ClassNotFoundException, MalformedURLException {
        String rootDir="/Users/zejian/Downloads/Java8_Action/src/main/java/";
        //建立自定義檔案類載入器
        File file = new File(rootDir);
        //File to URI
        URI uri=file.toURI();
        URL[] urls={uri.toURL()};

        FileUrlClassLoader loader = new FileUrlClassLoader(urls);

        try {
            //載入指定的class檔案
            Class<?> object1=loader.loadClass("com.zejian.classloader.DemoObj");
            System.out.println(object1.newInstance().toString());

            //輸出結果:I am DemoObj
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}複製程式碼

非常簡潔除了需要重寫構造器外無需編寫findClass()方法及其class檔案的位元組流轉換邏輯。

自定義網路類載入器

自定義網路類載入器,主要用於讀取通過網路傳遞的class檔案(在這裡我們省略class檔案的解密過程),並將其轉換成位元組流生成對應的class物件,如下

/**
 * Created by zejian on 2017/6/21.
 */
public class NetClassLoader extends ClassLoader {

    private String url;//class檔案的URL

    public NetClassLoader(String url) {
        this.url = url;
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        byte[] classData = getClassDataFromNet(name);
        if (classData == null) {
            throw new ClassNotFoundException();
        } else {
            return defineClass(name, classData, 0, classData.length);
        }
    }

    /**
     * 從網路獲取class檔案
     * @param className
     * @return
     */
    private byte[] getClassDataFromNet(String className) {
        String path = classNameToPath(className);
        try {
            URL url = new URL(path);
            InputStream ins = url.openStream();
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            int bufferSize = 4096;
            byte[] buffer = new byte[bufferSize];
            int bytesNumRead = 0;
            // 讀取類檔案的位元組
            while ((bytesNumRead = ins.read(buffer)) != -1) {
                baos.write(buffer, 0, bytesNumRead);
            }
            //這裡省略解密的過程.......
            return baos.toByteArray();
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }

    private String classNameToPath(String className) {
        // 得到類檔案的URL
        return url + "/" + className.replace('.', '/') + ".class";
    }

}複製程式碼

比較簡單,主要是在獲取位元組碼流時的區別,從網路直接獲取到位元組流再轉車位元組陣列然後利用defineClass方法建立class物件,如果繼承URLClassLoader類則和前面檔案路徑的實現是類似的,無需擔心路徑是filePath還是Url,因為URLClassLoader內的URLClassPath物件會根據傳遞過來的URL陣列中的路徑判斷是檔案還是jar包,然後根據不同的路徑建立FileLoader或者JarLoader或預設類Loader去讀取對於的路徑或者url下的class檔案。

熱部署類載入器

所謂的熱部署就是利用同一個class檔案不同的類載入器在記憶體建立出兩個不同的class物件(關於這點的原因前面已分析過,即利用不同的類載入例項),由於JVM在載入類之前會檢測請求的類是否已載入過(即在loadClass()方法中呼叫findLoadedClass()方法),如果被載入過,則直接從快取獲取,不會重新載入。注意同一個類載入器的例項和同一個class檔案只能被載入器一次,多次載入將報錯,因此我們實現的熱部署必須讓同一個class檔案可以根據不同的類載入器重複載入,以實現所謂的熱部署。實際上前面的實現的FileClassLoader和FileUrlClassLoader已具備這個功能,但前提是直接呼叫findClass()方法,而不是呼叫loadClass()方法,因為ClassLoader中loadClass()方法體中呼叫findLoadedClass()方法進行了檢測是否已被載入,因此我們直接呼叫findClass()方法就可以繞過這個問題,當然也可以重新loadClass方法,但強烈不建議這麼幹。利用FileClassLoader類測試程式碼如下:

 public static void main(String[] args) throws ClassNotFoundException {
        String rootDir="/Users/zejian/Downloads/Java8_Action/src/main/java/";
        //建立自定義檔案類載入器
        FileClassLoader loader = new FileClassLoader(rootDir);
        FileClassLoader loader2 = new FileClassLoader(rootDir);

        try {
            //載入指定的class檔案,呼叫loadClass()
            Class<?> object1=loader.loadClass("com.zejian.classloader.DemoObj");
            Class<?> object2=loader2.loadClass("com.zejian.classloader.DemoObj");

            System.out.println("loadClass->obj1:"+object1.hashCode());
            System.out.println("loadClass->obj2:"+object2.hashCode());

            //載入指定的class檔案,直接呼叫findClass(),繞過檢測機制,建立不同class物件。
            Class<?> object3=loader.findClass("com.zejian.classloader.DemoObj");
            Class<?> object4=loader2.findClass("com.zejian.classloader.DemoObj");

            System.out.println("loadClass->obj3:"+object3.hashCode());
            System.out.println("loadClass->obj4:"+object4.hashCode());

            /**
             * 輸出結果:
             * loadClass->obj1:644117698
               loadClass->obj2:644117698
               findClass->obj3:723074861
               findClass->obj4:895328852
             */

        } catch (Exception e) {
            e.printStackTrace();
        }
    }複製程式碼

雙親委派模型的破壞者-執行緒上下文類載入器

在Java應用中存在著很多服務提供者介面(Service Provider Interface,SPI),這些介面允許第三方為它們提供實現,如常見的 SPI 有 JDBC、JNDI等,這些 SPI 的介面屬於 Java 核心庫,一般存在rt.jar包中,由Bootstrap類載入器載入,而 SPI 的第三方實現程式碼則是作為Java應用所依賴的 jar 包被存放在classpath路徑下,由於SPI介面中的程式碼經常需要載入具體的第三方實現類並呼叫其相關方法,但SPI的核心介面類是由引導類載入器來載入的,而Bootstrap類載入器無法直接載入SPI的實現類,同時由於雙親委派模式的存在,Bootstrap類載入器也無法反向委託AppClassLoader載入器SPI的實現類。在這種情況下,我們就需要一種特殊的類載入器來載入第三方的類庫,而執行緒上下文類載入器就是很好的選擇。
    執行緒上下文類載入器(contextClassLoader)是從 JDK 1.2 開始引入的,我們可以通過java.lang.Thread類中的getContextClassLoader()setContextClassLoader(ClassLoader cl)方法來獲取和設定執行緒的上下文類載入器。如果沒有手動設定上下文類載入器,執行緒將繼承其父執行緒的上下文類載入器,初始執行緒的上下文類載入器是系統類載入器(AppClassLoader),線上程中執行的程式碼可以通過此類載入器來載入類和資源,如下圖所示,以jdbc.jar載入為例

從圖可知rt.jar核心包是有Bootstrap類載入器載入的,其內包含SPI核心介面類,由於SPI中的類經常需要呼叫外部實現類的方法,而jdbc.jar包含外部實現類(jdbc.jar存在於classpath路徑)無法通過Bootstrap類載入器載入,因此只能委派執行緒上下文類載入器把jdbc.jar中的實現類載入到記憶體以便SPI相關類使用。顯然這種執行緒上下文類載入器的載入方式破壞了“雙親委派模型”,它在執行過程中拋棄雙親委派載入鏈模式,使程式可以逆向使用類載入器,當然這也使得Java類載入器變得更加靈活。為了進一步證實這種場景,不妨看看DriverManager類的原始碼,DriverManager是Java核心rt.jar包中的類,該類用來管理不同資料庫的實現驅動即Driver,它們都實現了Java核心包中的java.sql.Driver介面,如mysql驅動包中的com.mysql.jdbc.Driver,這裡主要看看如何載入外部實現類,在DriverManager初始化時會執行如下程式碼

//DriverManager是Java核心包rt.jar的類
public class DriverManager {
    //省略不必要的程式碼
    static {
        loadInitialDrivers();//執行該方法
        println("JDBC DriverManager initialized");
    }

//loadInitialDrivers方法
 private static void loadInitialDrivers() {
     sun.misc.Providers()
     AccessController.doPrivileged(new PrivilegedAction<Void>() {
            public Void run() {
                //載入外部的Driver的實現類
                ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
              //省略不必要的程式碼......
            }
        });
    }複製程式碼

在DriverManager類初始化時執行了loadInitialDrivers()方法,在該方法中通過ServiceLoader.load(Driver.class);去載入外部實現的驅動類,ServiceLoader類會去讀取mysql的jdbc.jar下META-INF檔案的內容,如下所示

而com.mysql.jdbc.Driver繼承類如下:

public class Driver extends com.mysql.cj.jdbc.Driver {
    public Driver() throws SQLException {
        super();
    }

    static {
        System.err.println("Loading class `com.mysql.jdbc.Driver'. This is deprecated. The new driver class is `com.mysql.cj.jdbc.Driver'. "
                + "The driver is automatically registered via the SPI and manual loading of the driver class is generally unnecessary.");
    }
}複製程式碼

從註釋可以看出平常我們使用com.mysql.jdbc.Driver已被丟棄了,取而代之的是com.mysql.cj.jdbc.Driver,也就是說官方不再建議我們使用如下程式碼註冊mysql驅動

//不建議使用該方式註冊驅動類
Class.forName("com.mysql.jdbc.Driver");
String url = "jdbc:mysql://localhost:3306/cm-storylocker?characterEncoding=UTF-8";
// 通過java庫獲取資料庫連線
Connection conn = java.sql.DriverManager.getConnection(url, "root", "root@555");複製程式碼

而是直接去掉註冊步驟,如下即可

String url = "jdbc:mysql://localhost:3306/cm-storylocker?characterEncoding=UTF-8";
// 通過java庫獲取資料庫連線
Connection conn = java.sql.DriverManager.getConnection(url, "root", "root@555");複製程式碼

這樣ServiceLoader會幫助我們處理一切,並最終通過load()方法載入,看看load()方法實現

public static ServiceLoader load(Class service) {
     //通過執行緒上下文類載入器載入
      ClassLoader cl = Thread.currentThread().getContextClassLoader();
      return ServiceLoader.load(service, cl);
  }複製程式碼

很明顯了確實通過執行緒上下文類載入器載入的,實際上核心包的SPI類對外部實現類的載入都是基於執行緒上下文類載入器執行的,通過這種方式實現了Java核心程式碼內部去呼叫外部實現類。我們知道執行緒上下文類載入器預設情況下就是AppClassLoader,那為什麼不直接通過getSystemClassLoader()獲取類載入器來載入classpath路徑下的類的呢?其實是可行的,但這種直接使用getSystemClassLoader()方法獲取AppClassLoader載入類有一個缺點,那就是程式碼部署到不同服務時會出現問題,如把程式碼部署到Java Web應用服務或者EJB之類的服務將會出問題,因為這些服務使用的執行緒上下文類載入器並非AppClassLoader,而是Java Web應用服自家的類載入器,類載入器不同。,所以我們應用該少用getSystemClassLoader()。總之不同的服務使用的可能預設ClassLoader是不同的,但使用執行緒上下文類載入器總能獲取到與當前程式執行相同的ClassLoader,從而避免不必要的問題。ok~.關於執行緒上下文類載入器暫且聊到這,前面闡述的DriverManager類,大家可以自行看看原始碼,相信會有更多的體會,另外關於ServiceLoader本篇並沒有過多的闡述,畢竟我們主題是類載入器,但ServiceLoader是個很不錯的解耦機制,大家可以自行查閱其相關用法。

ok~,本篇到此告一段落,如有誤處,歡迎留言,謝謝。

參考資料:
blog.csdn.net/yangcheng33…
ifeve.com/wp-content/…

《深入理解JVM虛擬機器》
《深入分析Java Web 技術內幕》

相關文章