JVM(三)-java虛擬機器類載入機制

負重前行的小牛發表於2020-11-29

概述:

  上一篇文章,介紹了java虛擬機器的執行時區域,Java虛擬機器根據不同的分工,把記憶體劃分為各個不同的區域。在java程式中,最小的執行單元一般都是建立一個物件,然後呼叫物件的某個

方法。通過上一篇文章我們知道呼叫某個方法是通過虛擬機器棧的棧幀並通過執行引擎來實現的,但是實際上一個方法的執行前提是,該物件對應的Class檔案需要載入到記憶體的方法區,並且

要new一個物件,物件的引用存放在虛擬機器棧的本地變數表,物件的例項存放在堆。本篇文章關注的重點就是Java虛擬機器如何將Class檔案載入到記憶體。

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

裡,型別的載入、連線、初始化都是在程式執行期間完成的,這種策略讓java語言進行提前編譯會面臨額外的困難,也會讓類載入時稍微增加一些效能開銷,但是確為java應用提供了極高的擴充套件性

和靈活性,java天生可以動態擴充套件的語言特性就是依賴執行期動態載入和動態連線這個特點實現的。

類載入的時機:

  一個型別從被載入到虛擬機器記憶體中開始,到解除安裝除記憶體為止,它的整個宣告週期將會經歷載入、驗證、準備、解析、初始化、使用和解除安裝七個階段,其中驗證、準備、解析三個部分統稱為連線。

這七個階段的發生順序如下圖所示:

 

   上圖中,載入、驗證、準備、初始化和解除安裝這個五個階段的順序是確定的,而解析階段則不一定:它在某些情況下可以在初始化階段之後開始,這是為了支援java語言的執行時繫結特性(也稱為動態繫結或晚繫結)。

初始化只有在以下六種情況下才會觸發:

  1. 使用new關鍵字例項化物件或者讀取或設定一個型別的靜態欄位(被final修飾、已在編譯器把結果放入常量池的靜態欄位除外)以及呼叫一個型別的靜態方法的時候。
  2. 對型別進行反射呼叫時,如果沒有進行過初始化,則需要先觸發其初始化。
  3. 當初始化類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化。
  4. 當虛擬機器啟動時,使用者需要指定一個執行的主類,虛擬機器會先初始化這個主類。
  5. 使用java7新加入的MethodHandle動態語言支援,在使用某些方法時,如果類沒有初始化,則需要先觸發初始化。
  6. 當介面中定義了JDK8新加入的預設方法,如果有這個介面的實現類發生了初始化,則需要優先初始化該介面。

  這六種場景中的行為稱為對一個型別進行主動引用。除此之外,所有引用型別的方式都不會觸發初始化,稱為被動引用。來看一個主動引用的例子,這是一個典型的餓漢式單例模式:

package singleton;

/**
 * @ClassName SingletonDemo1
 * @Description 餓漢式
 * 類載入到記憶體後,就例項化一個單例物件,JVM保證執行緒安全
 * @Author liuyi
 * @Date 2020/6/7 12:22
 * @Version 1.0
 */
public class SingletonDemo1 {
    //靜態塊方式
    private static final SingletonDemo1 instance;
    static {
        instance = new SingletonDemo1();
    }
    private SingletonDemo1(){
        System.out.println("我被初始化了");
    }
    public static SingletonDemo1 getInstance(){
        System.out.println("獲取單例類物件");
        return instance;
    }

    public static void main(String[] args) {
        SingletonDemo1.getInstance();
    }
}

  程式碼的執行結果如下:

   很多java程式設計師對單例的餓漢式都有一個誤解,就是static快裡面的例項化程式碼在程式啟動的過程中就會被執行,這樣的理解是完全錯誤的。餓漢式指的是類被載入的時候初始化,而

懶漢式是使用的時候才初始化。我在概述裡面也提到了,java是在程式執行期間進行類的載入、連線和初始化。所以這裡,我在呼叫getInstance()方法的時候類才會被載入,而getInstance()

 方法又恰好是一個靜態方法,滿足六條觸發初始化規則的第一條,所以當呼叫getInstance(),該類會被初始化,只有在類被初始化的時候才會執行static塊的程式碼,所以會先列印我被初始化了,

