JVM——深入理解Java類加載器(ClassLoader)

類加載的機制的層次結(jié)構(gòu)

每個編寫的”.java”拓展名類文件都存儲著需要執(zhí)行的程序邏輯,這些”.java”文件經(jīng)過Java編譯器編譯成拓展名為”.class”的文件,”.class”文件中保存著Java代碼經(jīng)轉(zhuǎn)換后的虛擬機指令,當需要使用某個類時,虛擬機將會加載它的”.class”文件,并創(chuàng)建對應(yīng)的class對象,將class文件加載到虛擬機的內(nèi)存,這個過程稱為類加載,這里我們需要了解一下類加載的過程,如下:


  • 加載:類加載過程的一個階段:通過一個類的完全限定查找此類字節(jié)碼文件,并利用字節(jié)碼文件創(chuàng)建一個Class對象

  • 驗證:目的在于確保Class文件的字節(jié)流中包含信息符合當前虛擬機要求,不會危害虛擬機自身安全。主要包括四種驗證,文件格式驗證,元數(shù)據(jù)驗證,字節(jié)碼驗證,符號引用驗證。

  • 準備:為類變量(即static修飾的字段變量)分配內(nèi)存并且設(shè)置該類變量的初始值即0(如static int i=5;這里只將i初始化為0,至于5的值將在初始化時賦值),這里不包含用final修飾的static,因為final在編譯的時候就會分配了,注意這里不會為實例變量分配初始化,類變量會分配在方法區(qū)中,而實例變量是會隨著對象一起分配到Java堆中。

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

  • 初始化:類加載最后階段,若該類具有超類,則對其進行初始化,執(zhí)行靜態(tài)初始化器和靜態(tài)初始化成員變量(如前面只初始化了默認值的static變量將會在這個階段賦值,成員變量也將被初始化)。

這便是類加載的5個過程,而類加載器的任務(wù)是根據(jù)一個類的全限定名來讀取此類的二進制字節(jié)流到JVM中,然后轉(zhuǎn)換為一個與目標類對應(yīng)的java.lang.Class對象實例,在虛擬機提供了3種類加載器,引導(Bootstrap)類加載器、擴展(Extension)類加載器、系統(tǒng)(System)類加載器(也稱應(yīng)用類加載器)

ClassLoader加載機制如下:

啟動(Bootstrap)類加載器

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

擴展(Extension)類加載器

擴展類加載器是指Sun公司(已被Oracle收購)實現(xiàn)的sun.misc.Launcher$ExtClassLoader類,由Java語言實現(xiàn)的,是Launcher的靜態(tài)內(nèi)部類,它負責加載<JAVA_HOME>/lib/ext目錄下或者由系統(tǒng)變量-Djava.ext.dir指定位路徑中的類庫,開發(fā)者可以直接使用標準擴展類加載器。

//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;
 }

系統(tǒng)(System)類加載器

也稱應(yīng)用程序加載器是指 Sun公司實現(xiàn)的sun.misc.Launcher$AppClassLoader。它負責加載系統(tǒng)類路徑j(luò)ava -classpath或-D java.class.path 指定路徑下的類庫,也就是我們經(jīng)常用到的classpath路徑,開發(fā)者可以直接使用系統(tǒng)類加載器,一般情況下該類加載是程序中默認的類加載器,通過ClassLoader#getSystemClassLoader()方法可以獲取到該類加載器。

在Java的日常應(yīng)用程序開發(fā)中,類的加載幾乎是由上述3種類加載器相互配合執(zhí)行的,在必要時,我們還可以自定義類加載器,需要注意的是,Java虛擬機對class文件采用的是按需加載的方式,也就是說當需要使用該類時才會將它的class文件加載到內(nèi)存生成class對象,而且加載某個類的class文件時,Java虛擬機采用的是雙親委派模式即把請求交由父類處理,它一種任務(wù)委派模式,下面我們進一步了解它。

理解雙親委派模式

雙親委派模式工作原理

雙親委派模式要求除了頂層的啟動類加載器外,其余的類加載器都應(yīng)當有自己的父類加載器,請注意雙親委派模式中的父子關(guān)系并非通常所說的類繼承關(guān)系,而是采用組合關(guān)系來復用父類加載器的相關(guān)代碼,類加載器間的關(guān)系如下:


雙親委派模式是在Java 1.2后引入的,其工作原理的是,如果一個類加載器收到了類加載請求,它并不會自己先去加載,而是把這個請求委托給父類的加載器去執(zhí)行,如果父類加載器還存在其父類加載器,則進一步向上委托,依次遞歸,請求最終將到達頂層的啟動類加載器,如果父類加載器可以完成類加載任務(wù),就成功返回,倘若父類加載器無法完成此加載任務(wù),子加載器才會嘗試自己去加載,這就是雙親委派模式。

雙親委派模式優(yōu)勢

類加載器的雙親委托模型好處:

  • 1、可以確保Java核心庫的類型安全:所有的Java應(yīng)用都至少會引用java.lang.Object類,也就是在運行期java.lang.Object類會加載到Java虛擬機中,如果這個加載過程是由Java自己的類加載器所完成,那么就很可能會在JVM中存在多個版本的java.lang.Object類,而且這些類還不兼容,并且互相不可見(正是命名空間在發(fā)揮作用),借助于雙親委托機制,Java核心類庫中的類的加載工作,都是由啟動類加載器來統(tǒng)一完成加載工作,從而確保了Java應(yīng)用使用的都是同一個版本的Java核心類庫,它們之間是相互兼容的。

  • 2、可以確保Java核心類庫所提供的類,不會被自定義的類所替代。

  • 3、不同的類加載器可以為相同名稱(binary name)的類創(chuàng)建額外的命名空間,相同名稱的類可以并存在Java虛擬機中,只需要不同的類加載器來加載它們即可,不同類加載器所加載的類之間是不兼容的,這就相當于在Java虛擬機內(nèi)部創(chuàng)建了一個又一個項目隔離的Java類空間,這類技術(shù)在很多框架中都得到了實際應(yīng)用。

  • 4、采用雙親委派模式的是好處是Java類隨著它的類加載器一起具備了一種帶有優(yōu)先級的層次關(guān)系,通過這種層級關(guān)可以避免類的重復加載,當父親已經(jīng)加載了該類時,就沒有必要子ClassLoader再加載一次。

