一文教你讀懂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. 類加載器的類圖關系

 
通過上文的分析,目前常用的三種類加載器分別為:啟動類加載器,擴展類加載器以及應用/系統加載器。但是查看源碼的類圖關系,可以發現 AppClassLoderExtClassLoader都是 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): 根據 $CLASSPATHExtClassLoader實例對象創建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的類加載機制並非想象中那么復雜,若靜下心來,仔細琢磨一二,亦感其中妙趣。
以上為個人解讀與理解,如有不明之處,望各位大佬不吝賜教。

作者:吳家二少
本文歡迎轉載,但未經作者同意必須保留此段聲明,且在文章頁面明顯位置給出原文連接 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM