JVM 的執行子程序詳解(類的加載過程、雙親委派)

目錄


JVM 的執行子程序詳解(類的加載過程、雙親委派)


JVM 的執行子程序詳解(類的加載過程、雙親委派)


Class文件結構:

JVM的無關性

平臺無關性:一次編寫,到處運行

語言無關性:字節碼(Byte-Code)

Class 類文件

任何一個 Class 文件都對應著唯一一個類或接口的定義信息,但反過來說,Class 文件實際上它並不一定以磁盤文件的形式存在。

Class 文件是一組以 8 位字節為基礎單位的二進制流。

Class 文件格式

各個數據項目嚴格按照順序緊湊地排列在 Class 文件之中,中間沒有添加任何分隔符,這使得整個 Class 文件中存儲的內容幾乎全部是程序運行的必要數據,沒有空隙存在。

Class 文件格式採用一種類似於 C 語言結構體的偽結構來存儲數據,這種偽結構中只有兩種數據類型:無符號數和表。無符號數屬於基本的數據類型,以 u1、u2、u4、u8 來分別代表 1 個字節、2 個字節、4 個字節和 8 個字節的無符號數,無符號數可以用來描述數字、索引引用、數量值或者按照 UTF-8 編碼構成字符串值。

表是由多個無符號數或者其他表作為數據項構成的複合數據類型,所有表都習慣性地以“_info”結尾。表用於描述有層次關係的複合結構的數據,整個Class 文件本質上就是一張表。

Class文件格式詳解


JVM 的執行子程序詳解(類的加載過程、雙親委派)

class文件

Class 的結構不像 XML 等描述語言,由於它沒有任何分隔符號,所以在其中的數據項,無論是順序還是數量,都是被嚴格限定的,哪個字節代表什麼含義,長度是多少,先後順序如何,都不允許改變。按順序包括:

<strong>魔數與 Class 文件的版本

每個 Class 文件的頭 4 個字節稱為魔數(Magic Number),它的唯一作用是確定這個文件是否為一個能被虛擬機接受的 Class 文件。使用魔數而不是擴展名來進行識別主要是基於安全方面的考慮,因為文件擴展名可以隨意地改動。文件格式的制定者可以自由地選擇魔數值,只要這個魔數值還沒有被廣泛採用過同時又不會引起混淆即可。

JVM 的執行子程序詳解(類的加載過程、雙親委派)

緊接著魔數的 4 個字節存儲的是 Class 文件的版本號:第 5 和第 6 個字節是次版本號(MinorVersion),第 7 和第 8 個字節是主版本號(Major Version)。Java 的版本號是從 45 開始的,JDK 1.1 之後的每個 JDK 大版本發佈主版本號向上加 1 高版本的 JDK 能向下兼容以前版本的 Class 文件,但不能運行以後版本的 Class 文件,即使文件格式並未發生任何變化,虛擬機也必須拒絕執行超過其版本號的 Class 文件。

JVM 的執行子程序詳解(類的加載過程、雙親委派)

34代表 JDK1.8

<strong>常量池:

常量池中常量的數量是不固定的,所以在常量池的入口需要放置一項 u2 類型的數據,代表常量池容量計數值(constant_pool_count)。與 Java 中語言習慣不一樣的是,這個容量計數是從 1 而不是 0 開始的。

常量池中主要存放兩大類常量:字面量(Literal)和符號引用(Symbolic References)。字面量比較接近於 Java 語言層面的常量概念,如文本字符串、聲明為 final 的常量值等。而符號引用則屬於編譯原理方面的概念,包括了下面三類常量:類和接口的全限定名(Fully Qualified Name)、字段的名稱和描述符(Descriptor)、方法的名稱和描述符。

<strong>訪問標誌

用於識別一些類或者接口層次的訪問信息,包括:這個Class是類還是接口;是否定義為public類型;是否定義為abstract類型;如果是類的話,是否被聲明為final等。

<strong>類索引、父類索引與接口索引集合

這三項數據來確定這個類的繼承關係。類索引用於確定這個類的全限定名,父類索引用於確定這個類的父類的全限定名。由於Java語言不允許多重繼承,所以父類索引只有一個,除了java.lang.Object之外,所有的Java類都有父類,因此除了java.lang.Object外,所有Java類的父類索引都不為0。接口索引集合就用來描述這個類實現了哪些接口,這些被實現的接口將按implements語句(如果這個類本身是一個接口,則應當是extends語句)後的接口順序從左到右排列在接口索引集合中。

<strong>字段表集合

描述接口或者類中聲明的變量。字段(field)包括類級變量以及實例級變量。而字段叫什麼名字、字段被定義為什麼數據類型,這些都是無法固定的,只能引用常量池中的常量來描述。字段表集合中不會列出從超類或者父接口中繼承而來的字段,但有可能列出原本Java代碼之中不存在的字段,譬如在內部類中為了保持對外部類的訪問性,會自動添加指向外部類實例的字段。

<strong>方法表集合

描述了方法的定義,但是方法裡的Java代碼,經過編譯器編譯成字節碼指令後,存放在屬性表集合中的方法屬性表集合中一個名為“Code”的屬性裡面。與字段表集合相類似的,如果父類方法在子類中沒有被重寫(Override),方法表集合中就不會出現來自父類的方法信息。但同樣的,有可能會出現由編譯器自動添加的方法,最典型的便是類構造器“<clinit>”方法和實例構造器“<init>”

<strong>屬性表集合

存儲Class文件、字段表、方法表都自己的屬性表集合,以用於描述某些場景專有的信息。如方法的代碼就存儲在Code屬性表中。



字節碼指令

java 虛擬機的指令由一個字節長度的、代表著某種特定操作含義的數字(稱為操作碼,Opcode)以及跟隨其後的零至多個代表此操作所需參數(稱為操作數,Operands)而構成。由於限制了 Java 虛擬機操作碼的長度為一個字節(即 0~255),這意味著指令集的操作碼總數不可能超過 256 條。大多數的指令都包含了其操作所對應的數據類型信息。例如:

iload 指令用於從局部變量表中加載 int 型的數據到操作數棧中,而 fload 指令加載的則是 float 類型的數據。

大部分的指令都沒有支持整數類型 byte、char 和 short,甚至沒有任何指令支持 boolean 類型。大多數對於 boolean、byte、short 和 char 類型數據的操作,實際上都是使用相應的 int 類型作為運算類型。

<strong>加載和存儲指令

用於將數據在棧幀中的局部變量表和操作數棧之間來回傳輸,這類指令包括如下內容。

將一個局部變量加載到操作棧:iload、iload_<n>、lload、lload_<n>、fload、fload_<n>、dload、dload_<n>、aload、aload_<n>。

將一個數值從操作數棧存儲到局部變量表:istore、istore_<n>、lstore、lstore_<n>、fstore、fstore_<n>、dstore、dstore_<n>、astore、astore_<n

>。

將一個常量加載到操作數棧:bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、iconst_m1、iconst_<i>、lconst_<l>、fconst_<f>、dconst_<d>。

擴充局部變量表的訪問索引的指令:wide。

<strong>運算或算術指令

用於對兩個操作數棧上的值進行某種特定運算,並把結果重新存入到操作棧頂。

加法指令:iadd、ladd、fadd、dadd。

減法指令:isub、lsub、fsub、dsub。

乘法指令:imul、lmul、fmul、dmul 等等

<strong>類型轉換指令

可以將兩種不同的數值類型進行相互轉換,

Java 虛擬機直接支持以下數值類型的寬化類型轉換(即小範圍類型向大範圍類型的安全轉換):

int 類型到 long、float 或者 double 類型。

long 類型到 float、double 類型。

float 類型到 double 類型。

處理窄化類型轉換(Narrowing Numeric Conversions)時,必須顯式地使用轉換指令來完成,這些轉換指令包括:i2b、i2c、i2s、l2i、f2i、f2l、d2i、d2l 和d2f

<strong>創建類實例的指令

new

<strong>創建數組的指令

newarray、anewarray、multianewarray。

<strong>訪問字段指令

getfield、putfield、getstatic、putstatic。

<strong>數組存取相關指令

把一個數組元素加載到操作數棧的指令:baload、caload、saload、iaload、laload、faload、daload、aaload。

將一個操作數棧的值存儲到數組元素中的指令:bastore、castore、sastore、iastore、fastore、dastore、aastore。

取數組長度的指令:arraylength。

<strong>檢查類實例類型的指令

instanceof、checkcast。

<strong>操作數棧管理指令

如同操作一個普通數據結構中的堆棧那樣,Java 虛擬機提供了一些用於直接操作操作數棧的指令,包括:將操作數棧的棧頂一個或兩個元素出棧:pop、pop2。

複製棧頂一個或兩個數值並將複製值或雙份的複製值重新壓入棧頂:dup、dup2、dup_x1、dup2_x1、dup_x2、dup2_x2。

將棧最頂端的兩個數值互換:swap。

<strong>控制轉移指令

控制轉移指令可以讓 Java 虛擬機有條件或無條件地從指定的位置指令而不是控制轉移指令的下一條指令繼續執行程序,從概念模型上理解,可以認為控制轉移指令就是在有條件或無條件地修改 PC 寄存器的值。控制轉移指令如下。條件分支:ifeq、iflt、ifle、ifne、ifgt、ifge、ifnull、ifnonnull、if_icmpeq、if_icmpne、if_icmplt、if_icmpgt、if_icmple、if_icmpge、if_acmpeq 和 if_acmpne。

複合條件分支:tableswitch、lookupswitch。

無條件分支:goto、goto_w、jsr、jsr_w、ret。

<strong>方法調用指令

invokevirtual 指令用於調用對象的實例方法,根據對象的實際類型進行分派(虛方法分派),這也是 Java 語言中最常見的方法分派方式。

invokeinterface 指令用於調用接口方法,它會在運行時搜索一個實現了這個接口方法的對象,找出適合的方法進行調用。

invokespecial 指令用於調用一些需要特殊處理的實例方法,包括實例初始化方法、私有方法和父類方法。

invokestatic 指令用於調用類方法(static 方法)。

invokedynamic 指令用於在運行時動態解析出調用點限定符所引用的方法,並執行該方法,前面 4 條調用指令的分派邏輯都固化在 Java 虛擬機內部,而invokedynamic 指令的分派邏輯是由用戶所設定的引導方法決定的。方法調用指令與數據類型無關。

<strong>方法返回指令

是根據返回值的類型區分的,包括 ireturn(當返回值是 boolean、byte、char、short 和 int 類型時使用)、lreturn、freturn、dreturn 和 areturn,另外還有一條 return 指令供聲明為 void 的方法、實例初始化方法以及類和接口的類初始化方法使用。

<strong>異常處理指令

在 Java 程序中顯式拋出異常的操作(throw 語句)都由 athrow 指令來實現

<strong>同步指令

有 monitorenter 和 monitorexit 兩條指令來支持 synchronized 關鍵字的語義



虛擬機棧再認識

之前的JVM內存結構篇講解過虛擬機棧,本文再認識一下。

棧幀中的數據在編譯後就已經確定了,寫在了字節碼文件的 code 屬性中(屬性表集合)


JVM 的執行子程序詳解(類的加載過程、雙親委派)


<strong>棧楨詳解

當前棧幀有效:一個線程的方法調用鏈可能會很長,這意味著虛擬機棧會被壓入很多棧幀,但在線程執行的某個時間點只有位於棧頂的棧幀才是有效的,該棧幀稱為“當前棧幀”,與這個棧幀相關聯的方法稱為“當前方法”。

<strong>局部變量表

局部變量表的容量以變量槽(Variable Slot,下稱 Slot)為最小單位,虛擬機規範中導向性地說到每個 Slot 都應該能存放一個 boolean、byte、char、short、int、float、double、long 8 種數據類型和 reference ,可以使用 32 位或更小的物理內存來存放。

對於 64 位的數據類型,虛擬機會以高位對齊的方式為其分配兩個連續的 Slot 空間。Java 語言中明確的(reference 類型則可能是 32 位也可能是 64 位)64 位的數據類型只有 long 和 double 兩種。

<strong>操作數棧

操作數棧(Operand Stack)也常稱為操作棧,它是一個先進後出(First In Last Out,FILO)棧。 同局部變量表一樣, 操作數棧的每一個元素可以是任意的Java 數據類型,包括 long 和 double。 32 位數據類型所佔的棧容量為 1,64 位數據類型所佔的棧容量為 2。

當一個方法剛剛開始執行的時候,這個方法的操作數棧是空的,在方法的執行過程中,會有各種字節碼指令往操作數棧中寫入和提取內容,也就是出棧/入棧操作。 例如,在做算術運算的時候是通過操作數棧來進行的,又或者在"調用其他方法的時候是通過操作數棧來進行參數傳遞的"。

java 虛擬機的解釋執行引擎稱為“基於棧的執行引擎”,其中所指的“棧”就是操作數棧。

<strong>數據重疊優化

虛擬機概念模型中每二個棧幀都是相互獨立的,但在實際應用是我們知道一個方法調用另一個方法時,往往存在參數傳遞,這種做法在虛擬機實現過程中會做一些優化,具體做法如下:令兩個棧幀出現一部分重疊。讓下面棧幀的一部分操作數棧與上面棧幀的部分局部變量表重疊在一起,進行方法調用時就可以共用一部分數據,無須進行額外的參數複製傳遞。


JVM 的執行子程序詳解(類的加載過程、雙親委派)

<strong>動態連接

既然是執行方法,那麼我們需要知道當前棧幀執行的是哪個方法,棧幀中會持有一個引用(符號引用),該引用指向某個具體方法。

符號引用是一個地址位置的代號,在編譯的時候我們是不知道某個方法在運行的時候是放到哪裡的,這時我用代號 com/pine/pojo/User.Say:()V 指代某個類的方法,將來可以把符號引用轉換成直接引用進行真實的調用。

用符號引用轉化成直接引用的解析時機,把解析分為兩大類,靜態解析:符號引用在類加載階段或者第一次使用的時候就直接轉換成直接引用。動態連接:符號引用在每次運行期間轉換為直接引用,即每次運行都重新轉換。

<strong>方法返回地址

方法退出方式有:正常退出與異常退出

理論上,執行完當前棧幀的方法,需要返回到當前方法被調用的位置,所以棧幀需要記錄一些信息,用來恢復上層方法的執行狀態。正常退出,上層方法的 PC 計數器可以做為當前方法的返回地址,被保存在當前棧幀中。"異常退出時,返回地址是要通過異常處理器表來確定的,棧幀中一般不會保存這部分信息"

方法退出時會做的操作:恢復上次方法的局部變量表、操作數棧,把當前方法的返回值,壓入調用者棧幀的操作數棧中,使用當前棧幀保存的返回地址調整 PC 計數器的值,當前棧幀出棧,隨後,執行 PC 計數器指向的指令。

<strong>附加信息

虛擬機規範允許實現虛擬機時增加一些額外信息,例如與調試相關的信息。

一般把把 動態連接、方法返回地址、其他額外信息歸成一類,稱為棧幀信息。


基於棧的字節碼解釋執行引擎

Java 編譯器輸出的指令流,基本上是一種基於棧的指令集架構,指令流中的指令大部分都是零地址指令,它們依賴操作數棧進行工作。與基於寄存器的指令集,最典型的就是 x86 的二地址指令集,說得通俗一些,就是現在我們主流 PC 機中直接支持的指令集架構,這些指令依賴寄存器進行工作。

<strong>基於棧的指令集

舉個最簡單的例子,分別使用這兩種指令集計算“1+1”的結果,基於棧的指令集會是這樣子的:

iconst_1

iconst_1

iadd

istore_0

兩條 iconst_1 指令連續把兩個常量 1 壓入棧後,iadd 指令把棧頂的兩個值出棧、相加,然後把結果放回棧頂,最後 istore_0 把棧頂的值放到局部變量表的第 0 個 Slot 中。

<strong>基於寄存器的指令集

如果基於寄存器,那程序可能會是這個樣子:

mov eax,1

add eax,1

mov 指令把 EAX 寄存器的值設為 1,然後 add 指令再把這個值加 1,結果就保存在 EAX 寄存器裡面。

基於棧的指令集主要的優點就是可移植,寄存器由硬件直接提供,程序直接依賴這些硬件寄存器則不可避免地要受到硬件的約束。棧架構指令集的主要缺點是執行速度相對來說會稍慢一些。所有主流物理機的指令集都是寄存器架構也從側面印證了這一點。


JVM 的執行子程序詳解(類的加載過程、雙親委派)


方法調用詳解

解析:調用目標在程序代碼寫好、編譯器進行編譯時就必須確定下來。這類方法的調用稱為解析。在 Java 語言中符合“編譯期可知,運行期不可變”這個要求的方法,主要包括靜態方法和私有方法兩大類,前者與類型直接關聯,後者在外部不可被訪問,這兩種方法各自的特點決定了它們都不可能通過繼承或別的方式重寫其他版本,因此它們都適合在類加載階段進行解析。

<strong>靜態分派:


JVM 的執行子程序詳解(類的加載過程、雙親委派)

“Human”稱為變量的靜態類型(Static Type),或者叫做的外觀類型(Apparent Type),後面的“Man”則稱為變量的實際類型(Actual Type),靜態類型和實際類型在程序中都可以發生一些變化,區別是靜態類型的變化僅僅在使用時發生,變量本身的靜態類型不會被改變,並且最終的靜態類型是在編譯期可知的;而實際類型變化的結果在運行期才可確定,編譯器在編譯程序的時候並不知道一個對象的實際類型是什麼。

代碼中定義了兩個靜態類型相同但實際類型不同的變量,但虛擬機(準確地說是編譯器)在重載時是通過參數的靜態類型而不是實際類型作為判定依據的。並且靜態類型是編譯期可知的,因此,在編譯階段,Javac 編譯器會根據參數的靜態類型決定使用哪個重載版本,所以選擇了 sayHello(Human)作為調用目標。所有依賴靜態類型來定位方法執行版本的分派動作稱為靜態分派。靜態分派的典型應用是方法重載。靜態分派發生在編譯階段,因此確定靜態分派的動作實際上不是由虛擬機來執行的。

<strong>動態分派:

靜態類型同樣都是 Human 的兩個變量 man 和 woman 在調用 sayHello()方法時執行了不同的行為,並且變量 man 在兩次調用中執行了不同的方法。導致這個現象的原因很明顯,是這兩個變量的實際類型不同。在實現上,最常用的手段就是為類在方法區中建立一個虛方法表。虛方法表中存放著各個方法的實際入口地址。如果某個方法在子類中沒有被重寫,那子類的虛方法表裡面的地址入口和父類相同方法的地址入口是一致的,都指向父類的實現入口。如果子類中重寫了這個方法,子類方法表中的地址將會替換為指向子類實現版本的入口地址。圖中,Son 重寫了來自 Father 的全部方法,因此 Son 的方法表沒有指向 Father 類型數據的箭頭。但是 Son 和 Father都沒有重寫來自 Object 的方法,所以它們的方法表中所有從 Object 繼承來的方法都指向了 Object 的數據類型。


JVM 的執行子程序詳解(類的加載過程、雙親委派)


類加載機制

類從被加載到虛擬機內存中開始,到卸載出內存為止,它的整個生命週期包括:加載(Loading)、驗證(Verification)、準備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸載(Unloading)7 個階段。其中驗證、準備、解析 3 個部分統稱為連接(Linking)

初始化:

始化階段,虛擬機規範則是嚴格規定了有且只有 5 種情況必須立即對類進行“初始化”(而加載、驗證、準備自然需要在此之前開始):

1)遇到 new、getstatic、putstatic 或 invokestatic 這 4 條字節碼指令時,如果類沒有進行過初始化,則需要先觸發其初始化。生成這 4 條指令的最常見的Java 代碼場景是:使用 new 關鍵字實例化對象的時候、讀取或設置一個類的靜態字段(被 final 修飾、已在編譯期把結果放入常量池的靜態字段除外)的時候,以及調用一個類的靜態方法的時候。

2)使用 java.lang.reflect 包的方法對類進行反射調用的時候,如果類沒有進行過初始化,則需要先觸發其初始化。

3)當初始化一個類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化。

4)當虛擬機啟動時,用戶需要指定一個要執行的主類(包含 main()方法的那個類),虛擬機會先初始化這個主類。

5)當使用 JDK 1.7 的動態語言支持時,如果一個 java.lang.invoke.MethodHandle 實例最後的解析結果 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,並且這個方法句柄所對應的類沒有進行過初始化,則需要先觸發其初始化。

加載階段

虛擬機需要完成以下 3 件事情:

1)通過一個類的全限定名來獲取定義此類的二進制字節流。

2)將這個字節流所代表的靜態存儲結構轉化為方法區的運行時數據結構。

3)在內存中生成一個代表這個類的 java.lang.Class 對象,作為方法區這個類的各種數據的訪問入口。

驗證

是連接階段的第一步,這一階段的目的是為了確保 Class 文件的字節流中包含的信息符合當前虛擬機的要求,並且不會危害虛擬機自身的安全。但從整體上看,驗證階段大致上會完成下面 4 個階段的檢驗動作:文件格式驗證、元數據驗證、字節碼驗證、符號引用驗證。

準備階段

是正式為類變量分配內存並設置類變量初始值的階段,這些變量所使用的內存都將在方法區中進行分配。這個階段中有兩個容易產生混淆的概念需要強調一下,首先,這時候進行內存分配的僅包括類變量(被 static 修飾的變量),而不包括實例變量,實例變量將會在對象實例化時隨著對象一起分配在 Java堆中。其次,這裡所說的初始值“通常情況”下是數據類型的零值,假設一個類變量的定義為:public static int value=123;那變量 value 在準備階段過後的初始值為 0 而不是 123,因為這時候尚未開始執行任何 Java 方法,而把 value 賦值為 123 的 putstatic 指令是程序被編譯後,存放於類構造器<clinit>()方法之中,所以把 value 賦值為 123 的動作將在初始化階段才會執行。假設上面類變量 value 的定義變為:public static final int value=123;編譯時 Javac 將會為 value 生成 ConstantValue 屬性,在準備階段虛擬機就會根據 ConstantValue 的設置將 value 賦值為 123。

解析階段

是虛擬機將常量池內的符號引用替換為直接引用的過程。

類初始化階段

是類加載過程的最後一步,前面的類加載過程中,除了在加載階段用戶應用程序可以通過自定義類加載器參與之外,其餘動作完全由虛擬機主導和控制。到了初始化階段,才真正開始執行類中定義的 Java 程序代碼在準備階段,變量已經賦過一次系統要求的初始值,而在初始化階段,則根據程序員通過程序制定的主觀計劃去初始化類變量和其他資源,或者可以從另外一個角度來表達:初始化階段是執行類構造器<clinit>()方法的過程。<clinit>()方法是由編譯器自動收集類中的所有類變量的賦值動作和靜態語句塊(static{}塊)中的語句合併產生的,編譯器收集的順序是由語句在源文件中出現的順序所決定的。<clinit>()方法對於類或接口來說並不是必需的,如果一個類中沒有靜態語句塊,也沒有對變量的賦值操作,那麼編譯器可以不為這個類生成<clinit>()方法。

<strong>初始化的單例模式(線程安全):

虛擬機會保證一個類的<clinit>()方法在多線程環境中被正確地加鎖、同步,如果多個線程同時去初始化一個類,那麼只會有一個線程去執行這個類的<clinit>()方法,其他線程都需要阻塞等待,直到活動線程執行<clinit>()方法完畢。如果在一個類的<clinit>()方法中有耗時很長的操作,就可能造成多個進程阻塞。所以類的初始化是線程安全的,項目中可以利用這點。

類加載器

對於任意一個類,都需要由加載它的類加載器和這個類本身一同確立其在 Java 虛擬機中的唯一性,每一個類加載器,都擁有一個獨立的類名稱空間。這句話可以表達得更通俗一些:比較兩個類是否“相等”,只有在這兩個類是由同一個類加載器加載的前提下才有意義,否則,即使這兩個類來源於同一個 Class 文件,被同一個虛擬機加載,只要加載它們的類加載器不同,那這兩個類就必定不相等。這裡所指的“相等”,包括代表類的 Class 對象的 equals()方法、isAssignableFrom()方法、isInstance()方法的返回結果,也包括使用 instanceof 關鍵字做對象所屬關係判定等情況。


雙親委派模型


JVM 的執行子程序詳解(類的加載過程、雙親委派)

對於任意一個類,都需要由加載它的類加載器和這個類本身一同確立其在 Java 虛擬機中的唯一性.從 Java 虛擬機的角度來講,只存在兩種不同的類加載器:一種是啟動類加載器(Bootstrap ClassLoader ),這個類加載器使用 C++語言實現,是虛擬機自身的一部分;另一種就是所有其他的類加載器,這些類加載器都由 Java 語言實現,獨立於虛擬機外部,並且全都繼承自抽象類 java.lang.ClassLoader。

啟動類加載器 (Bootstrap ClassLoader ):這個類將器負責將存放在<JAVA_HOME>\\lib 目錄中的,或者被-Xbootclasspath 參數所指定的路徑中的,並且是虛擬機識別的(僅按照文件名識別,如 rt.jar,名字不符合的類庫即使放在 lib 目錄中也不會被加載)類庫加載到虛擬機內存中。啟動類加載器無法被 Java程序直接引用,用戶在編寫自定義類加載器時,如果需要把加載請求委派給引導類加載器,那直接使用 null 代替即可。

擴展類加載器(Extension ClassLoader ):這個加載器由 sun.misc.Launcher$ExtClassLoader 實現,它負責加載<JAVA_HOME>\\lib\\ext 目錄中的,或者被java.ext.dirs 系統變量所指定的路徑中的所有類庫,開發者可以直接使用擴展類加載器。

應用程序類加載器(Application ClassLoader):這個類加載器由 sun.misc.Launcher $App-ClassLoader 實現。由於這個類加載器是 ClassLoader 中的getSystemClassLoader()方法的返回值,所以一般也稱它為系統類加載器。它負責加載用戶類路徑(ClassPath)上所指定的類庫,開發者可以直接使用這個類加載器,如果應用程序中沒有自定義過自己的類加載器,一般情況下這個就是程序中默認的類加載器。

我們的應用程序都是由這 3 種類加載器互相配合進行加載的,如果有必要,還可以加入自己定義的類加載器。

雙親委派模型要求除了頂層的啟動類加載器外,其餘的類加載器都應當有自己的父類加載器。這裡類加載器之間的父子關係一般不會以繼承(Inheritance)的關係來實現,而是都使用組合(Composition)關係來複用父加載器的代碼。使用雙親委派模型來組織類加載器之間的關係,有一個顯而易見的好處就是 Java 類隨著它的類加載器一起具備了一種帶有優先級的層次關係。例如類java.lang.Object,它存放在 rt.jar 之中,無論哪一個類加載器要加載這個類,最終都是委派給處於模型最頂端的啟動類加載器進行加載,因此 Object 類在程序的各種類加載器環境中都是同一個類。相反,如果沒有使用雙親委派模型,由各個類加載器自行去加載的話,如果用戶自己編寫了一個稱為java.lang.Object 的類,並放在程序的 ClassPath 中,那系統中將會出現多個不同的 Object 類,Java 類型體系中最基礎的行為也就無法保證,應用程序也將會變得一片混亂。

應用程序 類加載器

ClassLoader 中的 loadClass 方法中的代碼邏輯就是雙親委派模型:在自定義 ClassLoader 的子類時候,我們常見的會有兩種做法,一種是重寫 loadClass 方法,另一種是重寫 findClass 方法。其實這兩種方法本質上差不多,畢竟 loadClass 也會調用 findClass,但是從邏輯上講我們最好不要直接修改 loadClass 的內部邏輯。我建議的做法是隻在 findClass 裡重寫自定義類的加載方法。

loadClass 這個方法是實現雙親委託模型邏輯的地方,擅自修改這個方法會導致模型被破壞,容易造成問題。因此我們最好是在雙親委託模型框架內進行小範圍的改動,不破壞原有的穩定結構。同時,也避免了自己重寫 loadClass 方法的過程中必須寫雙親委託的重複代碼,從代碼的複用性來看,不直接修改這個方法始終是比較好的選擇。


Tomcat 類加載機制

Tomcat 本身也是一個 java 項目,因此其也需要被 JDK 的類加載機制加載,也就必然存在引導類加載器、擴展類加載器和應用(系統)類加載器。Common ClassLoader 作為 Catalina ClassLoader 和 Shared ClassLoader 的 parent,而 Shared ClassLoader 又可能存在多個 children 類加載器 WebApp ClassLoader,一個 WebApp ClassLoader 實際上就對應一個 Web 應用,那 Web 應用就有可能存在 Jsp 頁面,這些 Jsp 頁面最終會轉成 class 類被加載,因此也需要一個 Jsp的類加載器。

需要注意的是,在代碼層面 Catalina ClassLoader、Shared ClassLoader、Common ClassLoader 對應的實體類實際上都是 URLClassLoader 或者 SecureClassLoader,一般我們只是根據加載內容的不同和加載父子順序的關係,在邏輯上劃分為這三個類加載器;而 WebApp ClassLoader 和 JasperLoader 都是存在對應的類加載器類的。

當 tomcat 啟動時,會創建幾種類加載器:

1 Bootstrap 引導類加載器 加載 JVM 啟動所需的類,以及標準擴展類(位於 jre/lib/ext 下)

2 System 系統類加載器 加載 tomcat 啟動的類,比如 bootstrap.jar,通常在 catalina.bat 或者 catalina.sh 中指定。位於 CATALINA_HOME/bin 下。

3 Common 通用類加載器 加載 tomcat 使用以及應用通用的一些類,位於 CATALINA_HOME/lib 下,比如 servlet-api.jar

4 webapp 應用類加載器每個應用在部署後,都會創建一個唯一的類加載器。該類加載器會加載位於 WEB-INF/lib 下的 jar 文件中的 class 和 WEB-INF/classes下的 class 文件。


分享到:


相關文章: