JVM 的類加載機制和 Java 的類加載機制相似,但 JVM 的類加載過程稍有些複雜。
JVM 經過加載 .class 文件,可以將其中的字節碼解析成操做系統機器碼。那這些文件是怎麼加載進來的呢?又有哪些約定?接下來咱們就詳細介紹 JVM 的類加載機制,同時介紹三個實際的應用場景。java
現實中並非說,我把一個文件修改爲 .class 後綴,就可以被 JVM 識別。類的加載過程很是複雜,主要有這幾個過程:加載、驗證、準備、解析、初始化。
如圖所示。大多數狀況下,類會按照圖中給出的順序進行加載。下面咱們就來分別介紹下這個過程。mysql
加載的主要做用是將外部的 .class 文件,加載到 Java 的方法區內。加載階段主要是找到並加載類的二進制數據,好比從 jar 包裏或者 war 包裏找到它們。程序員
確定不能任何 .class 文件都能加載,那樣太不安全了,容易受到惡意代碼的攻擊。驗證階段在虛擬機整個類加載過程當中佔了很大一部分,不符合規範的將拋出 java.lang.VerifyError 錯誤。像一些低版本的 JVM,是沒法加載一些高版本的類庫的,就是在這個階段完成的。sql
從這部分開始,將爲一些類變量分配內存,並將其初始化爲默認值。此時,實例對象尚未分配內存,因此這些動做是在方法區上進行的。數據庫
下面兩段代碼,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);
}
}
爲何會有這種區別呢?編程
這是由於局部變量不像類變量那樣存在準備階段。類變量有兩次賦初始值的過程,一次在準備階段,賦予初始值(也能夠是指定值);另一次在初始化階段,賦予程序員定義的值。
所以,即便程序員沒有爲類變量賦值也沒有關係,它仍然有一個默認的初始值。但局部變量就不同了,若是沒有給它賦初始值,是不能使用的。tomcat
解析在類加載中是很是很是重要的一環,是將符號引用替換爲直接引用的過程。這句話很是的拗口,其實理解起來也很是的簡單。安全
符號引用是一種定義,能夠是任何字面上的含義,而直接引用就是直接指向目標的指針、相對偏移量。架構
直接引用的對象都存在於內存中,你能夠把通信錄裏的女朋友手機號碼,類比爲符號引用,把面對面和你吃飯的人,類比爲直接引用。app
解析階段負責把整個類激活,串成一個能夠找到彼此的網,過程不可謂不重要。那這個階段都作了哪些工做呢?大致能夠分爲:
咱們來看幾個常常發生的異常,就與這個階段有關。
解析過程保證了相互引用的完整性,把繼承與組合推動到運行時。
若是前面的流程一切順利的話,接下來該初始化成員變量了,到了這一步,才真正開始執行一些字節碼。
接下來是一道試題,你能夠猜測一下,下面的代碼,會輸出什麼?
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> 方法有什麼區別?
主要是爲了讓你弄明白類的初始化和對象的初始化之間的差異。
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 的類固然不能輕易被覆蓋,不然會被別有用心的人利用,這就太危險了。
那類加載器是如何保證這個過程的安全性呢?其實,它是有着嚴格的等級制度的。
首先,咱們介紹幾個不一樣等級的類加載器。
這是加載器中的大 Boss,任何類的加載行爲,都要經它過問。它的做用是加載核心類庫,也就是 rt.jar、resources.jar、charsets.jar 等。固然這些 jar 包的路徑是能夠指定的,-Xbootclasspath 參數能夠完成指定操做。
這個加載器是 C++ 編寫的,隨着 JVM 啓動。
擴展類加載器,主要用於加載 lib/ext 目錄下的 jar 包和 .class 文件。一樣的,經過系統變量 java.ext.dirs 能夠指定這個目錄。
這個加載器是個 Java 類,繼承自 URLClassLoader。
這是咱們寫的 Java 類的默認加載器,有時候也叫做 System ClassLoader。通常用來加載 classpath 下的其餘全部 jar 包和 .class 文件,咱們寫的代碼,會首先嚐試使用這個類加載器進行加載。
自定義加載器,支持一些個性化的擴展功能。
雙親委派機制的意思是除了頂層的啓動類加載器之外,其他的類加載器,在加載以前,都會委派給它的父加載器進行加載。這樣一層層向上傳遞,直到祖先們都沒法勝任,它纔會真正的加載。
打個比方。有一個家族,都是一些聽話的孩子。孫子想要買一塊棒棒糖,最終都要通過爺爺過問,若是力所能及,爺爺就直接幫孫子買了。
但你有沒有想過,「類加載的雙親委派機制,雙親在哪裏?明明都是單親?」
咱們仍是用一張圖來說解。能夠看到,除了啓動類加載器,每個加載器都有一個parent,並無所謂的雙親。可是因爲翻譯的問題,這個叫法已經很是廣泛了,必定要注意背後的差異。
咱們能夠翻閱 JDK 代碼的 ClassLoader#loadClass 方法,來看一下具體的加載過程。和咱們描述的同樣,它首先使用 parent 嘗試進行類加載,parent 失敗後才輪到本身。同時,咱們也注意到,這個方法是能夠被覆蓋的,也就是雙親委派機制並不必定生效。
這個模型的好處在於 Java 類有了一種優先級的層次劃分關係。好比 Object 類,這個毫無疑問應該交給最上層的加載器進行加載,即便是你覆蓋了它,最終也是由系統默認的加載器進行加載的。
若是沒有雙親委派模型,就會出現不少個不一樣的 Object 類,應用程序會一片混亂。
下面咱們就來聊一聊能夠打破雙親委派機制的一些案例。爲了支持一些自定義加載類多功能的需求,Java 設計者其實已經做出了一些妥協。
tomcat 經過 war 包進行應用的發佈,它實際上是違反了雙親委派機制原則的。簡單看一下 tomcat 類加載器的層次結構。
對於一些須要加載的非基礎類,會由一個叫做 WebAppClassLoader 的類加載器優先加載。等它加載不到的時候,再交給上層的 ClassLoader 進行加載。這個加載器用來隔毫不同應用的 .class 文件,好比你的兩個應用,可能會依賴同一個第三方的不一樣版本,它們是相互沒有影響的。
如何在同一個 JVM 裏,運行着不兼容的兩個版本,固然是須要自定義加載器才能完成的事。
那麼 tomcat 是怎麼打破雙親委派機制的呢?能夠看圖中的 WebAppClassLoader,它加載本身目錄下的 .class 文件,並不會傳遞給父類的加載器。可是,它卻可使用 SharedClassLoader 所加載的類,實現了共享和分離的功能。
可是你本身寫一個 ArrayList,放在應用目錄裏,tomcat 依然不會加載。它只是自定義的加載器順序不一樣,但對於頂層來講,仍是同樣的。
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 曾經很是流行,Eclipse 就使用 OSGi 做爲插件系統的基礎。OSGi 是服務平臺的規範,旨在用於須要長運行時間、動態更新和對運行環境破壞最小的系統。
OSGi 規範定義了不少關於包生命週期,以及基礎架構和綁定包的交互方式。這些規則,經過使用特殊 Java 類加載器來強制執行,比較霸道。
好比,在通常 Java 應用程序中,classpath 中的全部類都對全部其餘類可見,這是毋庸置疑的。可是,OSGi 類加載器基於 OSGi 規範和每一個綁定包的 manifest.mf 文件中指定的選項,來限制這些類的交互,這就讓編程風格變得很是的怪異。但咱們不難想象,這種與直覺相違背的加載方式,確定是由專用的類加載器來實現的。
隨着 jigsaw 的發展(旨在爲 Java SE 平臺設計、實現一個標準的模塊系統),我我的認爲,如今的 OSGi,意義已經不是很大了。OSGi 是一個龐大的話題,你只須要知道,有這麼一個複雜的東西,實現了模塊化,每一個模塊能夠獨立安裝、啓動、中止、卸載,就能夠了。
如何替換 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,讓加載過程更加靈活。
不管是遠程存儲字節碼,仍是將字節碼進行加密,這都是業務需求。要作這些,咱們實現一個新的類加載器就能夠了。