筆試中經常遇見的題目
在系統介紹類載入機制前,我們先看以下的程式碼(lz在面試題中經常會見到這種型別的題目),然後我們在這段面試中常出現的的程式碼裡去分析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的類載入機制有一定的瞭解,也許碰見多次這種的題型還是手足無措。
那麼接下來就通過學習Java類載入機制的七個階段來學會解決這種型別的題目。
Java類載入機制的七個階段
-
載入
載入階段是類載入過程的第一個階段。在這個階段,JVM 的主要目的是:將位元組碼從各個位置(網路、磁碟等)轉化為二進位制位元組流載入到記憶體中,接著會為這個類在 JVM 的方法區建立一個對應的 Class 物件,這個 Class物件就是這個類各種資料的訪問入口。
注: 這個過程對於解決這道題並沒有直接的影響,但是對於想要理解類載入機制的完整過程,這個階段是需要了解的。
-
驗證
當 JVM 載入完 Class 位元組碼檔案並在方法區建立對應的 Class 物件之後,JVM 便會啟動對該位元組碼流的校驗,這是連線階段的第一步,這一階段的目的是為了確保只有符合 JVM 位元組碼規範的檔案才能被 JVM 正確執行。
驗證階段大致上會完成下面4個階段的檢驗動作:檔案格式驗證、後設資料驗證、位元組碼驗證、符號引用驗證。
- 檔案格式驗證
這一階段主要驗證位元組流是否符合Class檔案格式的規範,並且能被當前版本虛擬機器所處理。例如:
①主、次版本號是否在當前虛擬機器的處理範圍之內;
②常量池中的常量是否有不被支援的常量型別(檢查常量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
類的初始化又會帶動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
方法所在類。
原文出處
- 周志明:《深入理解Java虛擬機器》
- 陳樹義:兩道面試題,帶你解析Java類載入機制