本課時我們主要從覆蓋 JDK 的類開始講解 JVM 的類加載機制。其實,JVM 的類加載機制和 Java 的類加載機制類似,但 JVM 的類加載過程稍有些復雜。
前面課時我們講到,JVM 通過加載 .class 文件,能夠將其中的字節碼解析成操作系統機器碼。那這些文件是怎么加載進來的呢?又有哪些約定?接下來我們就詳細介紹 JVM 的類加載機制,同時介紹三個實際的應用場景。
我們首先看幾個面試題。
* 我們能夠通過一定的手段,覆蓋 HashMap 類的實現么?
* 有哪些地方打破了 Java 的類加載機制?
* 如何加載一個遠程的 .class 文件?怎樣加密 .class 文件?
關于類加載,很多同學都知道雙親委派機制,但這明顯不夠。面試官可能要你講出幾個能打破這個機制的例子,這個時候不要慌。上面幾個問題,是我在接觸的一些比較高級的面試場景中,遇到的一些問法。在平常的工作中,也有大量的相關應用,我們會理論聯系實踐綜合分析這些問題。
### 類加載過程
現實中并不是說,我把一個文件修改成 .class 后綴,就能夠被 JVM 識別。類的加載過程非常復雜,主要有這幾個過程:加載、驗證、準備、解析、初始化。這些術語很多地方都出現過,我們不需要死記硬背,而應該要了解它背后的原理和要做的事情。

如圖所示。大多數情況下,類會按照圖中給出的順序進行加載。下面我們就來分別介紹下這個過程。
#### 加載
加載的主要作用是將外部的 .class 文件,加載到 Java 的方法區內,你可以回顧一下我們在上一課時講的內存區域圖。加載階段主要是找到并加載類的二進制數據,比如從 jar 包里或者 war 包里找到它們。
#### 驗證
肯定不能任何 .class 文件都能加載,那樣太不安全了,容易受到惡意代碼的攻擊。驗證階段在虛擬機整個類加載過程中占了很大一部分,不符合規范的將拋出 java.lang.VerifyError 錯誤。像一些低版本的 JVM,是無法加載一些高版本的類庫的,就是在這個階段完成的。
#### 準備
從這部分開始,將為一些類變量分配內存,并將其初始化為默認值。此時,實例對象還沒有分配內存,所以這些動作是在方法區上進行的。
我們順便看一道面試題。下面兩段代碼,code-snippet 1 將會輸出 0,而 code-snippet 2 將無法通過編譯。
```
code-snippet 1:
public class A {
static int a ;
public static void main(String[] args) {
System.out.println(a);
}
}
code-snippet 2:
public class A {
public static void main(String[] args) {
int a ;
System.out.println(a);
}
}
```
* [ ] 為什么會有這種區別呢?
這是因為局部變量不像類變量那樣存在準備階段。類變量有兩次賦初始值的過程,一次在準備階段,賦予初始值(也可以是指定值);另外一次在初始化階段,賦予程序員定義的值。
因此,即使程序員沒有為類變量賦值也沒有關系,它仍然有一個默認的初始值。但局部變量就不一樣了,如果沒有給它賦初始值,是不能使用的。
#### 解析
解析在類加載中是非常非常重要的一環,是將符號引用替換為直接引用的過程。這句話非常的拗口,其實理解起來也非常的簡單。
符號引用是一種定義,可以是任何字面上的含義,而直接引用就是直接指向目標的指針、相對偏移量。
直接引用的對象都存在于內存中,你可以把通訊錄里的女友手機號碼,類比為符號引用,把面對面和你吃飯的人,類比為直接引用。
解析階段負責把整個類激活,串成一個可以找到彼此的網,過程不可謂不重要。那這個階段都做了哪些工作呢?大體可以分為:
* 類或接口的解析
* 類方法解析
* 接口方法解析
* 字段解析
我們來看幾個經常發生的異常,就與這個階段有關。
* java.lang.NoSuchFieldError 根據繼承關系從下往上,找不到相關字段時的報錯。
* java.lang.IllegalAccessError 字段或者方法,訪問權限不具備時的錯誤。
* java.lang.NoSuchMethodError 找不到相關方法時的錯誤。
解析過程保證了相互引用的完整性,把繼承與組合推進到運行時。
#### 初始化
如果前面的流程一切順利的話,接下來該初始化成員變量了,到了這一步,才真正開始執行一些字節碼。
接下來是另一道面試題,你可以猜想一下,下面的代碼,會輸出什么?
```
public class A {
static int a = 0 ;
static {
a = 1;
b = 1;
}
static int b = 0;
public static void main(String[] args) {
System.out.println(a);
System.out.println(b);
}
}
```
結果是 `1 0`。a 和 b 唯一的區別就是它們的 static 代碼塊的位置。
這就引出一個規則:static 語句塊,只能訪問到定義在 static 語句塊之前的變量。所以下面的代碼是無法通過編譯的。
```
static {
b = b + 1;
}
static int b = 0;
```
我們再來看第二個規則:JVM 會保證在子類的初始化方法執行之前,父類的初始化方法已經執行完畢。
所以,JVM 第一個被執行的類初始化方法一定是 java.lang.Object。另外,也意味著父類中定義的 static 語句塊要優先于子類的。
`<cinit>與<init>`
說到這里,不得不再說一個面試題:<cinit> 方法和 <init> 方法有什么區別?
主要是為了讓你弄明白類的初始化和對象的初始化之間的差別。
```
public class A {
static {
System.out.println("1");
}
public A(){
System.out.println("2");
}
}
public class B extends A {
static{
System.out.println("a");
}
public B(){
System.out.println("b");
}
public static void main(String[] args){
A ab = new B();
ab = new B();
}
}
```
先公布下答案:
```
1
a
2
b
2
b
```
你可以看下這張圖。其中 static 字段和 static 代碼塊,是屬于類的,在類的加載的初始化階段就已經被執行。類信息會被存放在方法區,在同一個類加載器下,這些信息有一份就夠了,所以上面的 static 代碼塊只會執行一次,它對應的是 `<cinit>` 方法。
而對象初始化就不一樣了。通常,我們在 new 一個新對象的時候,都會調用它的構造方法,就是 `<init>`,用來初始化對象的屬性。每次新建對象的時候,都會執行。

