深入理解 Java 虛擬機(二)~ 類的加載過程剖析

Java 虛擬機系列文章目錄導讀:

深入理解 Java 虛擬機(一)~ class 字節碼文件剖析
深入理解 Java 虛擬機(二)~ 類的加載過程剖析
深入理解 Java 虛擬機(三)~ class 字節碼的執行過程剖析
深入理解 Java 虛擬機(四)~ 各種容易混淆的常量池
深入理解 Java 虛擬機(五)~ 對象的創建過程
深入理解 Java 虛擬機(六)~ Garbage Collection 剖析

本文主要內容:

  • 類的加載時機(主動引用、被動引用)
  • 類的加載過程(加載、驗證、準備、解析、初始化)
  • 類加載器(ClassLoader)
    • 數組的類加載器
    • 雙親委派機制
    • 類加載器如何如此設計?
    • 自定義類加載器
    • Class.forName() VS ClassLoader.loadClass()
    • 類加載器死鎖問題

1. 前言

我們已經在 深入理解 Java 虛擬機 ~ class字節碼剖析 一文中詳細介紹了 class 字節碼文件的組成和字節碼指令集。接下來就可以介紹 JVM 虛擬機如何去執行 class 字節碼。Java 是一門面向對象的語言,我們的代碼都是在類(Class)當中,所以在介紹虛擬機如何執行 class 字節碼之前,我們需要先搞清 3 個問題?

  • 什麼時候 JVM 虛擬機纔會加載一個類?
  • 類的加載過程是什麼?
  • 什麼是類加載器?

2. 類的加載時機

我們在開發的時候肯定會編寫很多的類,那麼 JVM 究竟在什麼時候纔會去加載這些類呢?

“加載” 這個動作實際上是類生命週期的某一個階段。一個類從被加載到虛擬機內存到卸載出內存,它的生命週期爲:

加載、驗證、準備、解析、初始化、使用、卸載 7個階段。其中驗證、準備、解析這 3 個階段統稱爲“連接”,如下圖所示:

class生命週期

需要的注意的是,其中加載、驗證、準備、初始化和卸載這 5 個階段順序是確定的,而解析階段則不一定,它在某些情況下可以在初始化階段之後再開始,這是爲了支持Java語言的運行時綁定。

實際上,Java虛擬機規範中對什麼時候開始 加載階段 並沒有強制的約束,但是對於初始化階段則有嚴格的規定。有且只有 5 種情況必須立即對類進行初始化,如果一個類被初始化了,那麼它的加載、驗證、準備階段在此之前就完成了。

  • 遇到 new(創建對象)、getstatic(讀取類的靜態變量)、putstatic(設置類的靜態變量值)、invokestatic(調用靜態方法) 這 4 個字節碼指令時,如果類還沒有進行過初始化,則需要先觸發其初始化。
  • 反射一個類的時候,如果該類沒有進行過初始化,則需要先觸發初始化。
  • 當初始化一個類時,如果其父類還沒有初始化過,需先觸發其父類的初始化。
  • 當啓動虛擬機時,用戶需要指定一個入口類,虛擬機會先初始化這個類。
  • 當使用 JDK1.7 的動態語言支持時,如果 MethodHandle 實例最後的解析結果爲 REF_getStatic、REF_putStatic、REF_putStatic、REF_invokeStatic 的方法句柄,並且這個方法句柄對應的類還沒有初始化過,需要先觸發其初始化。

這 5 種場景中對類的引用稱之爲主動引用。除此之外所有的引用都不會觸發初始化,將這種情況的引用稱之爲被動引用。下面舉幾個被動引用的例子:

案例一:使用父類的靜態變量

class Parent {
    static {
        System.out.println("Parent init");
    }

    public static int count = 1;
}

class Son extends Parent {
    static {
        System.out.println("Son init");
    }
}

// 測試
public class NotInitialization01 {
    public static void main(String[] args) {
        System.out.println(Son.count);
    }
}

此時只會調用 Parent 的靜態代碼塊,雖然 Son 不會被初始化,但是 Son 會被加載。我們可以通過 -XX:+TraceClassLoading 來監控類的加載情況:

案例一:使用父類的靜態變量

可見先加載了 Parent,然後在加載 Son,最後初始化了 Parent

案例二:通過數組引用類

public static void main(String[] args) {
    Son[] sons= new Son[10];
}

並不會執行 Son 的靜態代碼塊,只會加載 Parent 和 Son 類,因爲虛擬機會生成一個 數組類
案例二:通過數組引用類

案例三:引用常量

public class ConstClass {
    static {
        System.out.println("ConstClass init");
    }

    public static final String HELLO_WORLD = "HelloWorld";

}

public class Client{
	public static void main(String[] args) {
	    System.out.println(ConstClass.HELLO_WORLD);
	}
}

這個時候也不會初始化 ConstClass 這個類。不僅不會初始化,連加載的操作都沒有。因爲常量在編譯時會存入調用類的常量池中,這樣就和常量定義的類沒有什麼關係了。所以不會加載、初始化 ConstClass 類。

上面提到初始化一個類的時候需要先初始化父類,但是接口在初始化的時候,並不要求其父接口全部初始化完畢,只有真正用到父接口的時候(如引用接口中定義的常量)纔會初始化。

雖然 Java 虛擬機規範沒有對類的加載時機沒有強制的約束,但是從上面的案例來看,一般用到了某個類都會加載該類(如果沒有加載的話),除非引用的是該類中的常量。

案例四:引用常量2

我們將 案例三 中常量類型改成 Object 類型,

public class ConstTest {
    static {
        System.out.println("ConstTest init");
    }
    public static final Object HELLO_WORLD = "HelloWorld";
}

