- 「MoreThanJava」 宣揚的是 「學習,不止 CODE」。
- 如果覺得 「不錯」 的朋友,歡迎 「關注 + 留言 + 分享」,文末有完整的獲取連結,您的支援是我前進的最大的動力!
前言
ClassLoader 可以說是 Java 最為神祕的功能之一了,好像大家都知道怎麼回事兒 (雙親委派模型好像都都能說得出來...),又都說不清楚具體是怎麼一回事 (為什麼需要需要有什麼實際用途就很模糊了...)。
今天,我們就來深度扒一扒,揭開它神祕的面紗!
Part 1. 類載入是做什麼的?
首先,我們知道,Java 為了實現 「一次編譯,到處執行」 的目標,採用了一種特別的方案:先 編譯 為 與任何具體及其環境及作業系統環境無關的中間程式碼(也就是 .class
位元組碼檔案),然後交由各個平臺特定的 Java 直譯器(也就是 JVM)來負責 解釋 執行。
ClassLoader (顧名思義就是類載入器) 就是那個把位元組碼交給 JVM 的搬運工 (載入進記憶體)。它負責將 位元組碼形式 的 Class 轉換成 JVM 中 記憶體形式 的 Class 物件。
位元組碼可以是來自於磁碟上的 .class
檔案,也可以是 jar
包裡的 *.class
,甚至是來自遠端伺服器提供的位元組流。位元組碼的本質其實就是一個有特定複雜格式的位元組陣列 byte[]
。 (從後面解析 ClassLoader 類中的方法時更能體會)
另外,類載入器不光可以把 Class 載入到 JVM 之中並解析成 JVM 統一要求的物件格式,還有一個重要的作用就是 審查每個類應該由誰載入。
而且,這些 Java 類不會一次全部載入到記憶體,而是在應用程式需要時載入,這也是需要類載入器的地方。
Part 2. ClassLoader 類結構分析
以下就是 ClassLoader 的主要方法了:
-
defineClass()
用於將byte
位元組流解析成 JVM 能夠識別的 Class 物件。有了這個方法意味著我們不僅可以通過.class
檔案例項化物件,還可以通過其他方式例項化物件,例如通過網路接收到一個類的位元組碼。(注意,如果直接呼叫這個方法生成類的 Class 物件,這個類的 Class 物件還沒有
resolve
,JVM 會在這個物件真正例項化時才呼叫resolveClass()
進行連結) -
findClass()
通常和defineClass()
一起使用,我們需要直接覆蓋 ClassLoader 父類的findClass()
方法來實現類的載入規則,從而取得要載入類的位元組碼。(以下是 ClassLoader 原始碼)protected Class<?> findClass(String name) throws ClassNotFoundException { throw new ClassNotFoundException(name); }
如果你不想重新定義載入類的規則,也沒有複雜的處理邏輯,只想在執行時能夠載入自己制定的一個類,那麼你可以用
this.getClass().getClassLoader().loadClass("class")
呼叫 ClassLoader 的loadClass()
方法來獲取這個類的 Class 物件,這個loadClass()
還有過載方法,你同樣可以決定再什麼時候解析這個類。 -
loadClass()
用於接受一個全類名,然後返回一個 Class 型別的物件。(該方法原始碼蘊含了著名的雙親委派模型) -
resolveClass()
用於對 Class 進行 連結,也就是把單一的 Class 加入到有繼承關係的類樹中。如果你想在類被載入到 JVM 中時就被連結(Link),那麼可以在呼叫defineClass()
之後緊接著呼叫一個resolveClass()
方法,當然你也可以選擇讓 JVM 來解決什麼時候才連結這個類(通常是真正被實例項化的時候)。
ClassLoader 是個抽象類,它還有很多子類,如果我們要實現自己的 ClassLoader,一般都會繼承 URLClassLoader 這個子類,因為這個類已經幫我們實現了大部分工作。
例如,我們來看一下 java.net.URLClassLoader.findClass()
方法的實現:
// 入參為 Class 的 binary name,如 java.lang.String
protected Class<?> findClass(final String name) throws ClassNotFoundException {
// 以上程式碼省略
// 通過 binary name 生成包路徑,如 java.lang.String -> java/lang/String.class
String path = name.replace('.', '/').concat(".class");
// 根據包路徑,找到該 Class 的檔案資源
Resource res = ucp.getResource(path, false);
if (res != null) {
try {
// 呼叫 defineClass 生成 java.lang.Class 物件
return defineClass(name, res);
} catch (IOException e) {
throw new ClassNotFoundException(name, e);
}
} else {
return null;
}
// 以下程式碼省略
}
Part 3. Java 類載入流程詳解
以下就是 ClassLoader 載入一個 class 檔案到 JVM 時需要經過的步驟。
事實上,我們每一次在 IDEA 中點選執行時,IDE 都會預設替我們執行以下的命令:
javac Xxxx.java
➡️ 找到原始檔中的public class
,再找public class
引用的其他類,Java 編譯器會根據每一個類生成一個位元組碼檔案;java Xxxx
➡️ 找到檔案中的唯一主類public class
,並根據public static
關鍵字找到跟主類關聯可執行的main
方法 (這也是為什麼main
方法需要被定義為public static void
的原因了——我們需要在類沒有載入時訪問),開始執行。
在真正的執行 main
方法之前,JVM 需要 載入、連結 以及 初始化 上述的 Xxxx 類。
第一步:載入(Loading)
這一步是讀取到類檔案產生的二進位制流(findClass()
),並轉換為特定的資料結構(defineClass()
),初步校驗 cafe babe
魔法數 (二進位制中前四個位元組為 0xCAFEBABE
用來標識該檔案是 Java 檔案,這是很多軟體的做法,比如 zip壓縮檔案
)、常量池、檔案長度、是否有父類等,然後在 Java 堆 中建立對應類的 java.lang.Class
例項,類中儲存的各部分資訊也需要對應放入 執行時資料區 中(例如靜態變數、類資訊等放入方法區)。
以下是一個 Class 檔案具有的基本結構的簡單圖示:
如果對 Class 檔案更多細節感興趣的可以進一步閱讀:https://juejin.im/post/6844904199617003528
這裡我們可能會有一個疑問,為什麼 JVM 允許還沒有進行驗證、準備和解析的類資訊放入方法區呢?
答案是載入階段和連結階段的部分動作(比如一部分位元組碼檔案格式驗證動作)是 交叉進行 的,也就是說 載入階段還沒完成,連結階段可能已經開始了。但這些夾雜在載入階段的動作(驗證檔案格式等)仍然屬於連結操作。
第二步:連結(Linking)
Link 階段包括驗證、準備、解析三個步驟。下面?我們來詳細說說。
驗證:確保被載入的類的正確性
驗證是連線階段的第一步,這一階段的目的是 為了確保 Class 檔案的位元組流中包含的資訊符合當前虛擬機器的要求,並且不會危害虛擬機器自身的安全。驗證階段大致會完成 4
個階段的檢驗動作:
- 檔案格式驗證: 驗證位元組流是否符合 Class 檔案格式的規範;例如:是否以
0xCAFEBABE
開頭、主次版本號是否在當前虛擬機器的處理範圍之內、常量池中的常量是否有不被支援的型別。 - 後設資料驗證: 對位元組碼描述的資訊進行語義分析(注意:對比
javac
編譯階段的語義分析),以保證其描述的資訊符合 Java 語言規範的要求;例如:這個類是否有父類,除了java.lang.Object
之外。 - 位元組碼驗證: 通過資料流和控制流分析,確定程式語義是合法的、符合邏輯的。
- 符號引用驗證: 確保解析動作能正確執行。
驗證階段是非常重要的,但不是必須的,它對程式執行期沒有影響,如果所引用的類經過反覆驗證,那麼可以考慮採用 -Xverifynone
引數來關閉大部分的類驗證措施,以縮短虛擬機器類載入的時間。
準備:為類的靜態變數分配記憶體,並將其初始化為預設值
準備階段是正式為類變數分配記憶體並設定類變數初始值的階段,這些記憶體都將在 方法區 中分配。對於該階段有以下幾點需要注意:
-
1️⃣ 這時候進行記憶體分配的 僅包括類變數(static),而不包括例項變數,例項變數會在物件例項化時隨著物件一塊分配在 Java 堆中。
-
2️⃣ 這裡所設定的 初始值通常情況下是資料型別預設的零值(如
0
、0L
、null
、false
等),而不是被在 Java 程式碼中被顯式地賦予的值。 -
3️⃣ 如果類欄位的欄位屬性表中存在 ConstantValue 屬性,即 同時被
final
和static
修飾,那麼在準備階段變數value
就會被初始化為 ConstValue 屬性所指定的值。
➡️ 例如,假設這裡有一個類變數 public static int value = 666;
,在準備階段時初始值是 0
而不是 666
,在 初始化階段 才會被真正賦值為 666
。
➡️ 假設是一個靜態類變數 public static final int value = 666;
,則再準備階段 JVM 就已經賦值為 666
了。
解析:把類中的符號引用轉換為直接引用(重要)
解析階段是虛擬機器將常量池內的 符號引用 替換為 直接引用 的過程,解析動作主要針對類或介面、欄位、類方法、介面方法、方法型別、方法控制程式碼和呼叫點限定符 7
類符號引用進行。
➡️ 符號引用 的作用是在編譯的過程中,JVM 並不知道引用的具體地址,所以用符號引用進行代替,而在解析階段將會將這個符號引用轉換為真正的記憶體地址。
➡️ 直接引用 可以理解為指向 類、變數、方法 的指標,指向 例項 的指標和一個 間接定位 到物件的物件控制程式碼。
為了理解?上面兩種概念的區別,來看一個實際的例子吧:
public class Tester {
public static void main(String[] args) {
String str = "關注【我沒有三顆心臟】,關注更多精彩";
System.out.println(str);
}
}
我們先在該類同級目錄下執行 javac Tester
編譯成 .class
檔案然後再利用 javap -verbose Tester
檢視類的詳細資訊 (為了節省篇幅只擷取了 main
方法反編譯後的程式碼):
// 上面是類的詳細資訊省略...
{
// .....
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=1
0: ldc #7 // String 關注【我沒有三顆心臟】,關注更多精彩
2: astore_1
3: getstatic #9 // Field java/lang/System.out:Ljava/io/PrintStream;
6: aload_1
7: invokevirtual #15 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
10: return
LineNumberTable:
line 4: 0
line 5: 3
line 6: 10
}
SourceFile: "Tester.java"
可以看到,上面?定義的 str
變數在編譯階段會被解析稱為 符號引用,符號引用的標誌是 astore_<n>
,這裡就是 astore_1
。
store_1
的含義是將運算元棧頂的 關注【我沒有三顆心臟】,關注更多精彩
儲存回索引為 1
的區域性變數表中,此時訪問變數 str
就會讀取區域性變數表索引值為 1
中的資料。所以區域性變數 str
就是一個符號引用。
再來看另外一個例子:
public class Tester {
public static void main(String[] args) {
System.out.println("關注【我沒有三顆心臟】,關注更多精彩");
}
}
這一段程式碼反編譯之後得到如下的程式碼:
// 上面是類的詳細資訊省略...
{
// ......
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #13 // String 關注【我沒有三顆心臟】,關注更多精彩
5: invokevirtual #15 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 4: 0
line 5: 8
}
SourceFile: "Tester.java"
我們可以看到這裡直接使用了 ldc
指令將 關注【我沒有三顆心臟】,關注更多精彩
推送到了棧,緊接著就是呼叫指令 invokevirtual
,並沒有將字串存入區域性變數表中,這裡的字串就是一個 直接引用。
第三步:初始化(Initialization)
初始化,為類的靜態變數賦予正確的初始值,JVM 負責對類進行初始化,主要對類變數進行初始化。在 Java 中對類變數進行初始值設定有兩種方式:
- 1️⃣ 宣告類變數是指定初始值;
- 2️⃣ 使用靜態程式碼塊為類變數指定初始值;
JVM 初始化步驟:
- 1️⃣ 假如這個類還沒有被載入和連線,則程式先載入並連線該類
- 2️⃣ 假如該類的直接父類還沒有被初始化,則先初始化其直接父類
- 3️⃣ 假如類中有初始化語句,則系統依次執行這些初始化語句
類初始化時機:只有當對類的主動使用的時候才會導致類的初始化,類的主動使用包括以下幾種:
- 建立類的例項,也就是
new
的方式 - 訪問某個類或介面的靜態變數,或者對該靜態變數賦值
- 呼叫類的靜態方法
- 反射(如
Class.forName("com.wmyskxz.Tester")
) - 初始化某個類的子類,則其父類也會被初始化
- Java 虛擬機器啟動時被標明為啟動類的類,直接使用
java.exe
命令來執行某個主類 - 使用 JDK 7 新加入的動態語言支援時,如果一個
java.lang.invoke.MethodHanlde
例項最後的解析結果為REF_getstatic
、REF_putstatic
、REF_invokeStatic
、REF_newInvokeSpecial
四種型別的方法控制程式碼時,都需要先初始化該控制程式碼對應的類 - 介面中定義了 JDK 8 新加入的預設方法(
default
修飾符),實現類在初始化之前需要先初始化其介面
Part 4. 深入理解雙親委派模型
我們在上面?已經瞭解了一個類是如何被載入進 JVM 的——依靠類載入器——在 Java 語言中自帶有三個類載入器:
- Bootstrap ClassLoader 最頂層的載入類,主要載入 核心類庫,
%JRE_HOME%\lib
下的rt.jar
、resources.jar
、charsets.jar
和class
等。 - Extention ClassLoader 擴充套件的類載入器,載入目錄
%JRE_HOME%\lib\ext
目錄下的jar
包和class
檔案。 - Appclass Loader 也稱為 SystemAppClass 載入當前應用的
classpath
的所有類。
我們可以通過一個簡單的例子來簡單瞭解 Java 中這些自帶的類載入器:
public class PrintClassLoader {
public static void main(String[] args) {
printClassLoaders();
}
public static void printClassLoaders() {
System.out.println("Classloader of this class:"
+ PrintClassLoader.class.getClassLoader());
System.out.println("Classloader of Logging:"
+ com.sun.javafx.util.Logging.class.getClassLoader());
System.out.println("Classloader of ArrayList:"
+ java.util.ArrayList.class.getClassLoader());
}
}
上方程式列印輸出如下:
Classloader of this class:sun.misc.Launcher$AppClassLoader@18b4aac2
Classloader of Logging:sun.misc.Launcher$ExtClassLoader@60e53b93
Classloader of ArrayList:null
如我們所見,這裡分別對應三種不同型別的類載入器:AppClassLoader、ExtClassLoader 和 BootstrapClassLoader(顯示為 null
)。
一個很好的問題是:Java 類是由 java.lang.ClassLoader
例項載入的,但類載入器本身也是類,那麼誰來載入類載入器呢?
我們假裝不知道,先來跟著原始碼一步一步來看。
先來看看 Java 虛擬機器入口程式碼
在 JDK 原始碼 sun.misc.Launcher
中,蘊含了 Java 虛擬機器的入口方法:
public class Launcher {
private static Launcher launcher = new Launcher();
private static String bootClassPath =
System.getProperty("sun.boot.class.path");
public static Launcher getLauncher() {
return launcher;
}
private ClassLoader loader;
public Launcher() {
// Create the extension class loader
ClassLoader extcl;
try {
extcl = ExtClassLoader.getExtClassLoader();
} catch (IOException e) {
throw new InternalError(
"Could not create extension class loader", e);
}
// 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", e);
}
// 設定 AppClassLoader 為執行緒上下文類載入器,這個文章後面部分講解
Thread.currentThread().setContextClassLoader(loader);
}
/*
* Returns the class loader used to launch the main application.
*/
public ClassLoader getClassLoader() {
return loader;
}
/*
* The class loader used for loading installed extensions.
*/
static class ExtClassLoader extends URLClassLoader {}
/**
* The class loader used for loading from java.class.path.
* runs in a restricted security context.
*/
static class AppClassLoader extends URLClassLoader {}
}
原始碼有精簡,但是我們可以得到以下資訊:
1️⃣ Launcher 初始化了 ExtClassLoader 和 AppClassLoader。
2️⃣ Launcher 沒有看到 Bootstrap ClassLoader 的影子,但是有一個叫做 bootClassPath
的變數,大膽一猜就是 Bootstrap ClassLoader 載入的 jar
包的路徑。
(ps: 可以自己嘗試輸出一下 System.getProperty("sun.boot.class.path")
的內容,它正好對應了 JDK 目錄 lib
和 classes
目錄下的 jar
包——也就是通常你配置環境變數時設定的 %JAVA_HOME/lib
的目錄了——同樣的方式你也可以看看 Ext 和 App 的原始碼)
3️⃣ ExtClassLoader 和 AppClassLoader 都繼承自 URLClassLoader,進一步檢視 ClassLoader 的繼承樹,傳說中的雙親委派模型也並沒有出現。(甚至看不到 Bootstrap ClassLoader 的影子,Ext 也沒有直接繼承自 App 類載入器)
(⚠️注意,這裡可以明確看到每一個 ClassLoader 都有一個 parent
變數,用於標識自己的父類,下面?詳細說)
4️⃣ 注意以下程式碼:
ClassLoader extcl;
extcl = ExtClassLoader.getExtClassLoader();
loader = AppClassLoader.getAppClassLoader(extcl);
分別跟蹤檢視到這兩個 ClassLoader 初始化時的程式碼:
// 一直追蹤到最頂層的 ClassLoader 定義,構造器的第二個引數標識了類載入器的父類
private ClassLoader(Void unused, ClassLoader parent) {
this.parent = parent;
// 程式碼省略.....
}
// Ext 設定自己的父類為 null
public ExtClassLoader(File[] var1) throws IOException {
super(getExtURLs(var1), (ClassLoader)null, Launcher.factory);
SharedSecrets.getJavaNetAccess().getURLClassPath(this).initLookupCache(this);
}
// 手動把 Ext 設定為 App 的 parent(這裡的 var2 是傳進來的 extc1)
AppClassLoader(URL[] var1, ClassLoader var2) {
super(var1, var2, Launcher.factory);
this.ucp.initLookupCache(this);
}
由此,我們得到了這樣一個類載入器的關係圖:
類載入器的父類都來自哪裡?
奇怪,為什麼 ExtClassLoader 的 parent
明明是 null
,我們卻一般地認為 Bootstrap ClassLoader 才是 ExtClassLoader 的父載入器呢?
答案的一部分就藏在 java.lang.ClassLoader.loadClass()
方法裡面:(這也就是著名的「雙親委派模型」現場了)
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// 首先檢查是否已經載入過了
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
// 父載入器不為空則呼叫父載入器的 loadClass 方法
c = parent.loadClass(name, false);
} else {
// 父載入器為空則呼叫 Bootstrap ClassLoader
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
// 父載入器沒有找到,則呼叫 findclass
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()
resolveClass(c);
}
return c;
}
}
程式碼邏輯很好地解釋了雙親委派的原理。
1️⃣ 當前 ClassLoader 首先從 自己已經載入的類中查詢是否此類已經載入,如果已經載入則直接返回原來已經載入的類。(每個類載入器都有自己的載入快取,當一個類被載入了以後就會放入快取,等下次載入的時候就可以直接返回了。)
2️⃣ 當前 ClassLoader 的快取中沒有找到被載入的類的時候,委託父類載入器去載入,父類載入器採用同樣的策略,首先檢視自己的快取,然後委託父類的父類去載入,一直到 Bootstrap ClassLoader。(當所有的父類載入器都沒有載入的時候,再由當前的類載入器載入,並將其放入它自己的快取中,以便下次有載入請求的時候直接返回。)
所以,答案的另一部分是因為最高一層的類載入器 Bootstrap 是通過 C/C++ 實現的,並不存在於 JVM 體系內 (不是一個 Java 類,沒辦法直接表示為 ExtClassLoader 的父載入器),所以輸出為 null
。
(我們可以很輕易跟蹤到 findBootstrapClass()
方法被 native
修飾:private native Class<?> findBootstrapClass(String name);
)
➡️ OK,我們理解了為什麼 ExtClassLoader 的父載入器為什麼是表示為 null
的 Bootstrap 載入器,那我們 自己實現的 ClassLoader 父載入器應該是誰呢?
觀察一下 ClassLoader 的原始碼就知道了:
protected ClassLoader(ClassLoader parent) {
this(checkCreateClassLoader(), parent);
}
protected ClassLoader() {
this(checkCreateClassLoader(), getSystemClassLoader());
}
類載入器的 parent
的賦值是在 ClassLoader 物件的構造方法中,它有兩個情況:
1️⃣ 由外部類建立 ClassLoader 時直接指定一個 ClassLoader 為 parent
;
2️⃣ 由 getSystemClassLoader()
方法生成,也就是在 sun.misc.Laucher
通過 getClassLoader()
獲取,也就是 AppClassLoader。直白的說,一個 ClassLoader 建立時如果沒有指定 parent
,那麼它的 parent
預設就是 AppClassLoader。(建議去看一下原始碼)
為什麼這樣設計呢?
簡單來說,主要是為了 安全性,避免使用者自己編寫的類動態替換 Java 的一些核心類,比如 String,同時也 避免了重複載入,因為 JVM 中區分不同類,不僅僅是根據類名,相同的 class 檔案被不同的 ClassLoader 載入就是不同的兩個類,如果相互轉型的話會拋 java.lang.ClassCaseException
。
如果我們要實現自己的類載入器,不管你是直接實現抽象類 ClassLoader,還是繼承 URLClassLoader 類,或者其他子類,它的父載入器都是 AppClassLoader。
因為不管呼叫哪個父類構造器,建立的物件都必須最終呼叫 getSystemClassLoader()
作為父載入器 (我們已經從上面?的原始碼中看到了)。而該方法最終獲取到的正是 AppClassLoader (別稱 SystemClassLoader)。
這也就是我們熟知的最終的雙親委派模型了。
Part 5. 實現自己的類載入器
什麼情況下需要自定義類載入器
在學習了類載入器的實現機制之後,我們知道了雙親委派模型並非強制模型,使用者可以自定義類載入器,在什麼情況下需要自定義類載入器呢?
1️⃣ 隔離載入類。在某些框架內進行中介軟體與應用的模組隔離,把類載入器到不同的環境。比如,阿里內某容器框架通過自定義類載入器確保應用中依賴的 jar
包不會影響到中介軟體執行時使用的 jar
包。
2️⃣ 修改類載入方式。類的載入模型並非強制,除了 Bootstrap 外,其他的載入並非一定要引入,或者根據實際情況在某個時間點進行按需的動態載入。
3️⃣ 擴充套件載入源。比如從資料庫、網路,甚至是電視機頂盒進行載入。(下面?我們會編寫一個從網路載入類的例子)
4️⃣ 防止原始碼洩露。Java 程式碼容易被編譯和篡改,可以進行編譯加密。那麼類載入器也需要自定義,還原加密的位元組碼。
一個常規的例子
實現一個自定義的類載入器比較簡單:繼承 ClassLoader,重寫 findClass()
方法,呼叫 defineClass()
方法,就差不多行了。
Tester.java
我們先來編寫一個測試用的類檔案:
public class Tester {
public void say() {
System.out.println("關注【我沒有三顆心臟】,解鎖更多精彩!");
}
}
在同級目錄下執行 javac Tester.java
命令,並把編譯後的 Tester.class
放到指定的目錄下(我這邊為了方便就放在桌面上啦 /Users/wmyskxz/Desktop
)
MyClassLoader.java
我們編寫自定義 ClassLoader 程式碼:
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
public class MyClassLoader extends ClassLoader {
private final String mLibPath;
public MyClassLoader(String path) {
// TODO Auto-generated constructor stub
mLibPath = path;
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
// TODO Auto-generated method stub
String fileName = getFileName(name);
File file = new File(mLibPath, fileName);
try {
FileInputStream is = new FileInputStream(file);
ByteArrayOutputStream bos = new ByteArrayOutputStream();
int len = 0;
try {
while ((len = is.read()) != -1) {
bos.write(len);
}
} catch (IOException e) {
e.printStackTrace();
}
byte[] data = bos.toByteArray();
is.close();
bos.close();
return defineClass(name, data, 0, data.length);
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
return super.findClass(name);
}
// 獲取要載入的 class 檔名
private String getFileName(String name) {
// TODO Auto-generated method stub
int index = name.lastIndexOf('.');
if (index == -1) {
return name + ".class";
} else {
return name.substring(index + 1) + ".class";
}
}
}
我們在 findClass()
方法中定義了查詢 class 的方法,然後資料通過 defineClass()
生成了 Class 物件。
ClassLoaderTester 測試類
我們需要刪除剛才在專案目錄建立的 Tester.java
和編譯後的 Tester.class
檔案來觀察效果:
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
public class ClassLoaderTester {
public static void main(String[] args) {
// 建立自定義的 ClassLoader 物件
MyClassLoader myClassLoader = new MyClassLoader("/Users/wmyskxz/Desktop");
try {
// 載入class檔案
Class<?> c = myClassLoader.loadClass("Tester");
if(c != null){
try {
Object obj = c.newInstance();
Method method = c.getDeclaredMethod("say",null);
//通過反射呼叫Test類的say方法
method.invoke(obj, null);
} catch (InstantiationException | IllegalAccessException
| NoSuchMethodException
| SecurityException |
IllegalArgumentException |
InvocationTargetException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
} catch (ClassNotFoundException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
}
執行測試,正常輸出:
關注【我沒有三顆心臟】,解鎖更多精彩!
加密解密類載入器
突破了 JDK 系統內建載入路徑的限制之後,我們就可以編寫自定義的 ClassLoader。你完全可以按照自己的意願進行業務的定製,將 ClassLoader 玩出花樣來。
例如,一個加密解密的類載入器。(不涉及完整程式碼,我們可以來說一下思路和關鍵程式碼)
首先,在編譯之後的位元組碼檔案中動一動手腳,例如,給檔案每一個 byte
異或一個數字 2:(這就算是模擬加密過程)
File file = new File(path);
try {
FileInputStream fis = new FileInputStream(file);
FileOutputStream fos = new FileOutputStream(path+"en");
int b = 0;
int b1 = 0;
try {
while((b = fis.read()) != -1){
// 每一個 byte 異或一個數字 2
fos.write(b ^ 2);
}
fos.close();
fis.close();
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
} catch (FileNotFoundException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
然後我們再在 findClass()
中自己解密:
File file = new File(mLibPath,fileName);
try {
FileInputStream is = new FileInputStream(file);
ByteArrayOutputStream bos = new ByteArrayOutputStream();
int len = 0;
byte b = 0;
try {
while ((len = is.read()) != -1) {
// 將資料異或一個數字 2 進行解密
b = (byte) (len ^ 2);
bos.write(b);
}
} catch (IOException e) {
e.printStackTrace();
}
byte[] data = bos.toByteArray();
is.close();
bos.close();
return defineClass(name,data,0,data.length);
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
(程式碼幾乎與上面?一個例子等同,所以只說一下思路和完整程式碼)
網路類載入器
其實非常類似,也不做過多講解,直接上程式碼:
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.net.URL;
public class NetworkClassLoader extends ClassLoader {
private String rootUrl;
public NetworkClassLoader(String rootUrl) {
// 指定URL
this.rootUrl = rootUrl;
}
// 獲取類的位元組碼
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
byte[] classData = getClassData(name);
if (classData == null) {
throw new ClassNotFoundException();
} else {
return defineClass(name, classData, 0, classData.length);
}
}
private byte[] getClassData(String className) {
// 從網路上讀取的類的位元組
String path = classNameToPath(className);
try {
URL url = new URL(path);
InputStream ins = url.openStream();
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 (Exception e) {
e.printStackTrace();
}
return null;
}
private String classNameToPath(String className) {
// 得到類檔案的URL
return rootUrl + "/"
+ className.replace('.', '/') + ".class";
}
}
(程式碼來自:https://blog.csdn.net/justloveyou_/article/details/72217806)
Part 6. 必要的擴充套件閱讀
學習到這裡,我們對 ClassLoader 已經不再陌生了,但是仍然有一些必要的知識點需要去掌握 (限於篇幅和能力這裡不擴充套件了),希望您能認真閱讀以下的材料:(可能排版上面層次不齊,但內容都是有質量的,並用 ♨️ 標註了更加重點一些的內容)
1️⃣ ♨️能不能自己寫一個類叫 java.lang.System
或者 java.lang.String
? - https://blog.csdn.net/tang9140/article/details/42738433
2️⃣ 深入理解 Java 之 JVM 啟動流程 - https://cloud.tencent.com/developer/article/1038435
3️⃣ ♨️真正理解執行緒上下文類載入器(多案例分析) - https://blog.csdn.net/yangcheng33/article/details/52631940
4️⃣ ♨️曹工雜談:Java 類載入器還會死鎖?這是什麼情況? - https://www.cnblogs.com/grey-wolf/p/11378747.html#_label2
5️⃣ 謹防JDK8重複類定義造成的記憶體洩漏 - https://segmentfault.com/a/1190000022837543
7️⃣ ♨️Tomcat 類載入器的實現 - https://juejin.im/post/6844903945496690695
8️⃣ ♨️Spring 中的類載入機制 - https://www.shuzhiduo.com/A/gVdnwgAlzW/
參考資料
- 《深入分析 Java Web 技術內幕》 | 許令波 著
- Java 類載入機制分析 - https://www.jianshu.com/p/3615403c7c84
- Class 檔案解析實戰 - https://juejin.im/post/6844904199617003528
- 圖文兼備看懂類載入機制的各個階段,就差你了! - https://juejin.im/post/6844904119258316814
- Java面試知識點解析(三)——JVM篇 - https://www.wmyskxz.com/2018/05/16/java-mian-shi-zhi-shi-dian-jie-xi-san-jvm-pian/
- 一看你就懂,超詳細Java中的ClassLoader詳解 - https://blog.csdn.net/briblue/article/details/54973413
- 本文已收錄至我的 Github 程式設計師成長系列 【More Than Java】,學習,不止 Code,歡迎 star:https://github.com/wmyskxz/MoreThanJava
- 個人公眾號 :wmyskxz,個人獨立域名部落格:wmyskxz.com,堅持原創輸出,下方掃碼關注,2020,與您共同成長!
非常感謝各位人才能 看到這裡,如果覺得本篇文章寫得不錯,覺得 「我沒有三顆心臟」有點東西 的話,求點贊,求關注,求分享,求留言!
創作不易,各位的支援和認可,就是我創作的最大動力,我們下篇文章見!