02-類載入子系統

十問發表於2020-10-06

一、類載入器子系統

1.1 JVM體系結構

02-類載入子系統

JVM被分為三個主要的子系統:

(1)類載入器子系統(2)執行時資料區(3)執行引擎

1.2 類載入器子系統作用

(1)類載入子系統負責從檔案系統或者網路中載入class檔案,class檔案在檔案開有特定的檔案標識(0xCAFEBABE)。

(2)類載入器(Class Loader)只負責class檔案的載入,至於它是否可以執行,則由執行引擎(Execution Engine)決定。

(3)載入的類資訊存放於一塊稱為方法區的記憶體空間。除了類的資訊外,方法區中還會存放執行時常量池資訊,可能還包括字串字面量和數字常量(這部分常量資訊是Class檔案中常量池部分的記憶體對映)。

(4)Class物件是存放在堆區的。

假如有一個Car.java檔案,編譯後生成一個Car.class位元組碼檔案:

02-類載入子系統
  • class file存在於本地硬碟上,可以理解為一個模板。而這個模板在執行的時候是需要載入到JVM當中,JVM再根據這個模板例項化出N個一模一樣的例項。
  • class file載入到JVM中後,被稱為DNA後設資料模板,放在方法區。
  • 在.class檔案–>JVM–>最終成為後設資料模板,此過程就要一個運輸工具(類裝載器Class Loader),扮演一個快遞員的角色。

1.3 類的載入過程

一個型別從被載入到虛擬機器記憶體中開始,到解除安裝出記憶體為止,它的整個生命週期將會經歷載入(Loading)、驗證(Verification)、準備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和解除安裝(Unloading)七個階段,其中驗證、準備、解析三個部分統稱為連線(Linking)。完整的流程圖如下所示:
02-類載入子系統

載入、驗證、準備、初始化和解除安裝這五個階段的順序是確定的。為了支援Java語言的執行時繫結解析階段也可以是在初始化之後進行的。(以上順序流程指的是程式開始的順序,在實際執行中,這些階段通常都是互相交叉地混合進行的,會在一個階段執行的過程中呼叫、啟用另一個階段)。

(1)載入階段

“載入”(Loading)階段是整個“類載入”(Class Loading)過程中的一個階段,JVM需要完成三件事:

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

載入class檔案的方式

  • 從本地系統中直接載入。
  • 通過網路獲取,典型場景:Web Applet。
  • 從zip壓縮包中讀取,成為日後jar、war格式的基礎。
  • 執行時計算生成,使用最多的是:動態代理技術。
  • 由其他檔案生成,典型場景:JSP應用從專有資料庫中提取.class檔案,比較少見。
  • 從加密檔案中獲取,典型的防Class檔案被反編譯的保護措施。
  • … …

(2)連結階段

  1. 驗證 Verify

目的在於確保Class檔案的位元組流中包含資訊符合當前虛擬機器要求,保證被載入類的正確性,不會危害虛擬機器自身安全。

主要包括四種驗證:檔案格式驗證,後設資料驗證,位元組碼驗證,符號引用驗證。

檔案格式驗證

第一階段要驗證位元組流是否符合Class檔案格式的規範,並且能被當前版本的虛擬機器處理。

  • 是否以魔數0xCAFEBABE開頭。
  • 主、次版本號是否在當前Java虛擬機器接受範圍之內。
  • 常量池的常量中是否有不被支援的常量型別(檢查常量tag標誌)。
  • 指向常量的各種索引值中是否有指向不存在的常量或不符合型別的常量。
  • CONSTANT_Utf8_info型的常量中是否有不符合UTF-8編碼的資料。
  • Class檔案中各個部分及檔案本身是否有被刪除的或附加的其他資訊。

後設資料驗證

