類加載流程,類加載機制及自定義類加載器詳解(面試再也不怕了)


一、引言二、類的加載、鏈接、初始化1、加載1.1、加載的class來源2、類的鏈接2.1、驗證2.2、准備2.3、解析3、類的初始化3.1、< clinit>方法相關3.2、類初始化時機3.3、final定義的初始化3.4、ClassLoader只會對類進行加載,不會進行初始化三、類加載器1、JVM類加載器分類1.1、Bootstrap ClassLoader1.2 、Extension ClassLoader1.3、 System ClassLoader四、類加載機制1.1、JVM主要的類加載機制。1.2、類加載流程圖五、創建並使用自定義類加載器1、自定義類加載分析2、實現自定義類加載器六、總結

一、引言

當程序使用某個類時,如果該類還未被加載到內存中,則JVM會通過加載、鏈接、初始化三個步驟對該類進行類加載。

二、類的加載、鏈接、初始化

1、加載

類加載指的是將類的class文件讀入內存,並為之創建一個java.lang.Class對象。類的加載過程是由類加載器來完成,類加載器由JVM提供。我們開發人員也可以通過繼承ClassLoader來實現自己的類加載器。

1.1、加載的class來源
  • 從本地文件系統內加載class文件
  • 從JAR包加載class文件
  • 通過網絡加載class文件
  • 把一個java源文件動態編譯,並執行加載。

2、類的鏈接

通過類的加載,內存中已經創建了一個Class對象。鏈接負責將二進制數據合並到 JRE中。鏈接需要通過驗證、准備、解析三個階段。

2.1、驗證

驗證階段用於檢查被加載的類是否有正確的內部結構,並和其他類協調一致。即是否滿足java虛擬機的約束。

2.2、准備

類准備階段負責為類的類變量分配內存,並設置默認初始值。

2.3、解析

我們知道,引用其實對應於內存地址。思考這樣一個問題,在編寫代碼時,使用引用,方法時,類知道這些引用方法的內存地址嗎?顯然是不知道的,因為類還未被加載到虛擬機中,你無法獲得這些地址。舉例來說,對於一個方法的調用,編譯器會生成一個包含目標方法所在的類、目標方法名、接收參數類型以及返回值類型的符號引用,來指代要調用的方法。

解析階段的目的,就是將這些符號引用解析為實際引用。如果符號引用指向一個未被加載的類,或者未被加載類的字段或方法,那么解析將觸發這個類的加載(但未必會觸發解析與初始化)。

3、類的初始化

類的初始化階段,虛擬機主要對類變量進行初始化。虛擬機調用< clinit>方法,進行類變量的初始化。

java類中對類變量進行初始化的兩種方式:

  1. 在定義時初始化
  2. 在靜態初始化塊內初始化
3.1、< clinit>方法相關
  • 虛擬機會收集類及父類中的類變量及類方法組合為< clinit>方法,根據定義的順序進行初始化。虛擬機會保證子類的< clinit>執行之前,父類的< clinit>方法先執行完畢。因此,虛擬機中第一個被執行完畢的< clinit>方法肯定是java.lang.Object方法。
public class Test {
    static int A = 10;
    static {
        A = 20;
    }
}

class Test1 extends Test {
    private static int B = A;
    public static void main(String[] args) {
        System.out.println(Test1.B);
    }
}
//輸出結果
//20

從輸出中看出,父類的靜態初始化塊在子類靜態變量初始化之前初始化完畢,所以輸出結果是20,不是10。

  • 如果類或者父類中都沒有靜態變量及方法,虛擬機不會為其生成< clinit>方法。

  • 接口與類不同的是,執行接口的<clinit>方法不需要先執行父接口的<clinit>方法。 只有當父接口中定義的變量使用時,父接口才會初始化。 另外,接口的實現類在初始化時也一樣不會執行接口的<clinit>方法。

public interface InterfaceInitTest {
    long A = CurrentTime.getTime();

}

interface InterfaceInitTest1 extends InterfaceInitTest {
    int B = 100;
}

class InterfaceInitTestImpl implements InterfaceInitTest1 {
    public static void main(String[] args) {
        System.out.println(InterfaceInitTestImpl.B);
        System.out.println("---------------------------");
        System.out.println("當前時間:"+InterfaceInitTestImpl.A);
    }
}

class CurrentTime {
    static long getTime() {
        System.out.println("加載了InterfaceInitTest接口");
        return System.currentTimeMillis();
    }
}
//輸出結果
//100
//---------------------------
//加載了InterfaceInitTest接口
//當前時間:1560158880660

從輸出驗證了:對於接口,只有真正使用父接口的類變量才會真正的加載父接口。這跟普通類加載不一樣。

  • 虛擬機會保證一個類的< clinit>方法在多線程環境中被正確地加鎖和同步,如果多個線程同時去初始化一個類,那么只有一個線程去執行這個類的< clinit>方法,其他線程都需要阻塞等待,直到活動線程執行< clinit>方法完畢。
public class MultiThreadInitTest {
    static int A = 10;
    static {
           System.out.println(Thread.currentThread()+"init MultiThreadInitTest");
        try {
            TimeUnit.SECONDS.sleep(10);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    public static void main(String[] args) {
        Runnable runnable = () -> {
            System.out.println(Thread.currentThread() + "start");
            System.out.println(MultiThreadInitTest.A);
            System.out.println(Thread.currentThread() + "run over");
        };
        Thread thread1 = new Thread(runnable);
        Thread thread2 = new Thread(runnable);
        thread1.start();
        thread2.start();
    }
}
//輸出結果
//Thread[main,5,main]init MultiThreadInitTest
//Thread[Thread-0,5,main]start
//10
//Thread[Thread-0,5,main]run over
//Thread[Thread-1,5,main]start
//10
//Thread[Thread-1,5,main]run over

從輸出中看出驗證了:只有第一個線程對MultiThreadInitTest進行了一次初始化,第二個線程一直阻塞等待等第一個線程初始化完畢。

3.2、類初始化時機
  1. 當虛擬機啟動時,初始化用戶指定的主類;
  2. 當遇到用以新建目標類實例的new指令時,初始化new指令的目標類;
  3. 當遇到調用靜態方法或者使用靜態變量,初始化靜態變量或方法所在的類;
  4. 子類初始化過程會觸發父類初始化;
  5. 如果一個接口定義了default方法,那么直接實現或者間接實現該接口的類的初始化,會觸發該接口初始化;
  6. 使用反射API對某個類進行反射調用時,初始化這個類;
  7. Class.forName()會觸發類的初始化
3.3、final定義的初始化

注意:對於一個使用final定義的常量,如果在編譯時就已經確定了值,在引用時不會觸發初始化,因為在編譯的時候就已經確定下來,就是“宏變量”。如果在編譯時無法確定,在初次使用才會導致初始化。

public class StaticInnerSingleton {
    /**
     * 使用靜態內部類實現單例:
     * 1:線程安全
     * 2:懶加載
     * 3:非反序列化安全,即反序列化得到的對象與序列化時的單例對象不是同一個,違反單例原則
     */

    private static class LazyHolder {
        private static final StaticInnerSingleton INNER_SINGLETON = new StaticInnerSingleton();
    }

    private StaticInnerSingleton() {
    }

    public static StaticInnerSingleton getInstance() {
        return LazyHolder.INNER_SINGLETON;
    }
}

看這個例子,單例模式靜態內部類實現方式。我們可以看到單例實例使用final定義,但在編譯時無法確定下來,所以在第一次使用StaticInnerSingleton.getInstance()方法時,才會觸發靜態內部類的加載,也就是延遲加載。這里想指出,如果final定義的變量在編譯時無法確定,則在使用時還是會進行類的初始化。

3.4、ClassLoader只會對類進行加載,不會進行初始化
public class Tester {
    static {
        System.out.println("Tester類的靜態初始化塊");
    }
}

class ClassLoaderTest {
    public static void main(String[] args) throws ClassNotFoundException {
        ClassLoader classLoader = ClassLoader.getSystemClassLoader();
        //下面語句僅僅是加載Tester類
        classLoader.loadClass("loader.Tester");
        System.out.println("系統加載Tester類");
        //下面語句才會初始化Tester類
        Class.forName("loader.Tester");
    }
}
//輸出結果
//系統加載Tester類
//Tester類的靜態初始化塊

從輸出證明:ClassLoader只會對類進行加載,不會進行初始化;使用Class.forName()會強制導致類的初始化。

三、類加載器

類加載器負責將.class文件(不管是jar,還是本地磁盤,還是網絡獲取等等)加載到內存中,並為之生成對應的java.lang.Class對象。一個類被加載到JVM中,就不會第二次加載了。

那怎么判斷是同一個類呢?

每個類在JVM中使用全限定類名(包名+類名)與類加載器聯合為唯一的ID,所以如果同一個類使用不同的類加載器,可以被加載到虛擬機,但彼此不兼容。

1、JVM類加載器分類

1.1、Bootstrap ClassLoader

Bootstrap ClassLoader為根類加載器,負責加載java的核心類庫。根加載器不是ClassLoader的子類,是有C++實現的。

public class BootstrapTest {
    public static void main(String[] args) {
        //獲取根類加載器所加載的全部URL數組
        URL[] urLs = Launcher.getBootstrapClassPath().getURLs();
        Arrays.stream(urLs).forEach(System.out::println);
    }
}
//輸出結果
//file:/C:/SorftwareInstall/java/jdk/jre/lib/resources.jar
//file:/C:/SorftwareInstall/java/jdk/jre/lib/rt.jar
//file:/C:/SorftwareInstall/java/jdk/jre/lib/sunrsasign.jar
//file:/C:/SorftwareInstall/java/jdk/jre/lib/jsse.jar
//file:/C:/SorftwareInstall/java/jdk/jre/lib/jce.jar
//file:/C:/SorftwareInstall/java/jdk/jre/lib/charsets.jar
//file:/C:/SorftwareInstall/java/jdk/jre/lib/jfr.jar
//file:/C:/SorftwareInstall/java/jdk/jre/classes

根類加載器負責加載%JAVA_HOME%/jre/lib下的jar包(以及由虛擬機參數 -Xbootclasspath 指定的類)。

我們將rt.jar解壓,可以看到我們經常使用的類庫就在這個jar包中。

1.2 、Extension ClassLoader

Extension ClassLoader為擴展類加載器,負責加載%JAVA_HOME%/jre/ext或者java.ext.dirs系統熟悉指定的目錄的jar包。大家可以將自己寫的工具包放到這個目錄下,可以方便自己使用。

1.3、 System ClassLoader

System ClassLoader為系統(應用)類加載器,負責加載加載來自java命令的-classpath選項、java.class.path系統屬性,或者CLASSPATH環境變量所指定的JAR包和類路徑。程序可以通過ClassLoader.getSystemClassLoader()來獲取系統類加載器。如果沒有特別指定,則用戶自定義的類加載器默認都以系統類加載器作為父加載器。

四、類加載機制

1.1、JVM主要的類加載機制。

  1. 全盤負責:當一個類加載器負責加載某個Class時,該Class所依賴和引用的其他Class也由該類加載器負責載入,除非顯示使用另一個類加載器來載入。
  2. 父類委托(雙親委派):先讓父加載器試圖加載該Class,只有在父加載器無法加載時該類加載器才會嘗試從自己的類路徑中加載該類。
  3. 緩存機制:緩存機制會將已經加載的class緩存起來,當程序中需要使用某個Class時,類加載器先從緩存區中搜尋該Class,只有當緩存中不存在該Class時,系統才會讀取該類的二進制數據,並將其轉換為Class對象,存入緩存中。這就是為什么更改了class后,需要重啟JVM才生效的原因。

注意:類加載器之間的父子關系並不是類繼承上的父子關系,而是實例之間的父子關系。

public class ClassloaderPropTest {
    public static void main(String[] args) throws IOException {
        //獲取系統類加載器
        ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
        System.out.println("系統類加載器:" + systemClassLoader);
        /*
        獲取系統類加載器的加載路徑——通常由CLASSPATH環境變量指定,如果操作系統沒有指定
        CLASSPATH環境變量,則默認以當前路徑作為系統類加載器的加載路徑
         */

        Enumeration<URL> eml = systemClassLoader.getResources("");
        while (eml.hasMoreElements()) {
            System.out.println(eml.nextElement());
        }
        //獲取系統類加載器的父類加載器,得到擴展類加載器
        ClassLoader extensionLoader = systemClassLoader.getParent();
        System.out.println("系統類的父加載器是擴展類加載器:" + extensionLoader);
        System.out.println("擴展類加載器的加載路徑:" + System.getProperty("java.ext.dirs"));
        System.out.println("擴展類加載器的parant:" + extensionLoader.getParent());
    }
}
//輸出結果
//系統類加載器:sun.misc.Launcher$AppClassLoader@18b4aac2
//file:/C:/ProjectTest/FengKuang/out/production/FengKuang/
//系統類的父加載器是擴展類加載器:sun.misc.Launcher$ExtClassLoader@1540e19d
//擴展類加載器的加載路徑:C:\SorftwareInstall\java\jdk\jre\lib\ext;C:\WINDOWS\Sun\Java\lib\ext
//擴展類加載器的parant:null

從輸出中驗證了:系統類加載器的父加載器是擴展類加載器。但輸出中擴展類加載器的父加載器是null,這是因為父加載器不是java實現的,是C++實現的,所以獲取不到。但擴展類加載器的父加載器是根加載器。

1.2、類加載流程圖

圖中紅色部分,可以是我們自定義實現的類加載器來進行加載。

五、創建並使用自定義類加載器

1、自定義類加載分析

除了根類加載器,所有類加載器都是ClassLoader的子類。所以我們可以通過繼承ClassLoader來實現自己的類加載器。

ClassLoader類有兩個關鍵的方法:

  1. protected Class loadClass(String name, boolean resolve):name為類名,resove如果為true,在加載時解析該類。
  2. protected Class findClass(String name) :根據指定類名來查找類。

所以,如果要實現自定義類,可以重寫這兩個方法來實現。但推薦重寫findClass方法,而不是重寫loadClass方法,因為loadClass方法內部回調用findClass方法。

我們來看一下loadClass的源碼

protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            //第一步,先從緩存里查看是否已經加載
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                long t0 = System.nanoTime();
                try {
                //第二步,判斷父加載器是否為null
                    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) {
                   //第三步,如果前面都沒有找到,就會調用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加載方法流程:

  1. 判斷此類是否已經加載;
  2. 如果父加載器不為null,則使用父加載器進行加載;反之,使用跟加載器進行加載;
  3. 如果前面都沒加載成功,則使用findClass方法進行加載。

所以,為了不影響類的加載過程,我們重寫findClass方法即可簡單方便的實現自定義類加載。

2、實現自定義類加載器

基於以上分析,我們簡單重寫findClass方法進行自定義類加載。

public class Hello {
   public void test(String str){
       System.out.println(str);
   }
}

public class MyClassloader extends ClassLoader {

    /**
     * 讀取文件內容
     *
     * @param fileName 文件名
     * @return
     */

    private byte[] getBytes(String fileName) throws IOException {
        File file = new File(fileName);
        long len = file.length();
        byte[] raw = new byte[(int) len];
        try (FileInputStream fin = new FileInputStream(file)) {
            //一次性讀取Class文件的全部二進制數據
            int read = fin.read(raw);
            if (read != len) {
                throw new IOException("無法讀取全部文件");
            }
            return raw;
        }
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        Class clazz = null;
        //將包路徑的(.)替換為斜線(/)
        String fileStub = name.replace(".""/");
        String classFileName = fileStub + ".class";
        File classFile = new File(classFileName);

        //如果Class文件存在,系統負責將該文件轉換為Class對象
        if (classFile.exists()) {
            try {
                //將Class文件的二進制數據讀入數組
                byte[] raw = getBytes(classFileName);
                //調用ClassLoader的defineClass方法將二進制數據轉換為Class對象
                clazz = defineClass(name, raw, 0, raw.length);
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        //如果clazz為null,表明加載失敗,拋出異常
        if (null == clazz) {
            throw new ClassNotFoundException(name);
        }
        return clazz;
    }

    public static void main(String[] args) throws Exception {
        String classPath = "loader.Hello";
        MyClassloader myClassloader = new MyClassloader();
        Class<?> aClass = myClassloader.loadClass(classPath);
        Method main = aClass.getMethod("test", String.class);
        System.out.println(main);
        main.invoke(aClass.newInstance(), "Hello World");
    }
}
//輸出結果
//Hello World

ClassLoader還有一個重要的方法defineClass(String name, byte[] b, int off, int len)。此方法的作用是將class的二進制數組轉換為Calss對象。

此例子很簡單,我寫了一個Hello測試類,並且編譯過后放在了當前路徑下(大家可以在findClass中加入判斷,如果沒有此文件,可以嘗試查找.java文件,並進行編譯得到.class文件;或者判斷.java文件的最后更新時間大於.class文件最后更新時間,再進行重新編譯等邏輯)。

六、總結

本篇從類加載的三大階段:加載、鏈接、初始化開始細說每個階段的過程;詳細講解了JVM常用的類加載器的區別與聯系,以及類加載機制流程,最后通過自定義的類加載器例子結束本篇。小弟能力有限,大家看出有問題請指出,讓博主學習改正。歡迎討論啊。

注意:本篇博客總結主要來源。如有轉載,請注明出處

  1. 《瘋狂java講義(第3版)》
  2. 《深入理解java虛擬機++JVM高級特性與最佳實踐》


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM