JVM之類加載機制總結

筆試中常常碰見的題目


在系統介紹類加載機制前,咱們先看如下的代碼(lz在面試題中常常會見到這種類型的題目),而後咱們在這段面試中常出現的的代碼裏去分析Java的類加載機制。java

class Grandpa
{
    static
    {
        System.out.println("爺爺在靜態代碼塊");
    }
}    
class Father extends Grandpa
{
    static
    {
        System.out.println("爸爸在靜態代碼塊");
    }

    public static int factor = 55;

    public Father()
    {
        System.out.println("我是爸爸~");
    }
}
class Son extends Father
{
    static 
    {
        System.out.println("兒子在靜態代碼塊");
    }

    public Son()
    {
        System.out.println("我是兒子~");
    }
}
public class InitializationDemo
{
    public static void main(String[] args)
    {
        System.out.println("爸爸的歲數:" + Son.factor);  //入口
    }
}
複製代碼

請寫出代碼最後的輸出結果:程序員

正確答案見文章目錄:初探代碼面試

對於剛看到這種類型題目的同窗來講,也許是無從下手的,若是不對Java的類加載機制有必定的瞭解,也許遇見屢次這種的題型仍是手足無措。bash

那麼接下來就經過學習Java類加載機制的七個階段來學會解決這種類型的題目。網絡

Java類加載機制的七個階段


  • 加載

加載階段是類加載過程的第一個階段。在這個階段,JVM 的主要目的是:將字節碼從各個位置(網絡、磁盤等)轉化爲二進制字節流加載到內存中,接着會爲這個類在 JVM 的方法區建立一個對應的 Class 對象,這個 Class對象就是這個類各類數據的訪問入口。函數

注: 這個過程對於解決這道題並無直接的影響,可是對於想要理解類加載機制的完整過程,這個階段是須要了解的。學習

  • 驗證

當 JVM 加載完 Class 字節碼文件並在方法區建立對應的 Class 對象以後,JVM 便會啓動對該字節碼流的校驗,這是鏈接階段的第一步,這一階段的目的是爲了確保只有符合 JVM 字節碼規範的文件才能被 JVM 正確執行。spa

驗證階段大體上會完成下面4個階段的檢驗動做:文件格式驗證、元數據驗證、字節碼驗證、符號引用驗證。code

  • 文件格式驗證

這一階段主要驗證字節流是否符合Class文件格式的規範,而且能被當前版本虛擬機所處理。例如:cdn

①主、次版本號是否在當前虛擬機的處理範圍以內;

②常量池中的常量是否有不被支持的常量類型(檢查常量tag標誌);

...(等)

  • 元數據驗證

這一階段是對字節碼描述的信息進行語義分析,以保證其描述的信息符合Java語言規範的要求。例如:

①這個類的父類是否繼承了不容許被繼承的類(被final修飾的類);

②若是這個類不是抽象類,是否實現了其父類或接口之中要求實現的全部方法;

...(等)

  • 字節碼驗證

這一階段的主要目的是經過對數據流和控制流分析,確保程序語義是合法的,符合邏輯的。例如:

①保證跳轉指令不會跳轉到方法體之外的字節碼指令上;

...(等)

  • 符號引用驗證

最後一個階段的校驗發生在虛擬機將符號引用轉化爲直接引用的時候,這個轉化動做將在鏈接的第三階段,解析階段發生。符號引用驗證能夠看作是對類自身之外(常量池中的各類符號引用)的信息進行匹配校驗。例如:

①符號引用中經過字符串描述的權限定名是否能找到對應的類;

...(等)

注: 這個過程對於解決這道題並無直接的影響,可是對於想要理解類加載機制的完整過程,這個階段是須要了解的。

  • 準備

當完成字節碼文件的校驗以後,JVM便會開始爲類變量分配內存並初始化。這裏須要注意兩個關鍵點,即內存分配的對象以及初始化的類型。

內存分配的對象: Java 中的變量有「類變量」和「類成員變量」兩種類型,「類變量」指的是被 static 修飾的變量,而其餘全部類型的變量都屬於「類成員變量」。在準備階段,JVM 只會爲「類變量」分配內存,而不會爲「類成員變量」分配內存。「類成員變量」的內存分配須要等到初始化階段纔開始。 例以下面的代碼在準備階段,只會爲 a 屬性分配內存,而不會爲 b 屬性分配內存。

public static int a = 3;
public String b = "java";
複製代碼

初始化的類型。在準備階段,JVM會爲類變量分配內存,併爲其初始化。可是這裏的初始化指的是爲變量賦予 Java 語言中該數據類型的零值,而不是用戶代碼裏初始化的值。 例以下面的代碼在準備階段以後,c 的值將是 0,而不是 3。

public static int c = 3;
複製代碼

但若是一個變量是常量(被 static final 修飾)的話,那麼在準備階段,屬性便會被賦予用戶但願的值。例以下面的代碼在準備階段以後,number 的值將是 3,而不是 0。

public static final int number = 3;
複製代碼

之因此static final 會直接被複制,而 static 變量會被賦予零值。其實咱們稍微思考一下就能想明白了。

兩個語句的區別是一個有 final 關鍵字修飾,另一個沒有。而 final 關鍵字在 Java 中表明不可改變的意思,意思就是說number的值一旦賦值就不會在改變了。既然一旦賦值就不會再改變,那麼就必須一開始就給其賦予用戶想要的值,所以被final修飾的類變量在準備階段就會被賦予想要的值。而沒有被 final修飾的類變量,其可能在初始化階段或者運行階段發生變化,因此就沒有必要在準備階段對它賦予用戶想要的值。

  • 解析

當經過準備階段以後,JVM針對類或接口、字段、類方法、接口方法、方法類型、方法句柄和調用點限定符 7 類引用進行解析。這個階段的主要任務是將其在常量池中的符號引用替換成直接其在內存中的直接引用。

注: 同上。

  • 初始化

類初始化階段是類加載過程的最後一步,這個時候用戶定義的 Java 程序代碼才真正開始執行。

在準備階段,變量已經賦過一次系統要求的初始值,而在初始化階段,則根據程序員的經過程序指定的主觀計劃去初始化類變量和其餘資源,或者能夠從另一個角度來表達:初始化階段是執行類構造器<clinit>()方法的過程。<clinit>()方法執行過程當中有如下特色:

<clinit>()方法是由編譯器自動收集類中的全部類變量的賦值動做和靜態語塊(static{}塊) 中的語句合併產生的,編譯器收集的順序是由語句在源文件中出現的順序所決定的。例如:

public class Book {
    public static void main(String[] args)
    {
        System.out.println("Hello ShuYi.");
    }

    Book()
    {
        System.out.println("書的構造方法");
        System.out.println("price=" + price +",amount=" + amount);
    }

    {
        System.out.println("書的普通代碼塊");
    }

    int price = 110;

    static
    {
        System.out.println("書的靜態代碼塊");
    }

    static int amount = 112;
}
複製代碼

在這段代碼中,<clinit>()方法就是:

static
    {
        System.out.println("書的靜態代碼塊");
    }
    static int amount = 112;
複製代碼

靜態語句塊只能訪問到定義在靜態語句塊以前的變量,定義在塔以後的變量,在前面的靜態語句塊能夠賦值,可是不能訪問。

注意<clinit>()方法與類的構造函數(或者說實例構造器<init>()方法)不一樣,它不須要顯式地調用父類構造器,虛擬機會保證子類的<clinit>()方法執行以前,父類的<clinit>()方法已經執行完畢。

③因爲父類的<clinit>()方法先執行,也就意味着父類中定義的靜態語句快要優先於子類的變量賦值操做。

<clinit>()方法方法對於類或接口並非必須的,若是一個類中沒有靜態語句塊,也沒有對類變量的賦值操做,那麼編譯器就能夠不爲這個類生成<clinit>()方法。

  • 使用

當 JVM 完成初始化階段以後,JVM 便開始從入口方法開始執行用戶的程序代碼。

注: 同上。

  • 卸載

當用戶程序代碼執行完畢後,JVM 便開始銷燬建立的 Class 對象,最後負責運行的 JVM 也退出內存。

注: 同上。

初探代碼


文章開頭那段代碼的正確結果爲:

爺爺在靜態代碼塊
爸爸在靜態代碼塊
爸爸的歲數:55
複製代碼

這裏咱們觀察到,咱們在Son 類中明明定義瞭如下靜態代碼塊,但並無輸出兒子在靜態代碼塊

static 
    {
        System.out.println("兒子在靜態代碼塊");
    }
複製代碼

這是由於對於靜態字段,只有直接定義這個字段的類纔會被初始化(執行靜態代碼塊)。就像上面的代碼同樣,Son的父類Father定義了factor 即:public static int factor=55;而子類Son並無定義factor的語句,因此,經過其子類來引用父類中定義的靜態字段,只會觸發父類的初始化而不會觸發子類的初始化。

對面上面的這個例子,咱們能夠從入口開始分析一路分析下去:

  • 首先程序到 main 方法這裏,使用標準化輸出 Son 類中的 factor 類成員變量,可是 Son 類中並無定義這個類成員變量。因而往父類去找,咱們在 Father 類中找到了對應的類成員變量,因而觸發了 Father 的初始化。
  • 但根據咱們上面說到的初始化的 5 種狀況中的第 2 種(當初始化一個類的時候,若是發現其父類尚未進行過初始化,則須要先觸發其父類的初始化)。咱們須要先初始化 Father 類的父類,也就是先初始化 Grandpa 類再初始化 Father 類。因而咱們先初始化 Grandpa 類輸出:爺爺在靜態代碼塊,再初始化 Father 類輸出:爸爸在靜態代碼塊
  • 最後,全部父類都初始化完成以後,Son 類才能調用父類的靜態變量,從而輸出:爸爸的歲數:55

而當咱們在Son類中一樣定義factor,並賦予不同的值時,即public static int factor =66;那麼最終的結果又會變爲:

爺爺在靜態代碼塊
爸爸在靜態代碼塊
兒子在靜態代碼塊
爸爸的歲數:66
複製代碼

Son類被初始化,並輸出其靜態代碼塊,輸出的factor值是Son類中的定義的值。

再探代碼


接下來再看一個升級版的例子:

class Grandpa
{
    static
    {
        System.out.println("爺爺在靜態代碼塊");
    }

    public Grandpa() {
        System.out.println("我是爺爺~");
    }
}
class Father extends Grandpa
{
    static
    {
        System.out.println("爸爸在靜態代碼塊");
    }

    public Father()
    {
        System.out.println("我是爸爸~");
    }
}
class Son extends Father
{
    static 
    {
        System.out.println("兒子在靜態代碼塊");
    }

    public Son()
    {
        System.out.println("我是兒子~");
    }
}
public class InitializationDemo
{
    public static void main(String[] args)
    {
        new Son();  //入口
    }
}
複製代碼

輸出結果爲:

爺爺在靜態代碼塊
爸爸在靜態代碼塊
兒子在靜態代碼塊
我是爺爺~
我是爸爸~
我是兒子~
複製代碼

分析執行流程:

  • 首先在入口這裏咱們實例化一個 Son 對象,所以會觸發 Son 類的初始化,而 Son 類的初始化又會帶動 FatherGrandpa 類的初始化,從而執行對應類中的靜態代碼塊。所以會輸出:
爺爺在靜態代碼塊
爸爸在靜態代碼塊
兒子在靜態代碼塊
複製代碼

Son 類完成初始化以後,便會調用 Son 類的構造方法,而 Son 類構造方法的調用一樣會帶動 FatherGrandpa 類構造方法的調用,最後會輸出:

我是爺爺~
我是爸爸~
我是兒子~
複製代碼

再看一個例子:

public class Book {
    public static void main(String[] args)
    {
        staticFunction();
    }

    static Book book = new Book();

    static
    {
        System.out.println("書的靜態代碼塊");
    }

    {
        System.out.println("書的普通代碼塊");
    }

    Book()
    {
        System.out.println("書的構造方法");
        System.out.println("price=" + price +",amount=" + amount);
    }

    public static void staticFunction(){
        System.out.println("書的靜態方法");
    }

    int price = 110;
    static int amount = 112;
}
複製代碼

最終結果:

書的普通代碼塊
書的構造方法
price=110,amount=0
書的靜態代碼塊
書的靜態方法
複製代碼

分析:

在上面兩個例子中,由於 main 方法所在類並無多餘的代碼,咱們都直接忽略了 main 方法所在類的初始化。

但在這個例子中,main 方法所在類有許多代碼,咱們就並不能直接忽略了。

  • 當 JVM 在準備階段的時候,便會爲類變量分配內存和進行初始化。此時,咱們的 book 實例變量被初始化爲 nullamount 變量被初始化爲 0。 當進入初始化階段後,由於 Book 方法是程序的入口,由於當虛擬機啓動時,用戶須要指定一個要執行的主類(包含main()方法的那個類,虛擬機會先初始化這個主類,因此JVM 會初始化 Book 類,即執行類構造器 。
  • JVM 對 Book 類進行初始化首先是執行類構造器(按順序收集類中全部靜態代碼塊和類變量賦值語句就組成了類構造器 後執行對象的構造器(按順序收集成員變量賦值和普通代碼塊,最後收集對象構造器,最終組成對象構造器 )

對於 Book 類,其類構造方法()能夠簡單表示以下:

static Book book = new Book();
static
{
    System.out.println("書的靜態代碼塊");
}
static int amount = 112;
複製代碼

因而首先執行static Book book = new Book();這一條語句,這條語句又觸發了類的實例化。因而 JVM 執行對象構造器 ,收集後的對象構造器 代碼:

{
    System.out.println("書的普通代碼塊");
}
int price = 110;
Book()
{
    System.out.println("書的構造方法");
    System.out.println("price=" + price +", amount=" + amount);
}
複製代碼

因而此時 price 賦予 110 的值,輸出:

書的普通代碼塊
書的構造方法
複製代碼

而此時 price 爲 110 的值,而 amount 的賦值語句並未執行,因此只有在準備階段賦予的零值,因此以後輸出price=110,amount=0

當類實例化完成以後,JVM 繼續進行類構造器的初始化:

static Book book = new Book();  //完成類實例化
static
{
    System.out.println("書的靜態代碼塊");
}
static int amount = 112;
複製代碼

即輸出:書的靜態代碼塊,以後對 amount 賦予 112 的值。

到這裏,類的初始化已經完成,JVM 執行 main 方法的內容。

public static void main(String[] args)
{
    staticFunction();
}
複製代碼

即輸出:書的靜態方法

總結


從上面幾個例子能夠看出,分析一個類的執行順序大概能夠按照以下步驟:

  • 肯定類變量的初始值。在類加載的準備階段,JVM會爲類變量初始化零值,這時候類變量會有一個初始的零值。若是是被 final 修飾的類變量,則直接會被初始成用戶想要的值。
  • 初始化入口方法。當進入類加載的初始化階段後,JVM 會尋找整個 main 方法入口,從而初始化 main 方法所在的整個類。當須要對一個類進行初始化時,會首先初始化類構造器(),以後初始化對象構造器()。
  • 初始化類構造器。JVM 會按順序收集類變量的賦值語句、靜態代碼塊,最終組成類構造器由 JVM 執行。
  • 初始化對象構造器。JVM 會按照收集成員變量的賦值語句、普通代碼塊,最後收集構造方法,將它們組成對象構造器,最終由 JVM 執行。

若是在初始化 main 方法所在類的時候遇到了其餘類的初始化,那麼就先加載對應的類,加載完成以後返回。如此反覆循環,最終返回 main 方法所在類。

原文出處


相關文章
相關標籤/搜索