1. 程式人生 > >一文教你讀懂JVM的類載入機制

一文教你讀懂JVM的類載入機制

Java執行程式又被稱為WORA(Write Once Run Anywhere,在任何地方執行只需寫入一次),意味著我們程式設計師小哥哥可以在任何一個系統上開發Java程式,但是卻可以在所有系統上暢通執行,無需任何調整,大家都知道這是JVM的功勞,但具體是JVM的哪個模組或者什麼機制實現這一功能呢? JVM(Java Virtual Machine, Java虛擬機器)作為執行java程式的執行時引擎,也是JRE(Java Runtime Environment, Java執行時環境)的一部分。 說起它想必不少小夥伴任處於似懂非懂的狀態吧,說實話,著實是塊難啃的骨頭。但古語有云:千里之行,始於足下。我們今天主要談談,為什麼JVM無需瞭解底層檔案或者檔案系統即可執行Java程式? --這主要是類載入機制在執行時將Java類動態載入到JVM的緣故。

當我們編譯.java檔案時,Java編譯器會生成與.java檔案同名的.class檔案(包含位元組碼)。當我們執行時,.class檔案會進入到各個步驟,這些步驟共同描繪了整個JVM,上圖便是一張精簡的JVM架構圖。 今天,我們的主角就是類載入機制 - 說白了,就是將.class檔案載入到JVM記憶體中,並將其轉化為java.lang.Class物件的過程。這對這個過程,我們可以細分為如下幾個階段:
  • 載入
  • 連線(驗證,準備,解析)
  • 初始化

注意: 正常場景下,載入的流程如上。但是Java語言本身支援執行時繫結,所以解析階段是用可能放在初始化之後進行的,稱為動態繫結或者晚期繫結。  

I.類載入流程

1. 載入

載入:通過類的全侷限定名找到.class檔案,並利用.class檔案建立一個java.lang.Class物件。
  • 根據類的全侷限定名找到.class檔案,生成對應的二進位制位元組流。
  • 將靜態儲存結構轉換為執行時資料結構,儲存執行時資料結構到JVM記憶體方法區中。
  • JVM建立java.lang.Class型別的物件,保存於堆(Heap)中。利用該物件,可以獲取保存於方法區中的類資訊,例如:類名稱,父類名稱,方法和變數等資訊。
For Example:

package com.demo;

import java.lang.reflect.Field;
import java.lang.reflect.Method;

public class ClassLoaderExample {
    public static void main(String[] args) {
        StringOp stringOp = new StringOp();

        System.out.println("Class Name: " + stringOp.getClass().getName());
        for(Method method: stringOp.getClass().getMethods()) {
            System.out.println("Method Name: " + method.getName());
        }
        for (Field field: stringOp.getClass().getDeclaredFields()) {
            System.out.println("Field Name: " + field.getName());
        }
    }
}
StringOp.class
package com.demo;

public class StringOp {
    private String displayName;
    private String address;

    public String getDisplayName() {
        return displayName;
    }

    public String getAddress() {
        return address;
    }
}

output:

Class Name: com.demo.StringOp
Method Name: getAddress
Method Name: getDisplayName
Field Name: displayName
Field Name: address
注意:對於每個載入的.class檔案,僅會建立一個java.lang.Class物件.
StringOp stringOp1 = new StringOp();
StringOp stringOp2 = new StringOp();
System.out.println(stringOp1.getClass() == stringOp2.getClass()); 
//output: true

2. 連線

2.1 驗證

驗證:主要是確保.class檔案的正確性,由有效的編譯器生成,不會對影響JVM的正常執行。通常包含如下四種驗證:
  • 檔案格式:驗證檔案的格式是否符合規範,如果符合規範,則將對應的二進位制位元組流儲存到JVM記憶體的方法區中;否則丟擲java.lang.VerifyError異常。
  • 元資料:對位元組碼的描述資訊進行語義分析,確保符合Java語言規範。例如:是否有父類;是否繼承了不允許繼承的類(final修飾的類);如果是實體類實現介面,是否實現了所有的方法;等。。
  • 位元組碼:驗證程式語義是否合法,確保目標類的方法在被呼叫時不會影響JVM的正常執行。例如int型別的變數是否被當成String型別的變數等。
  • 符號引用:目標類涉及到其他類的的引用時,根據引用類的全侷限定名(例如:import com.demo.StringOp)能否找到對應的類;被引用類的欄位和方法是否可被目標類訪問(public, protected, package-private, private)。這裡主要是確保後續目標類的解析步驟可以順利完成。