public class Client{
	public static void main(String[] args) {
	    System.out.println(ConstTest.HELLO_WORLD);
	}
}

反編譯後可以查看,HELLO_WORLD 會在靜態代碼塊中被初始化:

public static final java.lang.Object HELLO_WORLD;

static {};
Code:
   0: getstatic     #2          // Field java/lang/System.out:Ljava/io/PrintStream;
   3: ldc           #3          // String ConstTest init
   5: invokevirtual #4          // Method java/io/PrintStream.println:(Ljava/lang/String;)V
   8: ldc           #5          // String HelloWorld
  10: putstatic     #6          // Field HELLO_WORLD:Ljava/lang/Object;
  13: return

也就是說 Java 編譯器會將該常量當做靜態變量一樣,在靜態代碼塊進行初始化。

所以字符串常量是一個字面量,這是真正意義上的常量。而上面的 static final Object 在編譯器看來並不是真正的常量,只是語法層面的常量。

所以上面的三種案例都不會初始化類,但是 案例四 不同,輸出 ConstTest.HELLO_WORLD ,會初始化 ConstTest 類

更多關於 常量 相關的概念,可以移步:《深入理解 Java 虛擬機(四)~ 各種容易混淆的常量池》

3. 類的加載過程

上面我們介紹了類的加載、初始化時機。下面我們開始介紹加載、驗證、準備、解析、初始化這 5 個階段執行哪些操作。

3.1 加載

在加載階段,虛擬機做了以下 3 件事情:

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

字節流的來源並沒有做嚴格的規定,只要是合法的字節碼文件流即可。

類的加載階段即可使用系統提供的類加載器來加載,也可以由自定義的類加載器來加載。

需要注意的是,數組類 不是通過類加載器創建,它是通過 Java 虛擬機直接創建的。但是數組類型和類加載器仍有密切的關係。因爲數據的元素的類型(Component type)可以是複雜類型,也可以使用基本類型。如果Component Type是複雜類型,那麼數組的類加載器爲 Component Type 的類加載器。如果 Component Type 是基本類型,那麼數組的類加載器爲 Bootstrap ClassLoader。關於類加載器後面會做詳細介紹。

3.2 驗證

驗證這個階段主要是驗證Class文件的字節流包含的信息是否符合當前虛擬機的要求,並且不會危害虛擬機自身的安全。