Bootstrap啟動類加載器作用:

在運行期,一個Java類是由該類的完全限定名(binary name,二進制名)和用于加載該類的定義類加載器(defining loader)所共同決定的,如果同樣名字(即相同的全限定名)的類是由兩個不同的加載器所加載,那么這些類就是不同的,即便.class文件的字節(jié)碼完全一樣,并且從相同的位置加載亦是如此。

在Oracle的Hotspot實現(xiàn)中,系統(tǒng)屬性sun.boot.class.path如果修改錯了,則運行出錯。

內(nèi)建于JVM中的啟動類加載器會加載java.lang.ClassLoader以及其他的Java平臺類,當JVM啟動時,一塊特殊的機器碼會運行,它會加載擴展類加載器與系統(tǒng)類加載器,這塊特殊的機器碼叫做啟動類加載器(Bootstrap),啟動類加載器并不是Java類,而其他的加載器是Java類,啟動類加載器是特于平臺的機器指令,它負責開啟整個加載過程

  • 所有類加載器(除了啟動類加載器)都被實現(xiàn)為Java類,不過總歸要有一個組件來加載第一個Java類加載器,從而讓整個加載過程能夠順利進行下去,加載第一個純Java類加載器就是啟動類加載器的職責

  • 啟動類加載器還會負責加載供JRE正常運行所需要的基本組件,即rt包下的類,包括java.util和java.lang包等

下面代碼可以看到各類加載器的加載路徑:

public class MyTest22 {

    public static void main(String[] args) {
        System.out.println(System.getProperty("sun.boot.class.path"));
        System.out.println(System.getProperty("java.ext.dirs"));
        System.out.println(System.getProperty("java.class.path"));

        System.out.println(ClassLoader.class.getClassLoader());
        System.out.println(Launcher.class.getClassLoader());
    }
}

下面我們從代碼層面了解幾個Java中定義的類加載器及其雙親委派模式的實現(xiàn),它們類圖關(guān)系如下


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

  • loadClass(String name, boolean resolve)
    該方法加載指定名稱(包括包名)的二進制類型,該方法在JDK1.2之后不再建議用戶重寫但用戶可以直接調(diào)用該方法,loadClass()方法是ClassLoader類自己實現(xiàn)的,該方法中的邏輯就是雙親委派模式的實現(xiàn),其源碼如下,loadClass(String name, boolean resolve)是一個重載方法,resolve參數(shù)代表是否生成class對象的同時進行解析相關(guān)操作。:
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
                // to find the class.
                long t1 = System.nanoTime();
                // 如果都沒有找到,則通過自定義實現(xiàn)的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()方法去加載(關(guān)于findClass()稍后會進一步介紹)。從loadClass實現(xiàn)也可以知道如果不想重新定義加載類的規(guī)則,也沒有復雜的邏輯,只想在運行時加載自己指定的類,那么我們可以直接使用this.getClass().getClassLoder.loadClass("className"),這樣就可以直接調(diào)用ClassLoader的loadClass方法獲取到class對象。

  • findClass(String name)
    在JDK1.2之前,在自定義類加載時,總會去繼承ClassLoader類并重寫loadClass方法,從而實現(xiàn)自定義的類加載類,但是在JDK1.2之后已不再建議用戶去覆蓋loadClass()方法,而是建議把自定義的類加載邏輯寫在findClass()方法中,從前面的分析可知,findClass()方法是在loadClass()方法中被調(diào)用的,當loadClass()方法中父加載器加載失敗后,則會調(diào)用自己的findClass()方法來完成類加載,這樣就可以保證自定義的類加載器也符合雙親委托模式。需要注意的是ClassLoader類中并沒有實現(xiàn)findClass()方法的具體代碼邏輯,取而代之的是拋出ClassNotFoundException異常,同時應(yīng)該知道的是findClass方法通常是和defineClass方法一起使用的(稍后會分析),ClassLoader類中findClass()方法源碼如下:
protected Class<?> findClass(String name) throws ClassNotFoundException {
        //直接拋出異常
    throw new ClassNotFoundException(name);
}
  • defineClass(byte[] b, int off, int len)
    defineClass()方法是用來將byte字節(jié)流解析成JVM能夠識別的Class對象(ClassLoader中已實現(xiàn)該方法邏輯),通過這個方法不僅能夠通過class文件實例化class對象,也可以通過其他方式實例化class對象,如通過網(wǎng)絡(luò)接收一個類的字節(jié)碼,然后轉(zhuǎn)換為byte字節(jié)流創(chuàng)建對應(yīng)的Class對象,defineClass()方法通常與findClass()方法一起使用,一般情況下,在自定義類加載器時,會直接覆蓋ClassLoader的findClass()方法并編寫加載規(guī)則,取得要加載類的字節(jié)碼后轉(zhuǎn)換成流,然后調(diào)用defineClass()方法生成類的Class對象,簡單例子如下:
public class MyClassLoader extends ClassLoader {

    @Override
    protected Class<?> findClass(String className) throws ClassNotFoundException {
        // 獲取類的字節(jié)數(shù)組
        byte[] data = this.loadClassData(className);
        if(data == null){
            throw new ClassNotFoundException();
        }
        //使用defineClass生成class對象
        return this.defineClass(className,data,0,data.length);
    }
}

需要注意的是,如果直接調(diào)用defineClass()方法生成類的Class對象,這個類的Class對象并沒有解析(也可以理解為鏈接階段,畢竟解析是鏈接的最后一步),其解析操作需要等待初始化階段進行。

  • resolveClass(Class<?> c)
    使用該方法可以使用類的Class對象創(chuàng)建完成也同時被解析。前面我們說鏈接階段主要是對字節(jié)碼進行驗證,為類變量分配內(nèi)存并設(shè)置初始值同時將字節(jié)碼文件中的符號引用轉(zhuǎn)換為直接引用。

上述4個方法是ClassLoader類中的比較重要的方法,也是我們可能會經(jīng)常用到的方法。接看SercureClassLoader擴展了 ClassLoader,新增了幾個與使用相關(guān)的代碼源(對代碼源的位置及其證書的驗證)和權(quán)限定義類驗證(主要指對class源碼的訪問權(quán)限)的方法,一般我們不會直接跟這個類打交道,更多是與它的子類URLClassLoader有所關(guān)聯(lián),前面說過,ClassLoader是一個抽象類,很多方法是空的沒有實現(xiàn),比如 findClass()、findResource()等。而URLClassLoader這個實現(xiàn)類為這些方法提供了具體的實現(xiàn),并新增了URLClassPath類協(xié)助取得Class字節(jié)碼流等功能,在編寫自定義類加載器時,如果沒有太過于復雜的需求,可以直接繼承URLClassLoader類,這樣就可以避免自己去編寫findClass()方法及其獲取字節(jié)碼流的方式,使自定義類加載器編寫更加簡潔,下面是URLClassLoader的類圖(利用IDEA生成的類圖)


  • 從類圖結(jié)構(gòu)看出URLClassLoader中存在一個URLClassPath類,通過這個類就可以找到要加載的字節(jié)碼流,也就是說URLClassPath類負責找到要加載的字節(jié)碼,再讀取成字節(jié)流,最后通過defineClass()方法創(chuàng)建類的Class對象。

  • 從URLClassLoader類的結(jié)構(gòu)圖可以看出其構(gòu)造方法都有一個必須傳遞的參數(shù)URL[],該參數(shù)的元素是代表字節(jié)碼文件的路徑,換句話說在創(chuàng)建URLClassLoader對象時必須要指定這個類加載器的到那個目錄下找class文件。

  • 同時也應(yīng)該注意URL[]也是URLClassPath類的必傳參數(shù),在創(chuàng)建URLClassPath對象時,會根據(jù)傳遞過來的URL數(shù)組中的路徑判斷是文件還是jar包,然后根據(jù)不同的路徑創(chuàng)建FileLoader或者JarLoader或默認Loader類去加載相應(yīng)路徑下的class文件,而當JVM調(diào)用findClass()方法時,就由這3個加載器中的一個將class文件的字節(jié)碼流加載到內(nèi)存中,最后利用字節(jié)碼流創(chuàng)建類的class對象。

  • 請記住,如果我們在定義類加載器時選擇繼承ClassLoader類而非URLClassLoader,必須手動編寫findclass()方法的加載邏輯以及獲取字節(jié)碼流的邏輯。

了解完URLClassLoader后接著看看剩余的兩個類加載器,即拓展類加載器ExtClassLoader和系統(tǒng)類加載器AppClassLoader,這兩個類都繼承自URLClassLoader,是sun.misc.Launcher的靜態(tài)內(nèi)部類。sun.misc.Launcher主要被系統(tǒng)用于啟動主應(yīng)用程序,ExtClassLoader和AppClassLoader都是由sun.misc.Launcher創(chuàng)建的,其類主要類結(jié)構(gòu)如下:

它們間的關(guān)系正如前面所闡述的那樣,同時我們發(fā)現(xiàn)ExtClassLoader并沒有重寫loadClass()方法,這足矣說明其遵循雙親委派模式,而AppClassLoader重載了loadCass()方法,但最終調(diào)用的還是父類loadClass()方法,因此依然遵守雙親委派模式,重載方法源碼如下:

/**
 * Override loadClass 方法,新增包權(quán)限檢測功能 
 */
public Class<?> loadClass(String var1, boolean var2) throws ClassNotFoundException {
    int var3 = var1.lastIndexOf(46);
    if (var3 != -1) {
        SecurityManager var4 = System.getSecurityManager();
        if (var4 != null) {
            var4.checkPackageAccess(var1.substring(0, var3));
        }
    }

    if (this.ucp.knownToNotExist(var1)) {
        Class var5 = this.findLoadedClass(var1);
        if (var5 != null) {
            if (var2) {
                this.resolveClass(var5);
            }

            return var5;
        } else {
            throw new ClassNotFoundException(var1);
        }
    } else {
        //依然調(diào)用父類的方法
        return super.loadClass(var1, var2);
    }
}

其實無論是ExtClassLoader還是AppClassLoader都繼承URLClassLoader類,因此它們都遵守雙親委托模型,這點是毋庸置疑的。

到此我們對ClassLoader、URLClassLoader、ExtClassLoader、AppClassLoader以及Launcher類間的關(guān)系有了比較清晰的了解,同時對一些主要的方法也有一定的認識,這里并沒有對這些類的源碼進行詳細的分析,畢竟沒有那個必要,因為我們主要弄得類與類間的關(guān)系和常用的方法同時搞清楚雙親委托模式的實現(xiàn)過程,為編寫自定義類加載器做鋪墊就足夠了。前面出現(xiàn)了很多父類加載器的說法,但每個類加載器的父類到底是誰,一直沒有闡明,下面我們就通過代碼驗證的方式來闡明這答案。