2.2 準備

準備:為目標類的靜態欄位分配記憶體並設定預設初始值(當欄位被final修飾時,會直接賦值而不是預設值)。需要注意的是,非靜態變數只有在例項化物件時才會進行欄位的記憶體分配以及初始化。
public class CustomClassLoader {
    //載入CustomClassLoader類時,便會為var1變數分配記憶體
    //準備階段,var1賦值256
    public static final int var1 = 256;  
    //載入CustomClassLoader類時,便會為var2變數分配記憶體
    //準備階段,var2賦值0, 初始化階段賦值128
    public static int var2 = 128; 
    //例項化一個CustomClassLoader物件時,便會為var1變數分配記憶體和賦值
    public int var3 = 64; 
}
注意:靜態變數存在方法區記憶體中,例項變數存在堆記憶體中。 這裡簡單貼一下Java不同變數的預設值:  
資料型別 預設值
int 0
float 0.0f
long 0L
double 0.0d
short (short)0
char '\u0000'
byte (byte)0
String null
boolean false
ArrayList null
HashMap null

2.3 解析

解析:將符號引用轉化為直接引用的過程。
  • 符號引用(Symbolic Reference):描述所引用目標的一組符號,使用該符號可以唯一標識到目標即可。比如引用一個類:com.demo.CustomClassLoader,這段字串就是一個符號引用,並且引用的物件不一定事先載入到記憶體中。
  • 直接引用(Direct Reference):直接指向目標的指標,相對偏移量或者一個能間接定位到目標的控制代碼。根據直接引用的定義,被引用的目標一定事先載入到了記憶體中。

3. 初始化

前面的準備階段時,JVM為目標類的靜態變數分配記憶體並設定預設初始值(final修飾的靜態變數除外),但到了初始化階段會根據使用者編寫的程式碼重新賦值。換句話說:初始化階段就是JVM執行類構造器方法<clinit>()的過程。   <init>()和<clinit>()從名字上來看,非常的類似,或許某些童鞋會給雙方畫上等號。然則,對於JVM來說,雖然兩者皆被稱為構造器方法,但此構造器非彼構造器。
  • <init>():物件構造器方法,用於初始化例項物件
    • 例項物件的constructor(s)方法,和非靜態變數的初始化;
    • 執行new建立例項物件時使用。
  • <clinit>():類構造器方法,用於初始化類
    • 類的靜態語句塊和靜態變數的初始化;
    • 類載入的初始化階段執行。
For Example:  
public class ClassLoaderExample {
    private static final Logger logger = LoggerFactory.getLogger(ClassLoaderExample.class);//<clinit>
    private String property = "custom"; //<init>
    
    //<clinit>
    static {
        System.out.println("Static Initializing...");
    }
    
    //<init>
    ClassLoaderExample() {
        System.out.println("Instance Initializing...");
    }
    
    //<init>
    ClassLoaderExample(String property) {
        this.property = property;
        System.out.println("Instance Initializing...");
    }
}    
檢視對應的位元組碼: public ClassLoaderExample();  <init>
Code:
 0 aload_0  //將區域性變量表中第一個引用載入到操作樹棧
 1 invokespecial #1 <java/lang/Object.<init>> //呼叫java.lang.Object的例項初始化方法
 4 aload_0 //將區域性變量表中第一個引用載入到操作樹棧
 5 ldc #2 <custom> //將常量custom從常量池第二個位置推送至棧頂
 7 putfield #3 <com/kaiwu/ClassLoaderExample.property> //設定com.kaiwu.ClassLoaderExample例項物件的property欄位值為custom
