摘要:本文圍繞 Java 字節碼與類加載機制展開,詳解字節碼文件組成、類的生命周期,介紹類加載器分類、雙親委派機制及打破該機制的方式,還闡述了線程上下文類加載器與 SPI 機制在 JDBC 驅動加載中的應用,幫助深入理解 Java 類加載核心原理。
1.?Java 字節碼文件與類加載機制
1.1 Java 虛擬機的組成
Java 虛擬機主要分為以下幾個組成部分:
類加載子系統:核心組件是類加載器,負責將字節碼文件中的內容加載到內存中。
運行時數據區:JVM 管理的內存,創建出來的對象、類的信息等內容都會放在這塊區域中。
執行引擎:包含即時編譯器、解釋器、垃圾回收器。執行引擎使用解釋器將字節碼指令解釋成機器碼,使用即時編譯器優化性能,使用垃圾回收器回收不再使用的對象。
本地接口:調用本地使用 C/C++ 編譯好的方法,本地方法在 Java 中聲明時,都會帶上
native
關鍵字。
1.2 字節碼文件的組成
1.2.1 以正確的姿勢打開文件
字節碼文件中保存了源代碼編譯之后的內容,以二進制的方式存儲,無法直接用記事本打開閱讀。通過 NotePad++ 使用十六進制插件查看 class 文件:
無法解讀出文件里包含的內容,推薦使用 jclasslib 工具查看字節碼文件。
1.2.2 字節碼文件的組成
字節碼文件總共可以分為以下幾個部分:
基礎信息:魔數、字節碼文件對應的 Java 版本號、訪問標識、父類和接口信息
常量池:保存了字符串常量、類或接口名、字段名,主要在字節碼指令中使用
字段:當前類或接口聲明的字段信息
方法:當前類或接口聲明的方法信息,核心內容為方法的字節碼指令
屬性:類的屬性,比如源碼的文件名、內部類的列表等
1.2.2.1 基本信息
基本信息包含了 jclasslib 中能看到的 “一般信息” 相關內容,具體如下:
Magic 魔數
每個 Java 字節碼文件的前四個字節是固定的,用 16 進制表示為0xcafebabe
。文件無法通過擴展名確定類型(擴展名可隨意修改),軟件會通過文件頭(前幾個字節)校驗類型,不支持則報錯。
常見文件格式的校驗方式如下:
文件類型 | 字節數 | 文件頭 |
---|---|---|
JPEG (jpg) | 3 | FFD8FF |
PNG (png) | 4 | 89504E47(文件尾也有要求) |
bmp | 2 | 424D |
XML (xml) | 5 | 3C3F786D6C |
AVI (avi) | 4 | 41564920 |
Java 字節碼文件 (.class) | 4 | CAFEBABE |
Java 字節碼文件的文件頭稱為 magic 魔數,Java 虛擬機會校驗字節碼文件前四個字節是否為0xcafebabe
,若不是則無法正常使用,會拋出錯誤。
主副版本號
主副版本號指編譯字節碼文件時使用的 JDK 版本號:
主版本號:標識大版本號,JDK1.0-1.1 使用 45.0-45.3,JDK1.2 為 46,之后每升級一個大版本加 1;1.2 之后大版本號計算方法為 "主版本號 – 44",例如主版本號 52 對應 JDK8。
副版本號:主版本號相同時,用于區分不同版本,一般只需關注主版本號。
版本號的作用是判斷當前字節碼版本與運行時 JDK 是否兼容。若用較低版本 JDK 運行較高版本 JDK 編譯的字節碼文件,會顯示錯誤:
類文件具有錯誤的版本 52.0,應為 50.0,請刪除該文件或確保該文件位于正確的類路徑子目錄中。
解決兼容性問題的兩種方案:
其他基礎信息
其他基礎信息包括訪問標識、類和接口索引,具體說明如下:
名稱 | 作用 |
---|---|
訪問標識 | 標識是類 / 接口 / 注解 / 枚舉 / 模塊;標識 public、final、abstract 等訪問權限 |
類、父類、接口索引 | 通過這些索引可找到類、父類、接口的詳細信息 |
1.2.2.2 常量池
字節碼文件中常量池的作用是避免相同內容重復定義,節省空間。例如,代碼中編寫兩個相同的字符串 “我愛北京天安門”,字節碼文件及后續內存使用時只需保存一份,將該字符串及字面量放入常量池即可實現空間節省。
常量池中的數據都有編號(從 1 開始),例如 “我愛北京天安門” 在常量池中的編號為 7,字段或字節碼指令中通過編號 7 可快速找到該字符串。字節碼指令中通過編號引用常量池的過程稱為符號引用,示例如下:
字節碼指令:
ldc #7
(符號引用編號 7 對應的字符串)常量池:編號 7 對應數據 “我愛北京天安門”
為什么需要符號引用?
編譯期(如?
javac
?編譯?.java
?為?.class
)根本不知道:
- 被引用的類 / 方法在運行時會被加載到內存的哪個位置(內存地址由 JVM 動態分配);
- 同一資源在不同 JVM、不同操作系統中的內存地址可能完全不同。
符號引用通過 “延遲綁定” 解決這個問題:編譯期只記錄 “要引用什么”,等到運行期類加載的 “解析階段”,JVM 再根據符號引用的信息,在內存中找到對應的資源,將其轉換為 “直接引用”(即內存地址)。
1.2.2.3 字段
字段中存放當前類或接口聲明的字段信息,包含字段的名字,描述符(字段類型:int,long),訪問標識(修飾符:public、static、final 等)
1.2.2.4 方法
字節碼中的方法區域是存放字節碼指令的核心位置,字節碼指令的內容存放在方法的 Code 屬性中。例如,分析以下代碼的字節碼指令:
要理解字節碼指令執行過程,需先了解操作數棧和局部變量表:
操作數棧:存放臨時數據的棧式結構,先進后出
局部變量表:存放方法的局部變量(含參數、方法內定義的變量)
1. iconst_0
:將常量 0 放入操作數棧,此時棧中只有 0。
2. istore_1
:從操作數棧彈出棧頂元素(0),放入局部變量表 1 號位置(編譯期確定為局部變量 i 的位置),完成 i 的賦值。
3. iload_1
:將局部變量表 1 號位置的數據(0)放入操作數棧,此時棧中為 0。
4. iconst_1
:將常量 1 放入操作數棧,此時棧中有 0 和 1。
5. iadd
:將操作數棧頂部兩個數據(0 和 1)相加,結果 1 放入操作數棧,此時棧中只有 1。
6. istore_2
:從操作數棧彈出 1,放入局部變量表 2 號位置(局部變量 j 的位置)。
7. return
:方法結束并返回。
同理,可分析i++
和++i
的字節碼指令差異:
i++ 字節碼指令:iinc 1 by 1
指將局部變量表 1 號位置值加 1,實現 i++ 操作。
++i 字節碼指令:僅調整了iinc
和iload_1
的順序。
面試題:
int i = 0; i = i++;
最終 i 的值是多少?答:答案是 0。通過字節碼指令分析:i++ 先將 0 取出放入臨時操作數棧,接著對 i 加 1(i 變為 1),最后將操作數棧中保存的臨時值 0 放入 i,最終 i 為 0。
1.2.2.5 屬性
屬性主要指類的屬性,如源碼的文件名、內部類的列表等。例如,在 jclasslib 中查看 SimpleClass 的屬性,會顯示 SourceFile 屬性:
1.2.3 玩轉字節碼常用工具
1.2.3.1 javap
javap 是 JDK 自帶的反編譯工具,可通過控制臺查看字節碼文件內容,適合在服務器上使用。
查看所有參數:直接輸入
javap
。查看具體字節碼信息:輸入
javap -v 字節碼文件名稱
。若為 jar 包:需先使用
jar –xvf jar包名稱
命令解壓,再查看內部 class 文件。
?
1.2.3.2 jclasslib 插件
jclasslib 有 Idea 插件版本,開發時使用可在代碼編譯后實時查看字節碼文件內容。
1. 打開 Idea 的插件頁面,搜索 “jclasslib Bytecode Viewer” 并安裝。
2. 選中要查看的源代碼文件,選擇 “視圖(View)- Show Bytecode With Jclasslib”,右側會展示對應字節碼文件內容。
3. 文件修改后需重新編譯,再點擊刷新按鈕查看最新字節碼。
1.2.3.3 Arthas
Arthas 是一款線上監控診斷產品,可實時查看應用 load、內存、gc、線程狀態信息,且能在不修改代碼的情況下診斷業務問題,提升線上問題排查效率。
安裝方法
1. 將下載好的 arthas-boot.jar 文件復制到任意工作目錄。
2. 使用java -jar arthas-boot.jar
啟動程序。
3. 輸入需要 Arthas 監控的進程 ID(啟動后會列出當前運行的 Java 進程)。
???
常用命令
dump:將字節碼文件保存到本地。
示例:將java.lang.String
的字節碼文件保存到/tmp/output
目錄:
jad:將類的字節碼文件反編譯成源代碼,用于確認服務器上的字節碼是否為最新。
示例:反編譯demo.MathGame
并顯示源代碼
1.3 類的生命周期
類的生命周期描述了一個類加載、使用、卸載的整個過程,整體分為:
加載(Loading)
連接(Linking):包含驗證、準備、解析三個子階段
初始化(Initialization)
使用(Using)
卸載(Unloading)
類加載本身是一個過程,這個過程又細分為多個階段,包含加載,連接和初始化階段
1.3.1 加載階段
1. 加載階段第一步:類加載器根據類的全限定名,通過不同渠道以二進制流的方式獲取字節碼信息,程序員可通過 Java 代碼拓展渠道,常見渠道如下:
2. 類加載器加載完類后,Java 虛擬機會將字節碼中的信息保存到方法區,生成一個InstanceKlass
對象,該對象保存類的所有信息(含實現多態的虛方法表等)。
3. Java 虛擬機同時會在堆上生成與方法區中數據類似的java.lang.Class
對象,作用是在 Java 代碼中獲取類的信息,以及存儲靜態字段的數據(JDK8 及之后)。
步驟 1:類的 “來源獲取”
類的字節碼可以從多種來源被加載,如圖 1 所示:
- 本地文件:最常見的情況,類的
.class
文件存儲在本地磁盤(如項目的classes
目錄、jar
包中),類加載器從本地文件系統讀取這些字節碼文件。- 網絡傳輸:在分布式應用(如 Applet、遠程服務調用)中,類的字節碼可通過網絡(如 HTTP、RPC)從遠程服務器傳輸到本地 JVM。
- 動態代理生成:運行時通過字節碼生成庫(如 JDK 動態代理、CGLIB)動態生成類的字節碼,無需預先存在物理文件。
步驟 2:類加載器(
ClassLoader
)的 “加載動作”類加載器(如圖 1 右側的
ClassLoader
)是加載階段的核心執行者,它的工作是:
- 根據類的 “全限定名”(如
java.lang.String
),找到對應的字節碼數據。? ? ? ?JVM 不僅要加載我們自己寫的應用類,還必須加載像
java.lang.String
這樣的核心類
- 將字節碼數據以二進制流的形式讀取到 JVM 中。
步驟 3:生成
InstanceKlass
對象(方法區存儲類元數據)如圖 2 所示,JVM 在方法區生成一個
InstanceKlass
對象:
InstanceKlass
是 JVM 內部用于表示類的核心數據結構,包含類的全部元數據:
- 基本信息:類的訪問修飾符(public、final 等)、類名、父類、接口等。
- 常量池:存儲類中用到的常量(如字符串、符號引用等)。
- 字段(Field):類中定義的成員變量信息。
- 方法:類中定義的方法信息(包括方法名、參數、返回值、字節碼指令等)。
- 虛方法表:支持多態的關鍵結構,存儲方法的動態調用入口。
步驟 4:生成
java.lang.Class
對象(堆中供開發者訪問)如圖 3、圖 4 所示:
- JVM 在堆區生成一個
java.lang.Class
對象,這個對象是開發者(Java 代碼)能直接訪問的 “類的鏡像”。Class
對象與方法區的InstanceKlass
對象關聯:Class
對象中保存了訪問InstanceKlass
的 “入口”,但屏蔽了底層復雜的元數據細節。步驟 5:開發者與
Class
對象的交互(訪問控制)如圖 5 所示:
- 開發者無需直接操作方法區的
InstanceKlass
(包含 JVM 內部實現的敏感 / 復雜信息)。- 開發者只需通過堆中的
Class
對象,就能獲取類的公開可訪問信息(如通過Class.getMethods()
獲取方法、Class.getFields()
獲取字段等)。【反射】- 這種設計既讓開發者能便捷地反射(Reflection)操作類,又由 JVM 控制了訪問范圍(避免開發者直接篡改方法區的核心元數據)。
1.3.2 連接階段
連接階段分為三個子階段:
驗證(Verification)
驗證的主要目的是檢測 Java 字節碼文件是否遵守《Java 虛擬機規范》的約束,無需程序員參與,主要包含四部分(具體詳見《Java 虛擬機規范》):
文件格式驗證:如文件是否以
0xCAFEBABE
開頭,主次版本號是否滿足要求。??
元信息驗證:例如類必須有父類(super 不能為空)。
語義驗證:驗證程序執行指令的語義,如方法內指令跳轉至不正確的位置。
符號引用驗證:例如是否訪問了其他類中 private 的方法。
JDK8 源碼中對版本號的驗證邏輯如下:
編譯文件主版本號不高于運行環境主版本號;若相等,副版本號不超過運行環境副版本號。
準備(Preparation)
準備階段為靜態變量(static)分配內存并設置初值。
不同數據類型的初值如下:
解析(Resolution)
解析階段主要是將常量池中的符號引用替換成指向內存的直接引用:
符號引用:字節碼文件中使用編號訪問常量池中的內容。
直接引用:使用內存地址訪問具體數據,無需依賴編號。
1.3.3 初始化階段
初始化階段會執行字節碼文件中clinit
(class init,類的初始化)方法的字節碼指令,包含靜態代碼塊中的代碼,并為靜態變量賦值。
1. iconst_1
:將常量 1 放入操作數棧。
2. putstatic #2
:彈出操作數棧中的 1,放入堆中靜態變量value
的位置(#2
指向常量池中的value
,解析階段已替換為變量地址),此時value=1
。
3. iconst_2
:將常量 2 放入操作數棧。
4. putstatic #2
:彈出 2,更新value
為 2。
5. return
:clinit
方法執行結束,最終value=2
。
觸發類初始化的場景
clinit 不執行的情況
無靜態代碼塊且無靜態變量賦值語句。
有靜態變量的聲明,但沒有賦值語句(如
public static int a;
)。靜態變量的定義使用 final 關鍵字(這類變量在準備階段直接初始化)。
面試題 1
分析步驟:
步驟 1:類加載時執行靜態代碼塊
當 JVM 首次加載
Test1
類時,會執行靜態代碼塊(被static
修飾的代碼塊)。靜態代碼塊在類加載階段執行,且只執行一次(無論創建多少個類的實例,靜態代碼塊都只執行一次)。所以,程序啟動后,JVM 加載
Test1
類,首先執行static
塊中的代碼:此時輸出:D
步驟 2:執行
main
方法中的代碼
main
方法是程序入口,加載完類后,執行main
方法內的代碼:
- 第一行:
System.out.println("A");
?→ 輸出:A
- 第二行:
new Test1();
?→ 創建Test1
的實例,觸發實例初始化。- 第三行:
new Test1();
?→ 再次創建Test1
的實例,再次觸發實例初始化。步驟 3:實例初始化的順序(重點)
每次創建
Test1
實例時,實例初始化的順序是:
- 執行實例初始化塊(類中直接用
{}
包裹的代碼塊);- 執行構造方法。
所以,每次
new Test1()
時,執行順序為:
- 實例初始化塊:
System.out.println("C");
?→ 輸出:C
- 構造方法:
System.out.println("B");
?→ 輸出:B
兩次
new Test1()
的輸出第一次
new Test1()
:
- 實例初始化塊輸出:
C
- 構造方法輸出:
B
第二次
new Test1()
:
- 實例初始化塊再次輸出:
C
- 構造方法再次輸出:
B
最終輸出順序
D
(靜態代碼塊,類加載時執行)→?A
(main
方法第一行)→?C
(第一次實例的初始化塊)→?B
(第一次實例構造方法)→?C
(第二次實例的初始化塊)→?B
(第二次實例構造方法)
面試題 2
分析步驟:
調用
new B02()
創建對象,需初始化 B02,優先初始化父類 A02。執行 A02 的初始化代碼,
a
賦值為 1。執行 B02 的初始化代碼,
a
賦值為 2。輸出
B02.a
,結果為 2。變化:若注釋
new B02();
,僅訪問B02.a
(父類 A02 的靜態變量),則只初始化父類 A02,a=1
,輸出結果為 1。
1.4 類加載器
1.4.1 什么是類加載器
類加載器(ClassLoader)是 Java 虛擬機提供給應用程序,用于實現獲取類和接口字節碼數據的技術。類加載器僅參與加載過程中 “字節碼獲取并加載到內存” 這一部分,具體流程如下:
類加載器通過二進制流獲取字節碼文件內容。
將獲取的數據交給 Java 虛擬機。
虛擬機會在方法區生成
InstanceKlass
對象,在堆上生成java.lang.Class
對象,保存字節碼信息。
1.4.2 類加載器的分類
JDK8 及之前的默認類加載器
JDK8 及之前版本中,默認類加載器有三種,其關系如下:
啟動類加載器(Bootstrap):無父類加載器,加載 Java 最核心的類。
擴展類加載器(Extension):父類加載器為啟動類加載器,允許擴展 Java 中通用的類。
應用程序類加載器(Application):父類加載器為擴展類加載器,加載應用使用的類。
可通過 Arthas 的
classloader
命令查看類加載器信息
1.4.3 啟動類加載器
實現方式:由 Hotspot 虛擬機提供,使用 C++ 編寫。
默認加載路徑:Java 安裝目錄
/jre/lib
下的類文件(如 rt.jar、tools.jar、resources.jar 等)。擴展示例:-Xbootclasspath/a:D:/jvm/jar/classloader-test.jar
說明:
String
類由啟動類加載器加載,但 JDK8 中啟動類加載器用 C++ 編寫,Java 代碼中無法直接獲取,故返回 null。
1.4.4 擴展類加載器和應用程序類加載器
擴展類加載器
擴展類加載器加載用戶 jar 包示例
- 擴展示例:-Djava.ext.dirs="C:\Program Files\Java\jdk1.8.0\_181\jre\lib\ext;D:\jvm\jar"
應用程序類加載器
應用程序類加載器會加載classpath下的類文件,默認加載的是項目中的類以及通過maven引入的第三方jar包中的類。
默認加載路徑:classpath 下的類文件(項目中的類、maven 引入的第三方 jar 包中的類)。
說明:項目類和第三方依賴類均由應用程序類加載器加載。
可通過 Arthas 的classloader -c 類加載器hash值?
查看加載路徑
1.5 雙親委派機制
雙親委派機制指:當一個類加載器接收到加載類的任務時,會自底向上查找是否已加載,再由頂向下嘗試加載。
類加載器的父子關系
詳細流程
1. 類加載器接收到加載任務后,先檢查自身是否已加載該類,若已加載則直接返回。
2. 若未加載,將任務委派給父類加載器,父類加載器重復步驟 1-2。
3. 若父類加載器(直至啟動類加載器)均未加載,且啟動類加載器無法加載(類不在其加載路徑),則由擴展類加載器嘗試加載。
4. 若擴展類加載器也無法加載,由應用程序類加載器嘗試加載。
案例分析
案例 1:類在啟動類加載器路徑中
假設com.itheima.my.A
在啟動類加載器加載目錄(如/jre/lib
),應用程序類加載器接收到加載任務:
1. 應用程序類加載器未加載過A
,委派給父類(擴展類加載器)。
2. 擴展類加載器未加載過A
,委派給父類(啟動類加載器)。
3. 啟動類加載器已加載過A
,直接返回。
案例 2:類在擴展類加載器路徑中
假設com.itheima.my.B
在擴展類加載器加載目錄(如/jre/lib/ext
),應用程序類加載器接收到加載任務:
1. 應用程序類加載器未加載過B
,委派給擴展類加載器。
2. 擴展類加載器未加載過B
,委派給啟動類加載器。
3. 啟動類加載器未加載過B
,且B
不在其加載路徑,委派給擴展類加載器。
4. 擴展類加載器加載B
成功,返回。
補充問題:
雙親委派機制的作用
保證類加載安全性:避免惡意代碼替換 JDK 核心類庫(如
java.lang.String
),確保核心類庫完整性和安全性。避免重復加載:同一類不會被多個類加載器重復加載。
如何指定類加載器加載類
在 Java 中可通過兩種方式主動加載類:
1.使用Class.forName
方法:使用當前類的類加載器加載指定類,示例:
Class<?> clazz = Class.forName("com.itheima.my.A");
2.獲取類加載器,調用loadClass
方法:指定類加載器加載,示例:
// 獲取應用程序類加載器
?
ClassLoader classLoader = Demo1.class.getClassLoader();
?
// 使用應用程序類加載器加載com.itheima.my.A
?
Class<?> clazz = classLoader.loadClass("com.itheima.my.A");
Class.forName():
java.lang.Class
類的靜態方法,加載指定全類名的類時會主動執行類的初始化(如靜態代碼塊、靜態變量初始化),常用于反射或需觸發類初始化的場景。loadClass():
java.lang.ClassLoader
類的實例方法,僅將類加載到 JVM 但默認不進行初始化,主要用于類加載器自定義實現與類加載控制。- 區別:二者均可能拋出
ClassNotFoundException
,核心區別在于是否主動初始化類及調用主體、適用場景不同。
面試題
問:若一個類重復出現在三個類加載器的加載位置,由誰加載?
答:啟動類加載器加載,雙親委派機制中啟動類加載器優先級最高。
問:String 類能覆蓋嗎?在項目中創建java.lang.String
類,會被加載嗎?
答:不能。啟動類加載器會優先加載rt.jar
中的java.lang.String
類,項目中的String
類不會被加載。
問:類的雙親委派機制是什么?
答:當類加載器加載類時,自底向上查找是否已加載,若均未加載則由頂向下嘗試加載。應用程序類加載器父類是擴展類加載器,擴展類加載器父類是啟動類加載器。好處是保證核心類庫安全、避免重復加載。
1.6 打破雙親委派機制
打破雙親委派機制歷史上有三種方式,本質上僅第一種真正打破:
自定義類加載器并重寫
loadClass
方法(如 Tomcat 實現應用間類隔離)。線程上下文類加載器(如 JDBC、JNDI 使用)。
Osgi 框架的類加載器(歷史方案,目前很少使用)。
自定義類加載器
背景
原理
ClassLoader
核心方法
1. public Class<?> loadClass(String name)
:類加載入口,實現雙親委派機制,內部調用findClass
。
2. protected Class<?> findClass(String name)
:子類實現,獲取二進制數據并調用defineClass
。
3. protected final Class<?> defineClass(String name, byte[] b, int off, int len)
:校驗類名,調用虛擬機底層方法將字節碼加載到內存。
4. protected final void resolveClass(Class<?> c)
:執行類生命周期的連接階段。
1. 入口方法:
2. 再進入看下:
如果查找都失敗,進入加載階段,首先會由啟動類加載器加載,這段代碼在findBootstrapClassOrNull
中。如果失敗會拋出異常,父類加載器加載失敗就會拋出異常,回到子類加載器的這段代碼,這樣就實現了加載并向下傳遞。
3. 最后根據傳入的參數判斷是否進入連接階段:
自定義類加載器實現
重新實現下面的核心代碼(loadclass)就可以打破雙親委派機制
package classloader.broken;//package com.itheima.jvm.chapter02.classloader.broken;import org.apache.commons.io.IOUtils;import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.security.ProtectionDomain;
import java.util.regex.Matcher;/*** 打破雙親委派機制 - 自定義類加載器*/public class BreakClassLoader1 extends ClassLoader {private String basePath;private final static String FILE_EXT = ".class";//設置加載目錄public void setBasePath(String basePath) {this.basePath = basePath;}//使用commons io 從指定目錄下加載文件private byte[] loadClassData(String name) {try {String tempName = name.replaceAll("\\.", Matcher.quoteReplacement(File.separator));FileInputStream fis = new FileInputStream(basePath + tempName + FILE_EXT);try {return IOUtils.toByteArray(fis);} finally {IOUtils.closeQuietly(fis);}} catch (Exception e) {System.out.println("自定義類加載器加載失敗,錯誤原因:" + e.getMessage());return null;}}//重寫loadClass方法@Overridepublic Class<?> loadClass(String name) throws ClassNotFoundException {//如果是java包下,還是走雙親委派機制if(name.startsWith("java.")){return super.loadClass(name);}//從磁盤中指定目錄下加載byte[] data = loadClassData(name);//調用虛擬機底層方法,方法區和堆區創建對象return defineClass(name, data, 0, data.length);}public static void main(String[] args) throws ClassNotFoundException, InstantiationException, IllegalAccessException, IOException {//第一個自定義類加載器對象BreakClassLoader1 classLoader1 = new BreakClassLoader1();classLoader1.setBasePath("D:\\lib\\");Class<?> clazz1 = classLoader1.loadClass("com.itheima.my.A");//第二個自定義類加載器對象BreakClassLoader1 classLoader2 = new BreakClassLoader1();classLoader2.setBasePath("D:\\lib\\");Class<?> clazz2 = classLoader2.loadClass("com.itheima.my.A");System.out.println(clazz1 == clazz2);Thread.currentThread().setContextClassLoader(classLoader1);System.out.println(Thread.currentThread().getContextClassLoader());System.in.read();}
}
? ? ? 問題一:為什么這段代碼打破了雙親委派機制?
雙親委派機制的核心是:類加載器在加載類時,會先委托給父類加載器加載,只有父類加載器無法加載時,才自己嘗試加載。
而這段代碼通過重寫?
loadClass()
?方法打破了這一機制:
- 對于非?
java.
?開頭的類(如自定義類?com.itheima.my.A
),代碼直接跳過父類加載器,自己從指定目錄加載類(loadClassData()
?方法讀取字節碼)- 只有?
java.
?開頭的核心類才遵循雙親委派(調用?super.loadClass(name)
?讓父類加載器處理)正常情況下,
loadClass()
?方法的默認實現會先委托父類加載器,而這里重寫后改變了這一流程,因此打破了雙親委派機制。問題二:兩個自定義類加載器加載相同限定名的類,不會沖突嗎?
不會沖突,原因是:
在 JVM 中,一個類的唯一性由「類的全限定名 + 加載它的類加載器」共同決定。即:
- 即使兩個類的全限定名完全相同,只要由不同的類加載器加載,JVM 會認為它們是兩個不同的類
- 代碼中?
classLoader1
?和?classLoader2
?是兩個不同的實例(不同的類加載器對象),因此它們加載的?com.itheima.my.A
?會被視為兩個不同的類- 這也是為什么代碼中?
clazz1 == clazz2
?的輸出結果為?false
這種特性保證了即使類名相同,只要加載器不同,就不會產生沖突,這也是 Java 類加載機制的重要設計。
關鍵說明
自定義類加載器的父類:默認情況下,自定義類加載器的父類加載器是應用程序類加載器(
AppClassLoader
),因ClassLoader
構造方法中parent
由getSystemClassLoader()
(返回AppClassLoader
)設置。
線程上下文類加載器
背景
雙親委派機制核心:類加載器在加載類時,優先委托父類加載器去加載。只有當父類加載器無法加載(比如父類加載器的搜索路徑里沒有該類),當前類加載器才會嘗試自己加載。
原理
SPI?是 “約定好的配置方式”,讓核心庫能找到第三方實現的類名。
線程上下文類加載器?是 “工具”,讓核心庫(由父加載器加載)能突破雙親委派,用子加載器(應用程序類加載器)去加載第三方庫的類。
SPI 機制
SPI 機制通過在 jar 包META-INF/services
目錄下放置接口名文件(如java.sql.Driver
),文件中寫入實現類全限定名(如com.mysql.cj.jdbc.Driver
),從而找到接口實現類。
JDBC 加載驅動流程
啟動類加載器加載
DriverManager
。DriverManager
初始化時,調用LoadInitialDrivers
方法,通過 SPI 機制加載META-INF/services/java.sql.Driver
中的實現類。SPI 機制使用線程上下文類加載器(應用程序類加載器)加載 MySQL 驅動類(
com.mysql.cj.jdbc.Driver
)。驅動類初始化時,調用
DriverManager.registerDriver(new Driver())
,完成注冊。
JDBC案例中真的打破了雙親委派機制嗎?
最早這個論點提出是在周志明《深入理解Java虛擬機》中,他認為打破了雙親委派機制,這種由啟動類加載器加載的類,委派應用程序類加載器去加載類的方式,所以打破了雙親委派機制。
但是如果我們分別從DriverManager以及驅動類的加載流程上分析,JDBC只是在DriverManager加載完之后,通過初始化階段觸發了驅動類的加載,類的加載依然遵循雙親委派機制。
所以我認為這里沒有打破雙親委派機制,只是用一種巧妙的方法讓啟動類加載器加載的類,去引發的其他類的加載。
Osgi 框架的類加載器
Osgi 是模塊化框架,實現了同級類加載器委托加載,還支持熱部署(服務不停止時動態更新字節碼)。但目前使用較少,此處不展開。
熱部署案例:Arthas 不停機修復線上問題
注意事項
程序重啟后,字節碼恢復,需將新 class 文件放入 jar 包更新。
retransform
不能添加方法 / 字段,不能更新正在執行的方法。