第二階段是對位元組碼描述的資訊進行語義分析。

  • 這個類是否有父類(除了java.lang.Object之外,所有的類都應當有父類)。
  • 這個類的父類是否繼承了不允許被繼承的類(被final修飾的類)。
  • 如果這個類不是抽象類,是否實現了其父類或介面之中要求實現的所有方法。
  • 類中的欄位、方法是否與父類產生矛盾(例如覆蓋了父類的final欄位,或者出現不符合規則的方法過載,例如方法引數都一致,但返回值型別卻不同等)。

位元組碼驗證

通過資料流分析和控制流分析,確定程式語義是合法的、符合邏輯的。

  • 保證任意時刻運算元棧的資料型別與指令程式碼序列都能配合工作,例如不會出現類似於“在操作棧放置了一個int型別的資料,使用時卻按long型別來載入入本地變數表中”這樣的情況。
  • 保證任何跳轉指令都不會跳轉到方法體以外的位元組碼指令上。
  • 保證方法體中的型別轉換總是有效的。

符號引用驗證

對類自身以外(常量池中的各種符號引用)的各類資訊進行匹配性校驗,通俗來說就是,該類是否缺少或者被禁止訪問它依賴的某些外部類、方法、欄位等資源。

  • 符號引用中通過字串描述的全限定名是否能找到對應的類。
  • 在指定類中是否存在符合方法的欄位描述符及簡單名稱所描述的方法和欄位。
  • 符號引用中的類、欄位、方法的可訪問性(private、protected、public、)是否可被當前類訪問。

我們可以通過安裝IDEA的外掛——jclasslib Bytecode viewer,來檢視我們的Class檔案:

安裝完成後,我們編譯完一個class檔案後,點選View--> Show Bytecode With Jclasslib即可顯示我們安裝的外掛來檢視位元組碼。

  1. 準備 Prepare
  • 為類變數分配記憶體並且設定該類變數的預設初始值,即零值。(Boolean型別資料的零值為False)

例如下面這段程式碼:

public class Hello {
    private static int a = 1;  // 準備階段為0,在下個階段,也就是初始化的時候才是1。
    public static void main(String[] args) {
        System.out.println(a);
    }
}
  • 這裡不包含用final修飾的static,因為final在編譯的時候就會分配了,準備階段會顯式初始化

  • 這裡不會為例項變數分配初始化,類變數會分配在方法區中,而例項變數是會隨著物件一起分配到Java堆中。

  1. 解析 Resolve
  • 將常量池內的符號引用轉換為直接引用的過程

  • 事實上,解析操作往往會伴隨著JVM在執行完初始化之後再執行。

  • 符號引用就是一組符號來描述所引用的目標。符號引用的字面量形式明確定義在《java虛擬機器規範》的class檔案格式中。直接引用就是直接指向目標的指標、相對偏移量或一個間接定位到目標的控制程式碼。

  • 解析動作主要針對類或介面、欄位、類方法、介面方法、方法型別等。對應常量池中的CONSTANT Class info、CONSTANT Fieldref info、CONSTANT_Methodref_info等。

(3)初始化階段

初始化階段就是執行類構造器法clinit()的過程。此方法不需定義,是javac編譯器自動收集類中的所有類變數的賦值動作和靜態程式碼塊(static{}塊)中的語句合併而來,編譯器收集的順序是由語句在原始檔中出現的順序決定的。

  • 也就是說,當我們程式碼中包含static變數的時候,就會有clinit()方法

