你真的理解JVM類加載的各階段過程(加載、驗證、準備、解析、初始化)嗎?

0 使用類的準備工作

任何程序都需要加載到內存才能與CPU進行交流
同理, 字節碼.class文件同樣需要加載到內存中,纔可以實例化類
ClassLoader的使命就是提前加載.class 類文件到內存中
在加載類時,使用的是Parents Delegation Model(溯源委派加載模型)

Java的類加載器是一個運行時核心基礎設施模塊,主要是在啓動之初進行類的加載、鏈接、初始化
Java 類加載過程

Load

由類加載器執行。

讀取類文件(通常在 classpath 所指定的路徑中查找,但classpath非必須),查找字節碼,從而產生二進制流,並轉爲特定數據結構,初步校驗cafe babe魔法數、常量池、文件長度、是否有父類等,然後創建對應類的java.lang.Class實例

Link

包括驗證、準備、解析三個步驟:

  • 驗證類中的字節碼,是更詳細的校驗,比如final是否合規、類型是否正確、靜態變量是否合理等
  • 準備階段是爲static 字段分配內存,並設定默認值,解析類和方法確保類與類之間的相互引用正確性,完成內存結構佈局
  • 如果需要的話,將解析這個類創建的對其他類的所有引用。

Init

執行類構造器 方法,如果賦值運算是通過其他類的靜態方法來完成的,那麼會馬上解析另外一個類,在虛擬機棧中執行完畢後通過返回值進行賦值

類加載是一個將.class字節碼文件實例化成Class對象並進行相關初始化的過程。
在這個過程中,JVM會初始化繼承樹上還沒有被初始化過的所有父類,並且會執行這個鏈路上所有未執行過的靜態代碼塊、靜態變量賦值語句等。
某些類在使用時,也可以按需由類加載器進行加載。

全小寫的class是關鍵字,用來定義類
而首字母大寫的Class,它是所有class的類
這句話理解起來有難度,類已經是現實世界中某種事物的抽象,爲什麼這個抽象還是另外一個類Class的對象?
示例代碼如下:




● 第1處說明:
Class類下的newInstance()在JDK9中已經置爲過時,使用getDeclaredConstructor().newInstance()的方式
着重說明一下new與newInstance的區別

  • new是強類型校驗,可以調用任何構造方法,在使用new操作的時候,這個類可以沒有被加載過
  • 而Class類下的newInstance是弱類型,只能調用無參構造方法
    • 如果沒有默認構造方法,就拋出InstantiationException異常;
    • 如果此構造方法沒有權限訪問,則拋 IllegalAccessException異常

Java 通過類加載器把類的實現與類的定義進行解耦,所以是實現面向接口編程、依賴倒置的必然選擇。

● 第2處說明:
可以使用類似的方式獲取其他聲明,如註解、方法等
類的反射信息

● 第3處說明: private 成員在類外是否可以修改?
通過setccessible(true),即可使用Class類的set方法修改其值
如果沒有這一步,則拋出如下異常:

1 加載的定位

“加載”是“類加載”(Class Loading)過程的第一步

1.1 加載的過程

在加載的過程中,JVM主要做3件事情

  • 通過一個類的全限定名來獲取定義此類的二進制字節流(class文件)
    在程序運行過程中,當要訪問一個類時,若發現這個類尚未被加載,並滿足類初始化的條件時,就根據要被初始化的這個類的全限定名找到該類的二進制字節流,開始加載過程
  • 將這個字節流的靜態存儲結構轉化爲方法區的運行時數據結構
  • 在內存中創建一個該類的java.lang.Class對象,作爲方法區該類的各種數據的訪問入口

程序在運行中所有對該類的訪問都通過這個類對象,也就是這個Class對象是提供給外界訪問該類的接口

1.2 加載源

JVM規範對於加載過程給予了較大的寬鬆度.一般二進制字節流都從已經編譯好的本地class文件中讀取,此外還可以從以下地方讀取

  • zip包
    Jar、War、Ear等
  • 其它文件生成
    由JSP文件中生成對應的Class類.
  • 數據庫中
    將二進制字節流存儲至數據庫中,然後在加載時從數據庫中讀取.有些中間件會這麼做,用來實現代碼在集羣間分發
  • 網絡
    從網絡中獲取二進制字節流.典型就是Applet.
  • 運行時計算生成
    動態代理技術,用PRoxyGenerator.generateProxyClass爲特定接口生成形式爲"*$Proxy"的代理類的二進制字節流.

1.3 類和數組加載過程的區別

數組也有類型,稱爲“數組類型”.如:

String[] str = new String[10];

這個數組的數組類型是Ljava.lang.String,而String只是這個數組的元素類型

當程序在運行過程中遇到new關鍵字創建一個數組時,由JVM直接創建數組類,再由類加載器創建數組中的元素類型.

而普通類的加載由類加載器創建.既可以使用系統提供的引導類加載器,也可以由用戶自定義的類加載器完成(即重寫一個類加載器的loadClass()方法)

1.4 加載過程的注意點

  • JVM規範並未給出類在方法區中存放的數據結構
    類完成加載後,二進制字節流就以特定的數據結構存儲在方法區中,但存儲的數據結構是由虛擬機自己定義的,虛擬機規範並沒有指定
  • JVM規範並沒有指定Class對象存放的位置
    在二進制字節流以特定格式存儲在方法區後,JVM會創建一個java.lang.Class類的對象,作爲本類的外部訪問接口
    既然是對象就應該存放在Java堆中,不過JVM規範並沒有給出限制,不同的虛擬機根據自己的需求存放這個對象
    HotSpot將Class對象存放在方法區.
  • 加載階段和鏈接階段是交叉的
    類加載的過程中每個步驟的開始順序都有嚴格限制,但每個步驟的結束順序沒有限制.也就是說,類加載過程中,必須按照如下順序開始:

加載 -> 鏈接 -> 初始化

但結束順序無所謂,因此由於每個步驟處理時間的長短不一就會導致有些步驟會出現交叉

2 驗證

驗證階段比較耗時,它非常重要但不一定必要(因爲對程序運行期沒有影響),如果所運行的代碼已經被反覆使用和驗證過,那麼可以使用-Xverify:none參數關閉,以縮短類加載時間

2.1 驗證的目的

保證二進制字節流中的信息符合虛擬機規範,並沒有安全問題

2.2 驗證的必要性

雖然Java語言是一門安全的語言,它能確保程序猿無法訪問數組邊界以外的內存、避免讓一個對象轉換成任意類型、避免跳轉到不存在的代碼行.也就是說,Java語言的安全性是通過編譯器來保證的.

但是我們知道,編譯器和虛擬機是兩個獨立的東西,虛擬機只認二進制字節流,它不會管所獲得的二進制字節流是哪來的,當然,如果是編譯器給它的,那麼就相對安全,但如果是從其它途徑獲得的,那麼無法確保該二進制字節流是安全的。

通過上文可知,虛擬機規範中沒有限制二進制字節流的來源,在字節碼層面上,上述Java代碼無法做到的都是可以實現的,至少語義上是可以表達出來的,爲了防止字節流中有安全問題,需要驗證!

2.3 驗證的過程

  • 文件格式驗證
    驗證字節流是否符合Class文件格式的規範,並且能被當前的虛擬機處理.
    本驗證階段是基於二進制字節流進行的,只有通過本階段驗證,才被允許存到方法區
    後面的三個驗證階段都是基於方法區的存儲結構進行,不會再直接操作字節流.

通過上文可知,加載開始前,二進制字節流還沒進方法區,而加載完成後,二進制字節流已經存入方法區
而在文件格式驗證前,二進制字節流尚未進入方法區,文件格式驗證通過之後才進入方法區
也就是說,加載開始後,立即啓動了文件格式驗證,本階段驗證通過後,二進制字節流被轉換成特定數據結構存儲至方法區中,繼而開始下階段的驗證和創建Class對象等操作
這個過程印證了:加載和驗證是交叉進行的

  • 元數據驗證
    對字節碼描述信息進行語義分析,確保符合Java語法規範.
  • 字節碼驗證
    本階段是驗證過程的最複雜的一個階段.
    本階段對方法體進行語義分析,保證方法在運行時不會出現危害虛擬機的事件.
    字節碼驗證將對類的方法進行校驗分析,保證被校驗的方法在運行時不會做出危害虛擬機的事,一個類方法體的字節碼沒有通過字節碼驗證,那一定有問題,但若一個方法通過了驗證,也不能說明它一定安全
  • 符號引用驗證
    發生在JVM將符號引用轉化爲直接引用的時候,這個轉化動作發生在解析階段,對類自身以外的信息進行匹配校驗,確保解析能正常執行.

3 準備

完成兩件事情

  • 爲已在方法區中的類的靜態成員變量分配內存
  • 爲靜態成員變量設置初始值
    初始值爲0、false、null等
public static final int value = 123;

準備階段後 a 的值爲 0,而不是 123,要在初始化之後才變爲 123,但若被final修飾的常量如果有初始值,那麼在編譯階段就會將初始值存入constantValue屬性中,在準備階段就將constantValue的值賦給該字段(此處將value賦爲123).

4 解析

解析階段是虛擬機將常量池中的符號引用替換爲直接引用的過程.

5 初始化

真正開始執行類中定義的Java程序代碼(或者說是字節碼)
初始化階段就是執行類構造器clinit()的過程.

clinit()方法由編譯器自動產生,收集類中static{}代碼塊中的類變量賦值語句和類中靜態成員變量的賦值語句。在準備階段,類中靜態成員變量已經完成了默認初始化,而在初始化階段,clinit()方法對靜態成員變量進行顯示初始化。

5.1 初始化過程的注意點

  • clinit()方法是IDE自動收集類中所有類變量的賦值動作和靜態語句塊中的語句合併產生的,IDE收集的順序是由語句在源文件中出現的順序所決定的.
  • 靜態代碼塊只能訪問到出現在靜態代碼塊之前的變量,定義在它之後的變量,在前面的靜態語句塊可以賦值,但是不能訪問.
public class Test {
    static {
        i=0;
        System.out.println(i);//編譯失敗:"非法向前引用"
    }
    static int i = 1;
}
  • 實例構造器init()需要顯示調用父類構造函數,而類的clinit()不需要調用父類的類構造函數,虛擬機會確保子類的clinit()方法執行前已經執行完畢父類的clinit()方法.因此在JVM中第一個被執行的clinit()方法的類肯定是java.lang.Object.
  • 如果一個類/接口中沒有靜態代碼塊,也沒有靜態成員變量的賦值操作,那麼編譯器就不會爲此類生成clinit()方法.
  • 接口也需要通過clinit()方法爲接口中定義的靜態成員變量顯示初始化。
  • 接口中不能使用靜態代碼塊,但仍然有變量初始化的賦值操作,因此接口與類一樣都會生成clinit()方法.不同的是,執行接口的clinit()方法不需要先執行父接口的clinit()方法.只有當父接口中的靜態成員變量被使用到時纔會執行父接口的clinit()方法.
  • 虛擬機會保證在多線程環境中一個類的clinit()方法別正確地加鎖,同步.當多條線程同時去初始化一個類時,只會有一個線程去執行該類的clinit()方法,其它線程都被阻塞等待,直到活動線程執行clinit()方法完畢.

其他線程雖會被阻塞,只要有一個clinit()方法執行完,其它線程喚醒後不會再進入clinit()方法.同一個類加載器下,一個類型只會初始化一次.

參考

  • 《碼到成功》
  • 《深入理解Java虛擬機第三版》
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章