本文導讀:
1、前奏,舉個生活中的小栗子
2、為何Java類型加載、連接在程序運行期完成?
3、一個類在什么情況下才會被加載到JVM中?
什么是主動使用、被動使用?代碼示例助你透徹理解類初始化的時機。
4、類的加載(Loading)內幕透徹剖析
類加載做的那些事兒、雙親委派模型工作過程、ClassLoader源碼解析
5、Tomcat如何打破雙親委派模型的
6、上下文類加載器深入淺出剖析
7、最后總結
1、前奏,舉個生活中的小栗子
春節馬上要到了,大家是不是都在迫不及待的等著回家團圓了呢?
大春運早已啟動,回家的過程其實是個「辛苦活」,有的同學還沒有買到票呢,蒙眼狂奔終于搶到了,發現竟然是個站票~,退了,連站票的機會都沒了吧?
昨天還聽一位同學說:『嘿嘿,去年我提前就買到票了,但是... 但是... 去錯火車站了。。。尼瑪,當時那是啥心情啊~ 幸運的是后來又刷到票了,不然就真回不去了!』
回家大部分朋友都要乘坐交通工具,不管你乘坐什么樣的交通工具出行,對于「交通管理」內部來說,最最重要的任務就是保障大家得出行安全。
那么如何保障大家的出行安全呢?
乘坐地鐵、飛機等這些公共交通工具,必不可少的最重要的環節就是『安檢』,不是什么東西都可以隨便讓你帶的,都是有明文規定的,比如易燃易爆、酒類等都是有限制的。
交通出行的大體過程,有點類似類文件加載到Java虛擬機(簡稱 JVM)的過程,程序中運行的各種類文件(比如Java、Kotlin),也是要必須經過『安檢』的,才能允許進入到JVM中的,一切都是為了安全。
當然,安檢的標準是不同的。
接下來,我們進入正題,一起來看看類文件是如何被加載到JVM當中的。
上圖的對比只是為了方便理解 ,抽象出來一層『安全檢查』,其實就是『類加載』的過程。
這個過程JVM當中約束了規范和標準,都會經過加載、驗證、準備、解析、初始化五個階段。
這里一定要說一個概念,個人認為對于理解類加載過程挺重要的。
更準確的說法,應該是類型
的加載過程,在Java代碼中,類型的加載、連接、初始化都是在程序運行時完成的。
這里的類型,是指你在開發代碼時常見的class、interface、enum這些關鍵字的定義,并不是指具體的class對象。
舉個??:
Object obj = new Object();
new出來的obj是Object類型嗎?當然不是,obj只是通過new創建出來的Object對象,而類型實際是Object類本身
。而要想創建Object對象的前提,必須要有類型的信息,才能在Java堆中創建出來。所以,這里要明確區分開。
絕大多數情況下,類型是提前編寫好的,比如Object類是由JDK已經提供的。另外一些情況是可以在運行期間動態的生成出來,比如動態代理(程序運行期完成的)。
2、為何Java類型加載、連接在程序運行期完成?
其實,運行區間能做這件事,就為一些有創意的開發人員提供了很多的可能性。一切的文件都已經存在,程序運行的過程中可以采取一些特殊的處理方式把這些之前已經存在或者運行期生成出來的這些類型有機的裝配在一起。
Java本身是一門靜態的語言,而他的很多特性又具有動態語言才能擁有的特質,也因此類型的加載、連接和初始化在運行期間完成起到了很大的幫助作用。
類型的加載:查找并加載類的二進制數據(字節碼文件),最常見的,是將類的Class文件從磁盤加載到內存中。
類型的連接:將類與類的關系確定好,對于字節碼相關的處理、驗證、校驗在加載連接階段去完成的。字節碼本身可以被人為操縱的,也因此可能有惡意的可能性,所以需要校驗。
驗證:確保被加載類的正確性,就是要按照JVM規范定義的。
準備:為類的靜態變量分配內存,并將其初始化為默認值
class Test {
public static int num = 1;
}
上述代碼示例中的中間過程,在將類型加載到內存過程中,num分配內存,首先設置為0,1是在后續的初始化階段賦值給num變量。
- 解析:把類中的符號引用轉換為直接引用
符號引用: 間接的引用方式,通過一個符號的表示一個類引用了另外的類。 直接引用:直接引用到目標對象中的內存的位置
初始化階段:為類的靜態變量賦予正確的初始值。
類型的初始化:比如一些靜態的變量的賦值是在初始化階段完成的。
3、一個類在什么情況下才會被加載到JVM中?
Java程序對類的使用方式可分為兩種:
主動使用
被動使用
特別的重要:
所有的Java虛擬機實現必須在每個類或接口被java程序首次主動使用時才初始化他們。
主動使用(八種情況
):
1)創建類的實例,比如new一個對象
2)訪問某一個類或接口的靜態變量,或者對該靜態變量賦值 (訪問類的靜態變量的助記符getstatic,賦值是putstatic)。
3)調用類的靜態方法 (應用invokestatic助記符)。
4)使用java.lang.reflect包的方法對類型進行反射調用,比如:Class.forName(“com.test.Test") 通過反射的方式獲取類的Class對象。
5)初始化一個類的子類,比如有class Parent{}、子類class Child extends Parent{},當初始化Child類時也表示對Parent類的主動使用,Parent類也要全部初始化。
6)Java虛擬機啟動時被標注為啟動類的類,即有main方法的類。
7)JDK1.7開始提供的動態語言支持:java.lang.invoke.MethodHandle實例的解析結果REF_getStatic, REF_putStatic, REF_invokeStatic句柄對應的類沒有初始化,則初始化。
8)當一個接口中定義了JDK 8新加入的默認方法(被default關鍵字修飾的接口方法)時,如果有這個接口的實現類發生了初始化,那該接口要在其之前被初始化。
除了上述所講的八種情況,其他使用Java類的方式都被看作是類的被動使用,都不會導致類的初始化。
另外,要特別說明的一點
:
接口的加載過程與類加載過程會有所不同,接口不能使用 「static{}」語句塊,但是編譯器會為接口生成對應的 <clinit>()類構造器,用于初始化接口中所定義的成員變量。
主動使用的第5種:當子類初始化時,要求其父類也要全部初始化完成。但是,對于一個接口的初始化時,并不要求其父接口要全部初始化完成,只有在真正使用到父接口時(比如引用接口中定義的常量)時才會去初始化,有點延遲加載的意思。
被動使用示例:
1)通過子類引用父類的靜態字段,不會導致子類的初始化
public class Parent {
static {
System.out.println("Parent init....");
}
public static int a = 123;
}
public class Child extends Parent {
static {
System.out.println("Child init...");
}
}
// Test類打印,子類直接調用父類的靜態字段
public static void main(String[] args) {
System.out.println(Child.a);
}
輸出結果:
Parent init....
123
根據輸出結果看到,不會輸出 Child init...,通過其子類來引用父類中定義的靜態字段,只會觸發父類的初始化而不會觸發子類的初始化,對于靜態字段,只有直接定義這個字段的類才會被初始化。
2) 創建數組類對象,并不會導致引用的類初始化
public class Child extends Parent {
static {
System.out.println("Child init...");
}
}
// 使用 Child 引用創建個數組
public static void main(String[] args) {
Child[] child = new Child[1];
System.out.println(child);
}
輸出結果:
[Lcom.dskj.jvm.beidong.Child;@7852e922
并沒有輸出Child init...證明并沒有初始化com.dskj.jvm.beidong.Child類,根據輸出結果看到了[Lcom.dskj.jvm.beidong.Child
,帶了[L
說明觸發了數組類的初始化階段,它是由JVM自動生成的,繼承自java.lang.Object類,由于anewarray
助記符觸發創建動作的。
對于數組來說,JavaDoc通常將其所構成的元素稱作為Component,實際上就是將數組降低一個維度的類型。
助記符:
anewarray:表示創建一個引用類型的(如類、接口、數組)數組,并將其引用值壓入棧頂。
newarray:表示創建一個指定的原始類型的(如int、float、char、short、double、boolean、byte)的數組,并將其引用值壓入棧頂。
對應字節碼內容:
3)調用ClassLoader的loadClass()方法,不會導致類的初始化。
代碼如下:
public class LoadClassTest {
public static void main(String[] args) {
try {
ClassLoader.getSystemClassLoader().loadClass("com.dskj.jvm.passivemode.LoadClass");
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
}
class LoadClass {
public static final String STR = "Hello World";
static {
System.out.println("LoadClass init...");
}
}
沒有輸出 LoadClass init...,證明了調用系統類加載器的loadClass()方法,并不會初始化LoadClass類,因為ClassLoader#loadClass()方法內部傳入的resolve參數為false,表示Class不會進入到連接
階段,也就不會導致類的初始化。
public Class<?> loadClass(String name) throws ClassNotFoundException {
return loadClass(name, false);
}
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
...
if (resolve) {
//** Links the specified class**
resolveClass(c);
}
}
4)final修飾的常量,編譯時會存入調用類常量池中,本質上沒有引用到定義常量的類,不會導致類的初始化動作。
看下面代碼:
public class ConstClassTest {
public static void main(String[] args) {
System.out.println(ConstClass.STR);
}
}
class ConstClass {
static {
System.out.println("ConstClass init...");
}
public static final String STR = "Hello World";
}
輸出結果:
Hello World
結果只會輸出 Hello World,不會輸出ConstClass init...,ConstClassTest類對常量ConstClass.STR的引用,實際被轉化為ConstClassTest類對自身常量池的引用了。也就是說,實際上ConstClassTest的Class文件之中并沒有ConstClass類的符號引用入口。
編譯完成,兩個ConstClassTest和ConstClass就沒有任何關系了。這句話如何能證明一下?
你可以先運行一次,然后將編譯后的ConstClass.class文件從磁盤上刪除掉,再次運行跟上面輸出結果是一樣的。
還不信?如下圖所示Idea中的運行結果:
在IDEA下測試時,如果你使用的Gradle來構建,模擬上面的刪除class文件過程,要使用 xxx/out/production/ 目錄下生成編譯后的class文件,當類沒有發生變化時不會重新生成class文件。如果使用默認的 xxx/build/xx,每次運行都會重新生成新的class文件。
如果有問題,可以在 Project Settings -> Modules -> 項目的 Paths 中調整編譯輸出目錄。
我們繼續在這個示例基礎上做修改:
public class ConstClassTest {
public static void main(String[] args) {
System.out.println(ConstClass.STR);
}
}
class ConstClass {
// STR 定義的常量通過UUID生成一個隨機串
public static final String STR = "Hello World" + UUID.randomUUID();
static {
System.out.println("ConstClass init...");
}
}
注意,這里 STR 常量通過UUID生成一個隨機串,編譯是通過的。
直接運行,輸出結果:
ConstClass init...
Hello World:d26d7f1d-2d46-41cb-b5dc-2b7b3fe61e74
看到了ConstClass init...,說明ConstClass類被初始化了。
將ConstClass.class文件刪除后,再次運行:
Exception in thread "main" java.lang.NoClassDefFoundError: com/dskj/jvm/passivemode/ConstClass
at com.dskj.jvm.passivemode.ConstClassTest.main(ConstClassTest.java:7)
Caused by: java.lang.ClassNotFoundException: com.dskj.jvm.passivemode.ConstClass
at java.net.URLClassLoader.findClass(URLClassLoader.java:381)
at java.lang.ClassLoader.loadClass(ClassLoader.java:424)
at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:331)
at java.lang.ClassLoader.loadClass(ClassLoader.java:357)
... 1 more
大家看到了嗎?ConstClass.class文件被刪除后,再次運行就發生了 java.lang.NoClassDefFoundError
異常了,為什么?正是因為 ConstClass 類里定義的STR常量并不是編譯器能夠確定的值,那么其值就不會被放到調用類的常量池中。
這個示例可以好好理解下,同時印證了該類的初始化時機中,主動使用和被動使用的場景。
大家記住一個類的8種主動使用情況,都是在開發過程中常見的使用方式。另外,注意下被動使用的幾種情況,結合上面的列舉的代碼示例透徹理解。
類加載全過程的每一個階段,結合前文給出的圖示,詳細展開。
4、類的加載(Loading)內幕透徹剖析
前面提到的類文件,就是后綴文件為.class
的二進制文件。
JVM在加載階段主要完成如下三件事
1)通過一個類的全限定名,即包名+類名
來獲取定義此類的二進制字節流。
2)將這個字節流所代表的靜態存儲結構轉化為方法區的運行時數據結構。
3)JVM內存中生成一個代表該類的java.lang.Class對象
,作為方法區這個類的各種數據的訪問入口。
對于第一點來說,并沒有要求這個二進制字節流,具體以什么樣的方式從Class文件中讀取。
通過下面一張圖來匯總一下:
解釋下比較常見的Class文件讀取方式:
1)從ZIP包中讀取Class文件,流行的SpringBoot/SpringCoud框架基本都打成Jar包形式,內嵌了Tomcat,俗稱Fat Jar
,通過java -jar可以直接啟動,非常方便。
另外,還有一些項目仍然是使用War包形式,并且使用單獨使用Tomcat這類應用容器來部署的。
2)運行時生成的Class文件,應用最多的就是動態代理技術了,比如CGLIB、JDK動態代理。
雙親委派模型
思考個問題,這些Class文件是由誰來加載的呢?
實現這個動作的代碼正是類加載器來完成的,類加載器在類層次劃分、OSGi、程序熱部署、代碼加密等領域大放異彩,成為Java技術體系中一塊重要的基石。
對于任意一個類,如何確定在JVM當中的唯一性?必須是由加載該類的類加載器和該類本身一起共同確立在JVM中的唯一性。
每一個類加載器,都擁有一個獨立的類名稱空間。通俗理解:比較兩個類是否『相等』,這兩個類只有在同一個類加載器加載的前提下才有意義。否則,即使這兩個類來源于同一個Class文件,被同一個JVM加載,只要加載它們的類加載器不同,那這兩個類就必定不相等。
類加載器之間是什么關系?
如下圖所示,三種加載器之間的層次關系被稱為類加載器的 『雙親委派模型(Parents Delegation Model)』。
雙親委派模型要求除了頂層的啟動類加載器外,其余的類加載器都應有自己的父類加載器。不過這里類加載器之間的父子關系一般不是以繼承(Inheritance)的關系來實現的,而是通常使用組合(Composition)關系來復用父加載器的代碼。圖:
這里說個有意思的問題,不止一次在某些文章留言中看到糾結:『為什么叫做雙親?』國外文章寫的 parent delegation model,這里的parent不是單親嗎??應該翻譯為單親委派模型才對,全互聯網都跟著錯誤走。。。其實parent這個英文單詞翻譯過來也有雙親的意思,不需要做個『杠精』,沒啥意義哈。
雙親委派模型工作過程
結合類加載器的自底向上的委托關系總結:
假設一個類處于ClassPath下,版本是JDK8,默認使用應用類加載器進行加載。
1)當應用類加載器收到了類加載的請求,會把這個請求委派給它的父類(擴展類)加載器去完成。
2)擴展類加載器收到類加載的請求,會把這個請求委派給它的父類(引導類)加載器去完成。
3)引導類加載器收到類加載的請求,查找下自己的特定庫是否能加載該類,即在rt.jar、tools.jar...包中的類。發現不能呀!返回給擴展類加載器結果。
4)擴展類加載器收到返回結果,查找下自己的擴展目錄下是否能加載該類,發現不能啊!返回給應用類加載器結果。
5)應用類加載器收到結果,額!都沒有加載成功,那只能自己加載這個類了,發現在ClassPath中找到了,加載成功。
你對并發很感興趣,自己創建了個跟JDK一樣的全限定名類LongAdder, java.util.concurrent.atomic.LongAdder
,然后程序啟動交給類加載器去加載,能成功嗎?
當然不能!這個LongAdder是 Doug Lea 大神寫的,貢獻到JDK并發包下的,并且被安排在rt.jar包中了,因此是由 Bootstrap ClassLoader 類加載器優先加載的,別人誰寫同樣的類,那就是故意跟JDK作對,是絕對不容許的。
即使你寫了同樣的類,編譯可以通過,但是永遠不會被加載運行,被JDK直接忽略掉。
ClassLoader源碼分析
雙親委派模型在JDK中內部是如何實現的?
JDK中提供了一個抽象的類加載器 ClassLoader,其中提供了三個非常核心的方法。
public abstract class ClassLoader {
//每個類加載器都有個父加載器
private final ClassLoader parent;
public Class<?> loadClass(String name) {
//查找一下這個類是不是已經加載過了
Class<?> c = findLoadedClass(name);
//如果沒有加載過
if( c == null ){
//先委托給父加載器去加載,注意這是個遞歸調用
if (parent != null) {
c = parent.loadClass(name);
}else {
// 如果父加載器為空,查找Bootstrap加載器是不是加載過了
c = findBootstrapClassOrNull(name);
}
}
// 如果父加載器沒加載成功,調用自己的findClass去加載
if (c == null) {
c = findClass(name);
}
return c;
}
protected Class<?> findClass(String name){
//1. 根據傳入的類名name,到在特定目錄下去尋找類文件,把.class文件讀入內存
...
//2. 調用defineClass將字節數組轉成Class對象
return defineClass(buf, off, len);
}
// 將字節碼數組解析成一個Class對象,用native方法實現
protected final Class<?> defineClass(byte[] b, int off, int len){
...
}
}
參見ClassLoader核心代碼注釋,提取和印證幾個關鍵信息:
1)JVM 的類加載器是分層次的,它們有父子關系,每個類加載器都有個父加載器,是parent字段。
2)loadClass() 方法是 public 修飾的,說明它才是對外提供服務的接口。根據源碼可看出這是一個遞歸調用,父子關系是一種組合關系,子加載器持有父加載器的引用,當一個類加載器需要加載一個 Java 類時,會先委托父加載器去加載,然后父加載器在自己的加載路徑中搜索 Java 類,當父加載器在自己的加載范圍內找不到時,才會交還給子加載器加載,這就是所謂的『雙親委托模型』。
3)**findClass() **方法的主要職責就是找到 .class 文件,可能來自磁盤或者網絡,找到后把.class文件讀到內存得到byte[]字節碼數組,然后調用 defineClass() 方法得到 Class 對象。
4)defineClass() 是個工具方法,它的職責是調用 native 方法把 Java 類的字節碼解析成一個 Class 對象,所謂的 native 方法就是由 C 語言實現的方法,Java 通過 JNI 機制調用。
雙親委派模型在JDK不同版本中有哪些變化?
JDK8中的三層類加載器:
JDK8以及之前的JDK版本都是如下三層類加載器實現方式。
1)啟動類加載器(Bootstrap ClassLoader),這個類加載器是由C++實現的,負載加載$JAVA_HOME/jre/lib目錄下的jar文件,比如 rt.jar、tools.jar,或者-Xbootclasspath系統環境變量指定目錄下的路徑。它是個超級公民,即使開啟了Security Manager的時候,它也能擁有加載程序的所有權限,使用null作為擴展類加載器的父類。
同時,啟動類加載器在JVM啟動后也用于加載擴展類加載器和系統類加載器。
2)擴展類加載器(Extension ClassLoader),這個類加載器由sun.misc.Launcher$ExtClassLoader
來實現,負責加載$JAVA_HOME/jre/lib/ext目錄中,或者java.ext.dirs系統變量指定路徑中所有的類庫,允許用戶將具備通用性的類庫可以放到ext目錄下,擴展Java SE功能。在JDK 9之后,這種擴展機制被模塊化帶來的天然的擴展能力所取代。
3)應用類加載器(App/System ClassLoader),也稱作為系統類加載器,這個類加載器由sun.misc.Launcher$AppClassLoader
來實現。 它負責加載用戶應用類路徑(ClassPath)上所有的類庫,開發者同樣可以直接在代碼中使用這個類加載器。如果應用程序中沒有自定義過自己的類加載器,一般情況下這個就是程序中默認的類加載器。
JDK9中的類加載器有哪些變化?
1)擴展類加載器被重命名為平臺類加載器(Platform ClassLoader),部分不需要 AllPermission 的 Java 基礎模塊,被降級到平臺類加載器中,相應的權限也被更精細粒度地限制起來。
2) 擴展類加載器機制被移除。這會帶來什么影響呢?就是說如果我們指定 java.ext.dirs 環境變量,或者 $JAVA_HOME/jre/lib/ext目錄存在,JVM會返回錯誤。 建議解決辦法就是將其放入 classpath 里。部分不需要 AllPermission 的 Java 基礎模塊,被降級到平臺類加載器中,相應的權限也被更精細粒度地限制起來。
3)在$JAVA_HOME/jre/lib路徑下的 rt.jar 和 tools.jar 同樣是被移除了。JDK 的核心類庫以及相關資源,被存儲在 jimage 文件中,并通過新的 JRT 文件系統訪問,而不是原有的 JAR 文件系統。
4)增加了 Layer 的抽象, JVM 啟動默認創建 BootLayer,開發者也可以自己去定義和實例化 Layer,可以更加方便的實現類似容器一般的邏輯抽象。
新增的Layer的抽象,去內部的BootLayer作為內建類加載器,包括了 BootStrap Loader、Platform Loader、Application Loader,其他 Layer 內部有自定義的類加載器,不同版本模塊可以同時工作在不同的 Layer。
結合了 Layer,目前最新的 JVM 內部結構如下圖所示:
5、Tomcat如何打破雙親委派模型的
因為JDK里的類加載器ClassLoader是抽象類,如果你自定義類加載器可以重寫 findClass() 方法,重寫 findClass() 方法還是會按照既定的雙親委派機制運作的。
而我們發現loadClass()方法也是public修飾的,說明也是允許重寫的,重寫loadClass()方法就可以『為所欲為』了,不按照既定套路出牌了,不遵循雙親委派模型。
典型的就是Tomcat應用容器,就是自定義WebAppClassLoader類加載器,打破了雙親委派模型。
WebAppClassLoader 類加載器具體實現是重寫了 ClassLoader 的兩個方法:loadClass() 和 findClass()。其大致工作過程:首先類加載器自己嘗試去加載某個類,如果找不到再委托代理給父類加載器,其目的是優先加載 Web 應用自己定義的類。
這也正是一個Tomcat能夠部署多個應用實例的根本原因。
接下來,我們分析下源碼實現:
loadClass() 重寫方法的源碼實現,僅保留最核心的代碼便于理解:
// 重寫了 loadClass() 方法
public Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
// 使用了synchronized同步鎖
synchronized (getClassLoadingLock(name)) {
Class<?> clazz = null;
//1)先在本地緩存中,查找該類是否已經加載過
clazz = findLoadedClass0(name);
if (clazz != null) {
if (resolve)
// 本地緩存找到,連接該類
resolveClass(clazz);
return clazz;
}
//2) 從系統類加載器的緩存中,查找該類是否已經加載過
clazz = findLoadedClass(name);
if (clazz != null) {
if (resolve)
// 從系統類加載器緩存找到,連接該類
resolveClass(clazz);
return clazz;
}
// 3)嘗試用ExtClassLoader類加載器類加載
ClassLoader javaseLoader = getJavaseClassLoader();
try {
clazz = javaseLoader.loadClass(name);
if (clazz != null) {
if (resolve)
// 從擴展類加載器中找到,連接該類
resolveClass(clazz);
return clazz;
}
} catch (ClassNotFoundException e) {
// Ignore
}
// 4)嘗試在本地目錄查找加載該類
try {
clazz = findClass(name);
if (clazz != null) {
if (resolve)
// 從本地目錄找到,連接該類
resolveClass(clazz);
return clazz;
}
} catch (ClassNotFoundException e) {
// Ignore
}
// 5) 嘗試用系統類加載器來加載
try {
clazz = Class.forName(name, false, parent);
if (clazz != null) {
if (resolve)
// 從系統類加載器中找到,連接該類
resolveClass(clazz);
return clazz;
}
} catch (ClassNotFoundException e) {
// Ignore
}
}
//6. 上述過程都加載失敗,拋出異常
throw new ClassNotFoundException(name);
}
loadClass() 重寫的方法實現上會復雜些,畢竟打破雙親委派機制就在這里實現的。
主要有如下幾個步驟:
1)先在本地緩存 Cache 查找該類是否已經加載過,即 Tomcat 自定義類加載器 WebAppClassLoader 是否已加載過。
2)如果 Tomcat 類加載器沒有加載過這個類,再看看系統類加載器是否加載過。
3)如果系統類加載器也沒有加載過,此時,會讓 ExtClassLoader 擴展類加載器去加載,很關鍵,其目的防止 Web 應用自己的類覆蓋 JRE 的核心類。
因為 Tomcat 需要打破雙親委托機制,假如 Web 應用里有類似上面舉的例子自定義了 Object 類,如果先加載這些JDK中已有的類,會導致覆蓋掉JDK里面的那個 Object 類。
這就是為什么 Tomcat 的類加載器會優先嘗試用 ExtClassLoader 去加載,因為 ExtClassLoader 會委托給 BootstrapClassLoader 去加載,JRE里的類由BootstrapClassLoader安全加載,然后返回給 Tomcat 的類加載器。
這樣 Tomcat 的類加載器就不會去加載 Web 應用下的 Object 類了,也就避免了覆蓋 JRE 核心類的問題。
4)如果 ExtClassLoader 加載器加載失敗,也就是說 JRE 核心類中沒有這類,那么就在本地 Web 應用目錄下查找并加載。
5)如果本地目錄下沒有這個類,說明不是 Web 應用自己定義的類,那么由系統類加載器去加載。這里請你注意:Web 應用是通過Class.forName調用交給系統類加載器的,因為Class.forName的默認加載器就是系統類加載器。
6)如果上述加載過程全部失敗,拋出 ClassNotFoundException 異常。
findClass() 重寫方法的源碼實現,僅展示最核心代碼便于理解:
// 重寫了 findClass 方法
public Class<?> findClass(String name) throws ClassNotFoundException {
...
Class<?> clazz = null;
try {
//1) 優先在自己Web應用目錄下查找類
clazz = findClassInternal(name);
} catch (RuntimeException e) {
throw e;
}
if (clazz == null) {
try {
//2) 如果在本地目錄沒有找到當前類,則委托代理給父加載器去查找
clazz = super.findClass(name);
} catch (RuntimeException e) {
throw e;
}
//3) 如果父類加載器也沒找到,則拋出ClassNotFoundException
if (clazz == null) {
throw new ClassNotFoundException(name);
}
return clazz;
}
在 findClass() 重寫的方法里,主要有三個步驟:
1)先在 Web 應用本地目錄下查找要加載的類。
2)如果沒有找到,交給父加載器去查找,它的父加載器就是上面提到的系統類加載器 AppClassLoader。
3)如何父加載器也沒找到這個類,拋出 ClassNotFoundException 異常。
6、上下文類加載器深入淺出剖析
我們都知道Jdbc是一個標準,那么具體數據庫廠商會根據Jdbc標準提供自己的數據庫實現,既然Jdbc是一個標準,這些類原生的會存在JDK中了,比如Connection、Statement,而且是位于rt.jar包中的,他們在啟動的時候是由BootstrapClassLoader加載的。
那么怎么具體加載廠商的實現呢?
肯定是通過廠商提供相應的jar包,然后放到我們應用的ClassPath下,這樣的話,廠商所提供的jar中的肯定不是由啟動類加載器去加載的。
所以,廠商的具體驅動的實現是由應用類加載器進行加載的 。
Connection是一個接口,它是由啟動類加載器加載的,而它具體的實現啟動類加載器無法加載,由系統類加載器加載的。這樣會存在什么樣的問題?
根據**類加載原則: **
- 父類加載器的加載類或接口是看不到子類加載器加載的類或接口的。
- 子加載器所加載的類或接口是能看到父加載器加載的類或接口的。
SPI(Service Provider Interface)
父ClassLoader可以使用當前線程Thread.currentThread().getContextClassLoader()所指定的classloader加載的類。
這就改變了父ClassLoader不能使用子ClassLoader或是其他沒有直接父子關系的ClassLoader所加載類的情況,即改變了雙親委托模型。
線程上下文類加載器就是當前線程的Current Classloader。
在雙親委托模型下,類加載器是由下而上,即下層的類加載器會委托上層進行加載。但是對于SPI來說,有些接口是Java核心庫所提供的,而Java核心庫是由啟動類加載器來加載的,而這些接口的實現卻來自于不同jar包(廠商提供),Java的啟動類加載器是不會加載其他來源的jar包,這樣傳統的雙親委托模型就無法滿足SPI的要求。
而通過給當前線程設置上下文類加載器,就可以由設置的上下文類加載器來實現對于接口實現類的加載。
線程上下文類加載器的一般使用模式:
獲取 ---> 使用 --> 還原
ClassLoader classloader = Thread.currentThread().getContextClassLoader();
try {
// 將目標類加載器設置到上下文類加載器
Thread.currentThread().setContextClassLoader(targetTccl);
// 在該方法中使用設置的上下文類加載器加載所需的類
doSomethingUsingContextClassLoader();
} finally {
// 將原來的classloader設置到上下文類加載器
Thread.currentThread().setContextClassLoader(classloader);
}
doSomethingUsingContextClassLoader()方法中則調用了 Thread.currentThread().getContextClassLoader() ,獲取當前線程的上下文類加載器做某些事情。
如果一個類由類加載器A加載,那么這個類的依賴類也是由相同的類加載器加載的(如果該依賴類之前沒有被加載過的話)。
在SPI的接口代碼當中,就可以通過上下文類加載器成功的加載到SPI的實現類。因此,上下文類加載器在很多的SPI的實現中都會得到大量的應用。
當高層提供了統一的接口讓低層(比如Jdbc各個廠商提供的具體實現類)去實現,同時又要在高層加載(或實例化)低層的類時,就必須要通過線程上下文類加載器來幫助高層的類加載器并加載該類(本質上,高層的類加載器與低層的類加載器是不一樣的)
一般情況下,我們沒有修改過線程上下文類加載器,默認的就是系統類加載器。由于是運行期間是設置的上下文類加載器,所以,不管當前程序在什么地方,在啟動類的加載器的范圍內還是擴展類加載器的范圍內,那么我們在任何有需要的時候都是可以通過Thread.currentThread().getContextClassLoader()獲取設置的上下文類加載器來完成操作。
這個也有點像ThreadLocal的類,如果借助于ThreadLocal的話就沒有必要同步,因為每一個線程都有相應的數據副本,這些數據副本之間是互不干擾的,他們只能被當前的線程所使用和訪問,既然每個線程都有數據副本,每個線程當然操作的是副本,所以線程之間就不需要同步、鎖就可以處理并發。ThreadLocal本質上是用空間換時間的概念,因為我們將數據拷貝多份會占用一定的內存空間,每個線程中去使用。
7、最后的總結
限于篇幅,本文主要對類的初始化時機,類的加載過程中最重要的類加載器機制進行了分析,對其中的雙親委派模型,以及Tomcat是如何打破雙親委派模型的,結合源代碼進行了深入剖析,對上下文類加載器是如何改變雙親委派模型進行了分析。
總結一下:
一個類都是通過主動使用
的方式加載到JVM當中的,到目前為止一共總結了八種情況,除此之外的都屬于被動使用
,被動使用的列舉了代碼示例,結合示例可以更為清晰的理解。
詳細介紹了雙親委派模型的工作過程,JDK8和JDK9版本中類加載器層次關系,類加載器的結果本質上并不是一種樹形結構,而是一種包含關系。
同時,也介紹了Tomcat是如何打破雙親委派機制的,通過源碼透視打破規則的全過程。
最后,對上下文類加載器根據Jdbc的例子,進一步分析了使用模式,如何改變雙親委派機制做到父類加載器,可以加載和使用各個廠商提供的實現類的。
另外,回到最初的圖示,一個類要想順利進入到JVM內存結構中,除了類的加載階段外,還有驗證、準備、解析、初始化四個階段完成后,才算真正完成類的初始化操作。
在JVM中某個類的Class對象不再被引用,即不可觸及,Class對象就會結束生命周期,該類在方法區內的數據會被卸載,從而技術該類的整個生命周期。
一個類何時結束生命周期,取決于代表它的Class對象何時結束生命周期。
但是,JVM自帶的類加載器所加載的類,在虛擬機的生命周期中,始終不會被卸載。前面已經介紹過,JVM自帶的類加載器包括引導類加載器、擴展類加載器和系統類加載器(應用類加載器)。Java虛擬機本身會始終引用這些類加載器,而這些類加載器會始終引用它們所加載的類的Class對象,因此這些Class對象是始終可觸及的。
在如下情況下,JVM將結束生命周期。
執行了System.exit()
程序正常執行結束
程序在執行過程中遇到了異常或者錯誤而異常終止
由于操作系統出現錯誤而導致Java虛擬機進程終止
大家如何覺得本文有收獲關個注唄,碼字不易,文章不妥之處,歡迎留言斧正。本號不定期會發布精彩原創文章。
參考資料:
深入理解Java虛擬機
極客時間課程