clinit()不同於類的構造器函式。(關聯:構造器函式是虛擬機器視角下的init()方法。若該類具有父類,JVM會保證子類的clinit()執行前,父類的clinit()已經執行完畢。因此在Java虛擬機器中第一個被執行的clinit()方法的型別肯定是java.lang.Object

  • 任何一個類在宣告後,都有生成一個構造器,預設是空參構造器
public class ClassInitTest {
    private static int num = 1;
    static {
        num = 2;
        number = 20;
        System.out.println(num);
        System.out.println(number);  //報錯,非法的前向引用
    }

    private static int number = 10; // prepare:number = 0--> number-->initial: 20-->10

    public static void main(String[] args) {
        System.out.println(ClassInitTest.num); // 2
        System.out.println(ClassInitTest.number); // 10
    }
}

關於涉及到父類時候的變數賦值過程:

public class ClinitTest {
    static class Father {
        public static int A = 1;
        static {
            A = 2;
        }
    }

    static class Son extends Father {
        public static int B = A;
    }

    public static void main(String[] args) {
        // 載入Father類,其次載入Son類
        System.out.println(Son.B);
    }
}

我們輸出結果為 2,也就是說首先載入ClinitTest的時候,會找到main方法,然後執行Son的初始化,但是Son繼承了Father,因此還需要執行Father的初始化,同時將A賦值為2。我們通過反編譯得到Father的載入過程,首先我們看到原來的值被賦值成1,然後又被複製成2,最後返回:

iconst_1
putstatic #2 <com/kai/jvm/ClinitTest1$Father.A>
iconst_2
putstatic #2 <com/kai/jvm/ClinitTest1$Father.A>
return

虛擬機器必須保證一個類的clinit()方法在多執行緒下被同步加鎖。

public class DeadThreadTest {
    public static void main(String[] args) {
        new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "\t 執行緒t1開始");
            new DeadThread();
        }, "t1").start();

        new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "\t 執行緒t2開始");
            new DeadThread();
        }, "t2").start();
    }
}
class DeadThread {
    static {
        if (true) {
            System.out.println(Thread.currentThread().getName() + "\t 初始化當前類");
            while(true) {
            }
        }
    }
}

上面的程式碼,輸出結果為:

執行緒t1開始
執行緒t2開始
執行緒t2 初始化當前類

從上面可以看出只能夠執行一次初始化,其中一條執行緒一直在阻塞等待。

二、類載入器

2.1 類載入器的分類

在類載入階段中,實現“通過一個類的全限定名來獲取描述該類的二進位制位元組流”這個動作的程式碼就被稱為“類載入器”(ClassLoader)。

JVM支援兩種型別的類載入器 ,分別為啟動類載入器(Bootstrap ClassLoader)和自定義類載入器(User-Defined ClassLoader)。

從概念上來講,自定義類載入器一般指的是程式中由開發人員自定義的一類類載入器,但是Java虛擬機器規範卻沒有這麼定義,而是將所有派生於抽象類ClassLoader的類載入器都劃分為自定義類載入器。

無論類載入器的型別如何劃分,在程式中我們最常見的類載入器主要有3類,如下所示:

02-類載入子系統

Tips:各類載入器之間的關係不是傳統意義上的繼承關係。

我們通過一個類,獲取不同的載入器:

public class ClassLoaderTest {
    public static void main(String[] args) {
        // 獲取系統類載入器
        ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
        System.out.println(systemClassLoader);

        // 獲取擴充套件類載入器
        ClassLoader extClassLoader = systemClassLoader.getParent();
        System.out.println(extClassLoader);

        // 獲取啟動類載入器
        ClassLoader bootstrapClassLoader = extClassLoader.getParent();
        System.out.println(bootstrapClassLoader);

        // 獲取自定義載入器
        ClassLoader classLoader = ClassLoaderTest.class.getClassLoader();
        System.out.println(classLoader);
        
        // 獲取String型別的載入器
        ClassLoader classLoader1 = String.class.getClassLoader();
        System.out.println(classLoader1);
    }
}

