JVM系列之類載入流程-自定義類載入器

我又不是架構師發表於2017-12-01

JVM系列之類載入流程-自定義類載入器

老實說,類載入流程作者還是比較熟悉而且有實戰經驗的,因為有過一次自定義類載入器的實戰經驗(文章最後會和大家分享),雖然大部分小夥伴覺得這部分對coding沒什麼實際意義,如果你一直寫CRUD並且用現有的高階語言業務框架,我可以告訴你,確實沒什麼用。但話說回來,你如果想多瞭解底層,並且在類載入時做一些手腳,那麼這一塊就很有必要學了。很多框架都是利用了類載入機制裡的動態載入特性來搞事情,像比較出名的OSGI模組化(一個模組一個類載入器),JSP(執行時轉換為位元組流讓載入器動態載入),Tomcat(自定義了許多類載入器用來隔離不同工程)...這裡就不一一列舉了。本文還是先把類載入流程先講一講,然後分享一下作者的一次自定義類載入的經驗心得,概要如下:

文章結構
1 類載入的各個流程講解
2 自定義類載入器講解
3 實戰自定義類載入器

1. 類載入的各個流程講解

作者找了下網上的圖,參考著自己畫了一張類生命週期流程圖:

類的生命週期圖
類的生命週期圖

注意點:圖中各個流程並不是嚴格的先後順序,比如在進行1載入時,其實2驗證已經開始了,是交叉進行的。

載入

載入階段說白了,就是把我們編譯後的.Class靜態檔案轉換到記憶體中(方法區),然後暴露出來讓程式設計師能訪問到。具體展開:

  • 通過一個類的全限定名來獲取定義此類的二進位制位元組流(可以是.class檔案,也可以是網路上的io,也可以是zip包等)
  • 將這個位元組流所代表的靜態儲存結構轉化為方法區的執行時資料結構。
  • 在記憶體中(HotSpot的實現其實就是在方法區)生成一個代表這個類的java.lang.Class物件,作為方法區這個類的各種資料的訪問入口。

驗證

載入階段獲得的二進位制位元組流並不一定是來自.class檔案,比如網路上發來的,那麼如果不進行一定的格式校驗,肯定是不能載入的。所以驗證階段實際上是為了保護JVM的。對於一般Javaer來說,俺們都是.java檔案編譯出來的.class檔案,然後轉換成相應的二進位制流,沒啥危害。所以不用太關心這一部分。

準備

準備階段主要是給static變數分配記憶體(方法區中),並設定初始值。
比如: public static Integer value =1;在準備階段的值其實是為0的。需要注意的是常量是在準備階段賦值的:
public static final Integer value =1 ;在準備階段value就被賦值為了1;

解析

解析階段就更抽象了,稍微說一下,因為不太重要,有兩個概念,符號引用直接引用。說的通俗一點但是不太準確,比如在類A中呼叫了new B();大家想一想,我們編譯完成.class檔案後其實這種對應關係還是存在的,只是以位元組碼指令的形式存在,比如 "invokespecial #2" 大家可以猜到#2其實就是我們的類B了,那麼在執行這一行程式碼的時候,JVM咋知道#2對應的指令在哪,這就是一個靜態的傢伙,假如類B已經載入到方法區了,地址為(#f00123),所以這個時候就要把這個#2轉成這個地址(#f00123),這樣JVM在執行到這時不就知道B類在哪了,就去呼叫了。(說的這麼通俗,我都懷疑人生了).其他的,像方法的符號引用,常量的符號引用,其實都是一個意思,大家要明白,所謂的方法,常量,類,都是高階語言(Java)層面的概念,在.class檔案中,它才不管你是啥,都是以指令的形式存在,所以要把那種引用關係(誰呼叫誰,誰引用誰)都轉換為地址指令的形式。好了。說的夠通俗了。大家湊合理解吧。這塊其實不太重要,對於大部分coder來說,所以我就通俗的講了講。

初始化

