Java虛擬機器 —— 類的載入機制

xiaoyanger發表於2017-09-22

我們知道class檔案中儲存了類的描述資訊和各種細節的資料,在執行Java程式時,虛擬機器需要先將類的這些資料載入到記憶體中,並經過校驗、轉換、解析和初始化過後,最終形成可以直接使用的Java型別。

類從被載入到虛擬機器記憶體中開始,到解除安裝出記憶體為止,它的整個生命週期包括:載入、驗證、準備、解析、初始化、使用和解除安裝7個階段。其中驗證、準備、解析3個部分統稱為連線。

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

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

載入

載入是類的載入過程的第一個階段,在載入階段,虛擬機器需要完成以下3件事情:

  1. 通過一個類的全限定名來獲取定義此類的二進位制位元組流;
  2. 將這個位元組流所代表的靜態儲存結構轉化為方法區的執行時資料結構;
  3. 在記憶體中生成一個代表這個類的java.lang.Class物件,作為方法區這個類的各種資料的訪問入口。

通過全限定名來獲取二進位制流可以有很多種方式,比如從JAR、EAR、WAR檔案包中讀取,從網路獲取,也可以由其他檔案來生成(jsp檔案生成對應的Servlet類),甚至還可以通過執行時動態生成(Java動態代理)。

相比類載入過程的其他階段,載入階段是可控性最強的。因為開發者既可以利用系統提供的啟動類載入器來完成,也可以通過自定義類載入去完成(重寫loadClass方法,控制位元組流的獲取方式)。

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

載入階段完成後,虛擬機器外部的二進位制位元組流就按照虛擬機器所需的格式儲存在方法區之中。然後在記憶體中例項化一個java.lang.Class類的物件,這樣就可以通過這個物件來訪問方法區中的這些資料。

驗證

驗證是連線階段的第一步,這一階段的目的是為了確保class檔案的位元組流中包含的資訊符合當前虛擬機器的要求,並且不會危害虛擬機器自身的安全。驗證階段大致上會完成下面4個階段的檢驗動作:檔案格式驗證、後設資料驗證、位元組碼驗證、符號引用驗證。

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

準備

準備階段是正式為類變數(靜態變數)分配記憶體並設定初始值的階段,這些類變數所使用的記憶體都將在方法區中進行分配。

這裡有兩點需要注意:

  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 類載入機制詳解

相關文章