Java有根兒:Class檔案以及類載入器

2022-05-29 21:01:46

JVM 是Java的基石,Java從業者需要了解。然而相比JavaSE來講,不瞭解JVM的一般來說也不會影響到工作,但是對於有調優需求或者系統架構師的崗位來說,JVM非常重要。JVM不是一個新的知識,網上文章很多,本篇的不同之處在於參考一手資料、內容經過反覆推敲、思維邏輯更加連貫、知識更加系統化、研究路線採取按圖索驥的方式。本文將會有篩選地研究JVM的精華部分,至少達到準系統架構師夠用的程度。本篇主要分享學習Java Class檔案以及類載入器CLassLoader的知識。以下是一些說明:

①由於篇幅有限,預設一些基礎背景知識已經達成了共識,不會贅述。

②本文重點研究JVM的抽象標準(或者理解為一套介面),至於實現的內容不是本文的重點學習物件。

(那麼實現的內容包括哪些呢?例如像執行時資料區的記憶體排布、垃圾收集演演算法的使用,以及任何基於JVM指令集的內在優化等。這其中關於GC的部分是我們都比較熱衷的,將會額外開一篇進行學習。

③本文不會介紹Java不同版本的區別或特性升級,僅以目前工作中用到最多的java 8為學習材料。

④本文不會重點介紹javaSE的內容。

⑤class檔案的編譯過程(*.java =javac=> *.class)可能不會包含在本文中。

最後補充一下,文章題目「Java有根兒」的由來及含義:「有根兒」通常指胸有成竹、有底氣、有靠山、自信的來源。這裡通過這種比較戲謔的詞語表達了Class檔案以及類載入器對於Java的一個重要地位關係,同時也突出了娛樂時代,學習也是從興趣出發的一種心態,學習也是娛樂的一種 ^ ^。

關鍵字:JVM、Java、Class、位元組碼、BootstrapClassLoader、ClassLoader、雙親委派機制、熱部署

JVM前置知識

  1. JVM是Java的基石,但不限於Java語言使用,任何能夠生成class檔案的語言皆可使用。

    實際上,JVM對Java語言一無所知,它只認識class檔案,通過ClassLoader來載入,這是一種JVM特定的二進位制檔案,該檔案包含了JVM指令、符號表以及一些附加資訊。

  2. JVM是一個抽象計算機,有自己的指令集以及執行時記憶體操作區。

  3. JVM包括直譯器和JIT編譯器以及執行引擎,一般採用混合模式。編譯器會針對不同作業系統直接生成可執行檔案,而直譯器是在執行時邊解釋邊執行。一般呼叫次數較多的類庫或程式會直接編譯成原生程式碼,提高效率。

    編譯器和直譯器在其他語言也有廣泛的運用,總之活是一樣多,看你先幹還是後幹,各有利弊。純編譯器語言編譯的時候就慢但執行快,純直譯器語言編譯是很快,但執行稍慢。

  4. JVM對主流的不同作業系統都做了支援,JVM之上的語言層面不需要考慮作業系統的異構,繼而實現了語言的跨平臺。

  5. JRE包括JVM和JavaSE核心類庫。而JDK包括JRE和開發工具,包括核心類庫原始碼等。一般作為開發者需要JDK,而執行Java程式只需要JRE即可。

1.class檔案

class檔案是JVM的輸入,內容是已編譯的程式碼,它是一種跨硬體和跨作業系統的二進位制格式。class檔案可以準確定義類和介面,以及他們內部的針對不同平臺分配的記憶體位元組表示。下面我們看一下一個class檔案的16進位制內容。

圖1-A Class檔案位元組碼

圖1-A是通過IDEA的BinEd外掛,檢視到的一個最簡單的類編譯出來的class檔案的16進位制內容,這個類原始碼如下:

package com.evswards.jvm;
public class Test001 {}

由此我們能獲得一些資訊:

  1. 每個位元組由兩個16進位制數構成,每個16進位制數我們知道是4位元(bit),那麼一個位元組就是8位元。class檔案的最小描述單位就是8位元的一個位元組,表現為16進位制就是2個16進位制數,所以圖中每兩個數要組合在一起不可分割。
  2. 按照每2個16進位制數為最小單位來看,class檔案的16進位制格式有16列。圖1-A中是使用1個16進位制數來表示每列的標號,其實也可以用十進位制,但是由於列數固定在16,16進位制看起來比較方便。
  3. 行數依據原始碼的內容大小而定,是不固定的。圖1-A中仍舊是使用16進位製表示,好處是除去最右一位,剩下的位數可作為行數,而若算上最右一位,可作為整體位元組的個數。也相當於十進位制的行數乘以列數的計算。

1.1 class檔案結構

欄位 佔位(byte) 值(參照圖1-A) Decimal 解釋
magic 4 0xCAFEBABE 不用記 與擴充套件名功能類似,但不可輕易修改
minor_version 2 0x0000 0 次版本號:不能低於該版本
major_version 2 0x0034 52 主版本號:即java 1.8,不能高於該版本
constant_pool_count 2 0x0010 16 常數池計數器長度為16
constant_pool ↑count-1 0x0A...626A656374 見1.2 ∵從#1開始,#0參照留做他用了∴長度-1
access_flags 2 0x0021 不用記 類存取許可權public
this_class 2 0x0002 2 本類索引:#2【去常數池中找第2個】
super_class 2 0x0003 3 父類別索引:#3「constant_pool」
interfaces_count 2 0x0000 0 原始碼能看到就一個空類,沒宣告介面
interfaces ↑count 見1.3 ∵長度為0∴為空,不佔用位元組
fields_count 2 0x0000 0 同樣沒宣告欄位
fields ↑count 見1.4 ∵長度為0∴為空,不佔用位元組
methods_count 2 0x0001 1 有1個方法,是什麼呢?
methods 2||↑count 0x0001...000A0000 見1.5 其實是預設加的空建構函式
attributes_count 2 0x0001 1 有1個屬性資訊
attributes 2||↑count 0x000B...0002000C 見1.6 記錄值SourceFile:Test001.java
表1-1-A Class檔案結構

class檔案結構中共有16個欄位,其中需要深研究的有常數池、介面、欄位、方法、屬性,後面逐一展開。

這裡field和attribute有點容易混淆,多聊兩句他們的區別:

1、先說class檔案結構中的16個欄位,這種表述的理由是將class檔案看成一個結構體,它的內容分類就是表1-1-A中列出16行內容。其中fields這一行也是class檔案結構的欄位,但它也是class檔案代表的類原始碼Test001.java中我們顯示宣告的Java語言層面的欄位,例如:public String name;。

2、表1-1-A中的attributes這一行也是class檔案結構的欄位,但它同時也是class檔案代表的類原始碼Test001.java檔案的屬性,例如檔名。

1.2 常數池

JVM對於類、介面、類範例,以及陣列的參照並不是在執行時完成的,而是通過class檔案中的常數池來表示。常數池是一個陣列,每條記錄都是由:

1、佔用一個位元組的常數池標籤,例如CONSTANT_Methodref

2、對應的具體內容就是結尾加_info字尾,例如CONSTANT_Methodref_info

所組成。先貼一個常數池標籤的對照表。

標籤型別(字首CONSTANT_) 值(十進位制) 轉換十六進位制(1位元組) 解釋
Class 7 0x0007
Fieldref 9 0x09 欄位參照
Methodref 10 0x0A 方法參照
Interfacemethodref 11 0x0B 介面方法參照
String 8 0x08 字串
Integer 3 0x03 整型
Float 4 0x04 單精度浮點
Long 5 0x05 長整型
Double 6 0x06 雙精度浮點
NameAndType 12 0x0C 名稱型別
Utf8 1 0x01 utf8字串
MethodHandle 15 0x0F 方法處理
MethodType 16 0x10 方法型別
InvokeDynamic 18 0x12 動態呼叫
表1-2-A 常數池標籤對照表

至於表1-2-A為啥沒有2、13、14、17,不需要知道。。。

下面,仍舊以圖1-A為例,參照表1-2-A,我們去嘗試解析表1-1-A中常數池的十六進位制資料。首先先找正確答案,可通過IDEA的外掛jclasslib Bytecode Viewer,分析class檔案結構,其中常數池的部分如下圖1-2-A所示。

圖1-2-A 位元組碼檢視外掛

有了參考答案以後,我們去繼續解析表1-1-A中常數池的部分,它的值是圖1-A中的0x0A...626A656374部分。我們找到圖1-A中對應的部分,然後從0x0A開始往下解析:

1、0x0A是一個常數池標籤,對照表1-2-A,可以找到是CONSTANT_Methodref,它對應的具體內容是CONSTANT_Methodref_info。

2、通過官方JVM規範的4.4.2可查詢到CONSTANT_Methodref_info。(把官方檔案當做字典來查是正確的開啟方式。)看一下它的結構:

CONSTANT_Methodref_info {
       u1 tag;
       u2 class_index;
       u2 name_and_type_index;
   }

這是一個偽碼,主要看結構中的欄位,每個欄位前是位元組數,例如u1就是1個位元組,按照這個規範再回去跟蹤圖1-A的位元組碼。

3、0x0A本身就是1位元組的tag,再往後是2位元組的class_index,即0x0003,這是一個類索引,指向#3號的常數池記錄。

4、再往後是2位元組的name_and_type_index,即0x000D,這是一個名字和描述符,也是一個參照,執行#13的常數池記錄。

到此常數池的第一條記錄就解析完了,我們去看一下正確答案圖1-2-A的右側部分的內容,正好是與上面的分析對應上,證明我們的解析是正確的。

Bytecode Viewer

上面我們按照JVM規範逐一解析了class檔案的16進位制內容,解析的結果得到了驗證。JVM規範的本質就是在描述這件事,告知大家它是如何設定不同的區域所對應的位元組碼,如何通過這些位元組碼的規範去表示類、方法、欄位等等,由此可以支援非常複雜的資訊化需求。其實就是一本翻譯書,我說」hello「,它告訴我是」打招呼,你好「的意思。前面驗證位元組碼的方式是通過IDEA的外掛jclass Bytecode Viewer,那麼接下來就不用再費勁去比對十六進位制了,直接通過外掛來檢視即可。接下來繼續分析。

1、前面分析到常數池的第一條記錄,表示的是方法參照,其中類名是#3,名字描述符是#13。首先看#3,在外掛檢視中也可以直接點選,跳轉過去更加方便。由於篇幅有限,這裡就不貼上了,直接文字描述。

2、#3號常數池記錄是CONSTANT_Class_info,說明是類資訊,它的值指向了#15。

3、#15號常數池記錄是CONSTANT_Utf8_info,說明是utf8字串,長度是16,值是字面量:java/lang/Object。

4、回到1,我們已經知道了類名,繼續去查#13,#13是名字和描述符,其中名字指向#4,描述符指向#5。

5、#4也是字串,長度為6,值是<init>

6、#5是字串,長度為3,值是()V,代表的是引數為空,返回值為void。

好,到此我們總結一下,這個過程列出來,我們這個類由於內容為空,預設會新增父類別的空建構函式,即Object類別建構函式init(),返回值是void。另外,我們也能夠發現,也不需要去跳轉檢視,相關類資訊或者各種資料型別的值都會在外掛中顯示出來。這就更加方便了我們分析class檔案的內容。我們在這個過程中已經把常數池中的一部分記錄所覆蓋到了,剩下的內容將在下面的介面、欄位、方法以及屬性中會被參照到。

1.3 介面

由於圖1-A沒有介面的內容,我新寫一個介面,有了Bytecode Viewer外掛,看起來比較方便了。

圖1-3-A ①原始碼-②位元組碼-③位元組碼分析

圖1-3-A顯示幾個資訊:

1、①的部分是Test002的原始碼,②的部分是位元組碼,③的部分是位元組碼檢視外掛的顯示。

2、直接看③的部分,有疑議的可以參照①和②的部分。可以看到介面、欄位、方法、屬性都比較齊全。那麼下面的分析都將以此為例。

本小節是分析介面的部分,這裡的介面指的是類原始碼中實現的介面,參照①的部分,這裡實現了Cloneable介面。因此,可以在③的部分看到介面。介面項展開以後,有一條記錄,參照了#4號常數池。#4號常數池記錄是一個類資訊,又指向了#19的字串,最終顯示java/lang/Cloneable。這裡就不貼上圖片了,可自行檢視。

1.4 欄位

下面看欄位的部分,還是通過檢視③的區域,欄位有一條記錄,包括3個子項:

1、名字:指向#5常數池,對應的是一個字串,值為<name>

2、描述符:指向#6常數池,對應的也是一個字串<Ljava/lang/String>

3、存取標誌:0x0002,是代表private的含義,與表1-1-A class檔案結構中的access_flags的規則一致。

欄位的部分要注意對於原始碼欄位的型別(descriptor_index),是用常數池的字串來表示,例如private int age;欄位,也會在常數池中已utf8的方式儲存欄位的資料型別,這裡是int,存為utf8的字面量是I,String對應的是Ljava.lang.String,所有參照型別都是L加全限定類名。其他的對映關係是:byte->B, char->C, double->D, float->F, long->J, short->S, boolean->Z。

1.5 方法

方法的部分在本例中仍舊是預設新增的建構函式,這個內容在常數池的部分介紹到了。這裡再重申一下,方法有一條記錄,包括3個子項:

1、名字:參照#7常數池,值為<init>

2、描述符:參照#8常數池,值為<()V>

3、存取標誌:0x0001,為public。

而往下深入檢視,會發現在方法記錄中還有更深的層級,顯示的是[0]code

方法程式碼

使用位元組碼檢視檢視外掛,可以看到[0]code包括一般資訊和特有資訊,一般資訊就是將code以utf8儲存在常數池。特有資訊比較重要,這裡的是對應的空建構函式原始碼,給出的位元組碼是:

`0 aload_0

1 invokespecial #1 <java/lang/Object. : ()V>

4 return`

這是JVM的指令集,要去規範檔案中查詢所代表的意思。

1、aload_0代表本地變數儲存在記憶體中棧幀第0項,預設是this(下面記憶體的部分會學習),位元組碼是0x2a,如果細心的話可以在圖1-3-A②的位元組碼中找到。

2、invokespecial代表呼叫實體方法,包括對於父類別、私有以及範例初始化的處理。這裡指的是呼叫父類別即Object的方法。

3、return返回void。

處了程式碼的位元組碼以外,特有資訊還包括異常表和雜項,不在這裡介紹了。

[0]code再往下還有更深一層,包括:

1、[0]LineNumberTable,代表原始碼行號

2、[1]LocalVariableTable,方法執行時本地變數的值

1.6 屬性

屬性包括一條名稱為SourceFile的記錄,包括一般資訊和特有資訊,一般資訊就是記錄字串」SourceFile「,特有資訊就是原始碼檔案的實際名稱,Test002.java。

這裡要注意的是屬性也可以包括在欄位、方法中,也可以是整個class結構的屬性,他們的內容規範是一致的,只是取決於作用域。屬性是比較複雜的部分,上面提到的LineNumberTable和LocalVariableTable實際上都是屬性,code也是屬性(屬性資訊本身作為一個事物也可以有自己的屬性,就像方法的屬性code也可以有自己的屬性LineNumberTable和LocalVariableTable),這種屬性的規範還有很多,JVM規範檔案中4.7的章節有詳細說明,在有用到的時候可以根據目錄快速檢視。

2. ClassLoader

我們在第一章對Class檔案的結構建立了初步印象。作為JVM的輸入,class檔案在進入JVM的第一關就是通過ClassLoader也就是類載入器將Class靜態檔案中的位元組碼解析並載入到JVM記憶體中。本章就介紹類載入器ClassLoader。

JVM會動態的對類和介面進行載入、連結以及初始化。載入是一個過程,為一個類或介面型別的二進位制檔案找到一個特定的名字並從該二進位制描述中建立一個類或介面。連結是另外一個過程,拿到一個類或介面,將其合併到JVM執行時狀態中,由此它才可以被執行。最後,一個類或介面的初始化,其實就是執行類或介面的初始化方法,例如建構函式。

JVM的啟動過程:①通過bootstrap類載入器建立一個入口類。②連結該入口類、初始化,然後呼叫public的main方法。③main方法驅動所有其他的遠端執行,按照這個執行時機,所關聯到的其他類或介面都會被逐一載入、建立、連結以及初始化,包括他們的方法。(有一些JVM的實現,會將入口類作為JVM命令列啟動的引數,或者有固定的入口類設定。

2.1 雙親委派

類載入器並不是一個,而是多個,按照順序,他們是父子載入器的關係:

1、Bootstrap

2、Extension

3、App

4、Custom ClassLoader

其中最為基礎的是Bootstrap類載入器,它是JVM內建的由C++所編寫的,固定地用來載入核心類庫到JVM執行時,這是作業系統級別的程式碼。接下來是Extension擴充套件類載入器,載入擴充套件包jre/lib/ext/*.jar,或者由-Djava.ext.dirs引數來指定類載入路徑。接下來是App,載入classpath指定的內容。最後是自定義類載入器,對於我們JVM的使用者來講,這部分是應用最多的。

下面學習雙親委派的概念。

當一個類要被載入到JVM的時候,會自底向上的查詢是否載入過。 首先是自定義類載入器,找不到的話再向上去查App類載入器,接著是Extension,最後到Bootstrap。如果都沒有找到,則需要觸發類載入。類載入的過程是自頂向下的。Boostrap首先會執行載入的方法findClass(),但它不會載入核心類庫以外的類,所以會往下傳遞到Extension。如果這個類不在Extension載入的findClass()邏輯覆蓋,則它也不會載入,會往下繼續傳給App。同樣的,App類載入器也有自己的findClass(),如果也不在邏輯內,則繼續傳給自定義類載入器。如果自定義類載入器也沒有開發相關的邏輯,即重寫findClass(),這個類就會被丟棄,不再載入。而一般情況下,我們會在自定義類載入器中去重寫findClass()處理要自定義載入的類的邏輯。

這個載入過程就用到了雙親委派,前面提到了這4個類載入器按照順序是父子層級關係,因此一個新類的載入,需要孩子向父親方向逐層查詢,然後再從父親向孩子方向逐層載入的過程。這就是雙親委派。

雙親委派的意義

前面講到了,4中類載入器有各自不同的實現和許可權,那麼雙親委派的過程實際上就對新載入類進行了層層校驗,以避免底層類庫被替換的情況發生,所以主要是從安全形度考慮而設計的。

2.2 ClassLoader原始碼

進入java.lang.ClassLoader類原始碼中,首先看它的類註釋。第一段概況性描述了ClassLoader的功能,本質就是在系統中定位到class檔案並讀入進來,這個過程中做了一些處理,例如安全、並行(多執行緒情況下去執行類載入的策略,為保證不會重複載入,會加鎖,通過registerAsParallelCapable()方法),以及IO(class檔案不再是狹隘的系統中的一個檔案,而是一個二進位制檔案流,它的來源可以是本地檔案也可以是網路傳輸。通過defineClass()方法讀入)。

1、首先ClassLoader類是一個抽象類,定義了一個類載入器的規範,它的子類包括了SecureClassLoader、RBClassLoader、DelegatingClassLoader等,包括我們自己實現的子類也屬於直屬於java.lang.ClassLoader的子類。

2、Java語言裡面,型別的載入是在程式執行期間完成的,也就是說用到的時候再建立,而不是在程式編譯時或者啟動時就把所有的物件準備好,這一點常用Java的人應該瞭解。這種策略是與其他語言稍有不同的,雖然會令類載入時增加一些效能開銷,但會提高Java應用程式的靈活性。

Java裡天生可以動態擴充套件的語言特性就是依賴執行期動態載入這個特點實現的。(包括動態的連結,後面會學習到)。這種動態載入也被稱為懶載入。

3、根據以上2點,可以得知ClassLoader子類會在使用到的時候去建立範例,那麼核心類載入器的建立時機是什麼呢?其實在上面的JVM啟動過程中提到了,指定入口類的main方法作為整個JVM執行的開始,會執行Launcher類,該類是ClassLoader的包裝類,其中包括了前面提到的Bootstrap類載入器、Extension類載入器以及App類載入器,那麼剩下的自定義類載入器其實就是第一點中提到的java.lang.ClassLoader的子類,按照動態載入策略被載入進來。

下面我們進入原始碼的學習。

父類別載入器

private final ClassLoader parent;

每一個類載入器都會有一個類載入器物件作為屬性,屬性名稱是parent,這就是父類別載入器,它是final的,即定義好就不可修改。由於該父類別載入器是一個成員屬性,所以要與繼承的父類別概念相區分。當然,它也不是當前類載入器的建立者。

並行載入器類

private static final Set<Class<? extends ClassLoader>> loaderTypes =
    Collections.newSetFromMap(
        new WeakHashMap<Class<? extends ClassLoader>, Boolean>());

接下來是一個並行載入器類,該類中包含一個如上面貼上的原始碼內容的Set集合,該集合的元素只能是ClassLoader的子類,它的資料結構是由一個WeakHashMap型別轉型過來的集合。該WeakHashMap型別的key是ClassLoader子類(注意不是物件),value是Boolean型別。預設在靜態方法中會初始加入ClassLoader類。

靜態方法:該並行載入類定義好上面這個記憶體結構以後,又給出了註冊register(子類)以及判斷是否註冊isRegistered(子類)的方法。其中都包含了針對並行的synchronized處理。register方法會在registerAsParallelCapable()方法中被使用到。registerAsParallelCapable()方法在類註釋中提到過,主要是為了並行。

loadClass方法

類載入器最重要的是載入方法,loadClass方法就是核心方法,這個方法的原始碼就貼上完整一些。

protected Class<?> loadClass(String name, boolean resolve)
    throws ClassNotFoundException
{
    synchronized (getClassLoadingLock(name)) {
        // First, check if the class has already been loaded
        Class<?> c = findLoadedClass(name);
        if (c == null) {
            long t0 = System.nanoTime();
            try {
                if (parent != null) {
                    c = parent.loadClass(name, false);
                } else {
                    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();
                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(c);
        }
        return c;
    }
}

1、檢查該類是否已經被載入,通過findLoadedClass()方法(該方法最終實現指向了native方法,是系統級別方法,可能不是java寫的,無原始碼)。如果查到已被載入則執行解析邏輯resolve(解析的最終實現也是個native方法),再直接返回。

2、若該類未被載入,檢查父類別載入器是否存在,若不存在則去查詢Bootstrap類載入器中是否存在(最終實現也是個native方法),不存在會返回null。

3、若父類別載入器存在,則當前子類載入器的loadClass方法阻塞在這裡,執行緒轉而去執行父類別載入器的loadClass方法。父類別載入器同樣也是ClassLoader類的子類,loadClass方法的程式碼是相同的,因此它也會執行到這裡仍舊去查是否存在它的父類別載入器。就像執行一個遞迴函數那樣以此類推。

4、程式會執行直到沒有父類別載入器的最底層類載入器,我們前面介紹到了,就是Bootstrap類載入器,它是沒有父類別載入器的,因此通過findBootstrapClassOrNull(name)方法來查詢。這個方法的最終實現同樣要指向native原生程式碼,如果找到則返回Class類,未找到則返回null。到此我們的遞迴函數開始收攏。

5、Boostrap類載入器的一級子類載入器會得到前者的返回值,如果找到了,則執行解析邏輯resolve,再直接返回。

6、如果沒找到,則往下執行findClass方法。該方法是每一個ClassLoader子類都會重寫的方法,如果找不到仍舊會繼續往上返回給自己的子類null。遞迴函數繼續收攏。

7、繼續找,直到在某一層級的子類載入器中找到了,則執行解析邏輯resolve,再直接返回。如果最終整個遞迴函數已經收攏回首層也沒有找到,會有兩種可能。第一、直接返回null。第二,就是過程中某一層類載入器顯式丟擲了ClassNotFoundException異常,被下一層的孩子捕捉到了以後做了處理。注意,這個過程我們在ClassLoader原始碼中可以看到一個框架結構,但並沒有具體實現,這是留給子類去發揮的地方。

總結一下,我們會發現整個這個過程通過parent父類別載入器以及loadClass方法的程式碼邏輯,完成了對於雙親委派策略的實現。

findClass方法

前面在loadClass方法的原始碼分析中,在遞迴呼叫的各級類載入器的邏輯中,他們對於ClassLoader類的findClass方法的重寫內容顯得至關重要。由於子類非常多,也包括在jdk以外的子類實現,我們挑選到URLClassLoader類的原始碼作為研究物件,看一下它的findClass方法是如何重寫的。

protected Class<?> findClass(final String name)
    throws ClassNotFoundException
{
    final Class<?> result;
    try {
        result = AccessController.doPrivileged(
            new PrivilegedExceptionAction<Class<?>>() {
                public Class<?> run() throws ClassNotFoundException {
                    String path = name.replace('.', '/').concat(".class");
                    Resource res = ucp.getResource(path, false);
                    if (res != null) {
                        try {
                            return defineClass(name, res);
                        } catch (IOException e) {
                            throw new ClassNotFoundException(name, e);
                        }
                    } else {
                        return null;
                    }
                }
            }, acc);
    } catch (java.security.PrivilegedActionException pae) {
        throw (ClassNotFoundException) pae.getException();
    }
    if (result == null) {
        throw new ClassNotFoundException(name);
    }
    return result;
}

這個原始碼的邏輯簡單介紹一下。

1、引數約定傳入的是全限定類名,因此首先要對引數進行改造,得到它的檔案路徑。

2、然後通過getResource獲得檔案的Resource物件。

3、最後呼叫defineClass獲得類返回值。

defineClass方法

還是由前面的findClass方法繼續分析,一路追蹤到defineClass方法。首先來看它的入參,除了傳遞了全限定類名的字串以外,還傳入了Resource物件。核心的程式碼如下:

java.nio.ByteBuffer bb = res.getByteBuffer();
if (bb != null) {
    // Use (direct) ByteBuffer:
    CodeSigner[] signers = res.getCodeSigners();
    CodeSource cs = new CodeSource(url, signers);
    sun.misc.PerfCounter.getReadClassBytesTime().addElapsedTimeFrom(t0);
    return defineClass(name, bb, cs);
} else {
    byte[] b = res.getBytes();
    // must read certificates AFTER reading bytes.
    CodeSigner[] signers = res.getCodeSigners();
    CodeSource cs = new CodeSource(url, signers);
    sun.misc.PerfCounter.getReadClassBytesTime().addElapsedTimeFrom(t0);
    return defineClass(name, b, 0, b.length, cs);
}

這裡首先定義了一個nio包的ByteBuffer物件bb,然後有兩個分支。如果bb有值,則直接使用ByteBuffer資料結構。如果bb為空,則讀出它的位元組碼,然後去呼叫另一個入參為位元組碼的defineClass方法。其實直接使用ByteBuffer的分支跟蹤進去最終也會呼叫這個入參為位元組碼的defineClass方法,這個方法的最終實現也是native本地方法,實現細節我們不得而知,除非去分析C++原始碼。對於defineClass我們只要知道,不僅是檔案路徑,只要是能轉為位元組碼的格式,類載入器都支援。

雙親委派機制的打破

前面仔細介紹了類載入過程中的雙親委派機制,主要是在ClassLoader的loadClass方法中固定實現的,那麼有沒有情況是要打破這個機制的呢?答案是有的,當我們希望類的載入可以實現對JVM現有的類進行替換的時候。我們知道在雙親委派機制下,重複的類不會被載入進來,因為會自底向上去查詢,一旦查到JVM已經載入過了,就直接返回而不會再載入你新準備覆蓋傳入的同名類。

所以對應的實現方法就是我們自定義的類載入器不能僅僅去重寫findClass方法了,而是要重寫loadClass方法,把其中向上查詢,找到就返回的邏輯給去掉。修改為找到Class檔案,不再去判斷是否有同名。

Tomcat的底層實現就是基於對雙親委派機制的打破以及垃圾回收的結合應用,從而實現了熱部署,也即在不停機的情況下對程式碼進行更新操作。那麼具體是如何實現的呢?這裡不做tomcat原始碼級別的學習,而是說一個原理:

1、重寫loadClass方法,去除雙親委派的查詢邏輯,也就是允許同名的類載入進來。

2、然後同名類在載入的時候,不再使用原來的類載入器的範例,而是新建立一個範例來載入。

3、這時候,JVM記憶體中是存在兩個類載入器的範例,他們各自都載入了一個同名的類。

4、此時,再通過Java垃圾回收機制,通過判定標記,將舊的類載入器範例進行主動銷燬。

5、這時候記憶體中就只留下最新的類了,實現了不停機的一個程式碼替換。

不過這裡也有很多細節問題需要研究tomcat原始碼去完善,例如類載入器範例不僅僅載入了這一個類,還有很多未更新的類在新的範例建立的時候也要同時再載入一遍進來,這個邏輯的具體實現。還有像新建立一個類載入器的範例的機制,範例是如何被管理的,以及具體的判定舊範例的過時和銷燬等等。

2.3 Launcher原始碼

前面提到了Bootstrap、Extension以及App類載入器的層級關係,那麼他們是如何定義的,JVM在啟動時是如何初始化類載入器的,其實答案都在Launcher類中。

private ClassLoader loader;

1、Launcher類是ClassLoader的包裝類,它有一個ClassLoader的成員。

2、接著,它定義了Bootstrap、Extension以及App類載入器的檔案掃描路徑,這些路徑可以通過JVM啟動引數手動指定,但啟動以後就不可修改(不包括熱部署的情況)。

3、Launcher類包含了內部類APPClassLoader、BootClassPathHolder、ExtClassLoader分別對應以上三種類載入器,這裡面與其他不同的是Bootstrap類載入器並不是ClassLoader而是PathHolder。Bootstrap類載入器,前面提到它是C++編寫到作業系統的本地類庫,因此它的具體實現並不是java.lang.ClassLoader的子類。這裡只是通過它來確定檔案路徑sun.boot.class.path的邏輯。

4、其他兩個類載入器都是ClassLoader的子類,具體來說是URLClassLoader的子類,URLClassLoader我們在前面的findClass方法的重寫部分做了充分研究。這裡的兩個類載入器在URLClassLoader的基礎上,做了一些針對自己功能責任的調整。

2.4 findClass方法的妙用

前面詳細學習了findClass方法,ClassLoader的子類包括我們自定義的類載入器都會去重寫該方法。那麼通過對該方法的內容實現的靈活使用,可以實現一些特殊的功能。例如Class檔案的加密。我們可以給自己的原始碼編譯出來的Class檔案進行加密,Class檔案是一個二進位制檔案,可以通過位運算或其他加密演演算法的邏輯運算把原始位元組加密成密文位元組。所謂的密文位元組其實就是通用的解析方式不再適配了,這個通用的解析方式其實就是前面介紹的JVM規範。那麼我們自己如何進行載入呢?可以通過重寫findClass方法,因為我們知道自己Class位元組碼的加密方式,所以可以在findClass方法中寫入自己的解密邏輯,從而就實現了原始碼的加密保護,只有我自己可以載入,而其他人只要不清楚我的加密方式以及加密種子,就不會完成加密類檔案的一個正常載入,直接反編譯也會顯示亂碼。

參考資料

更多文章請轉到一面千人的部落格園