所以,上面代碼的 static 代碼塊只會執行一次,對象的構造方法執行兩次。再加上繼承關系的先后原則,不難分析出正確結果。
### 類加載器
整個類加載過程任務非常繁重,雖然這活兒很累,但總得有人干。類加載器做的就是上面 5 個步驟的事。
如果你在項目代碼里,寫一個 java.lang 的包,然后改寫 String 類的一些行為,編譯后,發現并不能生效。JRE 的類當然不能輕易被覆蓋,否則會被別有用心的人利用,這就太危險了。
那類加載器是如何保證這個過程的安全性呢?其實,它是有著嚴格的等級制度的。
#### 幾個類加載器
首先,我們介紹幾個不同等級的類加載器。
* **Bootstrap ClassLoader**
這是加載器中的大 Boss,任何類的加載行為,都要經它過問。它的作用是加載核心類庫,也就是 rt.jar、resources.jar、charsets.jar 等。當然這些 jar 包的路徑是可以指定的,-Xbootclasspath 參數可以完成指定操作。
這個加載器是 C++ 編寫的,隨著 JVM 啟動。
* **Extention ClassLoader**
擴展類加載器,主要用于加載 lib/ext 目錄下的 jar 包和 .class 文件。同樣的,通過系統變量 java.ext.dirs 可以指定這個目錄。
這個加載器是個 Java 類,繼承自 URLClassLoader。
* **App ClassLoader**
這是我們寫的 Java 類的默認加載器,有時候也叫作 System ClassLoader。一般用來加載 classpath 下的其他所有 jar 包和 .class 文件,我們寫的代碼,會首先嘗試使用這個類加載器進行加載。
* **Custom ClassLoader**
自定義加載器,支持一些個性化的擴展功能。
### 雙親委派機制
關于雙親委派機制的問題面試中經常會被問到,你可能已經倒背如流了。
雙親委派機制的意思是除了頂層的啟動類加載器以外,其余的類加載器,在加載之前,都會委派給它的父加載器進行加載。這樣一層層向上傳遞,直到祖先們都無法勝任,它才會真正的加載。
打個比方。有一個家族,都是一些聽話的孩子。孫子想要買一塊棒棒糖,最終都要經過爺爺過問,如果力所能及,爺爺就直接幫孫子買了。
但你有沒有想過,“類加載的雙親委派機制,雙親在哪里?明明都是單親?”
我們還是用一張圖來講解。可以看到,除了啟動類加載器,每一個加載器都有一個parent,并沒有所謂的雙親。但是由于翻譯的問題,這個叫法已經非常普遍了,一定要注意背后的差別。

我們可以翻閱 JDK 代碼的 ClassLoader#loadClass 方法,來看一下具體的加載過程。和我們描述的一樣,它首先使用 parent 嘗試進行類加載,parent 失敗后才輪到自己。同時,我們也注意到,這個方法是可以被覆蓋的,也就是雙親委派機制并不一定生效。