這一塊其實就是呼叫類的構造方法,注意是類的構造方法,不是例項建構函式,例項建構函式就是我們通常寫的構造方法,類的構造方法是自動生成的,生成規則:
static變數的賦值操作+static程式碼塊
按照出現的先後順序來組裝。
注意:1 static變數的記憶體分配和初始化是在準備階段.2 一個類可以是很多個執行緒同時併發執行,JVM會加鎖保證單一性,所以不要在static程式碼塊中搞一些耗時操作。避免執行緒阻塞。

使用&解除安裝

使用就是你直接new或者通過反射.newInstance了.
解除安裝是自動進行的,gc在方發區也會進行回收.不過條件很苛刻,感興趣可以自己看一看,一般都不會解除安裝類.

2. 自定義類載入器講解

2.1 類載入器

類載入器,就是執行上面類載入流程的一些類,系統預設的就有一些載入器,站在JVM的角度,就只有兩類載入器:

  • 啟動類載入器(Bootstrap ClassLoader):由C++語言實現(針對HotSpot),負責將存放在<JAVA_HOME>/lib目錄或-Xbootclasspath引數指定的路徑中的類庫載入到記憶體中。
  • 其他類載入器:由Java語言實現,繼承自抽象類ClassLoader。如:
    • 擴充套件類載入器(Extension ClassLoader):負責載入<JAVA_HOME>/lib/ext目錄或java.ext.dirs系統變數指定的路徑中的所有類庫。
    • 應用程式類載入器(Application ClassLoader)。負責載入使用者類路徑(classpath)上的指定類庫,我們可以直接使用這個類載入器。一般情況,如果我們沒有自定義類載入器預設就是用這個載入器。
    • 自定義類載入器,使用者根據需求自己定義的。也需要繼承自ClassLoader.

2.2 雙親委派模型

如果一個類載入器收到類載入的請求,它首先不會自己去嘗試載入這個類,而是把這個請求委派給父類載入器完成。每個類載入器都是如此,只有當父載入器在自己的搜尋範圍內找不到指定的類時(即ClassNotFoundException),子載入器才會嘗試自己去載入。見下圖:

雙親委派模型
雙親委派模型

需要注意的是,自定義類載入器可以不遵循雙親委派模型,但是圖中紅色區域這種傳遞關係是JVM預先定義好的,誰都更改不了。雙親委派模型有什麼好處呢?舉個例子,比如有人故意在自己的程式碼中定義了一個String類,包名類名都和JDK自帶的一樣,那麼根據雙親委派模型,類載入器會首先傳遞到父類載入器去載入,最終會傳遞到啟動類載入器,啟動載入類判斷已經載入過了,所以程式設計師自定義的String類就不會被載入。避免程式設計師自己隨意串改系統級的類。

2.3 自定義類載入器

上面說了半天理論,我都有點迫不及待的想上程式碼了。下面看看如何來自定義類載入器,並且如何在自定義載入器時遵循雙親委派模型(向上傳遞性).其實非常簡單,在這裡JDK用到了模板的設計模式,向上傳遞性其實已經幫我們封裝好了,在ClassLoader中已經實現了,在loadClass方法中:

protected Class<?> loadClass(String name, boolean resolve)
    throws ClassNotFoundException
{
    synchronized (getClassLoadingLock(name)) {
        // 1. 檢查是否已經載入過。
        Class c = findLoadedClass(name);
        if (c == null) {
            long t0 = System.nanoTime();
            try {
                if (parent != null) {
                //2 .如果沒有載入過,先呼叫父類載入器去載入
                    c = parent.loadClass(name, false);
                } else {
                // 2.1 如果沒有載入過,且沒有父類載入器,就用BootstrapClassLoader去載入
                c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // ClassNotFoundException thrown if class not found
                // from the non-null parent class loader
            }

            if (c == null) {
                //3. 如果父類載入器沒有載入到,呼叫findClass去載入
                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;
    }
}複製程式碼

從上面程式碼可以明顯看出,loadClass(String, boolean)函式即實現了雙親委派模型!整個大致過程如下:

  1. 檢查一下指定名稱的類是否已經載入過,如果載入過了,就不需要再載入,直接返回。
  2. 如果此類沒有載入過,那麼,再判斷一下是否有父載入器;如果有父載入器,則由父載入器載入(即呼叫parent.loadClass(name, false);).或者是呼叫bootstrap類載入器來載入。
  3. 如果父載入器及bootstrap類載入器都沒有找到指定的類,那麼呼叫當前類載入器的findClass方法來完成類載入。預設的findclass毛都不幹,直接丟擲ClassNotFound異常,所以我們自定義類載入器就要覆蓋這個方法了。
  4. 可以猜測:ApplicationClassLoader的findClass是去classpath下去載入,ExtentionClassLoader是去java_home/lib/ext目錄下去載入。實際上就是findClass方法不一樣罷了

由上面可以知道,抽象類ClassLoader的findClass函式預設是丟擲異常的。而前面我們知道,loadClass在父載入器無法載入類的時候,就會呼叫我們自定義的類載入器中的findeClass函式,因此我們必須要在loadClass這個函式裡面實現將一個指定類名稱轉換為Class物件.
如果是是讀取一個指定的名稱的類為位元組陣列的話,這很好辦。但是如何將位元組陣列轉為Class物件呢?很簡單,Java提供了defineClass方法,通過這個方法,就可以把一個位元組陣列轉為Class物件啦~

defineClass:將一個位元組陣列轉為Class物件,這個位元組陣列是class檔案讀取後最終的位元組陣列.

protected final Class<?> defineClass(String name, byte[] b, int off, int len)
        throws ClassFormatError  {
        return defineClass(name, b, off, len, null);複製程式碼

上面介紹了自定義類載入器的原理和幾個重要方法(loadClass,findClass,defineClass),相信大部分小夥伴還是一臉矇蔽,沒關係,我先上一副圖,然後上一個自定義的類載入器:

自定義類載入器方法呼叫流程圖
自定義類載入器方法呼叫流程圖

樣例自定義類載入器:

import java.io.InputStream;
public class MyClassLoader extends ClassLoader
{
    public MyClassLoader()
    {
    }
    public MyClassLoader(ClassLoader parent)
    {
        //一定要設定父ClassLoader不是ApplicationClassLoader,否則不會執行findclass
        super(parent);
    }
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException
    {
    //1. 覆蓋findClass,來找到.class檔案,並且返回Class物件
        try
        {
            String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";
            InputStream is = getClass().getResourceAsStream(fileName);
            if (is == null) {
            //2. 如果沒找到,return null
                return null;
            }
            byte[] b = new byte[is.available()];
            is.read(b);
            //3. 講位元組陣列轉換成了Class物件
            return defineClass(name, b, 0, b.length);
        }
        catch (Exception e)
        {
            e.printStackTrace();
        }
        return null;
    }
}複製程式碼

稍微說一下:
其實很簡單,繼承ClassLoader物件,覆蓋findClass方法,這個方法的作用就是找到.class檔案,轉換成位元組陣列,呼叫defineClass物件轉換成Class物件返回。就這麼easy..
演示下效果:

        MyClassLoader mcl = new MyClassLoader();
        Class<?> c1 = Class.forName("Student", true, mcl);
        Object obj = c1.newInstance();
        System.out.println(obj.getClass().getClassLoader());
        System.out.println(obj instanceof Student);複製程式碼

返回結果:
sun.misc.Launcher$AppClassLoader@6951a712
true

        MyClassLoader mcl = new MyClassLoader(ClassLoader.getSystemClassLoader().getParent());
        Class<?> c1 = Class.forName("Student", true, mcl);
        Object obj = c1.newInstance();
        System.out.println(obj.getClass().getClassLoader());
        System.out.println(obj instanceof Student);複製程式碼

返回結果:
MyClassLoader@3918d722
false

重點分析:
第一個程式碼和第二個程式碼唯一一點不同的就是在new MyClassLoader()時,一個傳入的ClassLoader.getSystemClassLoader().getParent();(這個其實就是擴充套件類載入器)

  1. 當不傳入這個值時,預設的父類載入器為Application ClassLoader,那麼大家可以知道,在這個載入器中已經載入了Student類(ClassPath路徑下的Student類),我們在呼叫Class.forName時傳入了自定義的類載入器,會呼叫自定義類載入器的loadClass,判斷自己之前沒有載入過,然後去呼叫父類的(ApplicationClassLoader)的loadClass,判斷結果為已經載入,所以直接返回。所以列印ClassLoader為AppClassLoader.
    驗證預設父類載入器為ApplicationClassLoader:

         MyClassLoader mcl = new MyClassLoader();
         System.out.println(mcl.getParent().getClass());複製程式碼

    列印結果:class sun.misc.Launcher$AppClassLoader

  2. 當我們傳入父類載入器為擴充套件類載入器時,當呼叫父類(擴充套件類載入器)的loadeClass時,由於擴充套件類載入器只載入java_home/lib/ext目錄下的類,所以classpath路徑下的它不能載入,返回null,根據loadClass的邏輯,接著會呼叫自定義類載入器findClass來載入。所以列印ClassLoader為MyClassLoader.

  3. instanceof返回true的條件是(類載入器+類)全部一樣,雖然這裡我們都是一個Student類,一個檔案,但是由兩個類載入器載入的,當然返回false了。
  4. 在JVM中判斷一個類唯一的標準是(類載入器+.class檔案)都一樣.像instanceof和強制型別轉換都是這樣的標準。
  5. 注意,這裡所說的父類類載入器,不是以繼承的方式來實現的,而是以成員變數的方式實現的。當呼叫建構函式傳入時,就把自己的成員變數parent設定成了傳入的載入器。
  • 課外衍生:這裡作者是遵循了雙親委託模型,所以覆蓋了findClass,沒有覆蓋loadClass,其實loadClass也是可以覆蓋的,比如你覆蓋了loadClass,實現為"直接載入檔案,不去判斷父類是否已經載入",這樣就打破了雙親委託模型,一般是不推薦這樣乾的。不過小夥伴們可以試著玩玩.

自定義類載入器就給大家說完了,雖然作者感覺已經講清楚了,因為無非就是幾個方法的問題(loadClass,findClass,defineClass),但還是給大家幾個傳送門,可以多閱讀閱讀,相互參閱一下:
www.cnblogs.com/xrq730/p/48…
www.importnew.com/24036.html

3. 實戰自定義類載入器

其實上面基本已經把自定義類載入器給講清楚了,這裡和大家分享一下作者一次實際的編寫自定義類載入器的經驗。背景如下:
我們在專案裡使用了某開源通訊框架,但由於更改了原始碼,做了一些定製化更改,假設更改原始碼前為版本A,更改原始碼後為版本B,由於專案中部分程式碼需要使用版本A,部分程式碼需要使用版本B。版本A和版本B中所有包名和類名都是一樣。那麼問題來了,如果只依賴ApplicationClassLoader載入,它只會載入一個離ClassPath最近的一個版本。剩下一個載入時根據雙親委託模型,就直接返回已經載入那個版本了。所以在這裡就需要自定義一個類載入器。大致思路如下圖:

雙版本設計圖
雙版本設計圖

這裡需要注意的是,在自定義類載入器時一定要把父類載入器設定為ExtentionClassLoader,如果不設定,根據雙親委託模型,預設父類載入器為ApplicationClassLoader,呼叫它的loadClass時,會判定為已經載入(版本A和版本B包名類名一樣),會直接返回已經載入的版本A,而不是呼叫子類的findClass.就不會呼叫我們自定義類載入器的findClass去遠端載入版本B了。

順便提一下,作者這裡的實現方案其實是為了遵循雙親委託模型,如果作者不遵循雙親委託模型的話,直接自定義一個類載入器,覆蓋掉loadClass方法,不讓它先去父類檢驗,而改為直接呼叫findClass方法去載入版本B,也是可以的.大家一定要靈活的寫程式碼。

結語

好了,JVM類載入機制給大家分享完了,希望大家在碰到實際問題的時候能想到自定義類載入器來解決 。Have a good day .

相關文章