驗證階段主要會完成 4 個檢驗動作:

  • 文件格式驗證

    這一階段主要驗證字節流是否符合 class 文件的規範,並且能夠被當前版本執行。

    例如,我們 class 字節碼文件的 magic 改成 cafe baee,然後 java 命令運行該 class 文件提示錯誤:

    Error: A JNI error has occurred, please check your installation and try again
    Exception in thread "main" java.lang.ClassFormatError: Incompatible magic value 3405691630 in class file class_bytecode/Client
    

    在比如將 class 版本(34)改成高過當前版本的值(35),會提示:

    Error: A JNI error has occurred, please check your installation and try again
    Exception in thread "main" java.lang.UnsupportedClassVersionError: class_bytecode/Client has been compiled by a more recent versi-
    on of the Java Runtime (class file version 53.0), this version of the Java Runtime only recognizes class file versions up to 52.0
    

    實際上驗證的工作非常多,我這裏只是舉了 2 個例子,有需要的可以下載 JDK8 的 Hotspot 源碼查看裏面的驗證流程: src/share/vm/classfile/classFileParser.cpp。通過了所有的驗證工作後,字節流纔會進入內存的 方法區 進行存儲,後面的 3 個驗證階段全部是基於方法區的存儲結構進行的,不會再直接分析字節流。

  • 元數據驗證

    這一階段的驗證主要是對字節碼描述信息進行語義分析,保證描述信息符合 Java 語言規範。例如:

    • 這個類是否有父類

      if (super_class_index == 0) {
          check_property(_class_name == vmSymbols::java_lang_Object(),
                     "Invalid superclass index %u in class file %s",
                     super_class_index,
                     CHECK_NULL);
      }
      
    • 這個類是否繼承了 final 的類

      // Make sure super class is not final
      if (super_klass->is_final()) {
          THROW_MSG_(vmSymbols::java_lang_VerifyError(), "Cannot inherit from final class", nullHandle);
      }
      

    這些語義分析同樣可以在源碼裏找到:src/share/vm/classfile/classFileParser.cpp

  • 字節碼驗證

    字節碼校驗將會對 類的方法 進行校驗分析,保證方法在運行時不會做出危害虛擬機安全的事情。例如執行方法的時候會有操作數棧,如果操作數棧裏的元素類型和相關指令要求的類型不一致則會報錯。例如下面的代碼:

    public static void main(String[]args){
        long b = 10;
        System.out.println(b);
    }
    

    通過 Sublime 將 long 對應的常量池中的條目的 tag 從 5 改成 6,也就是改成了 double 類型了。將 double 賦值給 long 肯定會報錯(VerifyError):

    Error: A JNI error has occurred, please check your installation and try again
    Exception in thread "main" java.lang.VerifyError: Bad type on operand stack
    Exception Details:
      Location:
        class_bytecode/BytecodeVerify.main([Ljava/lang/String;)V @6: lstore_2
      Reason:
        Type double (current frame, stack[0]) is not assignable to long
    ...
    
  • 符號引用驗證

    符號引用驗證法正在虛擬機將符號引用轉化爲直接引用的時候。關於符號引用和直接引用將在後面詳解介紹。

    這個階段主要是驗證類中引用到的常量池中的數據是否合法。例如:符號引用中通過全限定符能否找到對應的類,在指定的類中是否存在符合方法的字段描述符以及簡單名稱所描述的方法和字段等等。符號引用的目的是確保解析動作能夠正常執行。例如我們將上面的例子:

    public static void main(String[]args){
        long b = 10;
        System.out.println(b);
    }
    

    在常量池中將 println 的符號引用改成 printll,運行程序將會得到錯誤:

    Exception in thread "main" java.lang.NoSuchMethodError: java.io.PrintStream.printll(J)V
        at class_bytecode.BytecodeVerify.main(BytecodeVerify.java:6)
    
    

通過上面的驗證步驟來看,先從 class 字節碼文件的結構開始驗證,然後驗證是否符合語言規範,接着驗證類裏的方法是否合法,最後驗證符號引用是否合法,從而保證後面的解析能夠順序進行。可見這 4 個驗證過程是一個從淺入深的過程。

3.3 準備

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

public class PrepareStatus {
    public static int count = 10;
}

在準備階段 value 變量的初始值是 0 而不是 10,因爲在準備階段沒有執行任何方法。那麼類變量 count 是什麼時候賦值爲 10 的呢?

類變量會在靜態代碼塊中賦值,靜態代碼塊對應的就是 <clinit> 方法。將 PrepareStatus 反編譯:

  // 靜態變量
  public static int count;
    descriptor: I
    flags: ACC_PUBLIC, ACC_STATIC

  // 靜態代碼塊 <clinit>
  static {};
    descriptor: ()V
    flags: ACC_STATIC
    Code:
      stack=1, locals=0, args_size=0
         0: bipush        10
         // 設置變量的值
         2: putstatic     #2                  // Field count:I
         5: return
      LineNumberTable:
        line 4: 0

3.4 解析

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

  • 符號引用

    符號引用(Symbolic Reference)以一組符合來描述引用的的目標,符號可以是任何形式的字面量。
    符號引用與虛擬機的內存佈局無關,引用的目標並不一定加載到內存中。

  • 直接引用

    直接引用可以是直接指向目標的指針,相對偏移量或是一個能間接定位到目標的句柄。 直接引用是和虛擬機實現的內存佈局相關的。

查看 bytecodeInterpreter.cpp 文件的時候,發現通過 -XX:+TraceClassResolution 可以跟蹤類的解析情況。

虛擬機規範沒有規定解析階段放生的具體的時間,只要求了 anewarray, checkcast, getfield, getstatic, instanceof, invokedynamic, invokeinterface, invokespecial, invokestatic, invokevirtual, ldc, ldc_w, multianewarray, new, putfield, putstatic 這些指令執行的時候需要執行解析操作。

對於同一個符號引用解析多次是很常見的,除了 invokedynamic 指令外,虛擬機對第一次的解析結果緩存起來(在運行時常量池中記錄直接引用,並把常量標記爲已解析狀態),從而避免解析動作重複執行。

上面這句話是 《深入理解Java虛擬機 - JVM 高級特性與最佳時間(第二版)》 這本書上的論述,但是我在下載的 Java1.8 Hotspot 中的源碼中發現,除了 invokedynamic 會避免重複解析,上面的其他很多指令也有類似的邏輯。我們先來看下 invokedynamic 如:

// 文件位置:hotspot\src\share\vm\interpreter\bytecodeInterpreter.cpp

CASE(_invokedynamic): {

        // 判斷 invokedynamic 指令是可以可用
        if (!EnableInvokeDynamic) {
                  handle_exception);
          ShouldNotReachHere();
        }

        u4 index = Bytes::get_native_u4(pc+1);
        ConstantPoolCacheEntry* cache = cp->constant_pool()->invokedynamic_cp_cache_entry_at(index);

        // 判斷常量池中的 entry 是否已經解析
        if (! cache->is_resolved((Bytecodes::Code) opcode)) {
        
          // 如果沒有解析,則執行解析動作
          CALL_VM(InterpreterRuntime::resolve_invokedynamic(THREAD),
                  handle_exception);
                  
          // 將解析過的 entry 賦值給 cache
          cache = cp->constant_pool()->invokedynamic_cp_cache_entry_at(index);
        }

        // 省略其他代碼...
      }

除了 invokedynamic,還有 putfield、putstatic 指令也會判斷是否已經解析過:

CASE(_putfield):
CASE(_putstatic):
{
  u2 index = Bytes::get_native_u2(pc+1);
  // 獲取常量池中的 entry
  ConstantPoolCacheEntry* cache = cp->entry_at(index);
  // 判斷 entry 是否被解析過
  if (!cache->is_resolved((Bytecodes::Code)opcode)) {
    CALL_VM(InterpreterRuntime::resolve_get_put(THREAD, (Bytecodes::Code)opcode),
            handle_exception);
    // 將解析過的 entry 賦值給 cache
    cache = cp->entry_at(index);
  }

  // 省略其他代碼...  
}

《深入理解Java虛擬機 - JVM 高級特性與最佳時間(第二版)》 這本書是基於 JDK 1.7,可能是因爲版本的問題,然後我查看了 JDK1.7 Hotspotputfield、putstatic 也會判斷是否已經解析:

CASE(_putfield):
CASE(_putstatic):
{
  u2 index = Bytes::get_native_u2(pc+1);
  // 獲取常量池中的 entry
  ConstantPoolCacheEntry* cache = cp->entry_at(index);
  // 判斷 entry 是否被解析過
  if (!cache->is_resolved((Bytecodes::Code)opcode)) {
    CALL_VM(InterpreterRuntime::resolve_get_put(THREAD, (Bytecodes::Code)opcode),
            handle_exception);
    // 將解析過的 entry 賦值給 cache
    cache = cp->entry_at(index);
  }

  // 省略其他代碼...  
}