10 getstatic #4 <java/lang/System.out> //從java.lang.System類中獲取靜態欄位out
13 ldc #5 <Instance Initializing...> //將常量Instance Initializing...從常量池第5個位置推送至棧頂
15 invokevirtual #6 <java/io/PrintStream.println> //呼叫java.io.PrintStream物件的println例項方法,列印棧頂的Instance Initializing...
18 return //返回
public ClassLoaderExample(String property);  <init>
Code:
 0 aload_0 //將區域性變量表中第一個引用載入到操作樹棧
 1 invokespecial #1 <java/lang/Object.<init>>  //呼叫java.lang.Object的例項初始化方法
 4 aload_0  //將區域性變量表中第一個引用載入到操作樹棧
 5 ldc #2 <custom> //將常量custom從常量池第二個位置推送至棧頂
 7 putfield #3 <com/kaiwu/ClassLoaderExample.property> //將常量custom賦值給com.kaiwu.ClassLoaderExample例項物件的property欄位
10 aload_0 //將區域性變量表中第一個引用載入到操作樹棧
11 aload_1 //將區域性變量表中第二個引用載入到操作樹棧
12 putfield #3 <com/kaiwu/ClassLoaderExample.property> //將入參property賦值給com.kaiwu.ClassLoaderExample例項物件的property欄位
15 getstatic #4 <java/lang/System.out> //從java.lang.System類中獲取靜態欄位out
18 ldc #5 <Instance Initializing...> //將常量Instance Initializing...從常量池第5個位置推送至棧頂
20 invokevirtual #6 <java/io/PrintStream.println> //呼叫java.io.PrintStream物件的println例項方法, 列印棧頂的Instance Initializing...
23 return //返回
<clinit>():

Code:
 0 ldc #7 <com/kaiwu/ClassLoaderExample> //將com.kaiwu.ClassLoaderEexample的class_info常量從常量池第七個位置推送至棧頂
 2 invokestatic #8 <org/slf4j/LoggerFactory.getLogger> //從org.slf4j.LoggerFactory類中獲取靜態欄位getLogger
 5 putstatic #9 <com/kaiwu/ClassLoaderExample.logger> //設定com.kaiwu.ClassLoaderExample類的靜態欄位logger
 8 getstatic #4 <java/lang/System.out> //從java.lang.System類中獲取靜態欄位out
11 ldc #10 <Static Initializing...> //將常量Static Initializing...從常量池第10個位置推送至棧頂
13 invokevirtual #6 <java/io/PrintStream.println> //呼叫java.io.PrintStream物件的println例項方法, 列印棧頂的Static Initializing...
16 return //返回

II. 類載入器

1. 類載入器ClassLoader

java.lang.ClassLoader本身是一個抽象類,它的例項用來載入Java類到JVM記憶體中。這裡如果細心的小夥伴就會發現,java.lang.ClassLoader的例項用來載入Java類,但是它本身也是一個Java類,誰來載入它?先有雞,還是先有蛋?? 不急,待我們細細說來!! 首先,我們看一個簡單的示例,看看都有哪些不同的類載入器:
public static void printClassLoader() {
    // StringOP:自定義類
    System.out.println("ClassLoader of StringOp: " + StringOp.class.getClassLoader());
    // com.sun.javafx.binding.Logging:Java核心類擴充套件的類
    System.out.println("ClassLoader of Logging: " + Logging.class.getClassLoader());
    // java.lang.String: Java核心類
    System.out.println("ClassLoader of String: " + String.class.getClassLoader());
}

output:

