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>(),其他線程會阻塞直到構造器執行完畢。而一個類只會進行一次初始化,這就保證了多線程下類的正確初始化。

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

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章