類和接口的解析

假設在類 D 中,將未解析過的符號 N 解析爲一個類或接口 C ,需要以下步驟:

  • 1)如果 C 不是數組類型,那麼虛擬機將會把代表 N 的全限定名傳遞給 D 的類加載器去加載類 C,在加載的過程中,由於元數據驗證、字節碼驗證的需要,可能會觸發其他類的加載操作,如果整個過程出現任何異常,整個解析過程則宣告失敗。
  • 2)如果 C 是一個數組,並且數組的元素類型是對象,那麼會按照 1)的流程去加載這個元素的類型。
  • 3)如果上面的步驟沒有出現任何異常,那麼 C 在虛擬機中實際上已經成爲一個有效的類和接口了。

字段的解析

字段首先是屬於一個類,所以會去找常量池中字段對應的 class_index,如果解析類或接口的過程都失敗了,則字段解析宣告結束。如果成功,將類或接口用 C 表示,需要經過下面步驟找到字段:

  • 1)如果 C 本身就包含了簡單名稱和字段描述都與目標字段相匹配,則返回這個字段的直接引用。
  • 2)如果 C 實現了接口,則按照從下往上遞歸搜索各個接口和它的父接口,如果找到則直接返回。
  • 3)如果 C 不是 java.lang.Object,會按照繼從下往上遞歸搜搜父類,如果找到直接返回。
  • 4)經過以上步驟都沒有找到則拋出 java.lang.NoSuchFieldException 異常。

類中的方法解析

首先根據方法的 class_index 去解析看對應的類或接口,如果類或接口解析失敗,則整個解析過程失敗,如果解析成功,用 C 表示,需要經過下面步驟:

  • 1)如果 C 是接口,則拋出異常:java.lang.ImcompatibleClassChangeError 異常
  • 2)在 C 中查找是否有簡單名稱和描述符都與 目標匹配的方法。如果有則返回,查找結束。
  • 3)在 C 的父類中查找,如果有則返回直接引用,查找結束。
  • 4)在 C 實現的接口查找,如果存在說明 C 是一個抽象類,查找結束,拋出異常 java.lang.AbstractMethodError 異常。
  • 5)經過異常步驟都沒有找到,則拋出異常 java.lang.NoSuchMethodError 異常。

接口中的方法解析

首先根據方法的 class_index 去解析看對應的類或接口,如果類或接口解析失敗,則整個解析過程失敗,如果解析成功,用 C 表示,需要經過下面步驟:

  • 1)如果 C 不是接口,則拋出異常:java.lang.ImcompatibleClassChangeError 異常
  • 2)在 C 中查找是否有簡單名稱和描述符都與 目標匹配的方法。如果有則返回,查找結束。
  • 3)在接口 C 的符接口中遞歸查找,直到 java.lang.Object(包含)查看是否能找到匹配的方法,如果有,返回方法的直接引用,查找結束。
  • 4)經過異常步驟都沒有找到,則拋出異常 java.lang.NoSuchMethodError 異常。

3.5 初始化

初始化階段是類加載過程的最後一步,初始化階段,才真正纔是執行類中定義的代碼。

上面在介紹準備階段的時候,我們提到準備階段會爲類變量賦過初始值,在靜態代碼塊中爲類變量賦值開發者設置的值。

靜態代碼塊編譯後變成一個叫 <clinit> 的方法。初始化階段就是執行這個 <clinit> 方法的。我們知道對象的構造方法編譯後爲 <init>

下面開始介紹下 <clinit> 方法:

  • <clinit> 方法是由編譯器自動收集所有類變量的賦值動作,如果開發者定義了自己的靜態代碼塊,則會合並用戶編寫的靜態代碼塊。編譯器收集類變量的時候是根據類變量的定義先後順序決定的,靜態代碼塊只能使用定義在靜態語句塊之前的變量,定義在其之後的變量,靜態代碼塊只能賦值,不能訪問,例如:

    public class InitState {
        static {
            i = 10;                // 可以賦值
            System.out.println(i); //illegal forward reference
        }
        static int i = 0;
    }
    
  • <clinit> 和 對象構造器 <init> 不同,在對象的構造器中會調用父類的構造器,而 <clinit> 不會調用父類的 <clinit> 方法,虛擬機會保證父類的 <clinit> 方法要比子類先執行。

  • 虛擬機會保證一個類的 <clinit> 方法在多線程的環境下會被正確的加鎖、同步。所以多個線程同時去初始化一個類,只會有一個線程去執行類的 <clinit> 方法,其他的線程在阻塞等待。

4. 類加載器

上面介紹完了類加載的整個過程,但是在 加載階段 還有一個重要的概念沒有介紹,那就是類加載器。

加載階段 通過類的全限定名來獲取此類的二進制字節流。獲取二進制字節流的動作就是 類加載器 來做的。

這個字節流可是一個 class 字節文件,也可以是 一個 jar 文件,這些文件可以是本地,也可以是來自網絡,也可以是虛擬機動態生成的,只要符合虛擬機規範即可。

Java 是面嚮對象語言,那麼 Java 中的類也是一類事物,也需要有一個東西來描述,在 Java 中有一個類叫 Class,就是用來描述所有的類。

要使用某個類,首先要獲取這個類對應的 Class 對象,類加載器(ClassLoader)就是用來加載類的二進制字節流的,然後產出 Class 對象,從而 JVM 才能使用這個類。

所以 Class 對象是 class 字節碼文件在 JVM 層面的化身,有了這個 Class 對象,就可以 new 出這個類的實例對象,調用方法等等。

在每個 Class 對象中都有一個方法叫做 getClassLoader() 用來獲取該 Class 是由哪個 ClassLoader 加載的。