得到的結果,從結果可以看出啟動類載入器無法通過程式碼直接獲取,同時目前使用者程式碼所使用的載入器為系統類載入器。同時我們通過獲取String型別的載入器,發現是null,這間接說明了String型別是通過啟動類載入器進行載入的。(Java的核心類庫都是使用啟動類載入器進行載入的

sun.misc.Launcher$AppClassLoader@18b4aac2
sun.misc.Launcher$ExtClassLoader@4554617c
null
sun.misc.Launcher$AppClassLoader@18b4aac2
null

2.2 虛擬機器自帶的載入器

  1. 啟動類載入器(引導類載入器,Bootstrap ClassLoader)
  • 這個類載入使用C/C++語言實現的,巢狀在JVM內部。
  • 它用來載入Java的核心庫(JAVA_HOME/jre/lib/rt.jarresources.jar或sun.boot.class.path路徑下的內容),用於提供JVM自身需要的類。
  • 並不繼承自ava.lang.ClassLoader,沒有父載入器。
  • 載入擴充套件類和應用程式類載入器,並指定為他們的父類載入器。
  • 出於安全考慮,Bootstrap啟動類載入器只載入包名為java、javax、sun等開頭的類。

我們通過下面程式碼驗證一下:

public class ClassLoaderTest {
    public static void main(String[] args) {
        System.out.println("*********啟動類載入器************");
        // 獲取BootstrapClassLoader能夠載入的API的路徑
        URL[] urls = sun.misc.Launcher.getBootstrapClassPath().getURLs();
        for (URL url : urls) {
            System.out.println(url.toExternalForm());
        }

        // 從上面路徑中,隨意選擇一個類,來看看他的類載入器是什麼:得到的是null,則說明是啟動類載入器
        ClassLoader classLoader = Provider.class.getClassLoader();
        System.out.println(classLoader);
    }
}

得到的結果(%20是空格):

*********啟動類載入器************
file:/C:/Program%20Files/Java/jdk1.8.0_151/jre/lib/resources.jar
file:/C:/Program%20Files/Java/jdk1.8.0_151/jre/lib/rt.jar
file:/C:/Program%20Files/Java/jdk1.8.0_151/jre/lib/sunrsasign.jar
file:/C:/Program%20Files/Java/jdk1.8.0_151/jre/lib/jsse.jar
file:/C:/Program%20Files/Java/jdk1.8.0_151/jre/lib/jce.jar
file:/C:/Program%20Files/Java/jdk1.8.0_151/jre/lib/charsets.jar
file:/C:/Program%20Files/Java/jdk1.8.0_151/jre/lib/jfr.jar
file:/C:/Program%20Files/Java/jdk1.8.0_151/jre/classes
null
  1. 擴充套件類載入器(Extension ClassLoader)
  • Java語言編寫,由sun.misc.Launcher$ExtClassLoader實現。
  • 派生於ClassLoader類。
  • 父類載入器為啟動類載入器。
  • 從java.ext.dirs系統屬性所指定的目錄中載入類庫,或從JDK的安裝目錄的jre/lib/ext子目錄(擴充套件目錄)下載入類庫。如果使用者建立的JAR放在此目錄下,也會自動由擴充套件類載入器載入。

我們通過下面程式碼驗證一下:

public class ClassLoaderTest {
    public static void main(String[] args) {
        System.out.println("*********擴充套件類載入器************");
        String extDirs = System.getProperty("java.ext.dirs");
        for (String path : extDirs.split(";")) {
            System.out.println(path);
        }

        // Java\lib\ext目錄下隨意選擇一個類,檢視他的類載入器是什麼
        ClassLoader classLoader = CurveDB.class.getClassLoader();
        System.out.println(classLoader);
    }
}

得到的結果:

*********擴充套件類載入器************
C:\Program Files\Java\jdk1.8.0_151\jre\lib\ext
C:\WINDOWS\Sun\Java\lib\ext
sun.misc.Launcher$ExtClassLoader@7ea987ac
  1. 系統類載入器(應用程式類載入器,AppClassLoader)
  • Java語言編寫,由sun.misc.Launcher¥AppClassLoader實現。
  • 派生於ClassLoader類。
  • 父類載入器為擴充套件類載入器。
  • 它負責載入環境變數classpath或系統屬性java.class.path指定路徑下的類庫。
  • 該類載入是程式中預設的類載入器,一般來說,Java應用的類都是由它來完成載入。
  • 通過classLoader#getSystemclassLoader()方法可以獲取到該類載入器。

2.3 使用者自定義類載入器

在Java的日常應用程式開發中,類的載入幾乎是由上述3種類載入器相互配合執行的,在必要時,我們還可以自定義類載入器,來定製類的載入方式。
為什麼要自定義類載入器?

  • 隔離載入類
  • 修改類載入的方式
  • 擴充套件載入源
  • 防止原始碼洩漏

使用者自定義類載入器實現步驟:

  • 開發人員可以通過繼承抽象類ava.lang.ClassLoader類的方式,實現自己的類載入器,以滿足一些特殊的需求
  • 在JDK1.2之前,在自定義類載入器時,總會去繼承ClassLoader類並重寫loadClass()方法,從而實現自定義的類載入類,但是在JDK1.2之後已不再建議使用者去覆蓋1oadclass()方法,而是建議把自定義的類載入邏輯寫在findclass()方法中
  • 在編寫自定義類載入器時,如果沒有太過於複雜的需求,可以直接繼承URIClassLoader類,這樣就可以避免自己去編寫findclass()方法及其獲取位元組碼流的方式,使自定義類載入器編寫更加簡潔。

2.4 ClassLoader類

ClassLoader類,它是一個抽象類,其後所有的類載入器都繼承自ClassLoader(不包括啟動類載入器)。

方法名稱描述
getParent()返回該類載入器的超類載入器
loadClass(String name)載入名稱為name的類,返回結果為java.lang.Class類的例項
findClass(String name)查詢名稱為name的類,返回結果為java.lang.Class類的例項
findLoadedClass(String name)查詢名稱為name的已經被載入過的類,返回結果為java.lang.Class類的例項
defineClass(String name, byte[] b, int off, int len)把位元組陣列b中的內容轉換為一個Java類,返回結果為java.lang.Class類的例項
resolveClass(Class<?> c)連線指定的一個Java類

獲取ClassLoader的途徑:

  • 獲取當前ClassLoader:clazz.getClassLoader()
  • 獲取當前執行緒上下文的ClassLoader:Thread.currentThread().getContextClassLoader()
  • 獲取系統的ClassLoader:ClassLoader.getSystemClassLoader()
  • 獲取呼叫者的ClassLoader:DriverManager.getCallerClassLoader()

三、雙親委派機制

Java虛擬機器對class檔案採用的是按需載入的方式,也就是說當需要使用該類時才會將它的class檔案載入到記憶體生成class物件。而且載入某個類的class檔案時,Java虛擬機器採用的是雙親委派模式,即把優先將請求交由父類處理,它是一種任務委派模式。

3.1 工作原理

  • 如果一個類載入器收到了類載入請求,它並不會自己先去載入,而是把這個請求委託給父類的載入器去執行;
  • 如果父類載入器還存在其父類載入器,則進一步向上委託,依次遞迴,請求最終將到達頂層的啟動類載入器;
  • 如果父類載入器可以完成類載入任務,就成功返回,倘若父類載入器無法完成此載入任務,子載入器才會嘗試自己去載入,這就是雙親委派模式。(注意,此處的父類、子類不是繼承中父類、子類的概念。)
02-類載入子系統

下面用一個例子說明:

public class StringTest {
    public static void main(String[] args) {
        String string = new String();
        System.out.println("Hello World!");
    }
}

然後自定義一個java.lang.String類:

public class String {
    static {
        System.out.println("這是自定義的String類的靜態程式碼塊!");
    }
}

執行結果:Hello World!

3.2 雙親委派機制舉例

當我們載入jdbc.jar 用於實現資料庫連線的時候,首先我們需要知道的是 jdbc.jar是基於SPI介面進行實現的,所以在載入的時候,會進行雙親委派,最終從啟動類載入器中載入 SPI核心類。然後再載入SPI介面實現類,就進行反向委派,通過執行緒上下文類載入器進行實現jdbc.jar的載入。

02-類載入子系統

3.3 雙親委派機制的優勢

  • 避免類的重複載入
  • 保護程式安全,防止核心API被隨意篡改
    • 自定義類:java.lang.String
    • 自定義類:java.lang.XXXX(報錯:阻止建立 java.lang開頭的類)

3.4 沙箱安全機制

Java安全模型的核心就是Java沙箱(sandbox)。沙箱是一個限制程式執行的環境。沙箱機制就是將 Java 程式碼限定在虛擬機器(JVM)特定的執行範圍中,並且嚴格限制程式碼對本地系統資源訪問,通過這樣的措施來保證對程式碼的有效隔離,防止對本地系統造成破壞。

組成Java沙箱的基本元件如下:

  • 類載入體系結構
  • class檔案檢驗器
  • 內建於Java虛擬機器(及語言)的安全特性
  • 安全管理器及Java API

Java安全模型的前三個部分——類載入體系結構、class檔案檢驗器、Java虛擬機器(及語言)的安全特性一起達到一個共同的目的:保持Java虛擬 機的例項和它正在執行的應用程式的內部完整性,使得它們不被下載的惡意程式碼或有漏洞的程式碼侵犯。相反,這個安全模型的第四個組成部分是安全管理器,它主要 用於保護虛擬機器的外部資源不被虛擬機器內執行的惡意或有漏洞的程式碼侵犯。這個安全管理器是一個單獨的物件,在執行的Java虛擬機器中,它在對於外部資源的訪 問控制起中樞作用。

例如,自定義一個java.lang.String類,但是在載入自定義String類的時候會率先使用啟動類載入器載入,而啟動類載入器在載入的過程中會先載入jdk自帶的檔案(rt.jar包java.lang.中java\lang\String.class),報錯資訊說沒有main方法,就是因為載入的是rt.jar包中的string類。這樣可以保證對java核心原始碼的保護,這就是沙箱安全機制。

public class String {
    static {
        System.out.println("這是自定義的String類的靜態程式碼塊!");
    }
	
    // 錯誤
    public static void main(String[] args) {
        System.out.println("Hello World!");
    }
}

四、補充

5.1 比較class物件

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

  • 類的完整類名必須一致,包括包名。
  • 載入這個類的ClassLoader(指ClassLoader例項物件)必須相同。

換句話說,在JVM中,即使這兩個類物件(class物件)來源同一個Class檔案,被同一個虛擬機器所載入,但只要載入它們的ClassLoader例項物件不同,那麼這兩個類物件也是不相等的。

JVM必須知道一個型別是由啟動載入器載入的還是由使用者類載入器載入的。如果一個型別是由使用者類載入器載入的,那麼JVM會將這個類載入器的一個引用作為型別資訊的一部分儲存在方法區中。當解析一個型別到另一個型別的引用的時候,JVM需要保證這兩個型別的類載入器是相同的。

5.2 類的主動使用和被動使用

Java程式對類的使用方式分為:主動使用和被動使用
主動使用,又分為七種情況:

  • 建立類的例項
  • 訪問某個類或介面的靜態變數,或者對該靜態變數賦值
  • 呼叫類的靜態方法I
  • 反射(比如:Class.forName(“com.kai.Test”))
  • 初始化一個類的子類
  • Java虛擬機器啟動時被標明為啟動類的類
  • JDK7開始提供的動態語言支援:
  • java.lang.invoke.MethodHandle例項的解析結果REF getStatic、REF putStatic、REF invokeStatic控制程式碼對應的類沒有初始化,則初始化

除了以上七種情況,其他使用Java類的方式都被看作是對類的被動使用,都不會導致類的初始化

參考

深入理解Java虛擬機器:JVM高階特性與最佳實踐(第3版)

java中的安全模型(沙箱機制)

相關文章