1. 程式人生 > >一夜搞懂 | JVM 類載入機制

一夜搞懂 | JVM 類載入機制

## 前言 > 本文已經收錄到我的Github個人部落格,歡迎大佬們光臨寒舍: > > [我的GIthub部落格](https://lovelifeeveryday.github.io/) ## 學習導圖 ![學習導圖](https://cdn.jsdelivr.net/gh/LoveLifeEveryday/FigureBed@master/typora202004/03/105402-274667.png) ## 一.為什麼要學習類載入機制? 今天想跟大家嘮嗑嘮嗑`Java`的類載入機制,這是`Java`的一個很重要的創新點,曾經也是`Java`流行的重要原因之一。 `Oracle`當初引入這個機制是為了滿足`Java Applet`開發的需求,`JVM`咬咬牙引入了`Java`類載入機制,後來的基於`Jvm`的動態部署,外掛化開發包括大家熱議的熱修復,總之很多後來的技術都源於在`JVM`中引入了類載入器。 如今,類載入機制也在各個領域大放異彩,在面試中,由類載入機制所衍生出來各類面試題也層出不窮。 所以,我們要了解下類載入機制,為工作中或者是面試中實際的需要打好良好的基礎。 ## 二.核心知識點歸納 ### 2.1 概述 Q1:**`JVM`類載入機制定義**: 虛擬機器把描述類的資料從`Class`檔案**載入**到記憶體,並對資料進行**校驗**、**轉換解析**和**初始化**,最終形成可被虛擬機器直接使用的`Java`型別的過程 Q2:**特性** 執行期類載入。即在`Java`語言裡面,型別的載入、連線和初始化過程都是在程式**執行期**完成的,從而通過犧牲一些效能開銷來換取`Java`程式的高度靈活性 > 什麼是執行期,什麼是編譯期? > > - **編譯期**是指編譯器將**原始碼翻譯**為**機器能識別的程式碼**,`Java`被編譯為`Jvm`認識的**位元組碼檔案** > - **執行期**則是指`Java`程式碼的**執行**過程 `JVM`執行期動態載入+動態連線->`Java`的動態擴充套件特性 ### 2.2 類載入的過程 類從被載入到虛擬機器記憶體中開始、到卸載出記憶體為止,整個生命週期包括七個階段: - **載入** - **驗證** - **準備** - **解析** - **初始化** - **使用** - **解除安裝** 其中,驗證、準備、解析這3個部分統稱為**連線**,流程如下圖: ![類載入過程](https://cdn.jsdelivr.net/gh/LoveLifeEveryday/FigureBed@master/typora202004/02/185624-434838.png) 注意: > - 『載入』->『驗證』->『準備』->『初始化』->『解除安裝』這五個階段的順序是確定的,而『解析』可能為了支援`Java`的動態繫結會在『初始化』後才開始 > - 上述階段通常都是互相交叉地混合式進行的,比如會在一個階段執行的過程中呼叫、啟用另外一個階段 想要了解`Java`動態繫結和靜態繫結區別的話,可以看下這篇文章:[理解靜態繫結與動態繫結](https://juejin.im/post/5d8d2c6251882509155069e4#heading-0) #### 2.2.1 載入 Q1:**任務** - 通過類的全限定名來獲取定義此類的**二進位制位元組流**。如從`ZIP`包讀取、從網路中獲取、通過執行時計算生成、由其他檔案生成、從資料庫中讀取等等途徑...... > 想要詳細瞭解類的全限定名的知識,可以看下這篇文章:[全限定名、簡單名稱和描述符是什麼東西?](https://mingshan.fun/2018/09/18/fully-qualified-name-simple-name-descriptor/) - 將該二進位制位元組流所代表的**靜態儲存結構**轉化為**方法區**的**執行時資料結構**,該資料儲存資料結構由虛擬機器實現自行定義 - 在記憶體中生成一個代表這個類的`java.lang.Class`物件,它將作為程式訪問方法區中的這些型別資料的外部介面 #### 2.2.2 驗證 - 是**連線**階段的**第一步**,且工作量在`JVM`類載入子系統中佔了相當大的一部分 - 目的:為了**確保**`Class`檔案的位元組流中包含的資訊**符合**當前**虛擬機器的要求**,並且**不會危害虛擬機器自身的安全** >由此可見,它能直接決定`JVM`能否承受惡意程式碼的攻擊,因此驗證階段**很重要**,但由於它對程式執行期沒有影響,並**不一定必要**,可以考慮使用`-Xverify:none`引數來關閉大部分的類驗證措施,以縮短虛擬機器類載入的時間。 - 檢驗過程包括下面四個階段: A.檔案格式驗證: > - 內容:驗證**位元組流是否符合`Class`檔案格式的規範**、以及是否能被**當前版本的虛擬機器處理** > > - 目的:保證輸入的**位元組流**能正確地解析並存儲於**方法區**之內,且格式上符合描述一個`Java`型別資訊的要求。只有保證二進位制位元組流通過了該驗證後,它才會進入記憶體的方法區中進行儲存,所以**後續3個驗證階段全部是基於方法區**而不是位元組流了 > > - 例子: > > 1. 是否以魔數`0xCAFEBABE`開頭 > > 2. 主次版本號是否在`JVM`接受範圍內 > > 3. 索引值是否有指向不存在/不符合型別的常量 > > ...... B.元資料驗證: > - 內容:對位元組碼描述的資訊進行**語義**分析,以保證其描述的資訊符合`Java`語言規範的要求 > > - 目的:對類的**元資料資訊**進行語義校驗,保證不存在不符合`Java`語言規範的元資料資訊 > > - 例子: > > 1. 類是否有父類(除了`java.lang.Object`之外,所有類都應有父類) > > 2. 父類是否繼承了不允許被繼承的類(`final`修飾的類) > > 3. 如果該類不是抽象類,是否實現了其父類或介面中要求實現的所有方法 > > ...... ​ C.位元組碼驗證: > - 是驗證過程中**最複雜**的一個階段 > > - 內容:對類的**方法體**進行校驗分析,保證被校驗類的方法在執行時不會做出危害虛擬機器安全的事件 > > - 目的:通過資料流和控制流分析,確定程式語義是合法的、符合邏輯的 > > - 例子: > > 1. 保證任意時刻運算元棧的資料型別與指令程式碼序列都能配合工作,例如不會出現“在運算元棧的資料型別中放置了`int`型別的資料,使用時卻按`long`型別來載入本地變量表中” > > 2. 保證任何跳轉指令都不會跳轉到方法體外的位元組碼指令上 > > ...... ​ D.符號引用驗證: > - 內容:對**類自身以外(如常量池中的各種符號引用)的資訊**進行匹配性校驗 > - 目的:確保解析**動作能正常執行**,如果無法通過符號引用驗證,那麼將會丟擲一個`java.lang.IncompatibleClassChangeError`異常的子類 > - 注意:該驗證發生在虛擬機器將**符號引用**轉化為**直接引用**的時候,即『**解析**』階段 #### 2.2.3 準備 Q1:**任務** - 為類變數(靜態變數)**分配記憶體**:**因為這裡的變數是由方法區分配記憶體**的,所以**僅包括類變數**而不包括例項變數,後者將會在物件例項化時隨著物件一起分配在`Java`堆中 - 設定類變數**初始值**:通常情況下零值 #### 2.2.4 解析 > 之前提過,解析階段就是虛擬機器將**常量池**內的**符號引用替換為直接引用**的過程 - 符號引用:以一組符號來描述所引用的目標 > - 可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可 > - 與虛擬機器實現的記憶體佈局無關,因為符號引用的字面量形式明確定義在`Java`虛擬機器規範的`Class`檔案格式中,所以即使各種虛擬機器實現的記憶體佈局不同,但是能接受符號引用都是一致的 - 直接引用: > - 可以是直接指向目標的指標、相對偏移量或是一個能間接定位到目標的控制代碼 > - 與虛擬機器實現的記憶體佈局相關,同一個符號引用在不同虛擬機器例項上翻譯出來的直接引用一般不同 - 發生時間:`JVM`會根據需要來判斷,是在類被載入器**載入時**就對常量池中的符號引用進行解析,還是等到一個符號引用將要被**使用前**才去解析 - 解析動作:有七類符號及其對應在常量池的七種常量型別 > - **類或介面**(`CONSTANT_Class_info`) > - **欄位**(`CONSTANT_Fieldref_info`) > - **類方法**(`CONSTANT_Methodref_info`) > - **介面方法**(`CONSTANT_InterfaceMethodref_info`) > - **方法型別**(`CONSTANT_MethodType_info`) > - **方法控制代碼**(`CONSTANT_MethodHandle_info`) > - **呼叫點限定符**(`CONSTANT_InvokeDynamic_info`) 舉個例子,設當前程式碼所處的為類`D`,把一個從未解析過的**符號引用`N`**解析為一個**類或介面`C`的直接引用**,解析過程分三步: >- 若`C`不是陣列型別:`JVM`將會把代表`N`的全限定名傳遞給`D`類載入器去載入這個類`C`。在載入過程中,由於**元資料驗證**、**位元組碼驗證**的需要,又可能觸發其他相關類的載入動作。一旦這個載入過程出現了任何異常,解析過程就宣告失敗。 >- 若`C`是陣列型別且陣列元素型別為物件:`JVM`也會按照上述規則載入陣列元素型別 >- 若上述步驟無任何異常:此時`C`在`JVM`中已成為一個有效的類或介面,但在解析完成前還需進行**符號引用驗證**,來確認`D`是否具備對`C`的訪問許可權。如果發現不具備訪問許可權,將丟擲`java.lang.IllegalAccessError`異常 Q1:**欄位(成員變數/域)和屬性有什麼區別?** > - 屬性,是指物件的屬性,對於`JavaBean`來說,是`getXXX`方法定義的 > - 欄位,是成員變數 ```java class Person{ private String mingzi; //mingzi是欄位,一般來說欄位和屬性是相同的,但是這個例子是特例 public String getName(){ //name是屬性 return mingzi: } public void setName(){ mingzi= "張三"; } } ``` #### 2.2.5 初始化 - 是類載入過程的最後一步,會開始真正執行類中定義的`Java`程式碼。而之前的類載入過程中,除了在『**載入**』階段使用者應用程式可通過**自定義類載入器**參與之外,**其餘階段均由虛擬機器主導和控制** - 與『準備』階段的**區分**: > - 準備階段:變數賦初始零值 > - 初始化階段:根據Java程式的設定去初始化類變數和其他資源,或者說是執行類構造器`clinit`的過程 `clinit`:由編譯器自動收集類中的所有**類變數(靜態變數)的賦值動作**和靜態語句塊`static{}`中的語句合併產生 > - 是**執行緒安全**的,在多執行緒環境中被正確地加鎖、同步 > - 對於類或介面來說是**非**必需的,如果一個類中**沒有靜態語句塊**,也沒有對變數的賦值操作,那麼編譯器可以不為這個類生成 `clinit` > - **介面與類不同的是**,執行介面的 `clinit`**不需要先執行父介面**的 `clinit`,只有當父介面中定義的變數使用時,父接口才會初始化。另外,**介面的實現類在初始化時**也一樣不會執行介面的`clinit` 想詳細瞭解`clinit`以及其與`init`的區別的讀者,可以看下這篇文章:[深入理解jvm--Java中init和clinit區別完全解析](https://blog.csdn.net/u013309870/article/details/72975536) - 在虛擬機器規範中,規定了有且只有五種情況**必須立即**對類進行『**初始化**』: > - 遇到`new`、`getstatic`、`putstatic`或`invokestatic`這4條位元組碼指令時 > - 使用`java.lang.reflect`包的方法對類進行反射呼叫的時候 > - 當初始化一個類的時候,若發現其父類還未進行初始化,需先觸發其父類的初始化 > - 在虛擬機器啟動時,需指定一個要執行的**主類**,虛擬機器會先初始化它 > - 當使用`JDK1.7`的動態語言支援時,若一個`java.lang.invoke.MethodHandle`例項最後的解析結果為`REF_getStatic`、`REF_putStatic`、`REF_invokeStatic`的方法控制代碼,且這個方法控制代碼所對應的類未進行初始化,需先觸發其初始化。 ### 2.3 類載入器&雙親委派模型 >每個類載入器,都擁有一個獨立的名稱空間,它不僅用於載入類,還和這個類本身一起作為在`JVM`中的唯一標識。所以比較兩個類是否相等,只要看它們是否由同一個**類載入器**載入,即使它們來源於同一個`Class`檔案且被同一個`JVM`載入,只要載入它們的**類載入器不同,這兩個類就必定不相等** #### 2.3.1 類載入器 從`JVM`的角度,可將類載入器分為兩種: - 啟動類載入器 > - 由`C++`語言實現,是虛擬機器自身的一部分 > - 負責載入存放在`<JAVA_HOME>\lib`目錄中、或被`-Xbootclasspath`引數所指定路徑中的、且可被虛擬機器識別的類庫 > - 無法被`Java`程式直接引用,如果自定義類載入器想要把載入請求委派給引導類載入器的話,可直接用`null`代替 - 其他類載入器:由`Java`語言實現,獨立於虛擬機器外部,並且全都繼承自抽象類`java.lang.ClassLoader`,可被`Java`程式直接引用。常見幾種: > - **擴充套件類載入器** > > A.由`sun.misc.Launcher$ExtClassLoader`實現 > > B.負責載入`<JAVA_HOME>\lib\ext`目錄中的、或者被`java.ext.dirs`系統變數所指定的路徑中的所有類庫 > > - **應用程式類載入器** > > A.是**預設**的類載入器,是`ClassLoader#getSystemClassLoader()`的返回值,故又稱為**系統類載入器** > > B.由`sun.misc.Launcher$App-ClassLoader`實現 > > C.負責載入使用者類路徑上所指定的類庫 > > - **自定義類載入器**:如果以上類載入起不能滿足需求,可自定義 ![類載入器的關係](https://cdn.jsdelivr.net/gh/LoveLifeEveryday/FigureBed@master/typora202004/02/232509-203592.png) > 需要注意的是:雖然**陣列類**不通過類載入器建立而是由`JVM`直接建立的,但仍與類載入器有密切關係,因為**陣列類的元素型別最終還要靠類載入器去建立** #### 2.3.2 雙親委派模型 - 定義:表示類載入器之間的層次關係 - **前提**:除了頂層啟動類載入器外,**其餘類載入器都應當有自己的父類載入器**,且它們之間關係一般不會以**繼承**關係來實現,而是通過**組合**關係來複用父載入器的程式碼 - **工作過程**:若一個類載入器收到了類載入的請求,它先會把這個請求**委派**給父類載入器,並向上傳遞,最終請求都傳送到頂層的啟動類載入器中。只有當父載入器反饋自己無法完成這個載入請求時,子載入器才會嘗試自己去載入 - **注意**:不是一個強制性的約束模型,而是`Java`設計者推薦給開發者的一種類載入器實現方式 - **優點**:類會隨著它的類載入器一起具備帶有**優先順序**的層次關係,可保證`Java`程式的穩定運作;實現簡單,所有實現程式碼都集中在`java.lang.ClassLoader的loadClass()`中 >比如,某些類載入器要載入`java.lang.Object`類,最終都會委派給最頂端的啟動類載入器去載入,這樣`Object`類在程式的各種類載入器環境中都是同一個類。 > >相反,系統中將會出現多個不同的`Object`類,`Java`型別體系中最基礎的行為也就無法保證,應用程式也將會變得一片混亂 ## 三.課堂小測試 > 恭喜你!已經看完了前面的文章,相信你對`JVM`類載入機制已經有一定深度的瞭解,下面,進行一下課堂小測試,驗證一下自己的學習成果吧! Q1:類載入的全過程是怎樣的? Q2:什麼是雙親委派模型? Q3:`String`類如何被載入的 > 上面問題的答案,在前文都提到過,如果還不能回答出來的話,建議回顧下前文 Q4:請你談談類載入過程,以`Person a = new Person();`為例進行說明 > 這道題是在牛客的暑假實習`Tencent`一面的麵筋上找的,附上標準答案:[類的載入過程,Person person = new Person();為例進行說明](https://zhuanlan.zhihu.com/p/31898635) ------ 如果文章對您有一點幫助的話,希望您能點一下贊,您的點贊,是我前進的動力 本文參考連結: - 《深入理解Java虛擬機器》第3版 - [理解靜態繫結與動態繫結](https://juejin.im/post/5d8d2c6251882509155069e4#heading-0) - [全限定名、簡單名稱和描述符是什麼東西?](https://mingshan.fun/2018/09/18/fully-qualified-name-simple-name-descriptor/) - [要點提煉| 理解JVM之類載入機制](https://www.jianshu.com/p/9ea809edebb6) - [深入理解jvm--Java中init和clinit區別完全解析](https://blog.csdn.net/u013309870/article/details/72975536) - [2019校招Android面試題解1.0(下篇)](https://www.jianshu.com/p/168e52336b53) - [類的載入過程,Person person = new Person();為例進行說明](https://zhuanlan.zhihu.com/p/31898635) - [編譯器和執行期](https://www.jianshu.com/p/bcb00756b1eb) - [騰訊 暑期實習 安卓 一面二面三面 面經分享](https://www.nowcoder.com/discuss/392527)