類加載器間的關(guān)系

我們進一步了解類加載器間的關(guān)系(并非指繼承關(guān)系),主要可以分為以下4點:

  • 啟動類加載器,由C++實現(xiàn),沒有父類。
  • 拓展類加載器(ExtClassLoader),由Java語言實現(xiàn),父類加載器為null
  • 系統(tǒng)類加載器(AppClassLoader),由Java語言實現(xiàn),父類加載器為ExtClassLoader
  • 自定義類加載器,父類加載器肯定為AppClassLoader。

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

public class MyClassLoader extends ClassLoader {

    private String classLoaderName;

    private String path;

    private final String fileExtension = ".class";

    public MyClassLoader(String classLoaderName){
        super();//將系統(tǒng)類加載器作為該類加載器的父加載器
        this.classLoaderName = classLoaderName;
    }

    public MyClassLoader(ClassLoader parent,String classLoaderName){
        super(parent);//顯示指定該類加載器的父加載器
        this.classLoaderName = classLoaderName;
    }

    public String getPath() {
        return path;
    }

    public void setPath(String path) {
        this.path = path;
    }

    @Override
    public String toString() {
        return "MyTest15{" +
                "classLoaderName='" + classLoaderName + '\'' +
                '}';
    }

    @Override
    protected Class<?> findClass(String className) throws ClassNotFoundException {
        System.out.println("findClass invoked:"+className);
        System.out.println("class loader name:"+this.classLoaderName);
        byte[] data = this.loadClassData(className);
        if(data == null){
            throw new ClassNotFoundException();
        }
        return this.defineClass(className,data,0,data.length);
    }