Java 提供了 3 個類加載器,他們分別是:

  • BootstrapClassLoader

    用於加載 JRE/lib/rt.jar 裏的 class,JDK系統的類庫基本上都在這裏

  • ExtClassLoader

    用於加載 JRE/lib/ext/* 文件夾下所有的 class

  • AppClassLoader

    用於加載 CLASSPATH 目錄下所有的 class,也就是開發者編寫的類

其中 BootstrapClassLoader 是 C++ 編寫的,ExtClassLoader 和 AppClassLoader 是 Java 編寫的,它們都繼承自 java.lang.ClassLoader 這個類。

4.1 數組的類加載器

我們都知道數組也是一個引用類型,但是我們找不到數組這個類定義在哪裏。通常情況下,我們使用的類要那麼是 JDK 提供的,要麼是開發者編寫的,或者第三類庫提供的,但是數組這個複雜類型我們找不到它的定義。

其實數組的 class 是有 Java 虛擬機動態幫我們生成的,這個類繼承了 Object ,實現了 Serializable 接口。

有了數組的 class 字節流,那麼是哪個類加載器來加載呢?

根據 java.lang.ClassLoader 的註釋文檔:

* Class objects for array classes are not created by class
* loaders, but are created automatically as required by the Java runtime.
* The class loader for an array class, as returned by 
* Class.getClassLoader() is the same as the class loader for its element
* type; if the element type is a primitive type, then the array class has no
* class loader.

意思就是說:數組的 Class 對象不是由 ClassLoader 創建的,而是 Java 運行時根據需要自動創建的。數組 class 的 ClassLoader 就是數組元素的 ClassLoader,如果數組元素類型是基本類型,那麼這個數組就沒有 ClassLoader

上面介紹類加載器的時候提到,類加載器就是加載字節碼文件創建 Class 對象的。既然數組類的 Class 對象不是 ClassLoader 創建的,那爲什麼還要爲數組的 Class 設置 ClassLoader 呢?因爲數組也是一個類,這個類裏也有可能用到了其他類,如果數組類的 Class 沒有 ClassLoader,那麼沒辦法加載它引用到的其他類。

注意:Class 對象不一定是 ClassLoader 創建的,例如數組的 Class 對象。

java.lang.ClassLoader 的註釋文檔提到:數組 class 的 ClassLoader 就是數組元素的 ClassLoader。例如:

// MyClass 使我們自己定義的類
MyClass[] arr = new MyClass[1];
System.out.println(arr.getClass().getClassLoader());

// 輸出結果:
sun.misc.Launcher$AppClassLoader@18b4aac2

因爲我們定義的 MyClass 的 ClassLoader 是 AppClassLoader,所以數組 class 的 ClassLoader 就是 AppClassLoader

java.lang.ClassLoader 的註釋文檔提到:如果數組元素的基本數據類型,那麼數組 class 就沒有 ClassLoader。例如:

int[] arrInt = new int[1];
System.out.println(arrInt.getClass().getClassLoader());

// 輸出結果:
null

那麼輸出 null 就一定沒有 ClassLoader?

Object[] arr = new Object[1];
System.out.println(arr.getClass().getClassLoader());

// 輸出結果:
null

其實 Object 的 ClassLoader 是 BootstrapClassLoader,所以 Object[] 的 ClassLoader 也是 BootstrapClassLoader。因爲 BootstrapClassLoader 是 C++ 編寫的,所以 Java 方法獲取它,返回的是 null,

那麼基本數據數組,如 int[] 的 ClassLoader 是不是也是 BootstrapClassLoader,根據 Hotspot 的源碼可以看到:

// 代碼位置:hotspot\agent\src\share\classes\sun\jvm\hotspot\jdi\ArrayTypeImpl

public ClassLoaderReference classLoader() {
    if (ref() instanceof TypeArrayKlass) {
        // primitive array klasses are loaded by bootstrap loader
        return null;
    } else {
        Klass bottomKlass = ((ObjArrayKlass)ref()).getBottomKlass();
        if (bottomKlass instanceof TypeArrayKlass) {
            // multidimensional primitive array klasses are loaded by bootstrap loader
            return null;
        } else {
            // class loader of any other obj array klass is same as the loader
            // that loaded the bottom InstanceKlass
            Instance xx = (Instance)(((InstanceKlass) bottomKlass).getClassLoader());
            return vm.classLoaderMirror(xx);
        }
    }
}

根據源碼註釋來看呢,基本數據類型的數組,它的 ClassLoader 是 BootstrapClassLoader,通過 getClassLoader() 方法獲取,返回 null

4.2 雙親委派機制

上面我們介紹了 Java 提供的 3 個類加載器以及它們的功能。在類加載的時候實際上執行的是一種委託機制,業界一般稱之爲:雙親委派機制

什麼是雙親委派機制呢?就是加載一個類的時候把這個加載任務交給父加載器,父加載器收到這個請求後,也把這個請求交給自己的父加載器,以此類推。所以任何一個類加載操作一開始都會到最頂層的類加載器。如果最頂層的類加載無法去加載,那麼這個加載任務再向下逐級傳遞。如果都無法無加載,則提示找不到類。

下面是 Java 提供的 3 個類加載器的父子關係:

父子關係

我們可以通過一個簡單的程序打印出他們的父子關係:

public class Test {
    public static void main(String[] args) {
        // 自己編寫的 Test 類,類加載器是 AppClassLoader
        ClassLoader loader = Test.class.getClassLoader();
        while (loader != null) {
            // 打印當前的類加載器
            System.out.println(loader);
            // 獲取父加載器
            loader = loader.getParent();
        }
        System.out.println(loader);
    }
}

// 輸出結果:

sun.misc.Launcher$AppClassLoader@18b4aac2
sun.misc.Launcher$ExtClassLoader@1b6d3586
null

java.lang.ClassLoader 有一個 parent 屬性就是表示父加載器的。AppClassLoader 的父加載器是 ExtClassLoader,ExtClassLoader 的父加載器是 Bootstrap ClassLoader,但是 ExtClassLoader 的 getParent() 方法返回 null,這是因爲 BootstrapClassLoader 是 C++ 編寫。BootstrapClassLoader 加載器是最頂層的類加載器。

雙親委派制針對 JDK 爲我們提供的 3 個類加載器的,如果下面有我們自己定義的類加載器,那就不是雙親委派了,而是 N 親委派了。

關於類加載的雙親委派機制,我們還可以看看 java.lang.ClassLoader 源碼:

protected Class<?> loadClass(String name, boolean resolve)
    throws ClassNotFoundException
{
    synchronized (getClassLoadingLock(name)) {
        // 檢查加載的類是否已經被加載過
        Class<?> c = findLoadedClass(name);
        // 如果沒有被加載過
        if (c == null) {
            long t0 = System.nanoTime();
            try {
                // 如果有父加載器
                if (parent != null) {
                    // 交給父加載器去加載
                    c = parent.loadClass(name, false);
                } else {
                    // 如果 parent = null,說明當前的類加載器是 Bootstrap ClassLoader
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // ClassNotFoundException thrown if class not found
                // from the non-null parent class loader
            }

            if (c == null) {
                long t1 = System.nanoTime();
                // 如果父類找不到,則調用自己的 findClass 去加載
                c = findClass(name);
                // 省略其他代碼...
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}

可見類加載的委託機制實際上是一個遞歸調用。loadClass() 方法觸發了這個這個遞歸,loadClass() 如果沒有找到類,那麼 loadClass() 方法裏面會調用 findClass() 來進行類加載,所以真正的類加載操作要麼在 loadClass() 方法裏,要麼在 findClass() 方法裏。

4.3 類加載器如何如此設計?

有讀者可能會問:不就是加載類嘛,爲什麼要高搞這麼多層次的類加載器?一個類加載器加載所有的類不可以嗎?

Java 將類加載器設計成遞歸委託機制,有很多好處。比如 安全性上:

如果自定義了很多類加載器,前提是都是符合委託機制的,那麼加載 JDK 系統的類庫時,都會優先使用 Bootstrap ClassLoader 來加載。對於加載 Object 類,那麼內存中只會有一份 Object 的 Class 對象。因爲對於同一個類,不同的類加載器去加載,他們的 Class 是不相等的。例如:

public static void main(String[] args) throws ClassNotFoundException, IllegalAccessException, InstantiationException {

    // 自定義的 ClassLoader
    Class klass1 = new MyClassLoader().loadClass("SimpleClass");

    //AppClassLoader
    Class klass2 = ClassLoader.getSystemClassLoader().loadClass("class_bytecode.SimpleClass");

    //判斷 Class 對象是否相等
    System.out.println(klass1 == klass2);

    // instanceof
    System.out.println(klass1.newInstance() instanceof class_bytecode.SimpleClass); // false
    // 因爲都是由 AppClassLoader 加載的
    System.out.println(klass2.newInstance() instanceof class_bytecode.SimpleClass); // true
}

類加載器的委託機制,保證了系統提供的類庫由系統的類加載器去加載。

其實 Java 也在類加載器上做了很多安全的工作,比如對於我們想訪問 JDK 類庫中的 protected 方法,通常我們可以在項目中新建一個和這個類庫相同的包名,比如 java.lang,Java 已經在類加載器這一層做了限制,加載的時候會拋出異常,比如下面的類的包名爲就是 java.lang:

package java.lang;

public class Test {
    public static void main(String[]args){
        System.out.println("------");
    }
}

運行的時候出錯:

java.lang.SecurityException: Prohibited package name: java.lang
	at java.lang.ClassLoader.preDefineClass(ClassLoader.java:662)
	at java.lang.ClassLoader.defineClass(ClassLoader.java:761)
	at java.security.SecureClassLoader.defineClass(SecureClassLoader.java:142)
	at java.net.URLClassLoader.defineClass(URLClassLoader.java:468)
	at java.net.URLClassLoader.access$100(URLClassLoader.java:74)

我們稍微分析下 Java 是在哪裏做了限制:

// ClassLoader.java

private ProtectionDomain preDefineClass(String name,
                                        ProtectionDomain pd)
{
    if (!checkName(name))
        throw new NoClassDefFoundError("IllegalName: " + name);

    // Note:  Checking logic in java.lang.invoke.MemberName.checkForTypeAlias
    // relies on the fact that spoofing is impossible if a class has a name
    // of the form "java.*"
    if ((name != null) && name.startsWith("java.")) {
        throw new SecurityException
            ("Prohibited package name: " +
             name.substring(0, name.lastIndexOf('.')));
    }
    if (pd == null) {
        pd = defaultDomain;
    }

    if (name != null) checkCerts(name, pd.getCodeSource());

    return pd;
}

可以看出,原來只要 name 是以 java 開頭就會提示錯誤。我們再來看下是誰調用了 preDefineClass 方法:

// ClassLoader.java

protected final Class<?> defineClass(String name, byte[] b, int off, int len,
                                     ProtectionDomain protectionDomain)
    throws ClassFormatError
{
    protectionDomain = preDefineClass(name, protectionDomain);
    String source = defineClassSourceLocation(protectionDomain);
    Class<?> c = defineClass1(name, b, off, len, protectionDomain, source);
    postDefineClass(c, protectionDomain);
    return c;
}

defineClass 方法用於將字節流轉成 Class 對象,在該方法裏會調用 preDefineClass 方法校驗類的包名。

需要注意的是,類加載器的委託機制只是 Java 建議的機制,也有很多框架不是基於雙親委派機制的,所以不要提到類加載器就一定是雙親委派機制。類加載器是 Java 用於加載 Class 字節碼的一種技術規範,至於類加載器之間是不是用委託機制,並不是強制的,可以自由發揮。例如 Tomcat 的熱加載技術、OSGi技術 都沒遵循雙親委派機制。

除了安全性,還有就是 靈活性,我們可以自定義自己的類加載器來控制類加載的邏輯,比如對 class 文件加密, 然後自定義類加載器進行解密,如果系統只提供一個類加載器加載所有類,則實現不了這樣一點。例如下面我們自定義的類加載器,在執行來加載 class 字節碼文件的時候先對其進行解密,然後再開始加載操作。

4.4 自定義類加載器

我們知道了類加載就是加載class字節碼流,然後產生 Class 對象的。我們只要指定了字節碼文件不就可以了,所以自定義類加載器很簡單。

經過上面對 ClassLoader 的源碼分析,我們可以在 loadClass 或 findClass 方法裏將字節流轉成 Class 對象。Java 官方建議我們通過重載 findClass 方法而不是 loadClass方法來自定義類加載器。下面的自定類加載將採用重載 findClass() 的方式。

類加載機制讓開發者可以靈活的去制定加載類的邏輯,如可以將一個 class 文件按照某種加密規則進行加密,然後只有某種特定的類加載器才能正常的解密。下面我們來實現下:

首先我們準備一個簡單的類:

package class_load;

public class CipherClass {
    public CipherClass() {
        System.out.println("CipherClass Object was created");
    }
}

將 CipherClass 通過 javac 命令編譯成 CipherClass.class 文件,然後按照下面的加密算法將 CipherClass.class 字節碼進行加密:

/**
 * 加密方法,同時也是解密方法
 */
private static void cypher(InputStream ips, OutputStream ops) throws Exception {
    int b = -1;
    while ((b = ips.read()) != -1) {
        //1 就變成 0,0 就變成 1
        ops.write(b ^ 0xff);
    }
}

然後我們自定義一個 ClassLoader,在裏面可以對其進行解密,然後轉成 Class 對象:

public class CipherClassLoader extends ClassLoader {

    private String classDir;
    
    public CipherClassLoader(String classDir) {
        this.classDir = classDir;
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        // 定位加密後的 class 字節碼文件
        String classFileName = classDir + "\\" + name.substring(name.lastIndexOf('.') + 1) + ".class";
        try {
            FileInputStream fis = new FileInputStream(classFileName);
            ByteArrayOutputStream bos = new ByteArrayOutputStream();
            // 將加密的 class 字節碼進行解密
            cypher(fis, bos);
            fis.close();
            byte[] bytes = bos.toByteArray();
            // 將正常的 class 字節流轉成 Class 對象
            return defineClass(name, bytes, 0, bytes.length);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }

}

// 測試
public static void main(String[] args) throws Exception {
    String dir = "class file dir";
    Class clazz = new CipherClassLoader(dir).loadClass("class_load.CipherClass");
    clazz.newInstance();
}

// 輸出:

CipherClass Object was created

4.5 Class.forName() VS ClassLoader.loadClass()

有的時候我們無法直接拿到某個類,但是又需要使用這個類。這個時候可以使用 Class.forName()Classloader.loadClass() 來加載這個類。

這兩種方式的區別主要有兩個:

