JVM初探(三):類載入機制

Createsequence 發表於 2020-08-08
JVM

一、概述

我們知道java程式碼會被編譯為.class檔案,這裡class檔案中的類資訊最終還是需要jvm載入以後才能使用。

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

對於jvm類的載入機制,我們主要關注兩個問題:

  • 類的載入時機?(初始化的五種情況)
  • 類的載入過程?(類的五個載入過程)

二、類的載入時機

1.類的生命週期

類從被載入到虛擬機器記憶體中開始,到解除安裝出記憶體為止,整個生命週期包括載入、驗證、準備、解析、初始化、使用和解除安裝。其中驗證、準備、解析統稱為連線

類載入的時機

值得一提的是,載入,驗證,準備,初始化和解除安裝是固定的,但是解析階段不是:它在一定情況下可以在初始化後再開始,以支援java語言的動態繫結

這裡解釋一下動態繫結和靜態繫結:

靜態繫結:
在程式執行前方法已經被繫結(也就是說在編譯過程中就已經知道這個方法到底是哪個類中的方法),此時由編譯器或其它連線程式實現。

動態繫結:
後期繫結:在執行時根據具體物件的型別進行繫結。

另外,類的載入過程必須按步驟“開始”,但是並不等前一個步驟完成後才進行下一個步驟,而是在前一個步驟進行時就開始下一個步驟。

2.類的載入時機

這裡的“載入”只是類載入過程的一個階段,代表這“類的載入”的這一過程的開始,jvm並沒有強制性約束在什麼時候開始類載入過程

一般我們說類的載入,指的是整個載入過程。過程完成後,代表jvm將java檔案編譯成class檔案後,以二進位制流的方式存放到執行時資料的方法區中,並在java的堆中建立一個java.lang.Class物件,用來指向存放在方法堆中的資料結構。

3.類的初始化時機

首先我們得明確一下初始化和例項化的區別:

類的例項化是指建立一個類的例項(物件)的過程;

類的初始化是指為類中各個類成員(被static修飾的成員變數)賦初始值的過程,是類生命週期中的一個階段。

初始化一般是類使用前的最後一個階段,所以類初始化時機可以看成類的載入時機。

凡是有以下四種行為的成為對一個類進行主動引用只有主動引用會觸發類的初始化

  • 遇到四條位元組碼指令
    1. new:使用new關鍵字例項化物件;
    2. getstatic:獲取一個不被final修飾的類的靜態欄位;
    3. putstatic:設定一個不被final修飾的類的靜態欄位;
    4. invokestatic:呼叫一個類的靜態方法;
  • 使用java.lang.reflect包中的方法對類進行反射呼叫時,如果類還沒有初始化,則必須首先對其初始化;
  • 當初始化一個類時,如果其父類還沒有初始化,則必須首先初始化其父類;
  • 當虛擬機器啟動時,使用者需要指定一個要執行的主類(包含main()方法的那個類),虛擬機器會先初始化這個主類。
  • 當使用JDK7動態語言支援時,如果一個java.lang.invoke.MethodHandle例項最後的解析結果REF_getstatic,REF_putstatic,REF_invokeStatic的方法控制程式碼,並且這個方法控制程式碼所對應的類沒有進行初始化,則需要先觸發其初始化。

除了以上五種方式以外引用類的方式成為被動引用,並不會觸發初始化。

被動引用有以下幾種代表性的例子:

假設我們有以下兩種類:

/**
 * @Author:CreateSequence
 * @Date:2020-08-08 21:28
 * @Description:Parent類
 */
public class Parent {
    
    static int ParentAge = 10;
    
    static {
        System.out.println("我是Parent,我被初始化了!");
    }
}

/**
 * @Author:CreateSequence
 * @Date:2020-08-08 21:28
 * @Description:Child類
 */
public class Child extends Parent {

    public static final int cons = 55;

    static {
        System.out.println("我是Child,我被初始化了!");
    }
}
  • 通過子類引用父類的靜態欄位,不會導致子類初始化;

    public static void main( String[] args ) {
        System.out.println(Child.ParentAge);
    }
    
    //輸出
    我是Parent,我被初始化了!
    10
    
  • 通過陣列定義引用類不會初始化;

    public static void main( String[] args ) {
        Parent[] Parent = new Parent[10];
    }
    
  • 常量在編譯階段會存入呼叫類的常量池中,本質上並沒有引用到定義常量的類,因此不會觸發定義常量的類的初始化

    public static void main( String[] args ) {
        System.out.println(Child.cons);
    }
    
    //輸出
    55
    

三、類的載入過程

1.載入

載入”是由類載入器完成的“類載入過程”的第一個階段,在初始化之前完成。

載入階段完成以下三件事:

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

值得一提的是,二進位制流可以從zip包中獲取,這也是JAR或者WAR包格式也能部署專案基礎。

另外,類的載入階段涉及類載入器和雙親委派模型等知識點,此處將另起新隨筆詳細介紹,在本文就不多費筆墨了。

2.驗證

驗證是連線階段的第一步,目的是為了確保Class檔案的位元組流中包含的資訊符合當前虛擬機器的要求,並且不會危害虛擬機器自身的安全。

驗證階段完成以下四件事:

  • 檔案格式驗證:驗證位元組流是否符合Class檔案格式的規範,

    比如是否以魔數0xCAFEBABE開頭、主次版本號是否在當前虛擬機器處理範圍內、常量池的常量中是否有不被支援的常量型別等等;

  • 後設資料驗證:對位元組碼描述的資訊進行語義分析,以保證其描述的資訊符合Java語言規範的要求,

    比如父類是否繼承了被final修飾的類,非抽象類是否都實現了父類或者介面的方法等等;

  • 位元組碼驗證:通過資料流和控制流分析,確定程式語義是合法的、符合邏輯的;

  • 符號引用驗證:對類自身以外的資訊進行匹配性校驗,

    比如符號引用中通過字串描述的全限定名是否能找到對應的類等等。

3.準備

準備階段是正式為類被static修飾的變數(不包含例項變數)分配記憶體並設定類變數初始值的階段。

這裡區分常量與普通靜態變數:

對於普通靜態變數,比如 public staic int num = 1,準備階段賦值為0,而把value賦值為123的putstatic指令是程式被編譯後,存放於虛擬機器裝載一個類初始化的時候呼叫的類構造器方法<clinit>()之中,所以把value賦值為123的動作將在初始化階段才會執行。

而對於常量型別,比如 public static final int = 1,準備階段就會賦值為1。

4.解析

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

這裡我們需要理解一下符號引用和直接引用:

  • 符號引用:以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時可以無歧義的定位到目標即可。符號引用與虛擬機器實現的記憶體佈局無關,引用目標並不一定已經載入到記憶體中
  • 直接引用:直接指向目標的指標、相對偏移量或一個能間接定位到目標的控制程式碼,直接引用與虛擬機器實現的記憶體佈局相關,如果有了直接引用,引用目標必定已經載入到記憶體中

我們舉個簡單的例子:

最開始jvm要載入People類,但是一開始並不知道People的記憶體地址,此時就用符號“People”先表示它的地址,等到類載入器載入完People類的時候,就可以知道People類的實際地址了,於是就將“People”符號換成People這個類的實際記憶體地址。

5.初始化

類初始化階段是類載入過程的最後一步。在前面的類載入過程中,除了在載入階段使用者應用程式可以通過自定義類載入器參與之外,其餘動作完全由虛擬機器主導和控制。到了初始化階段,才真正開始執行類中定義的java程式程式碼(位元組碼)。

這裡我們可以回頭看準備階段,我們知道準備階段會呼叫類構造器<clinit>()方法.

實際上,初始化階段就是執行類構造器<clinit>()方法的過程。

四、初始化時的類構造器

我們在類載入的驗證和初始化時都提到過類構造器 <clinit>(),這裡稍微介紹一下。

<clinit>()方法是由編譯器自動收集類中的所有類變數的賦值動作靜態語句塊中的語句合併產生的。也就是說,如果一個類沒有靜態成員變數和靜態塊,是可以不執行類構造方法的。

1.父類子類類構造器的執行順序

類構造器<clinit>()與例項構造器<init>()不同,它不需要程式設計師進行顯式呼叫,虛擬機器會保證在子類類構造器<clinit>()執行之前,父類的類構造<clinit>()執行完畢。這就導致了父類靜態程式碼塊比子類靜態程式碼塊先執行

2.類構造器中的賦值操作

對於靜態塊中的賦值操作,我們需要注意:靜態語句塊只能訪問到定義在靜態語句塊之前的變數,定義在它之後的變數,在前面的靜態語句塊可以賦值,但是不能訪問

舉個例子:

static {
    i = 5;
    System.out.println(i);//在此處丟擲錯誤:非法的向前引用
}

public static int i = 0;

3.多執行緒環境下的類構造器

在多執行緒環境下,虛擬機器會保證總是隻有一個執行緒去執行類構造器 <clinit>(),其他執行緒會阻塞直到構造器執行完畢。而一個類只會進行一次初始化,這就保證了多執行緒下類的正確初始化。

事實上,這有點像在我關於多執行緒的這篇文章中提到的雙重檢查單例模式,也是因為這點,我們可以巧妙的使用內部類來實現一個執行緒安全的單例模式。

由於例項化的時候其他執行緒會阻塞,所以如果在類的靜態塊中進行了耗時較長的工作時,可能就會導致多個執行緒在你不知道的情況下堵塞,造成不必要的效能消耗。