    private byte[] loadClassData(String className){
        InputStream inputStream = null;
        byte[] data = null;
        ByteArrayOutputStream bos = null;

        try {
            this.path = path.replace(".","/");
            this.classLoaderName = classLoaderName.replace(".","/");
            inputStream = new FileInputStream(new File(this.path + className + this.classLoaderName));
            bos = new ByteArrayOutputStream();
            int ch;
            while((ch = inputStream.read()) != -1){
                bos.write(ch);
            }
            data = bos.toByteArray();
        } catch (Exception e) {
            e.printStackTrace();
        }finally {
            if(inputStream != null){
                try {
                    inputStream.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
            if(bos != null){
                try {
                    bos.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
        return data;
    }


    public static void main(String[] args) throws Exception {
        MyClassLoader loader1 = new MyClassLoader("loader1");
        System.out.println("自定義類加載器的父加載器: "+loader1.getParent());
        System.out.println("系統(tǒng)默認的AppClassLoader: "+ClassLoader.getSystemClassLoader());
        System.out.println("AppClassLoader的父類加載器: "+ClassLoader.getSystemClassLoader().getParent());
        System.out.println("ExtClassLoader的父類加載器: "+ClassLoader.getSystemClassLoader().getParent().getParent());

        System.out.println("------------------");

        MyClassLoader myClassLoader1 = new MyClassLoader("loader1");
        myClassLoader1.setPath("E:/gradleproject/jvm_study/build/classes/java/main/");
        Class<?> clazz1 = myClassLoader1.loadClass("com.yibo.jvm.classloader.MyTest1");
        System.out.println("clazz1:" + clazz1);
        Object object1 = clazz1.newInstance();
        System.out.println(object1);
        System.out.println(myClassLoader1.getClass().getClassLoader());

        System.out.println("------------------");

        MyClassLoader myClassLoader2 = new MyClassLoader(myClassLoader1,"loader12");
        myClassLoader2.setPath("E:/gradleproject/jvm_study/build/classes/java/main/");
        Class<?> clazz2 = myClassLoader2.loadClass("com.yibo.jvm.classloader.MyTest1");
        System.out.println("clazz2:" + clazz2);
        Object object2 = clazz2.newInstance();
        System.out.println(object2);

        System.out.println("------------------");

        MyClassLoader myClassLoader3 = new MyClassLoader("loader12");
        myClassLoader3.setPath("E:/gradleproject/jvm_study/build/classes/java/main/");
        Class<?> clazz3 = myClassLoader3.loadClass("com.yibo.jvm.classloader.MyTest1");
        System.out.println("clazz3:" + clazz3);
        Object object3 = clazz3.newInstance();
        System.out.println(object3);
    }
}

代碼中,我們自定義了一個MyClassLoader,這里我們繼承了ClassLoader而非URLClassLoader,因此需要自己編寫findClass()方法邏輯以及加載字節(jié)碼的邏輯,接著在main方法中,通過ClassLoader.getSystemClassLoader()獲取到系統(tǒng)默認類加載器,通過獲取其父類加載器及其父父類加載器,同時還獲取了自定義類加載器的父類加載器,最終輸出結(jié)果正如我們所預(yù)料的,AppClassLoader的父類加載器為ExtClassLoader,而ExtClassLoader沒有父類加載器。如果我們實現(xiàn)自己的類加載器,它的父加載器都只會是AppClassLoader。這里我們不妨看看Lancher的構(gòu)造器源碼:

public Launcher() {
    Launcher.ExtClassLoader var1;
    try {
        // 首先創(chuàng)建擴展類加載器
        var1 = Launcher.ExtClassLoader.getExtClassLoader();
    } catch (IOException var10) {
        throw new InternalError("Could not create extension class loader", var10);
    }

    try {
        //再創(chuàng)建AppClassLoader并把擴展類加載器作為父加載器傳遞給AppClassLoader
        this.loader = Launcher.AppClassLoader.getAppClassLoader(var1);
    } catch (IOException var9) {
        throw new InternalError("Could not create application class loader", var9);
    }
    //設(shè)置線程上下文類加載器,稍后分析
    Thread.currentThread().setContextClassLoader(this.loader);
    String var2 = System.getProperty("java.security.manager");
    if (var2 != null) {
        SecurityManager var3 = null;
        if (!"".equals(var2) && !"default".equals(var2)) {
            try {
                var3 = (SecurityManager)this.loader.loadClass(var2).newInstance();
            } catch (IllegalAccessException var5) {
            } catch (InstantiationException var6) {
            } catch (ClassNotFoundException var7) {
            } catch (ClassCastException var8) {
            }
        } else {
            var3 = new SecurityManager();
        }

        if (var3 == null) {
            throw new InternalError("Could not create SecurityManager: " + var2);
        }
        System.setSecurityManager(var3);
    }
}

顯然Lancher初始化時首先會創(chuàng)建ExtClassLoader類加載器,然后再創(chuàng)建AppClassLoader并把ExtClassLoader傳遞給它作為父類加載器,這里還把AppClassLoader默認設(shè)置為線程上下文類加載器,關(guān)于線程上下文類加載器稍后會分析。那ExtClassLoader類加載器為什么是null呢?看下面的源碼創(chuàng)建過程就明白,在創(chuàng)建ExtClassLoader強制設(shè)置了其父加載器為null。

// 首先創(chuàng)建擴展類加載器
var1 = Launcher.ExtClassLoader.getExtClassLoader();

static class ExtClassLoader extends URLClassLoader {
    private static volatile Launcher.ExtClassLoader instance;

    public static Launcher.ExtClassLoader getExtClassLoader() throws IOException {
        if (instance == null) {
            Class var0 = Launcher.ExtClassLoader.class;
            synchronized(Launcher.ExtClassLoader.class) {
                if (instance == null) {
                    instance = createExtClassLoader();
                }
            }
        }

        return instance;
    }

    public ExtClassLoader(File[] var1) throws IOException {
        //調(diào)用父類構(gòu)造URLClassLoader傳遞null作為parent
        super(getExtURLs(var1), (ClassLoader)null, Launcher.factory);
        SharedSecrets.getJavaNetAccess().getURLClassPath(this).initLookupCache(this);
    }
}

public URLClassLoader(URL[] urls, ClassLoader parent,
                      URLStreamHandlerFactory factory) {
    super(parent);
    // this is to make the stack depth consistent with 1.1
    SecurityManager security = System.getSecurityManager();
    if (security != null) {
        security.checkCreateClassLoader();
    }
    acc = AccessController.getContext();
    ucp = new URLClassPath(urls, factory, acc);
}

顯然ExtClassLoader的父類為null,而AppClassLoader的父加載器為ExtClassLoader,所有自定義的類加載器其父加載器只會是AppClassLoader,注意這里所指的父類并不是Java繼承關(guān)系中的那種父子關(guān)系。

類與類加載器

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

  • 類的全限定名必須一致,包括包名。
  • 加載這個類的ClassLoader(指ClassLoader實例對象)必須相同。

也就是說,在JVM中,即使這個兩個類對象(class對象)來源同一個Class文件,被同一個虛擬機所加載,但只要加載它們的ClassLoader實例對象不同,那么這兩個類對象也是不相等的,這是因為不同的ClassLoader實例對象都擁有不同的獨立的類名稱空間,所以加載的class對象也會存在不同的類名空間中,但前提是覆寫loadclass方法,從前面雙親委派模式對loadClass()方法的源碼分析中可以知,在方法第一步會通過Class<?> c = findLoadedClass(name);從緩存查找,類名完整名稱相同則不會再次被加載,因此我們必須繞過緩存查詢才能重新加載class對象。當然也可直接調(diào)用findClass()方法,這樣也避免從緩存查找。如果不從緩存查詢相同完全類名的class對象,那么只有ClassLoader的實例對象不同,同一字節(jié)碼文件創(chuàng)建的class對象自然也不會相同。

了解class文件的顯示加載與隱式加載的概念

所謂class文件的顯示加載與隱式加載的方式是指JVM加載class文件到內(nèi)存的方式,顯示加載指的是在代碼中通過調(diào)用ClassLoader加載class對象,如直接使用Class.forName(name)或this.getClass().getClassLoader().loadClass()加載class對象。
隱式加載則是不直接在代碼中調(diào)用ClassLoader的方法加載class對象,而是通過虛擬機自動加載到內(nèi)存中,如在加載某個類的class文件時,該類的class文件中引用了另外一個類的對象,此時額外引用的類將通過JVM自動加載到內(nèi)存中。在日常開發(fā)以上兩種方式一般會混合使用,這里我們知道有這么回事即可。

編寫自己的類加載器

通過前面的分析可知,實現(xiàn)自定義類加載器需要繼承ClassLoader或者URLClassLoader,繼承ClassLoader則需要自己重寫findClass()方法并編寫加載邏輯,繼承URLClassLoader則可以省去編寫findClass()方法以及class文件加載轉(zhuǎn)換成字節(jié)碼流的代碼。那么編寫自定義類加載器的意義何在呢?

  • 當class文件不在ClassPath路徑下,默認系統(tǒng)類加載器無法找到該class文件,在這種情況下我們需要實現(xiàn)一個自定義的ClassLoader來加載特定路徑下的class文件生成class對象。
  • 當一個class文件是通過網(wǎng)絡(luò)傳輸并且可能會進行相應(yīng)的加密操作時,需要先對class文件進行相應(yīng)的解密后再加載到JVM內(nèi)存中,這種情況下也需要編寫自定義的ClassLoader并實現(xiàn)相應(yīng)的邏輯。
  • 當需要實現(xiàn)熱部署功能時(一個class文件通過不同的類加載器產(chǎn)生不同class對象從而實現(xiàn)熱部署功能),需要實現(xiàn)自定義ClassLoader的邏輯。

繼承ClassLoader實現(xiàn)自定義類加載器

public class MyClassLoader extends ClassLoader {

    private String classLoaderName;

    private String path;

    private final String fileExtension = ".class";

    public MyClassLoader(String classLoaderName){
        super();//將系統(tǒng)類加載器作為該類加載器的父加載器
        this.classLoaderName = classLoaderName;
    }

    public MyClassLoader(ClassLoader parent,String classLoaderName){
        super(parent);//顯示指定該類加載器的父加載器
        this.classLoaderName = classLoaderName;
    }

    public String getPath() {
        return path;
    }

    public void setPath(String path) {
        this.path = path;
    }

    @Override
    public String toString() {
        return "MyTest15{" +
                "classLoaderName='" + classLoaderName + '\'' +
                '}';
    }

    @Override
    protected Class<?> findClass(String className) throws ClassNotFoundException {
        System.out.println("findClass invoked:"+className);
        System.out.println("class loader name:"+this.classLoaderName);
        byte[] data = this.loadClassData(className);
        if(data == null){
            throw new ClassNotFoundException();
        }
        return this.defineClass(className,data,0,data.length);
    }

    private byte[] loadClassData(String className){
        InputStream inputStream = null;
        byte[] data = null;
        ByteArrayOutputStream bos = null;

        try {
            this.path = path.replace(".","/");
            this.classLoaderName = classLoaderName.replace(".","/");
            inputStream = new FileInputStream(new File(this.path + className + this.classLoaderName));
            bos = new ByteArrayOutputStream();
            int ch;
            while((ch = inputStream.read()) != -1){
                bos.write(ch);
            }
            data = bos.toByteArray();
        } catch (Exception e) {
            e.printStackTrace();
        }finally {
            if(inputStream != null){
                try {
                    inputStream.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
            if(bos != null){
                try {
                    bos.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
        return data;
    }


    public static void main(String[] args) throws Exception {
        MyClassLoader myClassLoader1 = new MyClassLoader("loader1");
        myClassLoader1.setPath("E:/gradleproject/jvm_study/build/classes/java/main/");
        Class<?> clazz1 = myClassLoader1.loadClass("com.yibo.jvm.classloader.MyTest1");
        System.out.println("clazz1:" + clazz1);
        Object object1 = clazz1.newInstance();
        System.out.println(object1);
        System.out.println(myClassLoader1.getClass().getClassLoader());

        System.out.println("------------------");

        MyClassLoader myClassLoader2 = new MyClassLoader(myClassLoader1,"loader12");
        myClassLoader2.setPath("E:/gradleproject/jvm_study/build/classes/java/main/");
        Class<?> clazz2 = myClassLoader2.loadClass("com.yibo.jvm.classloader.MyTest1");
        System.out.println("clazz2:" + clazz2);
        Object object2 = clazz2.newInstance();
        System.out.println(object2);

        System.out.println("------------------");

        MyClassLoader myClassLoader3 = new MyClassLoader("loader12");
        myClassLoader3.setPath("E:/gradleproject/jvm_study/build/classes/java/main/");
        Class<?> clazz3 = myClassLoader3.loadClass("com.yibo.jvm.classloader.MyTest1");
        System.out.println("clazz3:" + clazz3);
        Object object3 = clazz3.newInstance();
        System.out.println(object3);
    }
}

顯然我們通過loadClassData()方法找到class文件并轉(zhuǎn)換為字節(jié)流,并重寫findClass()方法,利用defineClass()方法創(chuàng)建了類的class對象。在main方法中調(diào)用了setPath()方法加載指定路徑下的class文件,由于啟動類加載器、拓展類加載器以及系統(tǒng)類加載器都無法在其路徑下找到該類,因此最終將有自定義類加載器加載,即調(diào)用findClass()方法進行加載。如果繼承URLClassLoader實現(xiàn),那代碼就更簡潔了,如下:

繼承URLClassLoader現(xiàn)自定義類加載器

public class MyURLClassLoader extends URLClassLoader {


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

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

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

    public static void main(String[] args) throws MalformedURLException {
        String rootDir="E:/gradleproject/jvm_study/build/classes/java/main/";
        //創(chuàng)建自定義文件類加載器
        File file = new File(rootDir);
        //File to URI
        URI uri=file.toURI();
        URL[] urls = {uri.toURL()};

        MyURLClassLoader loader = new MyURLClassLoader(urls);

        try {
            //加載指定的class文件
            Class<?> object =loader.loadClass("com.yibo.jvm.classloader.MyTest1");
            System.out.println(object.newInstance().toString());
            
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

非常簡潔除了需要重寫構(gòu)造器外無需編寫findClass()方法及其class文件的字節(jié)流轉(zhuǎn)換邏輯。

自定義網(wǎng)絡(luò)類加載器

自定義網(wǎng)絡(luò)類加載器,主要用于讀取通過網(wǎng)絡(luò)傳遞的class文件(在這里我們省略class文件的解密過程),并將其轉(zhuǎn)換成字節(jié)流生成對應(yīng)的class對象,如下

public class NetClassLoader extends ClassLoader {

    private String url;//class文件的URL

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

    @Override
    protected Class<?> findClass(String className) throws ClassNotFoundException {
        byte[] data = this.loadClassData(className);
        if(data == null){
            throw new ClassNotFoundException();
        }
        return this.defineClass(className,data,0,data.length);
    }

    private byte[] loadClassData(String className){
        String path = classNameToPath(className);
        InputStream ins = null;
        byte[] data = null;
        ByteArrayOutputStream baos = null;
        try {
            URL url = new URL(path);
            ins = url.openStream();
            baos = new ByteArrayOutputStream();
            int bufferSize = 8192;
            byte[] buffer = new byte[bufferSize];
            int bytesNumRead = 0;
            // 讀取類文件的字節(jié)
            while ((bytesNumRead = ins.read(buffer)) != -1) {
                baos.write(buffer, 0, bytesNumRead);
            }
            //這里省略解密的過程.......
            data = baos.toByteArray();
        } catch (Exception e) {
            e.printStackTrace();
        }finally {
            if(ins != null){
                try {
                    ins.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
            if(baos != null){
                try {
                    baos.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
        return data;
    }

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

主要是在獲取字節(jié)碼流時的區(qū)別,從網(wǎng)絡(luò)直接獲取到字節(jié)流再轉(zhuǎn)車字節(jié)數(shù)組然后利用defineClass方法創(chuàng)建class對象,如果繼承URLClassLoader類則和前面文件路徑的實現(xiàn)是類似的,無需擔心路徑是filePath還是Url,因為URLClassLoader內(nèi)的URLClassPath對象會根據(jù)傳遞過來的URL數(shù)組中的路徑判斷是文件還是jar包,然后根據(jù)不同的路徑創(chuàng)建FileLoader或者JarLoader或默認類Loader去讀取對于的路徑或者url下的class文件。

熱部署類加載器

所謂的熱部署就是利用同一個class文件不同的類加載器在內(nèi)存創(chuàng)建出兩個不同的class對象(關(guān)于這點的原因前面已分析過,即利用不同的類加載實例),由于JVM在加載類之前會檢測請求的類是否已加載過(即在loadClass()方法中調(diào)用findLoadedClass()方法),如果被加載過,則直接從緩存獲取,不會重新加載。注意同一個類加載器的實例和同一個class文件只能被加載器一次,多次加載將報錯,因此我們實現(xiàn)的熱部署必須讓同一個class文件可以根據(jù)不同的類加載器重復加載,以實現(xiàn)所謂的熱部署。實際上前面的實現(xiàn)的MyClassLoader和MyURLClassLoader已具備這個功能,但前提是直接調(diào)用findClass()方法,而不是調(diào)用loadClass()方法,因為ClassLoader中l(wèi)oadClass()方法體中調(diào)用findLoadedClass()方法進行了檢測是否已被加載,因此我們直接調(diào)用findClass()方法就可以繞過這個問題,當然也可以重新loadClass方法,但強烈不建議這么干。

雙親委派模型的破壞者-線程上下文類加載器

當前類加載器(current ClassLoader)

  • 每個類都會使用自己的類加載器(即加載自身的類加載器)來加載其他類(值的是所依賴的類)
  • 如果ClassX引用了ClassY,那么ClassX的類加載器就會去加載ClassY(前提是ClassY尚未被加載)

線程上下文類加載器(Context ClassLoader)

  • 線程上下文類加載器(Context ClassLoader)是從JDK1.2開始引入的,類Thread中的getContextClassLoader()與setContextClassLoader(ClassLoader cl)分別用來獲取和設(shè)置線程上下文類加載器
  • 如果沒有通過setContextClassLoader(ClassLoader cl)進行設(shè)置的話,線程將繼承其父線程的上下文類加載器
  • Java應(yīng)用運行時的初始線程的上下文類加載器是系統(tǒng)類加載器,在線程中運行的代碼可以通過該類加載器來加載類與資源

線程上下文類加載器的重要性:

  • 在Java應(yīng)用中存在著很多服務(wù)提供者接口(Service Provider Interface,SPI),這些接口允許第三方為它們提供實現(xiàn),如常見的 SPI 有 JDBC、JNDI等,這些 SPI 的接口屬于 Java 核心庫,一般存在rt.jar包中,由Bootstrap類加載器加載,而 SPI 的第三方實現(xiàn)代碼則是作為Java應(yīng)用所依賴的 jar 包被存放在classpath路徑下,由于SPI接口中的代碼經(jīng)常需要加載具體的第三方實現(xiàn)類并調(diào)用其相關(guān)方法,但SPI的核心接口類是由引導類加載器來加載的,而Bootstrap類加載器無法直接加載SPI的實現(xiàn)類
  • 父ClassLoader可以使用當前線程Thread.currentThread().getContextClassLoader所指定的ClassLoader加載的類,這就改變了父ClassLoader不能使用子ClassLoader或是其他沒有直接父子關(guān)系的ClassLoader加載的類的情況,即改變了雙親委托模型
  • 線程上下文類加載器就是當前線程的Current ClassLoader,在雙親委托模型下,類加載是由下至上的,即下層的類加載器會委托上層進行加載。但是對于SPI來說,有些接口是Java核心庫所提供的,而Java核心庫是由啟動類加載器所加載的,而這些接口的實現(xiàn)確來自于不同的jar包(廠商提供),Java的啟動類加載器是不會加載其他來源的jar包,這樣傳統(tǒng)的雙親委托模型就無法滿足SPI的要求,而通過給當前線程設(shè)置上下文類加載器,就可以由設(shè)置的上下文類加載器來實現(xiàn)對于接口的實現(xiàn)類的加載。

如下圖所示,以jdbc.jar加載為例:


從圖可知rt.jar核心包是由Bootstrap類加載器加載的,其內(nèi)包含SPI核心接口類,由于SPI中的類經(jīng)常需要調(diào)用外部實現(xiàn)類的方法,而jdbc.jar包含外部實現(xiàn)類(jdbc.jar存在于classpath路徑)無法通過Bootstrap類加載器加載,因此只能委派線程上下文類加載器把jdbc.jar中的實現(xiàn)類加載到內(nèi)存以便SPI相關(guān)類使用。顯然這種線程上下文類加載器的加載方式破壞了“雙親委派模型”,它在執(zhí)行過程中拋棄雙親委派加載鏈模式,使程序可以逆向使用類加載器,當然這也使得Java類加載器變得更加靈活。為了進一步證實這種場景,不妨看看DriverManager類的源碼,DriverManager是Java核心rt.jar包中的類,該類用來管理不同數(shù)據(jù)庫的實現(xiàn)驅(qū)動即Driver,它們都實現(xiàn)了Java核心包中的java.sql.Driver接口,如mysql驅(qū)動包中的com.mysql.jdbc.Driver,這里主要看看如何加載外部實現(xiàn)類,在DriverManager初始化時會執(zhí)行如下代碼

//DriverManager是Java核心包rt.jar的類
public class DriverManager {

    //省略不必要的代碼......

    static {
        loadInitialDrivers();//執(zhí)行該方法
        println("JDBC DriverManager initialized");
    }

    private static void loadInitialDrivers() {
        //省略不必要的代碼......

        AccessController.doPrivileged(new PrivilegedAction<Void>() {
            public Void run() {
                //加載外部的Driver的實現(xiàn)類
                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;
            }
        });

        //省略不必要的代碼......
    }
}

在DriverManager類初始化時執(zhí)行了loadInitialDrivers()方法,在該方法中通過ServiceLoader.load(Driver.class);去加載外部實現(xiàn)的驅(qū)動類,ServiceLoader類會去讀取mysql的jdbc.jar下META-INF文件的內(nèi)容,如下所示:


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

/**
 * Backwards compatibility to support apps that call <code>Class.forName("com.mysql.jdbc.Driver");</code>.
 */
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驅(qū)動

//不建議使用該方式注冊驅(qū)動類
Class.forName("com.mysql.jdbc.Driver");
String url = "jdbc:mysql://localhost:3306/cm-storylocker?characterEncoding=UTF-8";
// 通過java庫獲取數(shù)據(jù)庫連接
Connection conn = java.sql.DriverManager.getConnection(url, "root", "root@555");

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

String url = "jdbc:mysql://localhost:3306/cm-storylocker?characterEncoding=UTF-8";
// 通過java庫獲取數(shù)據(jù)庫連接
Connection conn = java.sql.DriverManager.getConnection(url, "root", "root@555");

這樣ServiceLoader會幫助我們處理一切,并最終通過load()方法加載,看看load()方法實現(xiàn)

public static <S> ServiceLoader<S> load(Class<S> service) {
    //通過線程上下文類加載器加載
    ClassLoader cl = Thread.currentThread().getContextClassLoader();
    return ServiceLoader.load(service, cl);
}

很明顯了確實通過線程上下文類加載器加載的,實際上核心包的SPI類對外部實現(xiàn)類的加載都是基于線程上下文類加載器執(zhí)行的,通過這種方式實現(xiàn)了Java核心代碼內(nèi)部去調(diào)用外部實現(xiàn)類。我們知道線程上下文類加載器默認情況下就是AppClassLoader,那為什么不直接通過getSystemClassLoader()獲取類加載器來加載classpath路徑下的類的呢?其實是可行的,但這種直接使用getSystemClassLoader()方法獲取AppClassLoader加載類有一個缺點,那就是代碼部署到不同服務(wù)時會出現(xiàn)問題,如把代碼部署到Java Web應(yīng)用服務(wù)或者EJB之類的服務(wù)將會出問題,因為這些服務(wù)使用的線程上下文類加載器并非AppClassLoader,而是Java Web應(yīng)用服自家的類加載器,類加載器不同。,所以我們應(yīng)用該少用getSystemClassLoader()??傊煌姆?wù)使用的可能默認ClassLoader是不同的,但使用線程上下文類加載器總能獲取到與當前程序執(zhí)行相同的ClassLoader,從而避免不必要的問題。ok~.關(guān)于線程上下文類加載器暫且聊到這,前面闡述的DriverManager類,大家可以自行看看源碼,相信會有更多的體會,另外關(guān)于ServiceLoader本篇并沒有過多的闡述,畢竟我們主題是類加載器,但ServiceLoader是個很不錯的解耦機制,大家可以自行查閱其相關(guān)用法。

線程上下文類加載器的一般使用模式(獲取 - 使用 - 還原)

ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
try{
    Thread.currentThread().setContextClassLoader(targetTClassLoader);
    myMethod();
}finally{
    Thread.currentThread().setContextClassLoader(classLoader);
}
  • myMethod()里面調(diào)用了Thread.currentThread().getContextClassLoader()獲取當前線程的上下文類加載器做某些事情
  • 如果一個類由類加載器A加載,那么這個類的依賴類也是由相同的類加載器所加載的(前提是該類沒有被加載)
  • ContextClassLoader的作用就是為了破壞Java類加載的雙親委托機制
  • 當高層提供了統(tǒng)一的接口讓低層去實現(xiàn),同時又要在高層加載(或?qū)嵗?低層的類時,就必須要通過線程上下文類加載器來幫助高層的ClassLoader找到并加載該類

線程上下文類加載器的適用場景:

  • 當高層提供了統(tǒng)一接口讓低層去實現(xiàn),同時又要是在高層加載(或?qū)嵗┑蛯拥念悤r,必須通過線程上下文類加載器來幫助高層的ClassLoader找到并加載該類。

  • 當使用本類托管類加載,然而加載本類的ClassLoader未知時,為了隔離不同的調(diào)用者,可以取調(diào)用者各自的線程上下文類加載器代為托管。

參考:
https://www.cnblogs.com/mybatis/p/9396135.html

https://blog.csdn.net/yangcheng33/article/details/52631940

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

友情鏈接更多精彩內(nèi)容