  • Class.forName() 會執行類的初始化操作,也就是會執行 static 代碼塊,而 Classloader.loadClass() 的方式則不會:

    先來看下 Class.forName() 的源代碼:

    public static Class<?> forName(String className)
                throws ClassNotFoundException {
        Class<?> caller = Reflection.getCallerClass();
        return forName0(className, true, ClassLoader.getClassLoader(caller), caller);
    }
    
    private static native Class<?> forName0(String name, boolean initialize,
                                        ClassLoader loader,
                                        Class<?> caller) throws ClassNotFoundException;
    

    其中 initialize 參數就是表示是否執行初始化操作的,下面看一個例子:

    public static void main(String[] args) throws ClassNotFoundException {
        // 會執行初始化操作
        Class clazz1 = Class.forName("class_load.ConstTest");
        // 不會執行初始化操作
        Class clazz2 = new ClassLoader() {}.loadClass("class_load.ConstTest");
    }
    
    

    所以學過 JDBC 的都知道,在操作 MySQL 數據的之前需要通過如下方式註冊驅動:

    Class.forName("com.mysql.jdbc.Driver");
    

    因爲 com.mysql.jdbc.Driver 在其靜態代碼塊中進行註冊操作:

    static {
        try {
            java.sql.DriverManager.registerDriver(new Driver());
        } catch (SQLException E) {
            throw new RuntimeException("Can't register driver!");
        }
    } 
    

    所以註冊驅動只能使用 Class.forName() 而不能使用 Classloader.loadClass()。

    使用 Classloader.loadClass() 來加載一個類,哪怕調用它的 class.newInstance() 也不會執行其 static 靜態代碼塊。

  • Class.forName() 可以加載數組類,而 Classloader.loadClass() 則不行:

    我們知道,數組類是沒有事先定義好的,數組類是由虛擬機中動態創建的。

    Class.forName("[Ljava.lang.String;"); // String[]
    Class.forName("[I");                  // int[]   基本數據類型數組
    
    
    // 拋出異常:java.lang.ClassNotFoundException: [Ljava.lang.String;
    ClassLoader.getSystemClassLoader().loadClass("[Ljava.lang.String;");
    

    Class.forName 來加載數組類無法對數組類進行實例化和指定數組大小。可以通過 java.lang.reflect.Array.newInstance 反射一個數組對象:

    int[] arr = (int[]) java.lang.reflect.Array.newInstance(int.class, 10);
    System.out.println(arr.length); // 10
    
    

4.6 類加載器死鎖問題

在 JDK1.7 之前,ClassLoader 是有可能出現死鎖的,關於 ClassLoader 死鎖的問題可以查看官方對該問題的描述 (點擊進入查看)

下面是官方對死鎖情況的復現:

Class Hierarchy:
  class A extends B
  class C extends D

ClassLoader Delegation Hierarchy:

Custom Classloader CL1:
  directly loads class A 
  delegates to custom ClassLoader CL2 for class B

Custom Classloader CL2:
  directly loads class C
  delegates to custom ClassLoader CL1 for class D

Thread 1:
  Use CL1 to load class A (locks CL1)
    defineClass A triggers
      loadClass B (try to lock CL2)

Thread 2:
  Use CL2 to load class C (locks CL2)
    defineClass C triggers
      loadClass D (try to lock CL1)

本來打算在上面改成中文註釋的,但是上的描述已經非常簡潔明瞭,所以就不畫蛇添足了。

在對死鎖情況介紹之前,先來看下 JDK1.6 ClassLoader:

protected synchronized Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        // 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 = findBootstrapClass0(name);
                }
            } catch (ClassNotFoundException e) {
                // If still not found, then invoke findClass in order
                // to find the class.
                c = findClass(name);
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }

可以看到 synchronized 是放在方法上,是整個方法同步,那麼 ClassLoader 對象就是同步方法的鎖(lock)。

下面可以描述死鎖的產生情況了,有兩個線程:

線程1:CL1 去 loadClass(A) 獲取到了 CL1 對象鎖,因爲 A 繼承了類 B,defineClass(A) 會觸發 loadClass(B),嘗試獲取 CL2 對象鎖;

線程2:CL2 去 loadClass(C) 獲取到了 CL2 對象鎖,因爲 C 繼承了類 D,defineClass(C) 會觸發 loadClass(D),嘗試獲取 CL1 對象鎖

線程1 嘗試獲取 CL2 對象鎖的時候,CL2 對象鎖已經被線程2拿到了,那麼線程1等待線程2釋放 CL2 對象鎖。

線程2 嘗試獲取 CL1 對像鎖的時候,CL1 對像鎖已經被線程1拿到了,那麼線程2等待線程1釋放 CL1 對像鎖。

然後兩個線程一直在互相等中…從而產生了死鎖現象。

如果你是通過重載 findClass 方法來自定類加載器的,那麼將不會有死鎖問題,那麼也就沒有破壞雙親委派機制,這也是官方建議的機制。

如果是通過重載 loadClass 方法來實現自定義類加載器就有可能出現死鎖的。

那有的人會說那我通過重載 findClass 來實現自定義類加載器不就可以避免了麼?是的。

但是有的時候又不得不通過重載 loadClass 方法來實現自定義類加載器,比如我們實現的類加載器不想遵循雙親委派機制(官方稱之爲 acyclic delegation),那麼只能重載 loadClass 了,前面分析 loadClass 方法源碼就知道了,是這個方法執行遞歸操作(雙親委派的邏輯)。

從中可以看出,如果你僅僅是想自定義個類加載器而已,但是不會改變雙親委派機制,那麼重載 findClass 方法即可。

如果萬不得已要通過重載 loadClass 來實現,在 JDK1.7 中可以在定義類加載器中的靜態代碼塊中添加如下代碼來避免死鎖的出現:

static {
    ClassLoader.registerAsParallelCapable();
}

其實 JDK 爲我們提供的類加載器,如 AppClassLoader 默認就加上了:

static class AppClassLoader extends URLClassLoader {

    // 省略其他代碼..

    static {
        ClassLoader.registerAsParallelCapable();
    }
}

5. 小結

本文介紹了一開始的類的加載時機,以及被動引用的幾個案例。

然後詳細介紹了類的加載過程:加載、驗證、準備、解析、初始化,在此過程,通過修改 class 字節碼文件方式演示違反驗證階段會產生什麼錯誤,通過查看 JVM 源碼的方式理清每個階段具體工作。

最後重點介紹了和我們開發息息相關的類加載器,介紹了類加載器的作用以及數組的類加載器

通過分析源碼的方式介紹了類加載器的雙親委派機制,然後自定義一個解密的類加載器。

最後介紹類加載器的死鎖問題,分析了爲何會產生死鎖,分析了通過 findClass 和 loadClass 實現自定義類加載器的不同。最後介紹瞭如何解決死鎖問題。

至此,我們就把從一個 class 字節碼文件加載到內存,然後變成 Class 對象的過程介紹完了。JVM 如何執行 class 字節碼的呢?有興趣的可以查看 《深入理解 Java 虛擬機(三)~ class 字節碼的執行過程剖析》

Reference


如果你覺得本文幫助到你,給我個關注和讚唄!

另外本文涉及到的代碼都在我的 AndroidAll GitHub 倉庫中。該倉庫除了 Java虛擬機 技術,還有 Android 程序員需要掌握的技術棧,如:程序架構、設計模式、性能優化、數據結構算法、Kotlin、Flutter、NDK,以及常用開源框架 Router、RxJava、Glide、LeakCanary、Dagger2、Retrofit、OkHttp、ButterKnife、Router 的原理分析 等,持續更新,歡迎 star。

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