深入理解Java類載入
本文目的:
- 深入理解Java類載入機制;
- 理解各個類載入器特別是執行緒上下文載入器;
Java虛擬機器類載入機制
虛擬機器把描述類的資料從 Class 檔案載入到記憶體,並對資料進行校驗、轉換解析和初始化,最終形成可以被虛擬機器直接使用的 Java 型別,這就是虛擬機器的類載入機制。
在Java語言裡面,型別的載入、連線和初始化過程都是在程式執行期間完成的
類載入的過程
類的個生命週期如下圖:
為支援執行時繫結,解析過程在某些情況下可在初始化之後再開始,除解析過程外的其他載入過程必須按照如圖順序開始。
載入
- 通過全限定類名來獲取定義此類的二進位制位元組流。
- 將這個位元組流所代表的靜態儲存結構轉化為方法區的執行時資料結構。
- 在記憶體中生成一個代表這個類的 java.lang.Class 物件,作為方法區這個類的各種資料的訪問入口。
驗證
驗證是連線階段的第一步,這一階段的目的是為了確保 Class 檔案的位元組流中包含的資訊符合當前虛擬機器的要求,並且不會危害虛擬機器自身的安全。
- 檔案格式驗證:如是否以魔數 0xCAFEBABE 開頭、主、次版本號是否在當前虛擬機器處理範圍之內、常量合理性驗證等。
此階段保證輸入的位元組流能正確地解析並存儲於方法區之內,格式上符合描述一個 Java型別資訊的要求。 - 元資料驗證:是否存在父類,父類的繼承鏈是否正確,抽象類是否實現了其父類或介面之中要求實現的所有方法,欄位、方法是否與父類產生矛盾等。
- 位元組碼驗證:通過資料流和控制流分析,確定程式語義是合法的、符合邏輯的。例如保證跳轉指令不會跳轉到方法體以外的位元組碼指令上。
- 符號引用驗證:在解析階段中發生,保證可以將符號引用轉化為直接引用。
可以考慮使用 -Xverify:none
引數來關閉大部分的類驗證措施,以縮短虛擬機器類載入的時間。
準備
為類變數分配記憶體並設定類變數初始值,這些變數所使用的記憶體都將在方法區中進行分配。
解析
虛擬機器將常量池內的符號引用替換為直接引用的過程。
解析動作主要針對類或介面、欄位、類方法、介面方法、方法型別、方法控制代碼和呼叫點限定符 7 類符號引用進行。
初始化
到初始化階段,才真正開始執行類中定義的 Java 程式程式碼,此階段是執行 <clinit>()
方法的過程。
<clinit>()
方法是由編譯器按語句在原始檔中出現的順序,依次自動收集類中的所有類變數的賦值動作和靜態程式碼塊中的語句合併產生的。(不包括構造器中的語句。構造器是初始化物件的,類載入完成後,建立物件時候將呼叫的 <init>()
方法來初始化物件)
靜態語句塊中只能訪問到定義在靜態語句塊之前的變數,定義在它之後的變數,在前面的靜態語句塊可以賦值,但是不能訪問,如下程式:
public class Test {
static {
// 給變數賦值可以正常編譯通過
i = 0;
// 這句編譯器會提示"非法向前引用"
System.out.println(i);
}
static int i = 1;
}
<clinit>()
不需要顯式呼叫父類構造器,虛擬機器會保證在子類的 <clinit>()
方法執行之前,父類的 <clinit>()
方法已經執行完畢,也就意味著父類中定義的靜態語句塊要優先於子類的變數賦值操作。
<clinit>()
方法對於類或介面來說並不是必需的,如果一個類中沒有靜態語句塊,也沒有對變數的賦值操作,那麼編譯器可以不為這個類生成 <clinit>()
方法。
虛擬機器會保證一個類的 <clinit>()
方法在多執行緒環境中被正確地加鎖、同步,如果多個執行緒同時去初始化一個類,那麼只會有一個執行緒去執行這個類的 <clinit>()
方法,其他執行緒都需要阻塞等待,直到活動執行緒執行 <clinit>()
方法完畢。
類載入的時機
對於初始化階段,虛擬機器規範規定了有且只有 5 種情況必須立即對類進行“初始化”(而載入、驗證、準備自然需要在此之前開始):
- 遇到new、getstatic 和 putstatic 或 invokestatic 這4條位元組碼指令時,如果類沒有進行過初始化,則需要先觸發其初始化。對應場景是:使用 new 例項化物件、讀取或設定一個類的靜態欄位(被 final 修飾、已在編譯期把結果放入常量池的靜態欄位除外)、以及呼叫一個類的靜態方法。
- 對類進行反射呼叫的時候,如果類沒有進行過初始化,則需要先觸發其初始化。
- 當初始化類的父類還沒有進行過初始化,則需要先觸發其父類的初始化。(而一個介面在初始化時,並不要求其父介面全部都完成了初始化)
- 虛擬機器啟動時,使用者需要指定一個要執行的主類(包含 main() 方法的那個類),
虛擬機器會先初始化這個主類。
- 當使用 JDK 1.7 的動態語言支援時,如果一個 java.lang.invoke.MethodHandle 例項最後的解析結果 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法控制代碼,並且這個方法控制代碼所對應的類沒有進行過初始化,則需要先觸發其初始化。
第5種情況,我暫時看不懂。
以上這 5 種場景中的行為稱為對一個類進行主動引用。除此之外,所有引用類的方式都不會觸發初始化,稱為被動引用,例如:
- 通過子類引用父類的靜態欄位,不會導致子類初始化。
- 通過陣列定義來引用類,不會觸發此類的初始化。
MyClass[] cs = new MyClass[10];
- 常量在編譯階段會存入呼叫類的常量池中,本質上並沒有直接引用到定義常量的類,因此不會觸發定義常量的類的初始化。
類載入器
把實現類載入階段中的“通過一個類的全限定名來獲取描述此類的二進位制位元組流”這個動作的程式碼模組稱為“類載入器”。
將 class 檔案二進位制資料放入方法區內,然後在堆內(heap)建立一個 java.lang.Class 物件,Class 物件封裝了類在方法區內的資料結構,並且向開發者提供了訪問方法區內的資料結構的介面。
目前類載入器卻在類層次劃分、OSGi、熱部署、程式碼加密等領域非常重要,我們執行任何一個 Java 程式都會涉及到類載入器。
類的唯一性和類載入器
對於任意一個類,都需要由載入它的類載入器和這個類本身一同確立其在Java虛擬機器中的唯一性。
即使兩個類來源於同一個 Class 檔案,被同一個虛擬機器載入,只要載入它們的類載入器不同,那這兩個類也不相等。
這裡所指的“相等”,包括代表類的 Class 物件的 equals() 方法、 isAssignableFrom() 方法、isInstance() 方法的返回結果,也包括使用 instanceof 關鍵字做物件所屬關係判定等情況。
雙親委派模型
如果一個類載入器收到了類載入的請求,它首先不會自己去嘗試載入這個類,而是把這個請求委派給父類載入器去完成,每一個層次的類載入器都是如此,因此所有的載入請求最終都應該傳送到頂層的啟動類載入器中,只有當父載入器反饋自己無法完成這個載入請求(它的搜尋範圍中沒有找到所需的類)時,子載入器才會嘗試自己去載入。
這裡類載入器之間的父子關係一般不會以繼承(Inheritance)的關係來實現,而是都使用組合(Composition)關係來複用父載入器的程式碼。
Bootstrap 類載入器是用 C++ 實現的,是虛擬機器自身的一部分,如果獲取它的物件,將會返回 null;擴充套件類載入器和應用類載入器是獨立於虛擬機器外部,為 Java 語言實現的,均繼承自抽象類 java.lang.ClassLoader ,開發者可直接使用這兩個類載入器。
Application 類載入器物件可以由 ClassLoader.getSystemClassLoader()
方法的返回,所以一般也稱它為系統類載入器。它負責載入使用者類路徑(ClassPath)上所指定的類庫,如果應用程式中沒有自定義過自己的類載入器,一般情況下這個就是程式中預設的類載入器。
雙親委派模型對於保證 Java 程式的穩定運作很重要,例如類 java.lang.Object
,它存放在 rt.jar 之中,無論哪一個類載入器要載入這個類,最終都是委派給處於模型最頂端的啟動類載入器進行載入,因此 Object 類在程式的各種類載入器環境中都是同一個類。
雙親委派模型的載入類邏輯可參考如下程式碼:
// 程式碼摘自《深入理解Java虛擬機器》
protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
// 首先,檢查請求的類是否已經被載入過了
Class c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 如果父類載入器丟擲ClassNotFoundException
// 說明父類載入器無法完成載入請求
}
if (c == null) {
// 在父類載入器無法載入的時候
// 再呼叫本身的findClass方法來進行類載入
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
破壞雙親委派模型
雙親委派模型主要出現過 3 較大規模的“被破壞”情況。
1) 雙親委派模型在引入之前已經存在破壞它的程式碼存在了。
雙親委派模型在 JDK 1.2 之後才被引入,而類載入器和抽象類 java.lang.ClassLoader
則在 JDK 1.0 時代就已經存在,JDK 1.2之後,其添加了一個新的 protected 方法 findClass()
,在此之前,使用者去繼承 ClassLoader 類的唯一目的就是為了重寫 loadClass()
方法,而雙親委派的具體邏輯就實現在這個方法之中,JDK 1.2 之後已不提倡使用者再去覆蓋 loadClass()
方法,而應當把自己的類載入邏輯寫到 findClass()
方法中,這樣就可以保證新寫出來的類載入器是符合雙親委派規則的。
2) 基礎類無法呼叫類載入器載入使用者提供的程式碼。
雙親委派很好地解決了各個類載入器的基礎類的統一問題(越基礎的類由越上層的載入器進行載入),但如果基礎類又要呼叫使用者的程式碼,例如 JNDI 服務,JNDI 現在已經是 Java 的標準服務,它的程式碼由啟動類載入器去載入(在 JDK 1.3 時放進去的 rt.jar ),但 JNDI 的目的就是對資源進行集中管理和查詢,它需要呼叫由獨立廠商實現並部署在應用程式的 ClassPath 下的 JNDI 介面提供者(SPI,Service Provider Interface,例如 JDBC 驅動就是由 MySQL 等介面提供者提供的)的程式碼,但啟動類載入器只能載入基礎類,無法載入使用者類。
為此 Java 引入了執行緒上下文類載入器(Thread Context ClassLoader)。這個類載入器可以通過
java.lang.Thread.setContextClassLoaser()
方法進行設定,如果建立執行緒時還未設定,它將會從父執行緒中繼承一個,如果在應用程式的全域性範圍內都沒有設定過的話,那這個類載入器預設就是應用程式類載入器。
如此,JNDI 服務使用這個執行緒上下文類載入器去載入所需要的 SPI 程式碼,也就是父類載入器請求子類載入器去完成類載入的動作,這種行為實際上就是打通了雙親委派模型的層次結構來逆向使用類載入器,實際上已經違背了雙親委派模型的一般性原則,但這也是無可奈何的事情。Java 中所有涉及 SPI 的載入動作基本上都採用這種方式,例如 JNDI、JDBC、JCE、JAXB 和 JBI 等。
3) 使用者對程式動態性的追求。
程式碼熱替換(HotSwap)、模組熱部署(Hot Deployment)等,OSGi 實現模組化熱部署的關鍵則是它自定義的類載入器機制的實現。每一個程式模組(Bundle)都有一個自己的類載入器,當需要更換一個 Bundle 時,就把 Bundle 連同類載入器一起換掉以實現程式碼的熱替換。
在 OSGi 環境下,類載入器不再是雙親委派模型中的樹狀結構,而是進一步發展為更加複雜的網狀結構,當收到類載入請求時,OSGi 將按照下面的順序進行類搜尋:
1)將以 java.* 開頭的類委派給父類載入器載入。
2)否則,將委派列表名單內的類委派給父類載入器載入。
3)否則,將 Import 列表中的類委派給 Export 這個類的 Bundle 的類載入器載入。
4)否則,查詢當前 Bundle 的 ClassPath,使用自己的類載入器載入。
5)否則,查詢類是否在自己的 Fragment Bundle 中,如果在,則委派給 Fragment Bundle 的類載入器載入。
6)否則,查詢 Dynamic Import 列表的 Bundle,委派給對應 Bundle 的類載入器載入。
7)否則,類查詢失敗。
上面的查詢順序中只有開頭兩點仍然符合雙親委派規則,其餘的類查詢都是在平級的類載入器中進行的。OSGi 的 Bundle 類載入器之間只有規則,沒有固定的委派關係。
自定義類載入器
Java 預設 ClassLoader,只加載指定目錄下的 class,如果需要動態載入類到記憶體,例如要從遠端網路下來類的二進位制,然後呼叫這個類中的方法實現我的業務邏輯,如此,就需要自定義 ClassLoader。
自定義類載入器分為兩步:
- 繼承 java.lang.ClassLoader
- 重寫父類的 findClass() 方法
針對第 1 步,為什麼要繼承 ClassLoader 這個抽象類,而不繼承 AppClassLoader 呢?
因為它和 ExtClassLoader 都是 Launcher 的靜態內部類,其訪問許可權是預設的包訪問許可權。
static class AppClassLoader extends URLClassLoader{...}
第 2 步,JDK 的 loadCalss()
方法在所有父類載入器無法載入的時候,會呼叫本身的 findClass()
方法來進行類載入,因此我們只需重寫 findClass()
方法找到類的二進位制資料即可。
下面我自定義了一個簡單的類載入器,並載入一個簡單的類。
首先是需要被載入的簡單類:
// 存放於D盤根目錄
public class Test {
public static void main(String[] args) {
System.out.println("Test類已成功載入執行!");
ClassLoader classLoader = Test.class.getClassLoader();
System.out.println("載入我的classLoader:" + classLoader);
System.out.println("classLoader.parent:" + classLoader.getParent());
}
}
並使用 javac -encoding utf8 Test.java
編譯成 Test.class 檔案。
類載入器程式碼如下:
import java.io.*;
public class MyClassLoader extends ClassLoader {
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
// 載入D盤根目錄下指定類名的class
String clzDir = "D:\\" + File.separatorChar
+ name.replace('.', File.separatorChar) + ".class";
byte[] classData = getClassData(clzDir);
if (classData == null) {
throw new ClassNotFoundException();
} else {
return defineClass(name, classData, 0, classData.length);
}
}
private byte[] getClassData(String path) {
try (InputStream ins = new FileInputStream(path);
ByteArrayOutputStream baos = new ByteArrayOutputStream()
) {
int bufferSize = 4096;
byte[] buffer = new byte[bufferSize];
int bytesNumRead = 0;
while ((bytesNumRead = ins.read(buffer)) != -1) {
baos.write(buffer, 0, bytesNumRead);
}
return baos.toByteArray();
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
}
使用類載入器載入呼叫 Test 類:
public class MyClassLoaderTest {
public static void main(String[] args) throws Exception {
// 指定類載入器載入呼叫
MyClassLoader classLoader = new MyClassLoader();
classLoader.loadClass("Test").getMethod("test").invoke(null);
}
}
輸出資訊:
Test.test()已成功載入執行!
載入我的classLoader:class MyClassLoader
classLoader.parent:class sun.misc.Launcher$AppClassLoader
執行緒上下文類載入器
如上所說,為解決基礎類無法呼叫類載入器載入使用者提供程式碼的問題,Java 引入了執行緒上下文類載入器(Thread Context ClassLoader)。這個類載入器預設就是 Application 類載入器,並且可以通過 java.lang.Thread.setContextClassLoaser()
方法進行設定。
// Now create the class loader to use to launch the application
try {
loader = AppClassLoader.getAppClassLoader(extcl);
} catch (IOException e) {
throw new InternalError(
"Could not create application class loader" );
}
// Also set the context class loader for the primordial thread.
Thread.currentThread().setContextClassLoader(loader);
那麼問題來了,我們使用 ClassLoader.getSystemClassLoader()
方法也可以獲取到 Application 類載入器,使用它就可以載入使用者類了呀,為什麼還需要執行緒上下文類載入器?
其實直接使用 getSystemClassLoader()
方法獲取 AppClassLoader 載入類也可以滿足一些情況,但有時候我們需要使用自定義類載入器去載入某個位置的類時,例如Tomcat 使用的執行緒上下文類載入器並非 AppClassLoader ,而是 Tomcat 自定義類載入器。
以 Tomcat 為例,其每個 Web 應用都有一個對應的類載入器例項,該類載入器使用代理模式,首先嚐試去載入某個類,如果找不到再代理給父類載入器這與一般類載入器的順序是相反的。
這是 Java Servlet 規範中的推薦做法,其目的是使得 Web 應用自己的類的優先順序高於 Web 容器提供的類。
更多關於 Tomcat 類載入器的知識,這裡暫時先不講了。
new一個物件過程中發生了什麼?
- 確認類元資訊是否存在。當 JVM 接收到 new 指令時,首先在 metaspace 內檢查需要建立的類元資訊是否存在。 若不存在,那麼在雙親委派模式下,使用當前類載入器以 ClassLoader + 包名+類名為 Key 進行查詢對應的 class 檔案。 如果沒有找到檔案,則丟擲 ClassNotFoundException 異常 , 如果找到,則進行類載入(載入 - 驗證 - 準備 - 解析 - 初始化),並生成對應的 Class 類物件。
- 分配物件記憶體。 首先計算物件佔用空間大小,如果例項成員變數是引用變數,僅分配引用變數空間即可,即 4 個位元組大小,接著在堆中劃分—塊記憶體給新物件。 在分配記憶體空間時,需要進行同步操作,比如採用 CAS (Compare And Swap) 失敗重試、 區域加鎖等方式保證分配操作的原子性。
- 設定預設值。 成員變數值都需要設定為預設值, 即各種不同形式的零值。
- 設定物件頭。設定新物件的雜湊碼、 GC 資訊、鎖資訊、物件所屬的類元資訊等。這個過程的具體設定方式取決於 JVM 實現。
- 執行 init 方法。 初始化成員變數,執行例項化程式碼塊,呼叫類的構造方法,並把堆內物件的首地址賦值給引用變數。
最後,推薦與感謝:
深入理解Java虛擬機器(第2版)
碼出高效:Java開發手冊
java new一個物件的過程中發生了什麼 - 天風的文章 - 知乎
深入探討類載入器
Class.forName()用法詳解
真正理解執行緒上下文類載入器(多案例分析