深入理解JVM類載入機制

卡巴拉的樹發表於2017-11-29

前言

什麼是類載入?

虛擬機器把描述類的資料從Class檔案載入到記憶體,並對資料進行校驗、轉換解析和初始化,最終形成可以被虛擬機器直接使用的java型別。

載入什麼?

前面的定義已經講了是載入描述類的資料,也就是Class檔案,關於Class檔案,我在《深入解析Class類檔案的結構》一文中進行了分析。

誰來載入?

載入描述類的類檔案的二進位制流是由類載入器完成的,已有的三種類載入和自定義的類載入器組成了類載入器子系統,關於類載入器,下文會詳細講述。

怎麼載入?

這就是本文的重點,類載入機制中的類載入流程。 可以通過下圖整體上看一下類載入在JVM體系中的位置

JVM體系結構.png

類的生命週期

類的生命週期共有7個階段,分別如下圖:

類的生命週期.png
前5個階段屬於類載入流程的範圍,其中驗證、準備、解析又被稱為連線,類載入的5個階段並不是按照順序依次完成的,除了解析可能會在初始化之後開始,其他的幾個階段的開始順序是確定的,但結束順序不一定,可能會交叉著進行,載入還沒完成,連線可能已經開始。

類載入流程

類載入分為5個過程,分別是載入、驗證、準備、解析、初始化,下面分別對這幾個過程進行講述,儘量簡短明瞭。

載入

"載入"是"類載入"流程的一個階段

載入階段主要乾的3件事:

  1. 通過一個類的全限定名獲取定義此類的二進位制位元組流
  2. 將這個位元組流所代表的靜態儲存結構轉化為方法區的執行時資料結構
  3. 在記憶體中生成一個代表這個類的java.lang.Class例項,作為訪問入口

在這三件事裡,開發人員能干預的是第一件事,我們可以使用系統的三個類載入器去載入我們想要載入的類檔案,也可以自定義類載入器去獲取二進位制位元組流。

定義類的二進位制位元組流不一定是經過編譯後儲存在磁碟上的.class檔案,有可能是以下來源:

  1. 從ZIP包中讀取,如:JAR、EAR、WAR
  2. 從網路中獲取,如:Applet
  3. 執行時計算生成,如:動態代理技術
  4. 由其他檔案生成,如:JSP檔案生成.class
  5. 從資料庫中讀取,中介軟體伺服器,如:SAP Netweaver

Hotspot虛擬機器中,Class例項不是在堆上分配空間,而是存放在方法區中,這個例項在程式碼中可以輕鬆的獲取到,並通過它可以獲取代表某個類的各種資料結構。

驗證

驗證是對輸入的位元組流進行檢查的過程

為什麼要有驗證這個過程呢?就是因為載入的物件:描述類的二進位制位元組流,來源廣泛,不得不防止它被小人利用,損害虛擬機器的正常執行,導致崩潰。所以總共有四個驗證過程,分別如下圖:

4個驗證過程.jpg

  1. 檔案格式驗證 這個階段直接操作位元組流,後面的三個階段是基於方法區的儲存結構,這個階段主要是驗證檔案本身的位元組碼是不是符合規範,目的是保證輸入的位元組流可以被正確的儲存在方法區內。上圖中的四個檢查項只是其中的一小部分,真正的驗證點還有很多。
  2. 後設資料驗證 這個階段主要是驗證類的後設資料資訊是否符合Java語言規範,比如檢查是否有父類,除了Objec,其他類都應該要有父類,否則就不符合規範了;被final修飾的不允許被繼承。
  3. 位元組碼驗證 這個階段主要是對類的方法體進行驗證,保證類方法的執行不會對虛擬機器造成危害。這是4個驗證裡最複雜的一個,因為要通過資料流和控制流的分析,確定程式語義是合法的、符合邏輯的。
  4. 符號引用驗證 上面三個階段是對類本身進行驗證,而符號引用驗證階段主要是對類以外的資訊進行驗證,後面會講到解析是將符號引用替換成直接引用,所以這裡驗證的目的是確保符號引用是正確的,確保後面的解析過程能順利的進行。

準備

準備階段是正式為類變數分配記憶體並設定類變數初始值的階段

注意這裡是為類變數分配記憶體,而且是分配在方法區中,例項變數是後面隨著例項一起分配在堆上的。

設定初始值也不是程式碼裡賦的值,而是各個資料型別規定的零值,比如基礎型別是相應型別不同位元組長度的0,引用型別是null。

不是每個類變數都是設定為零值,被final修飾的常量,因為在編譯期帶有一個ConstantValue屬性,屬性值則是該常量在程式碼裡賦的值,這個值在準備階段前就已經確定了,所以在準備階段設定值的時候,直接取的ConstantValue給類常量。 下面的例子可以很好的瞭解準備階段,準備階段過後,a、b、c分別是多少?

public class Test {
    public static int a; 
    public static int b = 1;
    public static final int c = 2;
    public void say(){
        System.out.println("Hello");
    }
}
複製程式碼

答案揭曉:0, 0, 2 原因上文裡寫的很明白

解析

解析是將常量池內的符號引用替換為直接引用的過程

那什麼是符號引用和直接引用呢?

  • 符號引用:以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義的定位到目標即可。
  • 直接引用:直接引用可以是直接指向目標的指標、相對偏移量或是一個能間接定位到目標的控制程式碼。

符號引用與虛擬機器實現的記憶體佈局無關,引用的目標並不一定已經載入到記憶體中。各種虛擬機器實現的記憶體佈局可以各不相同,但是他們能接受的符號引用必須都是一致的,因為符號引用的字面量形式明確定義在Java虛擬機器規範的Class檔案格式中。 直接引用是和虛擬機器實現的記憶體佈局相關的,同一個符號引用在不同的虛擬機器例項上翻譯出來的直接引用一般不會相同。如果有了直接引用,那引用的目標必定已經在記憶體中存在。

解析的時機根據虛擬機器實現不同而不同,可以是類載入器載入時解析,也可以是符號引用使用前解析 解析主要是對7類符號引用進行:類或介面、欄位、類方法、介面方法、方法型別、方法控制程式碼、呼叫點限定符

7類符號引用.png

初始化

初始化是執行類構造器<clinit>()方法的過程

類初始化階段是類載入流程的最後一個階段,是執行<clinit>()方法的階段,這個階段才真正開始執行開發人員的程式碼。

<clinit>()方法是編譯器按照原始檔中定義的順序收集類變數和靜態語句塊形成的方法。它的一些特點和細節如下:

  1. 編譯器自動收集靜態變數和靜態程式碼塊合併產生的
  2. 不需要顯示的呼叫父類的<clinit>,虛擬機器保證父類先執行
  3. 父類定義的靜態語句塊優先於子類變數賦值操作
  4. 沒有靜態變數和靜態語句塊,可以不生成<clinit>()方法
  5. 介面也會有這個方法,但不需要先執行父類的<clinit>()方法
  6. 虛擬機器保證該方法在多執行緒環境下被正確的加鎖和同步

什麼時候發生初始化?

對一個類進行主動引用的時候必須初始化,主動引用的場景如下:

  1. 遇到new、getstatic、putstatic、invokestatic這四條指令時
  2. 使用java.lang.reflect包的方法對類進行反射呼叫時
  3. 初始化一個其父類還沒被初始化的類時
  4. 虛擬機器啟動時,包含main方法的主類還沒被初始化時
  5. 當使用動態語言支援時,如果一個java.lang.invoke.MethodHandle例項最後的解析結果REF_getStatic、REF_putStatic、REF_invokeStatic的方法控制程式碼,並且這個方法所對應的類沒有進行初始化時

什麼時候不發生初始化?

對一個類進行被動引用的時候不初始化,被動引用的場景有下面一些:

  1. 通過子類引用父類的靜態欄位,不會導致子類的初始化
  2. 通過陣列定義來引用類,不會觸發此類的初始化
  3. 引用類的常量時,不會觸發此類的初始化

類載入器

什麼是類載入器?

實現“通過一個類的全限定名來獲取描述此類的二進位制位元組流”這個動作的程式碼模組就叫做類載入器

類載入不僅僅是載入二進位制位元組碼的作用,還起著獨立的類名稱空間的作用,確定一個類的唯一性由三個因素決定:

  1. 同一個java虛擬機器
  2. 同一個類載入器
  3. 同一個全限定類名

雙親委派模型

下圖中各個載入器之間的層次關係被稱為類載入器的雙親委派模型

雙親委託模型圖.png
圖中可以看到,系統提供了三個類載入器:啟動類載入器、擴充套件類載入器和應用程式類載入器,java程式啟動的時候,三個類載入器分別從各自指定的路徑中載入所需的類。最下面是開發人員自定義的類載入器,繼承自ClassLoader,重寫findClass()方法。

一般我們自己寫的類是預設由應用程程式載入器載入的,自定義的類載入器的父類載入器預設是應用程式載入器,應用程式載入器的父類載入器是擴充套件類載入器,擴充套件類載入器的父類載入器是啟動類載入器,這種父子關係不是一般的繼承或實現關係,而是子載入器持有父載入器的引用,是一種組合關係。自定義類載入器時,可以在建構函式中傳入指定的父類載入器。

雙親委派模型的工作原理

一個類載入器收到了類載入的請求時,它首先會先檢查自身有沒有載入過這個類,實質就是在JVM的常量池中查詢該類的符號引用是否存在,如果有就直接返回,否則把這個請求委派給父類載入器,直至委派給啟動類載入器,只有當父類載入器載入失敗,子類載入器才會嘗試自己去載入。

下面是實現雙親委派模型的主要程式碼,程式碼簡單易懂:

//ClassLoader.java
protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        //加鎖,整個類載入期間都持有鎖
        synchronized (getClassLoadingLock(name)) {
            // 首先,檢查此類是否已被載入過,是的話直接返回
            Class<?> c = findLoadedClass(name);
            if (c == null) {  //如果沒有載入過,則繼續
                long t0 = System.nanoTime();
                try {
                    if (parent != null) { //有父類載入器,則交給父類載入器載入,遞迴執行loadClass方法
                        c = parent.loadClass(name, false);
                    } else {  //沒有父類載入器,交給啟動類載入器載入,執行一個本地方法
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // 除了啟動類載入器之外的類載入器載入類失敗拋異常,此處不進行任何處理
                }

                if (c == null) {
                    // 父類載入器未成功載入到類,則呼叫本載入器的findClass方法
                    long t1 = System.nanoTime();
                    c = findClass(name);

                    // 記錄一些狀態
                    sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    sun.misc.PerfCounter.getFindClasses().increment();
                }
            }
            //驗證解析
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }
複製程式碼

雖然易懂,但配合下面的圖更容易加深理解,下面是這段程式碼的資料流程圖:

雙親委派模型資料流程圖.png
下面按照一般的雙親委派模型來分析,假設是自定義的類載入器呼叫了loadClass方法,觸發了類載入的過程,則下面的過程會依次執行:

  • 自定義的類載入器首先會呼叫findLoadedClass(name)方法檢視有沒有被載入的這個類,如果有直接返回,否則執行下面步驟
  • 檢查是否存在父類,如果有則遞迴呼叫父類的loadClass方法,否則說明父類載入器是啟動類載入器,本類載入器是擴充套件類載入器,呼叫findBootstrapClassOrNull(name)使用啟動類載入器進行類載入
  • 啟動類載入器載入成功則返回,失敗則呼叫擴充套件類載入器的findClass(name)方法來載入,成功則返回,失敗則繼續呼叫應用類載入器的findClass(name)方法,同樣成功返回,失敗呼叫自定義類載入器的findClass(name)
  • 我們自定義的類載入器一般會重寫findClass方法,使用自定義的類載入器載入一個父類載入器載入不了的類的時候,就會執行自定義的findClass方法,在此方法中,會指定二進位制位元組碼的路徑讀入位元組陣列,最後呼叫defineClass返回載入成功的類

下面是自定義類載入器的示例程式碼:

public class MyClassLoader extends ClassLoader{
    private String classpath;

    //指定父類載入器的建構函式
    public MyClassLoader(String classpath,ClassLoader classLoader) {
        super(classLoader);
        this.classpath = classpath;
    }
    //預設父類載入器為應用程式載入器的建構函式
    public MyClassLoader(String classpath) {
        this.classpath = classpath;
    }

    //重寫findClass,載入類檔案,返回類
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        String classFilePath = null;
        String finalName = name.replace(".", "/");
        classFilePath = classpath + "/" + finalName + ".class";
        Path path = Paths.get(classFilePath);
        if (!Files.exists(path)) {
            return null;
        }
        try {
            byte[] classData =  Files.readAllBytes(path);
            return defineClass(name, classData, 0, classData.length);
        } catch (IOException e) {
            throw new RuntimeException("Can not read class file into byte array");
        }
    }
}
複製程式碼

為什麼要使用這個模型?

最後來講講為什麼要使用這個模型?用這個模型有什麼好處?

採用雙親委派模式的好處之一是類和它對應的類載入器一起具備了一種帶有優先順序的層次關係,通過這種層級關係可以避免類的重複載入,當父類載入器已經載入了該類時,子類載入器就沒有必要再載入一次。

其次是考慮到安全因素,保證java核心api中定義的型別不會被隨意替換,假設通過網路傳遞一個名為java.lang.Integer的類,通過雙親委託模式傳遞到啟動類載入器,而啟動類載入器在核心Java API發現這個名字的類,發現該類已被載入,並不會重新載入網路傳遞過來的java.lang.Integer,而直接返回已載入過的Integer.class,這樣便可以防止核心API庫被隨意篡改。


原文連結:www.jackielee.cn/posts/7a3ae…
歡迎掃描關注我的公眾號

程式猿隨記

相關文章