然後再列印獲取例項類物件。這裡之所以被稱為餓漢式單例是因為在獲取例項之前,物件已經先一步初始化好了。只是這裡恰好觸發初始化的方法是getInstance(),會給人一種誤解是我

呼叫了該方法才初始化的,但是實際上能夠觸發類初始化方式並不是只有這一種,上面列的六種情況中的任意一種都可以觸發類的初始化。當在我呼叫getInstance()之前,該類被初始化過,這種

情況就很好解釋它就是餓漢式了。

  我們來驗證是不是這樣的,我在程式碼中加了一個靜態變數a,然後呼叫該靜態變數,程式碼如下:

package singleton;

/**
 * @ClassName SingletonDemo1
 * @Description 餓漢式
 * 類載入到記憶體後,就例項化一個單例物件,JVM保證執行緒安全
 * @Author liuyi
 * @Date 2020/6/7 12:22
 * @Version 1.0
 */
public class SingletonDemo1 {

    private static int a = 2;

    //靜態塊方式
    private static final SingletonDemo1 instance;

    static {
        instance = new SingletonDemo1();
    }

    private SingletonDemo1() {
        System.out.println("我被初始化了");
    }

    public static SingletonDemo1 getInstance() {
        System.out.println("獲取單例類物件");
        return instance;
    }

    public static void main(String[] args) {
        System.out.println(SingletonDemo1.a);
//        SingletonDemo1.getInstance();
    }
}

  來看看執行結果:

   可以看到,我沒有呼叫getInstance()方法,該類還是被初始化了,這裡是因為我呼叫了靜態變數a,同樣滿足六種情況的第一種情況,所以類在載入的時候被初始化了。

static程式碼塊同時也被執行了,所以列印了我被初始化了。

  我們再了看看另外一種方法實現單例模式,靜態內部類方法,為什麼說這種方式不會提前初始化,先來看程式碼:

package singleton;

/**
 * @ClassName SingletonDemo5
 * @Description 靜態內部類方式
 * JVM保證執行緒安全
 * 載入外部類是不會載入內部類,實現了懶載入
 * 最完美的寫法
 * @Author liuyi
 * @Date 2020/6/7 13:52
 * * @Version 1.0
 */
public class SingletonDemo5 {

    private SingletonDemo5() {
        System.out.println("我被例項化了");
        //防止惡意通過反射破壞單例            
        if (SingletonDemo5Inside.instance != null) {
            throw new RuntimeException("不允許建立多個例項");
        }
    }
    private static class SingletonDemo5Inside {
        private static final SingletonDemo5 instance = new SingletonDemo5();
    }

    public static SingletonDemo5 getInstance() {
        System.out.println("獲取單例類物件");
        return SingletonDemo5Inside.instance;
    }

    public static void main(String[] args) {
        SingletonDemo5.getInstance();
    }
}

  來看程式碼的執行結果:.

   從程式碼的執行結果來看,是在呼叫getInstance()方法之後,類才被初始化的。為什麼會這樣呢,這是因為我們在呼叫該方法的時候,SingletonDemo5雖然被初始化了,

但是它並沒有被例項化,而靜態內部類SingletonDemo5Inside同樣滿足java是在程式執行期間進行類的載入、連線和初始化的原則,所以在沒有呼叫SingletonDemo5Inside.instance()

之前,它是不會別載入的。當我們呼叫SingletonDemo5Inside.instance()的時候,SingletonDemo5才被例項化,所以這種方式是除了列舉方式之外最完美的單例寫法。

  我們再來看看被動引用的例子:

package singleton;

/**
 * @ClassName Person
 * @description:
 * @author:liuyi
 * @Date:2020/11/29 1:00
 */
public class Person {
    static {
        System.out.println("初始化Person類");
    }

    public static int age = 28;
}
class Man extends Person{
    static {
        System.out.println("初始化Man類");
    }
}

class PersonTest{
    public static void main(String[] args) {
        System.out.println(Man.age);
    }
}

  程式碼執行結果:

 

   對於靜態欄位,只有直接定義這個欄位的類才會被初始化,所以就算使用子類呼叫該靜態變數,也只有父類才會被初始化。

  再來看一個例子:

