一文教你讀懂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實例方法 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實例方法 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實例方法 16 return //返回
II. 類載入器
1. 類載入器ClassLoader
java.lang.ClassLoader本身是一個抽象類,它的實例用來載入Java類。這裡如果細心的小夥伴就會發現,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類實例化出來的對象卻是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的類載入機制並非想像中那麼複雜,若靜下心來,仔細琢磨一二,亦感其中妙趣。
以上為個人解讀與理解,如有不明之處,望各位大佬不吝賜教。