Java類載入機制

來源:互聯網
上載者:User

標籤:錯誤   tran   類載入機制   file   調用   執行   理解   碼流   正確答案   

在許多Java面試中,我們經常會看到關於Java類載入機制的考察,例如下面這道題:

class Grandpa{    static    {        System.out.println("爺爺在靜態代碼塊");    }}    class Father extends Grandpa{    static    {        System.out.println("爸爸在靜態代碼塊");    }    public static int factor = 25;    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);  //入口    }}

請寫出最後的輸出字串。

正確答案是:

爺爺在靜態代碼塊爸爸在靜態代碼塊爸爸的歲數:25

我相信很多同學看到這個題目之後,表情是崩潰的,完全不知道從何入手。有的甚至遇到了幾次,仍然無法找到正確的解答思路。

其實這種面試題考察的就是你對Java類載入機制的理解。

如果你對Java載入機制不理解,那麼你是無法解答這道題目的。

所以這篇文章,我先帶大家學習Java類載入的基礎知識,然後再實戰分析幾道題目讓大家掌握思路。

下面我們先來學習下Java類載入機制的七個階段。

Java類載入機制的七個階段

當我們的Java代碼編譯完成後,會產生對應的 class 檔案。接著我們運行java Demo命令的時候,我們其實是啟動了JVM 虛擬機器執行 class 位元組碼檔案的內容。而 JVM 虛擬機器執行 class 位元組碼的過程可以分為七個階段:載入、驗證、準備、解析、初始化、使用、卸載。

載入

下面是對於載入過程最為官方的描述。

載入階段是類載入過程的第一個階段。在這個階段,JVM 的主要目的是將位元組碼從各個位置(網路、磁碟等)轉化為二進位位元組流載入到記憶體中,接著會為這個類在 JVM 的方法區建立一個對應的 Class 對象,這個 Class 對象就是這個類各種資料的訪問入口。

其實載入階段用一句話來說就是:把代碼資料載入到記憶體中。這個過程對於我們解答這道問題沒有直接的關係,但這是類載入機制的一個過程,所以必須要提一下。

驗證

當 JVM 載入完 Class 位元組碼檔案並在方法區建立對應的 Class 對象之後,JVM 便會啟動對該位元組碼流的校正,只有符合 JVM 位元組碼規範的檔案才能被 JVM 正確執行。這個校正過程大致可以分為下面幾個類型:

  • JVM規範校正。JVM 會對位元組流進行檔案格式校正,判斷其是否符合 JVM 規範,是否能被目前的版本的虛擬機器處理。例如:檔案是否是以 0x cafe bene開頭,主次版本號碼是否在當前虛擬機器處理範圍之內等。
  • 代碼邏輯校正。JVM 會對程式碼群組成的資料流和控制流程進行校正,確保 JVM 運行該位元組碼檔案後不會出現致命錯誤。例如一個方法要求傳入 int 類型的參數,但是使用它的時候卻傳入了一個 String 類型的參數。一個方法要求返回 String 類型的結果,但是最後卻沒有返回結果。代碼中引用了一個名為 Apple 的類,但是你實際上卻沒有定義 Apple 類。

當代碼資料被載入到記憶體中後,虛擬機器就會對代碼資料進行校正,看看這份代碼是不是真的按照JVM規範去寫的。這個過程對於我們解答問題也沒有直接的關係,但是瞭解類載入機制必須要知道有這個過程。

準備(重點)

當完成位元組碼檔案的校正之後,JVM 便會開始為類變數分配記憶體並初始化。這裡需要注意兩個關鍵點,即記憶體配置的對象以及初始化的類型。

  • 記憶體配置的對象。Java 中的變數有「類變數」和「類成員變數」兩種類型,「類變數」指的是被 static 修飾的變數,而其他所有類型的變數都屬於「類成員變數」。在準備階段,JVM 只會為「類變數」分配記憶體,而不會為「類成員變數」分配記憶體。「類成員變數」的記憶體配置需要等到初始化階段才開始。

例如下面的代碼在準備階段,只會為 factor 屬性分配記憶體,而不會為 website 屬性分配記憶體。

public static int factor = 3;public String website = "www.cnblogs.com/chanshuyi";
  • 初始化的類型。在準備階段,JVM 會為類變數分配記憶體,並為其初始化。但是這裡的初始化指的是為變數賦予 Java 語言中該資料類型的零值,而不是使用者代碼裡初始化的值。

例如下面的代碼在準備階段之後,sector 的值將是 0,而不是 3。

public static int sector = 3;

但如果一個變數是常量(被 static final 修飾)的話,那麼在準備階段,屬性便會被賦予使用者希望的值。例如下面的代碼在準備階段之後,number 的值將是 3,而不是 0。

public static final int number = 3;

之所以 static final 會直接被複製,而 static 變數會被賦予零值。其實我們稍微思考一下就能想明白了。

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

解析

當通過準備階段之後,JVM 針對類或介面、欄位、類方法、介面方法、方法類型、方法控制代碼和調用點限定符 7 類引用進行解析。這個階段的主要任務是將其在常量池中的符號引用替換成直接其在記憶體中的直接引用。

其實這個階段對於我們來說也是幾乎透明的,瞭解一下就好。

初始化(重點)

到了初始化階段,使用者定義的 Java 程式碼才真正開始執行。在這個階段,JVM 會根據語句執行順序對類對象進行初始化,一般來說當 JVM 遇到下面 5 種情況的時候會觸發初始化:

  • 遇到 new、getstatic、putstatic、invokestatic 這四條位元組碼指令時,如果類沒有進行過初始化,則需要先觸發其初始化。產生這4條指令的最常見的Java代碼情境是:使用new關鍵字執行個體化對象的時候、讀取或設定一個類的靜態欄位(被final修飾、已在編譯器把結果放入常量池的靜態欄位除外)的時候,以及調用一個類的靜態方法的時候。
  • 使用 java.lang.reflect 包的方法對類進行反射調用的時候,如果類沒有進行過初始化,則需要先觸發其初始化。
  • 當初始化一個類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化。
  • 當虛擬機器啟動時,使用者需要指定一個要執行的主類(包含main()方法的那個類),虛擬機器會先初始化這個主類。
  • 當使用 JDK1.7 動態語言支援時,如果一個 java.lang.invoke.MethodHandle執行個體最後的解析結果 REF_getstatic,REF_putstatic,REF_invokeStatic 的方法控制代碼,並且這個方法控制代碼所對應的類沒有進行初始化,則需要先出觸發其初始化。

看到上面幾個條件你可能會暈了,但是不要緊,不需要背,知道一下就好,後面用到的時候回到找一下就可以了。

使用

當 JVM 完成初始化階段之後,JVM 便開始從入口方法開始執行使用者的程式碼。這個階段也只是瞭解一下就可以。

卸載

當使用者程式碼執行完畢後,JVM 便開始銷毀建立的 Class 對象,最後負責啟動並執行 JVM 也退出記憶體。這個階段也只是瞭解一下就可以。

看完了Java的類載入機智之後,是不是有點懵呢。不怕,我們先通過一個小例子來醒醒神。

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;}

思考一下上面這段代碼輸出什嗎?

給你5分鐘思考,5分鐘後交卷,哈哈。

怎麼樣,想好了嗎,公布答案了。

書的靜態代碼塊Hello ShuYi.

怎麼樣,你答對了嗎?是不是和你想得有點不一樣呢。

下面我們來簡單分析一下,首先根據上面說到的觸發初始化的5種情況的第4種(當虛擬機器啟動時,使用者需要指定一個要執行的主類(包含main()方法的那個類),虛擬機器會先初始化這個主類),我們會進行類的初始化。

那麼類的初始化順序到底是怎麼樣的呢?

重點來了!

重點來了!

重點來了!

在我們代碼中,我們只知道有一個構造方法,但實際上Java代碼編譯成位元組碼之後,是沒有構造方法的概念的,只有類初始化方法 和 對象初始化方法 。

那麼這兩個方法是怎麼來的呢?

  • 類初始化方法。編譯器會按照其出現順序,收集類變數的指派陳述式、靜態代碼塊,最終組成類初始化方法。類初始化方法一般在類初始化的時候執行。

上面的這個例子,其類初始化方法就是下面這段代碼了:

    static    {        System.out.println("書的靜態代碼塊");    }    static int amount = 112;
  • 對象初始化方法。編譯器會按照其出現順序,收整合員變數的指派陳述式、普通代碼塊,最後收集建構函式的代碼,最終組成對象初始化方法。對象初始化方法一般在執行個體化類對象的時候執行。

上面這個例子,其對象初始化方法就是下面這段代碼了:

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

類初始化方法 和 對象初始化方法 之後,我們再來看這個例子,我們就不難得出上面的答案了。

但細心的朋友一定會發現,其實上面的這個例子其實沒有執行對象初始化方法。

因為我們確實沒有進行 Book 類對象的執行個體化。如果你在 main 方法中增加 new Book() 語句,你會發現對象的初始化方法執行了!

感興趣的朋友可以自己動手試一下,我這裡就不執行了。

通過了上面的理論和簡單例子,我們下面進入更加複雜的實戰分析吧!

實戰分析
class Grandpa{    static    {        System.out.println("爺爺在靜態代碼塊");    }}    class Father extends Grandpa{    static    {        System.out.println("爸爸在靜態代碼塊");    }    public static int factor = 25;    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);  //入口    }}

思考一下,上面的代碼最後的輸出結果是什嗎?

最終的輸出結果是:

爺爺在靜態代碼塊爸爸在靜態代碼塊爸爸的歲數:25

也許會有人問為什麼沒有輸出「兒子在靜態代碼塊」這個字串?

這是因為對於靜態欄位,只有直接定義這個欄位的類才會被初始化(執行靜態代碼塊)。因此通過其子類來引用父類中定義的靜態欄位,只會觸發父類的初始化而不會觸發子類的初始化。

對面上面的這個例子,我們可以從入口開始分析一路分析下去:

  • 首先程式到 main 方法這裡,使用標準化輸出 Son 類中的 factor 類成員變數,但是 Son 類中並沒有定義這個類成員變數。於是往父類去找,我們在 Father 類中找到了對應的類成員變數,於是觸發了 Father 的初始化。
  • 但根據我們上面說到的初始化的 5 種情況中的第 3 種(當初始化一個類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化)。我們需要先初始化 Father 類的父類,也就是先初始化 Grandpa 類再初始化 Father 類。於是我們先初始化 Grandpa 類輸出:「爺爺在靜態代碼塊」,再初始化 Father 類輸出:「爸爸在靜態代碼塊」。
  • 最後,所有父類都初始化完成之後,Son 類才能調用父類的靜態變數,從而輸出:「爸爸的歲數:25」。

怎麼樣,是不是覺得豁然開朗呢。

我們再來看一下一個更複雜點的例子,看看輸出結果是啥。

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 類的初始化又會帶動 Father 、Grandpa 類的初始化,從而執行對應類中的靜態代碼塊。因此會輸出:「爺爺在靜態代碼塊」、「爸爸在靜態代碼塊」、「兒子在靜態代碼塊」。
  • 當 Son 類完成初始化之後,便會調用 Son 類的構造方法,而 Son 類構造方法的調用同樣會帶動 Father、Grandpa 類構造方法的調用,最後會輸出:「我是爺爺~」、「我是爸爸~」、「我是兒子~」。

看完了兩個例子之後,相信大家都胸有成足了吧。

下面給大家看一個特殊點的例子,有點難哦!

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 執行個體變數被初始化為 null,amount 變數被初始化為 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 方法所在類。

轉自:https://www.cnblogs.com/chanshuyi/p/the_java_class_load_mechamism.html#%E5%AE%9E%E6%88%98%E5%88%86%E6%9E%90

Java類載入機制

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.