package singleton;

/**
 * @ClassName ConstantClass
 * @description:
 * @author:liuyi
 * @Date:2020/11/29 1:06
 */
public class ConstantClass {
    static {
        System.out.println("初始化ConstantClass");
    }

    public static final String text = "hello";
}

class ConstantTest{
    public static void main(String[] args) {
        System.out.println(ConstantClass.text);
    }
}

  程式碼執行結果:

 

   可以看到,雖然我們是訪問的static修飾的變數,但是依然沒有觸發該類的初始化。這是因為text是一個常量,會被放到常量池中,我們並不會通過類去獲取,所以

不需要對類進行初始化。

類的載入過程:

   接下來我們詳細瞭解java虛擬機器中類載入的全過程,即載入、驗證、準備、解析和初始化這五個階段所執行的具體動作。

載入:

  在載入階段,虛擬機器需要完成以下三件事情:

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

相對於類載入過程的其他階段,非陣列型別的載入階段是開發人員可控性最強的階段。開發人員可以使用java虛擬機器內建的引導類載入器來實現載入階段,也可以自定義類載入

來實現。而對於陣列而言,情況有所不同,因為陣列類本身不通過類載入器建立,它是由java虛擬機器直接在記憶體中動態構造出來的。但是如果陣列的型別是引用型別,整個陣列的

建立還是要依賴類載入器。

  載入階段和連線階段的部分動作是交叉進行的,載入階段尚未完成,連線階段可能已經開始,但是整體上的順序還是先載入,再進入連線階段。舉個簡單的例子,比如一個類

有兩個方法,可能一個方法載入完之後,立馬就進入這個方法的連線階段,但是此時第二個方法可能才剛開始載入。

驗證:

  驗證是連線階段的第一步,這一階段的目的是確保Class檔案的位元組流中包含的資訊符合java虛擬機器規範的全部約束要求,保證這些資訊被當作程式碼執行後不會危害虛擬機器自身的安全。

從整體上看,驗證階段大致上會完成下面四個階段的檢驗動作。

  1. 檔案格式驗證:驗證位元組流是否符合Class檔案的格式的規範,並且是否能被當前版本的虛擬機器處理。

  2. 後設資料驗證:對位元組碼描述的資訊進行語義分析,以保證其描述資訊符合java虛擬機器規範的要求。

  3. 位元組碼驗證:這個階段是整個驗證過程最複雜的一個階段,主要目的是通過資料流分析和控制流分析,確定程式語義是合法的、符合邏輯的。
  4. 符號引用驗證:這個階段的校驗行為發生在虛擬機器符號引用轉化為直接引用的時候,這個轉化動作將在連線的第三階段——解析階段發生。符號引用驗證可看作是對類自身以外(常量池中的

 

各種符號引用)的各類資訊進行匹配性校驗,通俗來說就是,該類是否缺少或者被禁止訪問它依賴的某這些外部類、方法、欄位等資源。

  驗證階段對於虛擬機器的類載入機制來說,是一個非常重要、但卻不是必須要執行的階段。如果程式的全部程式碼(都已經被反覆使用和驗證過),在生產環境的實施階段就可以考慮使用-Xverify:none

引數來關閉大部分的類驗證措施,以縮短虛擬機器類載入的時間。

準備:

  準備階段是正式為類中定義的變數(即靜態變數,被static修飾的變數)分配記憶體並設定類變數初始值的階段,關於準備階段,有兩個容易產生混淆的概念這裡需要著重強調,首先是這時候進行記憶體

分配的僅包括類變數,而不包括例項變數,例項變數將會在物件例項化時隨著物件一起分配在java堆中。其次這裡所說的初始值通常情況是資料型別的零值,假設一個型別變數的定義為:

public static int value = 666;那變數value在準備階段過後的初始值為0而不是666,value賦值為666要在類的初始化階段才會被執行。

解析:

  解析階段是java虛擬機器將常量池內的符號引用替換為直接引用的過程,先來看看符號引用和直接引用的概念:

 

  符號引用:以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可,符號引用與虛擬機器實現的記憶體佈局無關,引用的目標並不一定是已經載入到虛擬機器

記憶體當中的內容。各種虛擬機器的記憶體佈局可以各不相同,但是它們接受的符號引用必須都是一致的,因為符號引用的字面量形式明確定義在java虛擬機器規範的Class檔案格式中。

  直接引用:直接引用是可以直接指向目標的指標、相對偏移量或者一個能間接定位的目標的控制程式碼。直接引用是和虛擬機器實現的記憶體佈局直接相關的,同一個符號引用在不同的虛擬機器中翻譯出來的直接引用

一般不會相同。如果有了直接引用,那引用的目標必定已經在虛擬機器的記憶體中存在。

  解析主要包括類或者介面的解析、欄位解析、方法解析、介面方法解析,每項解析都有自己的解析步驟,這裡就不一一介紹了。

初始化:

  類的初始化階段是類載入過程的最後一個步驟,初始化完成之後,java虛擬機器才會正在的開始執行類中編寫的java程式程式碼,將主導權移交給應用程式。在準備階段中,變數已經賦過一次系統要求的初始

零值,而在初始化階段,則會根據程式設計師通過程式編碼制定的主觀計劃去初始化類變數和其他資源。我們也可以從另外一種更直接的形式來表達:初始化階段就是執行類構造器<clinit>()方法的過程。<clinit>()

並不是指程式設計師在java程式碼中直接編寫的類構造方法,它是javac編譯器自動生成的,但是我們非常有必要了解這個方法具體是怎麼產生的,以及該方法執行過程中各種可能影響程式執行行為的細節,這部分

比起其他類載入過程更貼近於普通開發人員的實際工作。

  <clinit>()方法是由編譯器自動收集類中的所有類變數的賦值動作和靜態語句塊(static{}塊)中的語句合併產生的,編譯器蒐集的順序是由語句在原始檔中出現的位置決定的,靜態語句塊中只能訪問到定義

在靜態語句塊之前的變數,定義在它之後的變數,在前面的靜態語句塊可以賦值,但是不能訪問,如下面程式碼:

/**
 * @ClassName Test1
 * @description:
 * @author:liuyi
 * @Date:2020/11/29 16:51
 */
public class Test1 {
    static {
        i = 0;//給變數賦值可以正常編譯通過
        System.out.println(i);//會提示"非法向前引用"
    }
    static int i = 1;
}

  <clinit>()方法與類的建構函式不同,它不需要顯示的呼叫父類的構造器,java虛擬機器保證在子類的<clinit>()方法執行之前,父類的<clinit>()方法一定執行完畢,因此虛擬機器中第一個被執行的<clinit>()

方法的型別肯定是java.lang.Object。所以父類中定義的靜態語句塊要優先於子類的變數賦值操作,如下程式碼的值將會是2而不是1.

package test;

/**
 * @ClassName Parent
 * @description:
 * @author:liuyi
 * @Date:2020/11/29 16:57
 */
public class Parent {
    public static int A = 1;
    static {
        A = 2;
    }

    public static void main(String[] args) {
        System.out.println(Sub.B);
    }
}
class Sub extends Parent{
    public static int B = A;
}

  <clinit>()方法對於類或介面來說並不是必需的,如果一個類沒有靜態程式碼塊,也沒有對變數的賦值操作,那麼編譯器可以不為這個類生成<clinit>()方法。介面中不能使用靜態程式碼塊,但仍然有變數初始化操作,

因此介面和類一樣都會生成<clinit>()方法。但介面與類不同的是,執行介面的<clinit>()方法不需要先執行父介面的<clinit>()方法,因為只有當父介面中定義的變數被使用時,父介面才會被初始化。此外,介面的實現

類在初始化時一樣不會執行介面的<clinit>()方法。

  java虛擬機器必須保證一個類的<clinit>()方法在多執行緒環境中被正確地加鎖同步,如果多執行緒同時去初始化一個類,那麼只會其中一個執行緒去執行這個類的<clinit>()方法,其他執行緒都需要阻塞等待。這也是為什麼說

餓漢式單例模式是由JVM保證執行緒安全的依據。

類載入器:

  java虛擬機器設計團隊把類載入階段的"通過一個類的完全限定名來獲取描述該類的二進位制位元組流"這個動作放到java虛擬機器外部去實現,以便應用程式自己決定如何去獲取所需的類。實現這個動作的程式碼被稱為“類載入器”。

需要注意的是對於同一個類,不同的類載入器載入之後,它們的型別是不同的。

  站在java虛擬機器的角度來看,只存在兩種不同的類載入器:一種是啟動類載入器,這個類載入器使用C++語言實現,是虛擬機器自身的一部分;另外一種是其他所有的類載入器,這些類載入器都由java語言實現,獨立存在

於虛擬機器外部,並且全部繼承自抽象類java.lang.Classloader。

  站在java開發人員的角度來看,類載入器就應當劃分得更細緻一些。自JDK1.2以來,java一直保持著三層類載入器、雙親委派的類載入架構。我們這裡只針對java8及之前版本來介紹三層類載入器以及雙親委派模型。

  啟動類載入器(Bootstrap):這個類載入器負責載入存放在lib目錄,或者被-Xbootclasspath引數所指定的路徑下存放的class類。啟動類載入程式無法被java程式直接引用,如果需要把載入器請求委派給引導類載入器去處理,直接用

null代替即可。

  擴充套件類載入器(Extension):這個類載入器是由java實現的,負責載入\lib\ext目錄中或者被java.ext.dirs系統變數指定的路徑中所有的類庫,主要包含java使用者(公司團隊或者個人)開發的擴充套件類庫。

  應用程式類載入器(App):這個載入器也是由java實現的,它負責載入使用者類路徑上所有的類庫,也可以理解為除了啟動類載入器和擴充套件載入器載入以外的所有類庫都是由應用程式類載入器來載入。

雙親委派模型:

  

 

   如圖所示,雙親委派的工作過程是:如果一個類載入器收到了類載入的請求,它首先不會自己去嘗試載入這個類,它會先檢查請求載入的型別是否被當前載入器載入過,如果沒有則把這個請求委派給父類(注意這裡的父類

並不是真正意義上的父類,原始碼中是以組合的形式來體現父子的關係的)載入器去完成,類載入實現的主要原始碼在java.lang.Classloader的loadClass()方法中,如下:

    protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // First, check if the class has already been loaded
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                long t0 = System.nanoTime();
                try {
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    } else {
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // ClassNotFoundException thrown if class not found
                    // from the non-null parent class loader
                }

                if (c == null) {
                    // If still not found, then invoke findClass in order
                    // to find the class.
                    long t1 = System.nanoTime();
                    c = findClass(name);

                    // this is the defining class loader; record the stats
                    sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    sun.misc.PerfCounter.getFindClasses().increment();
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }

  這段程式碼的邏輯清晰易懂:先檢查請求載入的型別是否已經被載入過,若沒有則呼叫父載入器的loadClass()方法,若父載入器為空則預設使用啟動類載入器作為父載入器。假如父載入器載入失敗,丟擲ClassNotFoundException

異常的話,才呼叫自己的findClass()方法嘗試進行載入。

  為什麼要採用雙親委派模型來實現類的載入呢?首先一個顯而易見的好處就是java中的類隨著它的類載入器一起具備了一種帶有優先順序的層次關係。例如類java.lang.Object,它存放在rt.jar之中,無論哪一個類載入器要載入這個類

最終都會委派給模型最頂端的啟動類載入器進行載入,因此Object類在程式的各種類載入器環境中都能保證是同一個類。反之,如果沒有使用雙親委派模型,如果我自己也寫一個java.lang.Object類,那系統中就會出現多個Object類,

java型別體系中最基礎的行為也就無從保障。

 總結:

  本篇文章主要介紹了java虛擬機器類載入的時機,主要關注類初始化的時機,哪些行為屬於主動引用,哪些行為屬於被動引用,接著介紹了類載入過程,主要包括載入、驗證、準備、解析和初始化五個階段。最後介紹了類載入器

以及雙親委派模型。下一篇文章,我們將對虛擬機器(Hotspot)物件的建立進行介紹。

相關文章