這個模型的好處在于 Java 類有了一種優先級的層次劃分關系。比如 Object 類,這個毫無疑問應該交給最上層的加載器進行加載,即使是你覆蓋了它,最終也是由系統默認的加載器進行加載的。
如果沒有雙親委派模型,就會出現很多個不同的 Object 類,應用程序會一片混亂。
### 一些自定義加載器
下面我們就來聊一聊可以打破雙親委派機制的一些案例。為了支持一些自定義加載類多功能的需求,Java 設計者其實已經作出了一些妥協。
#### 案例一:tomcat
tomcat 通過 war 包進行應用的發布,它其實是違反了雙親委派機制原則的。簡單看一下 tomcat 類加載器的層次結構。

對于一些需要加載的非基礎類,會由一個叫作 WebAppClassLoader 的類加載器優先加載。等它加載不到的時候,再交給上層的 ClassLoader 進行加載。這個加載器用來隔絕不同應用的 .class 文件,比如你的兩個應用,可能會依賴同一個第三方的不同版本,它們是相互沒有影響的。
如何在同一個 JVM 里,運行著不兼容的兩個版本,當然是需要自定義加載器才能完成的事。
那么 tomcat 是怎么打破雙親委派機制的呢?可以看圖中的 WebAppClassLoader,它加載自己目錄下的 .class 文件,并不會傳遞給父類的加載器。但是,它卻可以使用 SharedClassLoader 所加載的類,實現了共享和分離的功能。
但是你自己寫一個 ArrayList,放在應用目錄里,tomcat 依然不會加載。它只是自定義的加載器順序不同,但對于頂層來說,還是一樣的。
#### 案例二:SPI
Java 中有一個 SPI 機制,全稱是 Service Provider Interface,是 Java 提供的一套用來被第三方實現或者擴展的 API,它可以用來啟用框架擴展和替換組件。
這個說法可能比較晦澀,但是拿我們常用的數據庫驅動加載來說,就比較好理解了。在使用 JDBC 寫程序之前,通常會調用下面這行代碼,用于加載所需要的驅動類。
```
Class.forName("com.mysql.jdbc.Driver")
```
這只是一種初始化模式,通過 static 代碼塊顯式地聲明了驅動對象,然后把這些信息,保存到底層的一個 List 中。這種方式我們不做過多的介紹,因為這明顯就是一個接口編程的思路,沒什么好奇怪的。
**但是你會發現,即使刪除了 Class.forName 這一行代碼,也能加載到正確的驅動類,什么都不需要做,非常的神奇,它是怎么做到的呢**?
我們翻開 MySQL 的驅動代碼,發現了一個奇怪的文件。之所以能夠發生這樣神奇的事情,就是在這里實現的。
路徑:
```
mysql-connector-java-8.0.15.jar!/META-INF/services/java.sql.Driver
```
里面的內容是:
```
com.mysql.cj.jdbc.Driver
```
通過在 META-INF/services 目錄下,創建一個以接口全限定名為命名的文件(內容為實現類的全限定名),即可自動加載這一種實現,這就是 SPI。
SPI 實際上是“基于接口的編程+策略模式+配置文件”組合實現的動態加載機制,主要使用 java.util.ServiceLoader 類進行動態裝載。

這種方式,同樣打破了雙親委派的機制。
DriverManager 類和 ServiceLoader 類都是屬于 rt.jar 的。它們的類加載器是 Bootstrap ClassLoader,也就是最上層的那個。而具體的數據庫驅動,卻屬于業務代碼,這個啟動類加載器是無法加載的。這就比較尷尬了,雖然凡事都要祖先過問,但祖先沒有能力去做這件事情,怎么辦?
我們可以一步步跟蹤代碼,來看一下這個過程。
```
//part1:DriverManager::loadInitialDrivers
//jdk1.8 之后,變成了lazy的ensureDriversInitialized
...
ServiceLoader <Driver> loadedDrivers = ServiceLoader.load(Driver.class);
Iterator<Driver> driversIterator = loadedDrivers.iterator();
...
//part2:ServiceLoader::load
public static <T> ServiceLoader<T> load(Class<T> service) {
ClassLoader cl = Thread.currentThread().getContextClassLoader();
return ServiceLoader.load(service, cl);
}
```
通過代碼你可以發現 Java 玩了個魔術,它把當前的類加載器,設置成了線程的上下文類加載器。那么,對于一個剛剛啟動的應用程序來說,它當前的加載器是誰呢?也就是說,啟動 main 方法的那個加載器,到底是哪一個?
所以我們繼續跟蹤代碼。找到 Launcher 類,就是 jre 中用于啟動入口函數 main 的類。我們在 Launcher 中找到以下代碼。
```
public Launcher() {
Launcher.ExtClassLoader var1;
try {
var1 = Launcher.ExtClassLoader.getExtClassLoader();
} catch (IOException var10) {
throw new InternalError("Could not create extension class loader", var10);
}
try {
this.loader = Launcher.AppClassLoader.getAppClassLoader(var1);
} catch (IOException var9) {
throw new InternalError("Could not create application class loader", var9);
}
Thread.currentThread().setContextClassLoader(this.loader);
...
}
```
到此為止,事情就比較明朗了,當前線程上下文的類加載器,是應用程序類加載器。使用它來加載第三方驅動,是沒有什么問題的。
我們之所以花大量的篇幅來介紹這個過程,第一,可以讓你更好的看到一個打破規則的案例。第二,這個問題面試時出現的幾率也是比較高的,你需要好好理解。
#### 案例三:OSGi
OSGi 曾經非常流行,Eclipse 就使用 OSGi 作為插件系統的基礎。OSGi 是服務平臺的規范,旨在用于需要長運行時間、動態更新和對運行環境破壞最小的系統。
OSGi 規范定義了很多關于包生命周期,以及基礎架構和綁定包的交互方式。這些規則,通過使用特殊 Java 類加載器來強制執行,比較霸道。
比如,在一般 Java 應用程序中,classpath 中的所有類都對所有其他類可見,這是毋庸置疑的。但是,OSGi 類加載器基于 OSGi 規范和每個綁定包的 manifest.mf 文件中指定的選項,來限制這些類的交互,這就讓編程風格變得非常的怪異。但我們不難想象,這種與直覺相違背的加載方式,肯定是由專用的類加載器來實現的。
隨著 jigsaw 的發展(旨在為 Java SE 平臺設計、實現一個標準的模塊系統),我個人認為,現在的 OSGi,意義已經不是很大了。OSGi 是一個龐大的話題,你只需要知道,有這么一個復雜的東西,實現了模塊化,每個模塊可以獨立安裝、啟動、停止、卸載,就可以了。
不過,如果你有機會接觸相關方面的工作,也許會不由的發出感嘆:原來 Java 的類加載器,可以玩出這么多花樣。
### 如何替換 JDK 的類
讓我們回到本課時開始的問題,如何替換 JDK 中的類?比如,我們現在就拿 HashMap為例。
當 Java 的原生 API 不能滿足需求時,比如我們要修改 HashMap 類,就必須要使用到 Java 的 endorsed 技術。我們需要將自己的 HashMap 類,打包成一個 jar 包,然后放到 -Djava.endorsed.dirs 指定的目錄中。注意類名和包名,應該和 JDK 自帶的是一樣的。但是,java.lang 包下面的類除外,因為這些都是特殊保護的。
因為我們上面提到的雙親委派機制,是無法直接在應用中替換 JDK 的原生類的。但是,有時候又不得不進行一下增強、替換,比如你想要調試一段代碼,或者比 Java 團隊早發現了一個 Bug。所以,Java 提供了 endorsed 技術,用于替換這些類。這個目錄下的 jar 包,會比 rt.jar 中的文件,優先級更高,可以被最先加載到。
### 小結
通過本課時的學習我們可以了解到,一個 Java 類的加載,經過了加載、驗證、準備、解析、初始化幾個過程,每一個過程都劃清了各自負責的事情。
接下來,我們了解到 Java 自帶的三個類加載器。同時了解到,main 方法的線程上下文加載器,其實是 Application ClassLoader。
一般情況下,類加載是遵循雙親委派機制的。我們也認識到,這個雙親,很有問題。通過 3 個案例的學習和介紹,可以看到有很多打破這個規則的情況。類加載器通過開放的 API,讓加載過程更加靈活。
Java 的類加載器是非常重要的知識點,也是面試常考的知識點,本課時提供了多個面試題,你可以實際操作體驗一下。
所以我們在課時開始時的第三個問題就很簡單了,無論是遠程存儲字節碼,還是將字節碼進行加密,這都是業務需求。要做這些,我們實現一個新的類加載器就可以了。
### 課后問答
* 1、類加載器是加載字節碼變成機器碼給執行引擎去執行的,那么類加載器是誰來加載的?
答案:啟動類加載器,就是最上面那一個,是c代碼實現的,沒有繼承classloader類。它就是一段native邏輯,所以沒有加載這種概念。它的實現參考${openjdk}\hotspot\src\share\vm\classfile 目錄下的 classLoader.cpp 與classLoader.hpp
* 2、
```
static int a;
static {
a = 1;
b = 1;
}
static int b;
public static void main(String[] args) {
System.out.println("a = " + a);
System.out.println("b = " + b);
}
```
這段代碼的執行結果真的是a=1,b=1;如果static int b=0;那么結果就是a=1,b=0;如果static int b;那么結果就是a=1,b=1。這個我就想不通為什么了,請老師講解一下。
答案:這個原因可以用同樣的方式獲得,建議實操一下。可以看到只有“1: putstatic #3”和”5: putstatic #5“兩個賦值操作。注意聲明動作并沒有賦值動作,它早已經在第3小節的準備階段就已經初始化成默認值了。準備階段->cinit->init,按這個順序分析一下?
* 3、如何加載一個遠程的.class文件?怎么樣加密.class文件沒有提及到?
答案:自定義一個ClassLoader,通過覆蓋defineClass和findClass方法即可實現。具體的網絡和加密屬于業務范疇。
* 4、為什么說SPI是打破了類加載的雙親委派機制呢?使用System ClassLoader加載Driver的這個過程,System ClassLoader 仍然是會向上獲取Class,在上級的類加載器無法加載對應的Class后,System ClassLoader再去加載。這不正是雙親委派機制的流程嗎?
答案:SPI發起者是System ClassLoader,System ClassLoader已經是最上層的了。它直接獲取了App ClassLoader進行驅動加載,和雙親委派是相反的。
* 5、局部變量不像類變量那樣存在準備階段。類變量有兩次賦初始值的過程,一次在準備階段,賦予初始值(也可以是指定值)請問,怎么改成【任意的指定值】?修改源碼嗎?怎么改
答案:這里有兩種情況。
static int a = 1 ; 準備階段過后是0;
final static int a = 1; 準備階段后是1;區別是final。
* 6、
```
static int a;
static {
a = 1;
b = 1;
}
static int b;
public static void main(String[] args) {
System.out.println("a = " + a);
System.out.println("b = " + b);
}
```
為什么這段代碼的執行結果就是a=1,b=1
答案:問題更正下,結果是1 0哈(不是1 1)。文章下面也提到了,是代碼的順序問題。看一下編譯后的字節碼,putstatic操作的順序是a,a,b,b。和我們代碼的順序是一致的,值被按順序覆蓋了。
```
0: iconst_0
1: putstatic #3 // Field a:I
4: iconst_1
5: putstatic #3 // Field a:I
8: iconst_1
9: putstatic #5 // Field b:I
12: iconst_0
13: putstatic #5 // Field b:I
```
- 前言
- 開篇詞
- 基礎原理
- 第01講:一探究竟:為什么需要 JVM?它處在什么位置?
- 第02講:大廠面試題:你不得不掌握的 JVM 內存管理
- 第03講:大廠面試題:從覆蓋 JDK 的類開始掌握類的加載機制
- 第04講:動手實踐:從棧幀看字節碼是如何在 JVM 中進行流轉的
- 垃圾回收
- 第05講:大廠面試題:得心應手應對 OOM 的疑難雜癥
- 第06講:深入剖析:垃圾回收你真的了解嗎?(上)
- 第06講:深入剖析:垃圾回收你真的了解嗎?(下)
- 第07講:大廠面試題:有了 G1 還需要其他垃圾回收器嗎?
- 第08講:案例實戰:億級流量高并發下如何進行估算和調優
- 實戰部分
- 第09講:案例實戰:面對突如其來的 GC 問題如何下手解決
- 第10講:動手實踐:自己模擬 JVM 內存溢出場景
- 第11講:動手實踐:遇到問題不要慌,輕松搞定內存泄漏
- 第12講:工具進階:如何利用 MAT 找到問題發生的根本原因
- 第13講:動手實踐:讓面試官刮目相看的堆外內存排查
- 第14講:預警與解決:深入淺出 GC 監控與調優
- 第15講:案例分析:一個高死亡率的報表系統的優化之路
- 第16講:案例分析:分庫分表后,我的應用崩潰了
- 進階部分
- 第17講:動手實踐:從字節碼看方法調用的底層實現
- 第18講:大廠面試題:不要搞混 JMM 與 JVM
- 第19講:動手實踐:從字節碼看并發編程的底層實現
- 第20講:動手實踐:不為人熟知的字節碼指令
- 第21講:深入剖析:如何使用 Java Agent 技術對字節碼進行修改
- 第22講:動手實踐:JIT 參數配置如何影響程序運行?
- 第23講:案例分析:大型項目如何進行性能瓶頸調優?
- 彩蛋
- 第24講:未來:JVM 的歷史與展望
- 第25講:福利:常見 JVM 面試題補充