【轉】兩道面試題,帶你解析Java類加載機制(類初始化方法 和 對象初始化方法)

本文轉自 https://www.cnblogs.com/chanshuyi/p/the_java_class_load_mechamism.htmlhtml

關鍵語句 咱們只知道有一個構造方法,但實際上Java代碼編譯成字節碼以後,是沒有構造方法的概念的,只有類初始化方法 和 對象初始化方法 。java

在許多Java面試中,咱們常常會看到關於Java類加載機制的考察,例以下面這道題:web

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

我相信不少同窗看到這個題目以後,表情是崩潰的,徹底不知道從何入手。有的甚至遇到了幾回,仍然沒法找到正確的解答思路。less

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

若是你對Java加載機制不理解,那麼你是沒法解答這道題目的。學習

因此這篇文章,我先帶你們學習Java類加載的基礎知識,而後再實戰分析幾道題目讓你們掌握思路。ui

下面咱們先來學習下Java類加載機制的七個階段。spa

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(); //入口 } }

輸出結果是:

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

文章首發於【博客園-陳樹義】,點擊跳轉到原文《兩道面試題,帶你解析Java類加載機制》

怎麼樣,是否是以爲這道題和上面的有所不一樣呢。

讓咱們仔細來分析一下上面代碼的執行流程:

  • 首先在入口這裏咱們實例化一個 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 方法所在類。

樹義有話說

看完了上面的解析以後,再去看看開頭那道題是否是以爲簡單多了呢。不少東西就是這樣,掌握了必定的方法和知識以後,本來困難的東西也變得簡單許多了。

一時沒有看懂也不要灰心,畢竟我也是用了很多的時間才弄懂的。不懂的話能夠多看幾遍,或者加入樹義的技術交流羣,和小夥們一塊兒交流。

相關文章
相關標籤/搜索