面試官:說說類加載的幾個階段
一、摘要
我們知道 Java 是先通過編譯器將.java類文件轉成.class字節碼文件,然后再通過虛擬機將.class字節碼文件加載到內存中來實現應用程序的運行。
那么虛擬機是什么時候加載class文件?如何加載class文件?class文件進入到虛擬機后發生了哪些變化?
今天我們就一起來了解一下,虛擬機是如何加載類文件的。
二、類加載的時機
經常有面試官問,“類什么時候加載”和“類什么時候初始化”,從內容上來說,似乎都在問同一個問題:class文件是什么時候被虛擬機加載到內存中,并進入可以使用的狀態?
從虛擬機角度來說,加載和初始化是類的加載過程中的兩個階段。
對于“什么時候加載”,Java 虛擬機規范中并沒有約束,每個虛擬機實例都可以按自身需要來自由實現。但基本上都遵循類在進行初始化之前,需要先進行加載class文件。
對于“什么時候初始化”,Java 虛擬機規范有明確的規定,當符合以下條件時(包括但不限),并且虛擬機在內存中沒有找到對應的類信息,必須對類進行“初始化”操作:
- 使用new實例化對象時,讀取或者設置一個類的靜態字段或方法時
- 反射調用時,例如Class.forName("com.xxx.Test")
- 初始化一個類的子類,會首先初始化子類的父類
- Java 虛擬機啟動時標明的啟動類,比如main方法所在的類
- JDK8 之后,接口中存在default方法,這個接口的實現類初始化時,接口會在它之前進行初始化
類在初始化開始之前,需要先經歷加載、驗證、準備、解析這四個階段的操作。
下面我們一起來看看類的加載過程。
三、類的加載過程
當一個類需要被加載到虛擬機中執行時,虛擬機會通過類加載器,將其.class文件中的字節碼信息在內存中轉化成一個具體的java.lang.Class對象,以便被調用執行。
類從被加載到虛擬機內存中開始,到卸載出內存,整個生命周期包括七個階段:加載、驗證、準備、解析、初始化、使用和卸載,可以用如下圖來簡要概括。
圖片
其中類加載的過程,可以用三個步驟(五個階段)來簡要描述:加載 -> 連接(驗證、準備、解析)-> 初始化。(驗證、準備、解析這3個階段統稱為連接)
其次加載、驗證、準備和初始化這四個階段發生的順序是確定的,必須按照這種順序按部就班的開始,而解析階段則不一定。在某些情況下解析階段可以在初始化階段之后開始,這是為了支持 Java 語言的運行時綁定,也稱為動態綁定或晚期綁定。
同時,這五個階段并不是嚴格意義上的按順序完成,在類加載的過程中,這些階段會互相混合,可能有些階段完成了,有些階段沒有完成,會交叉運行,最終完成類的加載和初始化。
接下來依此分解一下加載、驗證、準備、解析、初始化這五個步驟,這五個步驟組成了一個完整的類加載過程。使用沒什么好說的,卸載通常屬于 GC 的工作,當一個類沒有被任何地方引用并且類加載器已被 GC 回收,GC 會將當前類進行卸載,在后續的文章我們會介紹 GC 的工作機制。
3.1、加載
加載是類加載的過程的第一個階段,這個階段的主要工作是查找并加載類的二進制數據,在虛擬機中,類的加載有兩種觸發方式:
- 預先加載:指的是虛擬機啟動時加載,例如JAVA_HOME/lib/下的rt.jar下的.class文件,這個jar包里面包含了程序運行時常用的文件內容,例如java.lang.*、java.util.*、java.io.*等等,因此會隨著虛擬機啟動時一起加載到內存中。要證明這一點很簡單,自己可以寫一個空的main函數,設置虛擬機參數為-XX:+TraceClassLoading,運行程序就可以獲取類加載的全部信息
- 運行時加載:虛擬機在用到一個.class文件的時候,會先去內存中查看一下這個.class文件有沒有被加載,如果沒有,就會按照類的全限定名來加載這個類;如果有,就不會加載。
無論是哪種觸發方式,虛擬機在加載.class文件時,都會做以下三件事情:
- 1.通過類的全限定名定位.class文件,并獲取其二進制字節流
- 2.將類信息、靜態變量、字節碼、常量這些.class文件中的內容放入運行時數據區的方法區中
- 3.在內存中生成一個代表這個.class文件的java.lang.Class對象,作為方法區這個類的各種數據的訪問入口,一般這個java.lang.Class對象會存在 Java 堆中
虛擬機規范對這三點的要求并不具體,因此具體虛擬機實現的靈活度都很大。比如第一條,沒有指明二進制字節流要從哪里來,單單就這一條,就能變出許多花樣來,比如下面幾種加載方式:
- 從 zip、jar、ear、war 等歸檔文件中加載.class文件
- 通過網絡下載并加載.class文件,典型應用就是 Applet
- 將Java源文件動態編譯為.class文件,典型應用就是動態代理技術
- 從數據庫中提取.class文件并進行加載
總的來說,加載階段(準確地說,是加載階段獲取類的二進制字節流的動作)對于開發者來說是可控性最強的一個階段。因為開發者既可以使用系統提供的類加載器來完成加載,也可以自定義類加載器來完成加載。
3.2、驗證
驗證是連接階段的第一步,這一階段的目的是為了確保.class文件的字節流中包含的信息符合當前虛擬機的要求,并且不會危害虛擬機自身的安全。
Java 語言本身是比較安全的語言,但是正如上面說到的.class文件未必是從 Java 源碼編譯而來,可以使用任何途徑來生成并加載。虛擬機如果不檢查輸入的字節流,對其完全信任的話,很可能會因為載入了有害的字節流而導致系統崩潰,所以驗證是虛擬機對自身保護的一項重要工作。
驗證階段大致會完成 4 項檢驗工作:
- 文件格式驗證:驗證字節流是否符合Class文件格式的規范,例如:是否以0xCAFEBABE開頭、主次版本號是否在當前虛擬機的處理范圍之內、常量池中的常量是否有不被支持的類型等
- 元數據驗證:對字節碼描述的元數據信息進行語義分析,要符合 Java 語言規范,例如:是否繼承了不允許被繼承的類(例如 final 修飾過的)、類中的字段、方法是否和父類產生矛盾等等
- 字節碼驗證:對類的方法體進行校驗分析,確保這些方法在運行時是合法的、符合邏輯的
- 符號引用驗證:確保解析動作能正確執行,例如:確保符號引用的全限定名能找到對應的類,符號引用中的類、字段、方法允許被當前類所訪問等等
驗證階段是非常重要的,但不是必須的,它對程序運行期沒有影響,如果所引用的類經過反復驗證,那么可以考慮采用-Xverify:none參數來關閉大部分的類驗證措施,以縮短虛擬機類加載的時間。
3.3、準備
準備是連接階段的第二步,這個階段的主要工作是正式為類變量分配內存并設置其初始值的階段,這些變量所使用的內存都將在方法區中分配。
不過這個階段,有幾個知識點需要注意一下:
- 1.這時候進行內存分配的僅僅是類變量(被static修飾的變量),而不是實例變量,實例變量將會在對象實例化的時候隨著對象一起分配在 Java 堆中
- 2.這個階段會設置變量的初始值,值為數據類型默認的零值(如 0、0L、null、false 等),不是在代碼中被顯式地賦予的值;但是當字段被final修飾時,這個初始值就是代碼中顯式地賦予的值
- 3.在 JDK1.8 取消永久代后,方法區變成了一個邏輯上的區域,這些類變量的內存實際上是分配在 Java 堆中的,跟 JDK1.7 及以前的版本稍有不同
關于第二個知識點,我們舉個簡單的例子進行講解,比如public static int value = 123,value在準備階段過后是0而不是123。
因為這時候尚未開始執行任何 Java 方法,把value賦值為123的public static指令是在程序編譯后存放于類構造器<clinit>()方法之中的,因此把value賦值為123的動作將在初始化階段才會執行。
假如被final修飾,比如public static final int value = 123就不一樣了,編譯時Javac將會為value生成ConstantValue屬性,在準備階段,虛擬機就會給value賦值為123,因為這個變量無法被修改,會存入類的常量池中。
各個數據類型的零值如下圖:
數據類型 | 零值 |
byte | 0 |
short | 0 |
int | 0 |
long | 0L |
float | 0.0f |
double | 0.0d |
boolean | false |
char | \u0000 |
reference | null |
3.4、解析
解析是連接階段的第三步,這個階段的主要工作是虛擬機會把這個.class文件中常量池內的符號引用轉換為直接引用。
主要解析的是類或接口、字段、方法等符號引用,我們可以把解析階段中符號引用轉換為直接引用的過程,理解為當前加載的這個類和它所引用的類,正式進行“連接“的過程。
我們先來了解一下符號引用和直接引用有什么區別:
- 符號引用:這個其實是屬于編譯原理方面的概念,Java 代碼在編譯期間,是不知道最終引用的類型,具體指向內存中哪個位置的,這時候會使用一個符號引用來表示具體引用的目標是"誰",符號引用和虛擬機的內存布局是沒有關系的
- 直接引用:指的是可以直接或間接指向目標內存位置的指針或句柄,直接引用和虛擬機實現的內存布局是有關系的
符號引用轉換為直接引用,可以理解成將某個符號與虛擬機中的內存位置建立連接,通過指針或句柄來直接訪問目標。
與此同時,同一個符號引用在不同的虛擬機實現上翻譯出來的直接引用一般不會相同。
3.5、初始化
初始化是類加載的過程的最后一步,這個階段的主要工作是執行類構造器 <clinit>()方法的過程。
簡單的說,初始化階段做的事就是給static變量賦予用戶指定的值,同時類中如果存在static代碼塊,也會執行這個靜態代碼塊里面的代碼。
初始化階段,虛擬機大致依此會進行如下幾個步驟的操作:
- 1.檢查這個類是否被加載和連接,如果沒有,則程序先加載并連接該類
- 2.檢查該類的直接父類有沒有被初始化,如果沒有,則先初始化其直接父類
- 3.類中如果有多個初始化語句,比如多個static代碼塊,則依次執行這些初始化語句
有個地方需要注意的是:虛擬機會保證類的初始化在多線程環境中被正確地加鎖、同步執行,所以無需擔心是否會出現變量初始化時線程不安全的問題。
如果多個線程同時去初始化一個類,那么只會有一個線程去執行這個類的<clinit>()方法,其他線程都會阻塞等待,直到<clinit>()方法執行完畢。同時,同一個類加載器下,一個類只會初始化一次,如果檢查到當前類沒有初始化,執行初始化;反之,不會執行初始化。
與此同時,只有當對類的主動使用的時候才會觸發類的初始化,觸發時機主要有以下幾種場景:
- 1.創建類的實例對象,比如new一個對象操作
- 2.訪問某個類或接口的靜態變量,或者對該靜態變量賦值
- 3.調用類的靜態方法
- 4.反射操作,比如Class.forName("xxx")
- 5.初始化某個類的子類,則其父類也會被初始化,并且父類具有優先被初始化的優勢
- 6.Java 虛擬機啟動時被標明為啟動類的類,比如SpringBootApplication入口類
最后,<clinit>()方法和<init>()方法是不同的,一個是類構造器初始化,一個是實例構造器初始化,千萬別搞混淆了啊。
3.6、小結
當一個符合 Java 虛擬機規范的.class字節碼文件,經歷加載、驗證、準備、解析、初始化這些 5 個階段相互協作執行完成之后,虛擬機會將此文件的二進制數據導入運行時數據區的方法區內,然后在堆內存中,創建一個java.lang.Class類的對象,這個對象描述了這個類所有的信息,同時提供了這個類在方法區的訪問入口。
可以用如下圖來簡要描述。
圖片
與此同時,在方法區中,使用同一加載器的情況下,每個類只會有一份Class字節流信息;在堆內存中,使用同一加載器的情況下,每個類也只會有一份java.lang.Class類的對象。
四、類加載器
在上文類的加載過程中,我們有提到在加載階段,通過一個類的全限定名來獲取此類的二進制字節流操作,其實類加載器就是用來實現這個操作的。
在虛擬機中,任何一個類,都需要由加載它的類加載器和這個類本身一同確立其唯一性,每一個類加載器,都擁有一個獨立的類名稱空間,對于類也同樣如此。
簡單的說,在虛擬機中看兩個類是否相同,只有在這兩個類是由同一個類加載器加載的前提下才有意義,否則即使這兩個類來源于同一個.class文件,被同一個虛擬機加載,但是它們的類加載器不同,這兩個類必定不相等。
當年為了滿足瀏覽器上 Java Applet 的需求,Java 的開發團隊設計了類加載器,它獨立于 Java 虛擬機外部,同時也允許用戶按自身需要自行實現類加載器。通過類加載器,可以讓同一個類可以實現訪問隔離、OSGi、程序熱部署等等場景。發展至今,類加載器已經是 Java 技術體系的一塊重要基石。
4.1、類加載器介紹
如果要查找類加載器,通過Thread.currentThread().getContextClassLoader()方法可以獲取。
簡單示例如下:
public class ClassLoaderTest {
public static void main(String[] args) {
ClassLoader loader = Thread.currentThread().getContextClassLoader();
System.out.println("current loader:" + loader);
System.out.println("parent loader:" + loader.getParent());
System.out.println("parent parent loader:" + loader.getParent().getParent());
}
}
輸出結果如下:
current loader:sun.misc.Launcher$AppClassLoader@18b4aac2
parent loader:sun.misc.Launcher$ExtClassLoader@511d50c0
parent parent loader:null
從運行結果可以看到,當前的類加載器是AppClassLoader,它的上一級是ExtClassLoader,再上一級是null。
其實ExtClassLoader的上一級是有類加載器的,它叫Bootstrap ClassLoader,是一個啟動類加載器,由 C++ 實現,不是 ClassLoader 子類,因此以 null 作為結果返回。
這幾種類加載器的層次關系,可以用如下圖來描述。
圖片
它們之間的啟動流程,可以通過以下內容來簡單描述:
- 1.在虛擬機啟動后,會優先初始化Bootstrap Classloader
- 2.接著Bootstrap Classloader負責加載ExtClassLoader,并且將 ExtClassLoader的父加載器設置為Bootstrap Classloader
- 3Bootstrap Classloader加載完ExtClassLoader后,就會加載AppClassLoader,并且將AppClassLoader的父加載器指定為 ExtClassLoader
因此,在加載 Java 應用程序中的class文件時,這里的父類加載器并不是通過繼承關系來實現的,而是互相配合進行加載。
站在虛擬機的角度,只存在兩種不同的類加載器:
- 啟動類加載器:它由 C++ 實現(這里僅限于 Hotspot,不同的虛擬機可能實現不太一樣),是虛擬機自身的一部分
- 其它類加載器:這些類加載器都由 Java 實現,獨立于虛擬機之外,并且全部繼承自抽象類java.lang.ClassLoader,比如ExtClassLoader、AppClassLoader等,這些類加載器需要由啟動類加載器加載到內存中之后才能去加載其他的類
站在開發者的角度,類加載器大致可以劃分為三類:
- 啟動類加載器:比如Bootstrap ClassLoader,負責加載<JAVA_HOME>\lib目錄,或者被-Xbootclasspath參數制定的路徑,例如jre/lib/rt.jar里所有的class文件。同時,啟動類加載器是無法被 Java 程序直接引用的
- 拓展類加載器:比如Extension ClassLoader,負責加載 Java 平臺中擴展功能的一些 jar 包,包括<JAVA_HOME>\lib\ext目錄中或java.ext.dirs指定目錄下的 jar 包。同時,開發者可以直接使用擴展類加載器
- 應用程序類加載器:比如Application ClassLoader,負責加載ClassPath路徑下所有 jar 包,如果應用程序中沒有自定義過自己的類加載器,一般情況下它就是程序中默認的類加載器
當然,如果有必要,也可以自定義類加載器,因為 JVM 自帶的 ClassLoader 只懂得從本地文件系統中加載標準的class文件,如果要從特定的場所取得class文件,例如數據庫中和網絡中,此時可以自己編寫對應的 ClassLoader 類加載器。
4.2、雙親委派模型
在上文中我們提到,在虛擬機中,任何一個類由加載它的類加載器和這個類一同來確立其唯一性。
也就是說,JVM 對類的唯一標識,可以簡單的理解為由ClassLoader id + PackageName + ClassName組成,因此在一個運行程序中有可能存在兩個包名和類名完全一致的類,但是如果這兩個類不是由一個 ClassLoader 加載,會被視為兩個不同的類,此時就無法將一個類的實例強轉為另外一個類,這就是類加載器的隔離性。
為了解決類加載器的隔離問題,JVM 引入了雙親委派模型。
雙親委派模式,可以用一句話來說表達:任何一個類加載器在接到一個類的加載請求時,都會先讓其父類進行加載,只有父類無法加載(或者沒有父類)的情況下,才嘗試自己加載。
大致流程圖如下:
圖片
使用雙親委派模式,可以保證,每一個類只會有一個類加載器。例如 Java 最基礎的 Object 類,它存放在 rt.jar 之中,這是 Bootstrap 的職責范圍,當向上委派到 Bootstrap 時就會被加載。
但如果沒有使用雙親委派模式,可以任由自定義加載器進行加載的話,Java 這些核心類的 API 就會被隨意篡改,無法做到一致性加載效果。
JDK 中ClassLoader.loadClass()類加載器中的加載類的方法,部分核心源碼如下:
public Class<?> loadClass(String name) throws ClassNotFoundException {
return loadClass(name, false);
}
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
// 1.首先要保證線程安全
synchronized (getClassLoadingLock(name)) {
// 2.先判斷這個類是否被加載過,如果加載過,直接跳過
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
// 3.有父類,優先交給父類嘗試加載;如果為空,使用BootstrapClassLoader類加載器
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 父類加載失敗,這里捕獲異常,但不需要做任何處理
}
// 4.沒有父類,或者父類無法加載,嘗試自己加載
if (c == null) {
long t1 = System.nanoTime();
c = findClass(name);
// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
4.3、自定義類加載器
在上文中我們提及過,針對某些特定場景,比如通過網絡來傳輸 Java 類的字節碼文件,為保證安全性,這些字節碼經過了加密處理,這時系統提供的類加載器就無法對其進行加載,此時我們可以自定義一個類加載器來完成文件的加載。
自定義類加載器也需要繼承ClassLoader類,簡單示例如下:
public class CustomClassLoader extends ClassLoader {
private String classPath;
public CustomClassLoader(String classPath) {
this.classPath = classPath;
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
Class<?> c = findLoadedClass(name);
if (c == null) {
byte[] data = loadClassData(name);
if (data == null) {
throw new ClassNotFoundException();
}
return defineClass(name, data, 0, data.length);
}
return null;
}
protected byte[] loadClassData(String name) {
try {
// package -> file folder
name = name.replace(".", "http://");
FileInputStream fis = new FileInputStream(new File(classPath + "http://" + name + ".class"));
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int len = -1;
byte[] b = new byte[2048];
while ((len = fis.read(b)) != -1) {
baos.write(b, 0, len);
}
fis.close();
return baos.toByteArray();
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
}
相關的測試類如下:
package com.example;
public class ClassLoaderTest {
public static void main(String[] args) {
ClassLoader loader = Thread.currentThread().getContextClassLoader();
System.out.println("current loader:" + loader);
}
}
將ClassLoaderTest.java源文件放在指定目錄下,并通過javac命令編譯成ClassLoaderTest.class,最后進行測試。
public class CustomClassLoaderTest {
public static void main(String[] args) throws Exception {
String classPath = "/Downloads";
CustomClassLoader customClassLoader = new CustomClassLoader(classPath);
Class<?> testClass = customClassLoader.loadClass("com.example.ClassLoaderTest");
Object obj = testClass.newInstance();
System.out.println(obj.getClass().getClassLoader());
}
}
輸出結果:
com.example.CustomClassLoader@60e53b93
在實際使用過程中,最好不要重寫loadClass方法,避免破壞雙親委派模型。
4.4、加載類的幾種方式
在類加載器中,有三種方式可以實現類的加載。
- 1.通過命令行啟動應用時由 JVM 初始化加載,在上文已提及過
- 2.通過Class.forName()方法動態加載
- 3.通過ClassLoader.loadClass()方法動態加載
其中Class.forName()和ClassLoader.loadClass()加載方法,稍有區別:
- Class.forName():表示將類的.class文件加載到 JVM 中之后,還會對類進行解釋,執行類中的static方法塊;
- Class.forName(name, initialize, loader):支持通過參數來控制是否執行類中的static方法塊;
- ClassLoader.loadClass():它只將類的.class文件加載到 JVM,但是不執行類中的static方法塊,只有在newInstance()才會去執行static方法塊;
我們可以看一個簡單的例子!
public class ClassTest {
static {
System.out.println("初始化靜態代碼塊!");
}
}
public class CustomClassLoaderTest {
public static void main(String[] args) throws Exception {
// 獲取當前系統類加載器
ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
// 1.使用Class.forName()來加載類,默認會執行初始化靜態代碼塊
Class.forName(ClassTest.class.getName());
// 2.使用Class.forName()來加載類,指定false,不會執行初始化靜態代碼塊
// Class.forName(ClassTest.class.getName(), false, classLoader);
// 3.使用ClassLoader.loadClass()來加載類,不會執行初始化靜態代碼塊
// classLoader.loadClass(ClassTest.class.getName());
}
}
運行結果如下:
初始化靜態代碼塊!
切換不同的加載方式,會有不同的輸出結果!
4.5、小結
從以上的介紹中,針對類加載器的機制,我們可以總結出以下幾點:
- 全盤負責:當一個類加載器負責加載某個Class文件時,該Class所依賴的和引用的其他Class也將由該類加載器負責載入,除非顯示使用另外一個類加載器來加載
- 雙親委派:在接受類加載請求時,會讓父類加載器試圖加載該類,只有在父類加載器無法加載該類或者沒有父類時,才嘗試從自己的類路徑中加載該類
- 按需加載:用戶創建的類,通常加載是按需進行的,只有使用了才會被類加載器加載
- 緩存機制:有被加載過的Class文件都會被緩存,當要使用某個Class時,會先去緩存查找,如果緩存中沒有才會讀取Class文件進行加載。這就是為什么修改了Class文件后,必須重啟 JVM,程序的修改才會生效的原因
五、小結
本文從類的加載過程到類加載器,做了一次知識內容講解,內容比較多,如果有描述不對的地方,歡迎大家留言指出,不勝感激!
六、參考
1.https://zhuanlan.zhihu.com/p/25228545
2.http://www.ityouknow.com/jvm/2017/08/19/class-loading-principle.html
3.https://www.cnblogs.com/xrq730/p/4844915.html
4.https://www.cnblogs.com/xrq730/p/4845144.html