ClassLoader of StringOp: sun.misc.Launcher$AppClassLoader@18b4aac2
ClassLoader of Logging: sun.misc.Launcher$ExtClassLoader@7c3df479
ClassLoader of String: null
從輸出可以看出,這裡有三種不同的類載入器:應用類載入器(Application/System class loader), 擴充套件類載入器(Extension class loader)以及啟動類載入器(Bootstrap class loader)。
  • 啟動類載入器:原生代碼(C++語言)實現的類載入器,負責載入JDK內部類(通常是$JAVA_HOME/jre/lib/rt.jar$JAVA_HOME/jre/lib目錄中的其他核心類庫)或者-Xbootclasspath選項指定的jar包到記憶體中。該載入器是JVM核心的一部分,以本機程式碼編寫,開發者無法獲得啟動類載入器的引用,所以上述java.lang.String類的載入為null。此外,該類充當所有其他java.lang.Class Loader例項共同的父級(區別為是否為直接父級),它載入所有直接子級的java.lang.ClassLoader類(其他子類逐層由直接父級類載入器載入)。
  • 擴充套件類載入器:啟動類載入器的子級,由Java語言實現的,用來載入JDK擴充套件目錄下核心類的擴充套件類(通常是$JAVA_HOME/lib/ext/*.jar)或者-Djava.ext.dir系統屬性中指定的任何其他目錄中存在的類到記憶體中。由sun.misc.Launcher$ExtClassLoader類實現,開發者可以直接使用擴充套件類載入器。
  • 應用/系統類載入器:擴充套件類載入器的子級,負責將java -classpath/-cp($CLASSPATH)或者-Djava.class.path變數指定目錄下類庫載入到JVM記憶體中。由sun.misc.Launcher$AppClassLoader類實現,開發者可以直接使用系統類載入器。

2. 類載入器的類圖關係

  通過上文的分析,目前常用的三種類載入器分別為:啟動類載入器,擴充套件類載入器以及應用/系統載入器。但是檢視原始碼的類圖關係,可以發現AppClassLoder和ExtClassLoader都是sun.misc.Laucher(主要被系統用於啟動主應用程式)這個類的靜態內部類,並且兩個類之間也不存在繼承關係,那為何說應用/系統類載入器是擴充套件類載入器的子級呢? 原始碼分析(JDK1.8): sun.misc.Laucher Launcher.ExtClassLoader.getExtClassLoader():獲取ExtClassLoader例項物件。 Launcher.AppClassLoader.getAppClassLoader(final ClassLoader var0): 根據ExtClassLoader例項物件獲取AppClassLoader例項物件。 Launcher.AppClassLoader(URL[] var1, ClassLoader var2): 根據$CLASSPATH和ExtClassLoader例項物件建立AppClassLoader例項物件。   層層剖析,可見雖然AppClassLoader類和ExtClassLoader類雖然並無繼承(父子)關係,但是在建立AppClassLoader類的例項物件時,顯式(this.parent=parent)設定其父級為ExtClassLoader例項物件,所以雖然從類本身來說兩者並無繼承關係,但例項化出來的物件卻存在父子關係。   一般而言,在Java的日常開發中,通常是由上述三種類載入器相互配合完成的,當然,也可以使用自定義類載入器。需要注意的是,這裡的JVM對.class檔案是按需載入的或者說是Lazy模式,當需要使用某個類時才會將該.class載入到記憶體中生成java.lang.Class物件,並且每個.class檔案只會生成一個java.lang.Class物件。   但幾種載入器時如何配合的呢?亦或是單槍匹馬,各領風騷? 鑑於此,則不得不提JVM採用的雙親委派機制了。  

3. 雙親委派機制

核心思想:自底向上檢查類是否已載入,自頂向下嘗試載入類。     使用雙親委派模式的優勢:
  • 使用雙親委派模式可以避免類的重複載入:當父級載入器已經載入了目標類,則子載入器沒有必要再載入一次。
  • 避免潛在的安全風險:啟動類載入器是所有其他載入器的共同父級,所以java的核心類庫不會被重複載入,意味著核心類庫不會被隨意篡改。例如我們自定義名為java.lang.String的類,通過雙親委派模式進行載入類,通過上述流程圖,啟動類載入器會發現目標類已經載入,直接返回核心類java.lang.String,而不會通過應用/系統類載入器載入自定義類java.lang.String。當然,一般而言我們是不可以載入全侷限定名與核心類同名的自定義類,否則會丟擲異常:java.lang.SecurityException: Prohibited package name: java.lang
原始碼分析(JDK1.8):java.lang.ClassLoader.class loadClass(String name): 根據類的全侷限定名稱,由類載入器檢索,載入,並返回java.lang.Class物件。   根據原始碼,我們發現流程如下:
  • 當載入器收到載入類的請求時,首先會根據該類的全侷限定名查目標類是否已經被載入,如果載入則萬事大吉;
  • 如果沒有載入,檢視是否有父級載入器,如果有則將載入類的請求委託給父級載入器;
  • 依次遞迴;
  • 直到啟動類載入器,如果在已載入的類中依舊找不到該類,則由啟動類載入器開始嘗試從所負責的目錄下尋找目標類,如果找到則載入到JVM記憶體中;
  • 如果找不到,則傳輸到子級載入器,從負責的目錄下尋找並載入目標類;
  • 依次遞迴;
  • 直到請求的類載入器依舊找不到,則丟擲java.lang.ClassNotFoundException異常。
如果看文字略感不清晰的話,請對照原始碼上面的流程圖結合來看。 findLoadedClass(String name): 從當前的類載入器的快取中檢索是否已經載入目標類。findLoadedClass0(name)其實是底層的native方法(C編寫)。   findBootstrapClassOrNull(String name): 從啟動類載入器快取中檢索目標類是否已載入;如果沒有載入,則在負責的目錄下($JAVA_HOME/jre/lib/rt.jar)所尋該類檔案(.class)並嘗試載入到記憶體中,並返回java.lang.Class物件,如果沒有找到則返回null。findBootstrapClass(String name)其實是底層的natvie方法。 findClass(String name): 從載入器負責的目錄下,根據類的全侷限定名查詢類檔案(.class),並返回一個java.lang.Class物件。根據原始碼我們可以發現在ClassLoader這個類中,findClass沒有任何的邏輯,直接丟擲java.lang.ClassNotFoundException異常,所以,我們使用的類載入器都需要重寫該方法。 defineClass(String name, byte[] b, int off, int len): 當找到.class檔案後獲取到對應的二進位制位元組流(byte[]),defineClass函式將位元組流轉換為JVM可以理解的java.lang.Class物件。需要注意的是,該方法的入參是二進位制的位元組流,這不一定是.class檔案形成的,也可能是通過網路等傳輸過來的。 resolveClass(Class<?> c): 該方法可以使載入完類時,同時完成連結中的解析步驟,使用的是native方法。如果這裡不解析,則在初始化之後再解析,稱為晚期繫結。 上述的原始碼讓我們可以很清晰的理解雙親委派的具體流程。 但是在ClassLoader.class中並沒有findClass(String name)方法的具體實現,僅僅是丟擲java.lang.ClassNotFoundException異常,需要實體類進行重寫,這裡以jave.netURLClassLoader.class實體類為例,分析原始碼是如何實現類的搜尋與載入。 原始碼分析(JDK1.8): java.net.URLClassLoader.class 流程分析:根據類的全侷限定名(例如:com.kaiwu.CustomClassLoader),轉換為對應的相對儲存路徑(com/kaiwu/CustomClassLoader.class),相應的載入器在對應的目錄下尋找目標.class檔案(這裡是應用/系統載入器,所以該檔案的具體路徑為$CLASSPATH/com/kaiwu/CustomClassLoader.class),利用ucp(sum.misc.URLClassPath)物件獲取該檔案的資源,並將目標資源轉換為系統可讀的二進位制位元組流(byte[]),通過defineClass()函式將位元組流轉換為JVM可讀的java.lang.Class物件,並返回。   案例分析: 請求載入自定義類com.kaiwu3.CustomClassLoader 請求載入擴充套件類com.sum.javafx.binding.Logging   除錯分析: 根據類的全侷限定名(例如:com.kaiwu3.CustomClassLoader)轉化為儲存目錄(com/kaiwu/CustomClassLoade.class),在應用/系統類載入器負責的目錄下($CLASSPATH)找到目標.class檔案。   將目標檔案轉化為java.lang.Class物件(Class@800),並利用應用/系統類載入器(Laucher$AppClassLoader@512)載入目標物件到記憶體中,父級載入器為擴充套件類載入器(Laucher$ExtClassLoader@346)。   根據類的全侷限定名(例如:com.sum.javafx.binding.Logging)轉化為儲存目錄(com/sum/javafx/binding/Logging.class),在擴充套件類類載入器負責的目錄下($JAVA_HOME/jre/lib/ext/jfxrt.jar/)找到目標.class檔案。   將目標檔案轉化為java.lang.Class物件(Class@793),並利用擴充套件類載入器(Launcher$ExtClassLoader@346)載入目標物件到記憶體中,父級類載入器為啟動載入器(null)。     總體而言,JVM的類載入機制並非想象中那麼複雜,若靜下心來,仔細琢磨一二,亦感其中妙趣。 以上為個人解讀與理解,如有不明之處,望各位大佬不吝賜教。
作者:吳家二少 部落格地址:https://www.cnblogs.com/cloudman-open/ 本文歡迎轉載,但未經作者同意必須保留此段宣告,且在文章頁面明顯位置給出原文連線