Java虛擬機 —— 類的加載機制

咱們知道class文件中存儲了類的描述信息和各類細節的數據,在運行Java程序時,虛擬機須要先將類的這些數據加載到內存中,並通過校驗、轉換、解析和初始化事後,最終造成能夠直接使用的Java類型。java

類從被加載到虛擬機內存中開始,到卸載出內存爲止,它的整個生命週期包括:加載、驗證、準備、解析、初始化、使用和卸載7個階段。其中驗證、準備、解析3個部分統稱爲鏈接。
android

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

類的加載機制實際上就是類的生命週期中加載、驗證、準備、解析、初始化5個過程。c++

加載

加載是類的加載過程的第一個階段,在加載階段,虛擬機須要完成如下3件事情:程序員

  1. 經過一個類的全限定名來獲取定義此類的二進制字節流;
  2. 將這個字節流所表明的靜態存儲結構轉化爲方法區的運行時數據結構;
  3. 在內存中生成一個表明這個類的java.lang.Class對象,做爲方法區這個類的各類數據的訪問入口。

經過全限定名來獲取二進制流能夠有不少種方式,好比從JAR、EAR、WAR文件包中讀取,從網絡獲取,也能夠由其餘文件來生成(jsp文件生成對應的Servlet類),甚至還能夠經過運行時動態生成(Java動態代理)。安全

相比類加載過程的其餘階段,加載階段是可控性最強的。由於開發者既能夠利用系統提供的啓動類加載器來完成,也能夠經過自定義類加載去完成(重寫loadClass方法,控制字節流的獲取方式)。bash

關於類加載器的詳細介紹將放在文章最後。網絡

加載階段完成後,虛擬機外部的二進制字節流就按照虛擬機所需的格式存儲在方法區之中。而後在內存中實例化一個java.lang.Class類的對象,這樣就能夠經過這個對象來訪問方法區中的這些數據。數據結構

驗證

驗證是鏈接階段的第一步,這一階段的目的是爲了確保class文件的字節流中包含的信息符合當前虛擬機的要求,而且不會危害虛擬機自身的安全。驗證階段大體上會完成下面4個階段的檢驗動做:文件格式驗證、元數據驗證、字節碼驗證、符號引用驗證。多線程

  • 文件格式驗證: 驗證字節流是否符合Class文件格式的規範,而且能被當前版本的虛擬機處理。該驗證階段的主要目的是保證輸入的字節流能正確地解析並存儲於方法區以內,格式上符合描述一個Java類型信息的要求。經過了這個階段的驗證後,字節流纔會進入內存的方法區中進行存儲,後面的
    3個驗證階段所有是基於方法區的存儲結構進行的,不會再直接操做字節流。
  • 元數據驗證: 對字節碼描述的信息進行語義分析,以保證其描述的信息符合Java語言規範的要求。這個主要目的是對類的元數據信息進行語義校驗,保證不存在不符合Java語言規範的元數據信息。
  • 字節碼驗證: 對類的方法體進行校驗分析,保證被校驗類的方法在運行時不會作出危害虛擬機安全的事件。
  • 符號驗證: 對類自身之外(常量池中的各類符號引用)的信息進行匹配性校驗,這個階段發生在將符號引用轉化爲直接引用的時候(解析階段中發生),目的是確保解析動做能正常執行。

準備

準備階段是正式爲類變量(靜態變量)分配內存並設置初始值的階段,這些類變量所使用的內存都將在方法區中進行分配。jsp

這裏有兩點須要注意:

  1. 成員變量不是在這裏分配內存的,成員變量是在類實例化對象的時候在堆中分配的。
  2. 這裏設置初始值是指類型的零值(好比0,null,false等),而不是代碼中被顯示的賦予的值。

好比:

public class Test {
    public int number = 111;
    public static int sNumber = 111; 
}複製代碼

成員變量number在這個階段就不會進行內存分配和初始化。而類變量sNunber會在方法區中分配內存,並設置爲int類型的零值0而不是111,賦值爲111是在初始化階段纔會執行。

Java基本數據類型和引用數據類型零值
Java基本數據類型和引用數據類型零值

可是呢,若是類變量若是是被final修飾,爲靜態常量,那麼在準備階段也會在方法區中分配內存,而且將其值設置爲顯示賦予的值。

好比:

public class Test {
    public static final int NUMBER = 111; 
}複製代碼

此時,就會在準備階段將NUMBER的值設置爲111。

解析

解析階段是虛擬機將常量池內的符號引用替換爲直接引用的過程。

  • 符號引用: 符號引用以一組符號來描述所引用的目標,符號能夠是任何形式的字面量,只要使用時能無歧義地定位到目標便可。
  • 直接引用: 直接引用能夠是直接指向目標的指針、相對偏移量或是一個能間接定位到目標的句柄。

解析動做主要就是在常量池中尋找類或接口、字段、類方法、接口方法、方法類型、方法句柄、調用點限定符等7類符號引用,把這些符號引用替換爲直接引用。下面主要介紹下類或接口、字段、類方法、接口方法的解析:

  1. 類或接口解析: 假設當前的類A經過符號X引用了類B,虛擬機會把表明類B的全限定名傳遞給A的類加載器去加載BB通過加載、驗證、準備過程,在解析過程又可能會觸發B引用的其餘的類的加載過程,至關於一個類引用鏈的遞歸加載過程,整個過程只要不出現異常,B的就是一個加載成功的類或接口了,也就是能夠獲取到表明Bjava.lang.Class對象。在驗證了A具有對B的訪問權限後,就將符號引用X替換爲B的直接引用。
  2. 字段解析: 解析未被解析過的字段,要先解析字段所屬的類或接口的符號引用。若是類自己就包含了簡單的名稱和字段描述與目標字段相匹配,就直接返回這個字段引用;若是實現了接口,將會按照繼承關係從下往上遞歸搜索各個接口和它的父接口,若是接口中包含了簡單名稱和字段描述符都與目標相匹配的字段,則返回這個字段;若是是繼承自其餘類的話,將會按照繼承關係從下往上遞歸搜索其父類,若是在父類中包含了簡單名稱和字段描述符都與目標相匹配的字段,則返回這個字段的直接引用。
  3. 類方法解析:類方法解析和字段解析的方式相似,也是依據繼承和實現關係從小到上搜索,只不過是先搜索類,後搜索接口。若是有簡單名稱和字段描述符都與目標相匹配的字段,就返回字段引用。
  4. 接口的方法解析: 與類方法解析相似,從小到上搜索接口(接口沒有父類,只可能有父接口)。若是存在簡單名稱和字段描述符都與目標相匹配的字段,就返回字段引用。

初始化

類的初始化類加載過程的最後一步,在前面的過中,除了在加載階段開發者能夠自定義加載器以外,其他的動做都是徹底有虛擬機主導和控制完成。到了初始化階段,才真正開始執行類中定義的Java代碼。

在準備階段,類變量已經設置了系統要求的零值,而在初始化階段,則根據程序員經過程序制定的主觀計劃去初始化類變量和其餘資源,或者能夠從另一個角度來表達:初始化階段是執行類構造器<clinit>()方法的過程。

<clinit>()方法是由編譯器自動收集類中全部的類變量(static變量)和靜態代碼塊(static{}塊)中的語句合併生成的。編譯器收集的順序是由語句在源文件中出現的順序所決定的,靜態代碼塊中只能訪問到定義在靜態代碼塊以前的變量,定義在它以後的變量,在前面的靜態代碼塊能夠賦值,可是不能訪問。

public class Test {
    static {
        number = 111;               // 能夠賦值
        System.out.println(number); // 不能讀取,編輯器或報錯Illegal forward reference
    }
    static int number;
}複製代碼

<clinit>()方法與類的構造函數(或者說實例構造器<init>()方法)不一樣,它不須要顯式地調用父類的<clinit>()方法,虛擬機會保證在子類的<clinit>()方法執行以前,父類的<clinit>()方法已經執行完畢。因此,父類定義的靜態代碼塊要先與子類的賦值操做。

class Parent {
    public static int A = 1;
    static {
        A = 2;
    }
}

class Sub extends Parent {
    public static int B = A;
    public static void main(String[] args) {
        System.out.println(Sub.B);
    }
}複製代碼

<clinit>()方法對於類或接口來講並非必需的,若是一個類中沒有靜態語句塊,也沒有對變量的賦值操做,那麼編譯器能夠不爲這個類生成<clinit>()方法。

接口中不能使用靜態語句塊,但仍然有變量初始化的賦值操做,所以接口與類同樣都會生成<clinit>()方法。但接口與類不一樣的是,執行接口的<clinit>()方法不須要先執行父接口的<clinit>()方法。只有當父接口中定義的變量使用時,父接口才會初始化。另外,接口的實現類在初始化時也同樣不會執行接口的<clinit>()方法。

虛擬機會保證一個類的<clinit>()方法在多線程環境中被正確地加鎖、同步,若是多個線程同時去初始化一個類,那麼只會有一個線程去執行這個類的<clinit>()方法,其餘線程都須要阻塞等待,直到活動線程執行<clinit>()方法完畢。若是在一個類的<clinit>()方法中有耗時很長的操做,就可能形成多個進程阻塞。

類加載器

在以前的加載過程當中,提到了類加載器經過一個類的全限定名來獲取描述此類的二進制字節流,這個過程可讓開發中自定義類加載器來決定如何獲取須要的字節流。那麼,什麼是類加載器呢?

對於任意一個Java類,都必須經過類加載器加載到方法區,並生成java.lang.Class對象才能使用類的各個功能,因此咱們能夠把類加載器理解爲一個將class類文件轉換爲java.lang.Class對象的工具。

對於任意一個類,都須要由加載它的類加載器和這個類自己一同確立其在Java虛擬機中的惟一性,每個類加載器,都擁有一個獨立的類名稱空間。也就是說,若是兩個類「相等」,那麼這兩個類必須是被同一個虛擬機中的同一個類加載器加載,而且來自同一個class文件。

在Java當中,已經有3個預製的類加載器,分別是BootStrapClassLoaderExtClassLoader、AppClassLoader

  • BootStrapClassLoader: 啓動類加載器,它是由C++來實現的,在Java程序中不能顯氏的獲取到。它負責加載存放在JDK\jre\lib(JDK表明JDK的安裝目錄,下同)下的類。
  • ExtClassLoader: 擴展類加載器,它是由sun.misc.Launcher$ExtClassLoader實現,負責加載JDK\jre\lib\ext目錄中,或者由java.ext.dirs系統變量指定的路徑中的全部類庫。開發者能夠直接使用它。
  • AppClassLoader: 應用程序類加載器,由sun.misc.Launcher$AppClassLoader來實現,它負責加載用戶類路徑(ClassPath)所指定的類,開發者能夠直接使用該類加載器。通常來講,開發者自定義的類就是由應用程序類加載器加載的。

ExtClassLoader做爲類加載器,但它也是一個Java類,是由BootStrapClassLoader來加載的,因此,ExtClassLoader的parent是BootStrapClassLoader。可是因爲BootStrapClassLoaderc++實現的,咱們經過ExtClassLoader.getParent獲取到的是null。一樣地,AppClassLoader是由ExtClassLoader加載,AppClassLoader的parent是ExtClassLoader

public class Test {
    public static void main(String[] args) {
        ClassLoader cl = Test.class.getClassLoader();
        while (cl != null) {
            System.out.println(cl);
            cl = cl.getParent();
        }
    }
}複製代碼

打印結果:

sun.misc.Launcher$AppClassLoader@232204a1
sun.misc.Launcher$ExtClassLoader@74a14482複製代碼

同時咱們能夠定義本身的類加載器CustomClassLoader,那麼它的parent確定就是AppClassLoader了。類加載器的這種層次關係稱爲雙親委派模型。

類加載器
類加載器

雙親委派模型

雙親委派模型要求除了頂層的啓動類加載器外,其他的類加載器都應當有本身的父類加載器。這裏類加載器之間的父子關係不是以繼承的關係來實現,而是都使用遞歸的方式來調用父加載器的代碼。

雙親委派模型的工做過程是:若是一個類加載器收到了類加載的請求,它首先不會本身去嘗試加載這個類,而是把這個請求委派給父類加載器去完成,每個層次的類加載器都是如此,所以全部的加載請求最終都應該傳送到頂層的啓動類加載器中,只有當父加載器反饋本身沒法完成這個加載請求(它的搜索範圍中沒有找到所需的類)時,子加載器纔會嘗試本身去加載。

ClassLoader的源碼:

protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
    synchronized (getClassLoadingLock(name)) {
        // First, check if the class has already been loaded
        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();
                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()方法進行加載。

使用雙親委派模型能使Java類隨着加載器一塊兒具有一種優先級的層次關係,保證同一個類只加載一次,避免了重複加載,同時也能阻止有人惡意替換加載系統類。

自定義類加載器

通常地,在ClassLoader方法的loadClass方法中已經給開發者實現了雙親委派模型,在自定義類加載器的時候,只須要複寫findClass方法便可。

public class CustomClassLoader extends ClassLoader {

    private String root;

    public CustomClassLoader(String root) {
        this.root = root;
    }

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

    private byte[] loadClassData(String name) {
        String fileName = root + File.separatorChar
                + name.replace('.', File.separatorChar)
                + ".class";
        try {
            InputStream ins = new FileInputStream(fileName);
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            int bufferSize = 1024;
            byte[] buffer = new byte[bufferSize];
            int length;
            while ((length = ins.read(buffer)) != -1) {
                baos.write(buffer, 0, length);
            }
            return baos.toByteArray();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }
}複製代碼

新建一個類com.xiao.U,編譯成class文件,放到桌面,來測試一下:

public class Test {
    public static void main(String[] args) {
        CustomClassLoader customClassLoader = new CustomClassLoader("C:\\Users\\PC\\Desktop");
        try {
            Class clazz = customClassLoader.loadClass("com.xiao.U");
            Object o = clazz.newInstance();
            System.out.println(o.getClass().getClassLoader());
        } catch (ClassNotFoundException | IllegalAccessException | InstantiationException e) {
            e.printStackTrace();
        }
    }
}複製代碼

打印結果:

CustomClassLoader@1540e19d複製代碼

自定義類加載器在能夠實現服務端的熱部署,在移動端好比android也能夠實現熱更新。


參考:

  1. 深刻理解Java虛擬機(第二版)
  2. Java 類加載機制詳解
相關文章
相關標籤/搜索