深入理解java虛擬機 第7章 虛擬機類加載機制

代碼編譯的結果從本地機器碼轉變爲字節碼,是存儲格式發展的一小步,卻是編程語言發展的一大步。

7.1 概述

什麼是類加載機制
虛擬機把描述類的數據從Class文件加載進內存,並對數據進行校驗,轉換解析和初始化,最終形成可以被虛擬機直接使用的Java類型,這就是虛擬機的類加載機制;

Java裏天生可以動態擴展的語言特性
與那些在編譯期需要進行連接工作的語言不同,在Java語言中,類型的加載,鏈接和初始化過程都是在程序運行期完成的。這種策略雖然會令類加載時稍微增加一些性能開銷,但是會爲Java應用程序提供高度的靈活性,Java語言裏天生可以動態擴展的語言特性就是依賴運行期可以動態加載和動態鏈接這個特點完成的。例如編寫一個面向接口的應用程序,可以等到運行時再指定其具體實現類。

爲了避免語言表達中可能產生的誤差,在本章正式開始之前,先設立兩個語言上的約定:
第一:Class文件可能表示類或接口
第二:Class文件夾應當是一串二進制字節流,無論以何種形式存在都可以;

7.2 類加載的時機

類加載生命週期

類從被加載到虛擬機內存中開始,到卸載出內存爲止,它的整個生命週期包括:加載(Loading),驗證(Verification),準備(Preparation),解析(Resolution),初始化(Initialization),使用(Using)和卸載(Unloading)7個階段。其中驗證,準備和解析三個階段統稱爲鏈接(Linking)。

在這裏插入圖片描述

開始順序

加載、驗證、準備、初始化和卸載這5個階段的順序是確定的,類的加載過程必須按照這種順序按部就班地開始,而解析階段則不一定:它在某些情況下可以在初始化階段之後再開始,這是爲了支持Java語言的運行時綁定(也稱爲動態綁定或晚期綁定)。注意:這裏筆者寫的是按部就班地“開始”,而不是按部就班的“進行”或者“完成”,強調這點是因爲這些階段通常都是互相交叉地混合式進行的,通常會在一個階段的執行過程中調用、激活另外一個階段。
什麼情況下要開始類加載過程的第一個階段:加載?
Java虛擬機規範中並沒有進行強制約束,這點可以交給虛擬機的具體實現來自由把握。

類初始化時機

主動引用

對於初始化階段,虛擬機規範則是嚴格規定了有且只有5種情況必須立即對類進行“初始化”(加載,驗證和準備自然需要在此之前開始)

  1. 遇到new,getstatic,putstatic,invokestatic這四條字節碼指令時,如果類沒有進行初始化,則需要先觸發其初始化。生成這四條字節碼執行的最常用的Java代碼場景是:使用new關鍵字實例化對象的時候、讀取或設置一個類的靜態字段(被final修飾,已在編譯期把結果放入常量池的靜態字段除外)的時候,以及調用一個類的靜態方法的時候
  2. 使用java.lang.reflect包的方法對類進行反射調用的時候,如果類沒有進行過初始化,則先觸發其初始化
  3. 當初始化一個類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化
  4. 當虛擬機啓動的時候,用戶需要指定一個要執行的主類(包含main()方法的那個類),虛擬機會先初始化這個主類
  5. 當使用JDK1.7的動態語言支持時,如果一個java.lang.invoke.MethodHandle實例最後的解析結果REF_getStatic,REF_putStatic,REF_invokeStatic的方法句柄時,並且這個方法句柄所對應的類沒有進行過初始化,則先觸發其初始化。
    對於這五種會觸發類進行初始化的場景,虛擬機規範種使用了一個很強烈的限定詞:”有且只有“,這五種場景中的行爲稱爲對一個類進行主動引用

被動引用

除此之外,所有引用類的方法都不會觸發初始化,稱爲被動引用

  1. 對於靜態字段,只有直接定義這個字段的類纔會被初始化。通過其子類來引用父類中定義的靜態字段,只會觸發父類的初始化而不會觸發子類的初始化。(是否要觸發子類的加載和驗證,在虛擬機規範中並未明確規定,取決於虛擬機的具體實現。對於Sun HotSpot虛擬機來說,可通過-XX:+TraceClassLoading參數看到此操作會導致子類的加載。)
public class SuperClass {
    static {
        System.out.println("SuperClass init!");
}
 
    public static int value = 123;
}
 
public class SubClass extends SuperClass{
    static {
        System.out.println("SubClass init!");
    }
}
 
public class NotInitialization{
    public static void main(String[] args){
        System.out.println(SubClass.value);
    }
}

執行結果:(並不會輸出SubClass init!)
SuperClass init!
123

  1. 通過數組定義來引用類,不會觸發此類的初始化。(SuperClass類的定義在上個例子裏)
public class NotInitialization{
 public static void main(String[] args){
        SuperClass[] sca = new SuperClass[10];
    }
}

執行結果:沒有輸出

例子裏包名爲:org.fenixsoft.classloading。該例子沒有觸發類org.fenixsoft.classloading.SuperClass的初始化階段,但觸發了另外一個名爲“[Lorg.fenixsoft.classloading.SuperClass”的類的初始化階段,對於用戶代碼來說,這並不是一個合法的類名稱,它是一個由虛擬機自動生成的、直接繼承於Object的子類,創建動作由字節碼指令newarray觸發。這個類代表了一個元素類型爲org.fenixsoft.classloading.SuperClass的一維數組,數組中應有的屬性和方法(用戶可直接使用的只有被修飾爲public的length屬性和clone()方法)都實現在這個類裏。

  1. 常量在編譯階段會存入調用類的常量池中,本質上沒有直接引用到定義常量的類,因此不會觸發定義常量的類的初始化。
public class ConstClass{
    static {
        System.out.println("ConstClass init!");
    }
 
    public static final String HELLOWORLD = "hello world";
}
 
public class NotInitialization{
    public static void main(String[] args){
        System.out.println(ConstClass.HELLOWORLD);
    }
}

執行結果:(沒有輸出ConstClass init!)
hello world

接口加載與類加載過程的區別

  • 接口也有初始化過程,這點是與類是一致的,上面代碼都是用靜態語句塊“static{}”來輸出初始化信息的,而接口中不能使用“static{}”語句塊,但編譯器仍然會爲接口生成“()”類構造器,用於初始化接口中所定義的成員變量。
  • 接口與類的真正區別是 主動引用的第三個,當一個接口在初始化時,並不要求其父類全部都完成了初始化,只有在真正使用到父接口的時候(如引用就扣中定義的常量)纔會初始化

7.3 類加載的過程

當程序要使用某個類時,如果該類還未被加載到內存中,則系統會通過加載,連接,初始化三步來實現這個類進行初始化。

7.3.1 加載

加載是類加載的一個階段,注意不要混淆。
加載階段虛擬機需要完成以下三件事情:

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

其中二進制字節流可以從以下方式中獲取:

  • 從 ZIP 包讀取,成爲 JAR、EAR、WAR 格式的基礎。
  • 從網絡中獲取,最典型的應用是 Applet。
  • 運行時計算生成,例如動態代理技術,在 java.lang.reflect.Proxy 使用 ProxyGenerator.generateProxyClass 的代理類的二進制字節流。
  • 由其他文件生成,例如由 JSP 文件生成對應的 Class 類。

7.3.2 驗證

驗證是連接階段的第一步,確保 Class 文件的字節流中包含的信息符合當前虛擬機的要求,並且不會危害虛擬機自身的安全。
驗證階段是非常重要的,這個階段是否嚴謹,直接決定了Java虛擬機能否承受惡意代碼的攻擊

  1. 文件格式驗證
    驗證class文件格式規範
  2. 元數據驗證
    就是對字節碼描述的信息進行語義分析,保證描述的信息符合java語言規範。驗證點可能包括(這個類是否有父類(除Object)、這個類是否繼承了不允許被繼承的類(final修飾的)、如果這個類的父類是抽象類,是否實現了父類或接口中要求實現的方法)。
  3. 字節碼驗證
    進行數據流和控制流分析,這個階段對類的方法體進行校驗,保證被校驗的方法在運行時不會做出危害虛擬機的行爲。
  4. 符號引用驗證
    符號引用中通過字符串描述的權限定名是否能找到對應的類、符號引用類中的類,字段和方法的訪問性(private protected public default)是否能被當前類訪問。

7.3.3 準備

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

  • 這時候進行內存分配的僅包括類變量(static),而不包括實例變量,實例變量會在對象實例化時隨着對象一塊分配在Java堆中,應該注意到,實例化不是類加載的一個過程,類加載發生在所有實例化操作之前,並且類加載只進行一次,實例化可以進行多次。
  • 這裏所設置的初始值通常情況下是數據類型默認的零值(如0、0L、null、false等),而不是被在Java代碼中被顯式地賦予的值

初始值一般爲 0 值,例如下面的類變量 value 被在準備階段過後的初始值爲 0 而不是 123。

public static int value = 123;

如果類變量是常量,那麼它將初始化爲表達式所定義的值而不是 0。例如下面的常量 value 被初始化爲 123 而不是 0。

public static final int value = 123;

7.3.4解析

解析階段是虛擬機將常量池的符號引用替換爲直接引用的過程。符號引用在Class文件中以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等類型的常量出現。

符號引用:符號引用是一組符號來描述所引用的目標對象,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可。符號引用與虛擬機實現的內存佈局無關,引用的目標對象並不一定已經加載到內存中。

直接引用:直接引用可以是直接指向目標對象的指針、相對偏移量或是一個能間接定位到目標的句柄。直接引用是與虛擬機內存佈局實現相關的,同一個符號引用在不同虛擬機實例上翻譯出來的直接引用一般不會相同,如果有了直接引用,那引用的目標必定已經在內存中存在。

虛擬機規範並沒有規定解析階段發生的具體時間,只要求了在執行anewarry、checkcast、getfield、instanceof、invokeinterface、invokespecial、invokestatic、invokevirtual、multianewarray、new、putfield和putstatic這13個用於操作符號引用的字節碼指令之前,先對它們使用的符號引用進行解析,所以虛擬機實現會根據需要來判斷,到底是在類被加載器加載時就對常量池中的符號引用進行解析,還是等到一個符號引用將要被使用前纔去解析它。

解析的動作主要針對類或接口、字段、類方法、接口方法四類符號引用進行。分別對應編譯後常量池內的CONSTANT_Class_Info、CONSTANT_Fieldref_Info、CONSTANT_Methodef_Info、CONSTANT_InterfaceMethoder_Info四種常量類型。

  1. 類、接口的解析
  2. 字段解析
  3. 類方法解析
  4. 接口方法解析

7.3.5 初始化

初始化階段才真正開始執行類中定義的 Java 程序代碼。初始化階段是虛擬機執行類構造器 () 方法的過程。在準備階段,類變量已經賦過一次系統要求的初始值,而在初始化階段,根據程序員通過程序制定的主觀計劃去初始化類變量和其它資源。

() 是由編譯器自動收集類中所有類變量的賦值動作和靜態語句塊中的語句合併產生的,編譯器收集的順序由語句在源文件中出現的順序決定。特別注意的是,靜態語句塊只能訪問到定義在它之前的類變量,定義在它之後的類變量只能賦值,不能訪問。例如以下代碼:


public class Test {
    static {
        i = 0;                // 給變量賦值可以正常編譯通過
        System.out.print(i);  // 這句編譯器會提示“非法向前引用”
    }
    static int i = 1;
}

由於父類的 () 方法先執行,也就意味着父類中定義的靜態語句塊的執行要優先於子類。例如以下代碼:


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

static class Sub extends Parent {
    public static int B = A;
}

public static void main(String[] args) {
     System.out.println(Sub.B);  // 2
}

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

虛擬機會保證一個類的 () 方法在多線程環境下被正確的加鎖和同步,如果多個線程同時初始化一個類,只會有一個線程執行這個類的 () 方法,其它線程都會阻塞等待,直到活動線程執行 () 方法完畢。如果在一個類的 () 方法中有耗時的操作,就可能造成多個線程阻塞,在實際過程中此種阻塞很隱蔽。

7.4 類加載器

虛擬機設計團隊把類加載階段中的“通過一個類的全限定名來獲取描述此類的二進制字節流”這個動作放到Java虛擬機外部去實現,以便讓應用程序自己決定如何去獲取所需要的類。實現這個動作的代碼模塊稱爲“類加載器”。

7.4.1 類與類加載器

對於任何一個類,都需要由加載它的類加載器和這個類來確立其在JVM中的唯一性。也就是說,兩個類來源於同一個Class文件,並且被同一個類加載器加載,這兩個類才相等。這是因爲每一個類加載器都擁有一個獨立的類名稱空間。
這裏的相等,包括類的 Class 對象的 equals() 方法、isAssignableFrom() 方法、isInstance() 方法的返回結果爲 true,也包括使用 instanceof 關鍵字做對象所屬關係判定結果爲 true。

7.4.2 雙親委派模型

從虛擬機的角度來說,只存在兩種不同的類加載器:

  • 一種是啓動類加載器(Bootstrap ClassLoader),該類加載器使用C++語言實現,屬於虛擬機自身的一部分。
  • 另外一種就是所有其它的類加載器,這些類加載器是由Java語言實現,獨立於JVM外部,並且全部繼承自抽象類java.lang.ClassLoader。

從Java開發人員的角度來看,大部分Java程序一般會使用到以下三種系統提供的類加載器:

  • 啓動類加載器(Bootstrap ClassLoader)此類加載器負責將存放在 <JRE_HOME>\lib 目錄中的,或者被 -Xbootclasspath 參數所指定的路徑中的,並且是虛擬機識別的(僅按照文件名識別,如 rt.jar,名字不符合的類庫即使放在 lib 目錄中也不會被加載)類庫加載到虛擬機內存中。啓動類加載器無法被 Java 程序直接引用,用戶在編寫自定義類加載器時,如果需要把加載請求委派給啓動類加載器,直接使用 null 代替即可。
  • 擴展類加載器(Extension ClassLoader)這個類加載器是由 ExtClassLoader(sun.misc.Launcher$ExtClassLoader)實現的。它負責將 <JAVA_HOME>/lib/ext 或者被 java.ext.dir 系統變量所指定路徑中的所有類庫加載到內存中,開發者可以直接使用擴展類加載器。
  • 應用程序類加載器(Application ClassLoader)這個類加載器是由 AppClassLoader(sun.misc.Launcher$AppClassLoader)實現的。由於這個類加載器是 ClassLoader 中的 getSystemClassLoader() 方法的返回值,因此一般稱爲系統類加載器。它負責加載用戶類路徑(ClassPath)上所指定的類庫,開發者可以直接使用這個類加載器,如果應用程序中沒有自定義過自己的類加載器,一般情況下這個就是程序中默認的類加載器

雙親委派模型
應用程序是由三種類加載器互相配合從而實現類加載,除此之外還可以加入自己定義的類加載器。

下圖展示了類加載器之間的層次關係,稱爲雙親委派模型(Parents Delegation Model)。該模型要求除了頂層的啓動類加載器外,其它的類加載器都要有自己的父類加載器。這裏的父子關係一般通過組合關係(Composition)來實現,而不是繼承關係(Inheritance)。

在這裏插入圖片描述

工作過程

如果一個類加載器收到了類請求,它首先不會自己去嘗試加載這個類,而是把這個請求委派給父加載器去完成,每一層都是如此,因此所有類加載的請求都會傳到啓動類加載器,只有當父加載器無法完成該請求時,子加載器纔去自己加載。

好處

使得 Java 類隨着它的類加載器一起具有一種帶有優先級的層次關係,從而使得基礎類得到統一。
例如 java.lang.Object 存放在 rt.jar 中,如果編寫另外一個 java.lang.Object 並放到 ClassPath 中,程序可以編譯通過。由於雙親委派模型的存在,所以在 rt.jar 中的 Object 比在 ClassPath 中的 Object 優先級更高,這是因爲 rt.jar 中的 Object 使用的是啓動類加載器,而 ClassPath 中的 Object 使用的是應用程序類加載器。rt.jar 中的 Object 優先級更高,那麼程序中所有的 Object 都是這個 Object。

實現

以下是抽象類 java.lang.ClassLoader 的代碼片段,其中的 loadClass() 方法運行過程如下:先檢查類是否已經加載過,如果沒有則讓父類加載器去加載。當父類加載器加載失敗時拋出 ClassNotFoundException,此時嘗試自己去加載。

public abstract class ClassLoader {
    // The parent class loader for delegation
    private final ClassLoader parent;

    public Class<?> loadClass(String name) throws ClassNotFoundException {
        return loadClass(name, false);
    }

    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) {
                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.
                    c = findClass(name);
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }

    protected Class<?> findClass(String name) throws ClassNotFoundException {
        throw new ClassNotFoundException(name);
    }
}
自定義類加載器實現

以下代碼中的 FileSystemClassLoader 是自定義類加載器,繼承自 java.lang.ClassLoader,用於加載文件系統上的類。它首先根據類的全名在文件系統上查找類的字節代碼文件(.class 文件),然後讀取該文件內容,最後通過 defineClass() 方法來把這些字節代碼轉換成 java.lang.Class 類的實例。java.lang.ClassLoader 的 loadClass() 實現了雙親委派模型的邏輯,自定義類加載器一般不去重寫它,但是需要重寫 findClass() 方法。

public class FileSystemClassLoader extends ClassLoader {

    private String rootDir;

    public FileSystemClassLoader(String rootDir) {
        this.rootDir = rootDir;
    }

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

    private byte[] getClassData(String className) {
        String path = classNameToPath(className);
        try {
            InputStream ins = new FileInputStream(path);
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            int bufferSize = 4096;
            byte[] buf
fer = new byte[bufferSize];
            int bytesNumRead;
            while ((bytesNumRead = ins.read(buffer)) != -1) {
                baos.write(buffer, 0, bytesNumRead);
            }
            return baos.toByteArray();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }

    private String classNameToPath(String className) {
        return rootDir + File.separatorChar
                + className.replace('.', File.separatorChar) + ".class";
    }
}

參考文章

JVM類加載過程
類加載過程和類加載器
Java虛擬機

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章