類的奇幻漂流-類加載機制探秘
我們寫的類,在編譯完成后,究竟是怎么加載進虛擬機的?虛擬機又做了什么神奇操作?本文可以帶著讀者初探類加載機制。上來先放類加載各個階段的主要任務,用于給讀者一個大概的印象體驗,現在記不住也沒有什么關系。
現在只需要記住三個名詞,裝載——>連接——>初始化,記住了嗎,我們要開始奇幻漂流了!

在文章的最后,我們使用幾個例子來加深對程序執行順序的理解。
1. 裝載
我覺得這里使用裝載更好一點,第一,可以避免與類加載過程中的“加載”混淆,第二,裝載體現的就是一個“裝”字,僅僅是把貨物從一個地方搬到另外一個地方而已,而這里的加載,卻包含搬運貨物、處理貨物等一系列流程。
裝載階段,將.class字節碼文件的二進制數據讀入內存中,然后將這些數據翻譯成類的元數據,元數據包括方法代碼,變量名,方法名,訪問權限與返回值,接著將元數據存入方法區。最后會在堆中創建一個Class對象,用來封裝類在方法區中的數據結構,因此我們可以通過訪問此Class對象,來間接訪問方法區中的元數據。
在Java7與Java8之后,方法區有不同的實現,這部分詳細內容可以參考我的另外一篇博客靈性一問——為什么用元空間替換永久代?
總結來講,裝載的子流程為:
.class文件讀入內存——>元數據放進方法區——>Class對象放進堆中
最后我們訪問此Class對象,即可獲取該類在方法區中的結構。
2. 連接
連接又包括驗證、準備、初始化
2.1 驗證
驗證被加載類的正確性與安全性,看class文件是否正確,是否對會對虛擬機造成安全問題等,主要去驗證文件格式、元數據、字節碼與符合引用。
2.1.1 驗證文件格式
2.1.1.1 驗證文件類型
每個文件都有特定的類型,類型標識字段存在于文件的開頭中,采用16進制表示,類型標識字段稱為魔數,class文件的魔數為0xCAFEBABY,關于此魔數的由來也很有意思,可以看這篇文章class文件魔數CAFEBABE的由來。
2.1.1.2 驗證主次版本號
檢查看主次版本號是否在當前jvm處理的范圍之內,主次版本號的存放位置緊隨在魔數之后。
2.1.1.3 驗證常量池
常量池是class文件中最為復雜的一部分,對常量池的驗證主要是驗證常量池中是否有不支持的類型。
例如,有以下簡答的代碼:
- public class Main {
- public static void main(String[] args) {
- int a=1;
- int b=2;
- int c=a+b;
- }
- }
在該類的路徑下,使用javac Main.java編譯,然后使用javap -v Main可以輸出以下信息:

以上標紅處,就是class文件中存儲常量池的地方。
2.1.2 驗證元數據
主要是對字節碼描述的信息進行語義分析,以保證其描述的信息符合java語言規范的要求,比如說驗證這個類是不是有父類,類中的字段方法是不是和父類沖突等等。
2.1.3 驗證字節碼
這是整個驗證過程最復雜的階段,主要是通過數據流和控制流分析,確定程序語義是合法的、符合邏輯的。
2.1.4 驗證符號引用
它是驗證的最后一個階段,發生在虛擬機將符號引用轉化為直接引用的時候。主要是對類自身以外的信息進行校驗。目的是確保解析動作能夠完成。
對整個類加載機制而言,驗證階段是一個很重要但是非必需的階段,如果我們的代碼能夠確保沒有問題,那么就沒有必要去驗證,畢竟驗證需要花費一定的的時間,可以使用-Xverfity:none來關閉大部分的驗證。
2.2 準備
在這個階段中,主要是為類變量(靜態變量)分配內存以及初始化默認值,因為靜態變量全局只有一份,是跟著類走的,因此分配內存其實是在方法區上分配。
這里有3個注意點:
(1)在準備階段,虛擬機只為靜態變量分配內存,實例變量要等到初始化階段才開始分配內存。這個時候還沒有實例化該類,連對象都沒有,因此這個時候還不存在實例變量。
(2)為靜態變量初始化默認值,注意,是初始化對應數據類型的默認值,不是自定義的值。
例如,代碼中是這樣寫的,自定義int類型的變量a的值為1
- private static int a=1;
但是,在準備階段完成之后,a的值只會被初始化為0,而不是1。
(3)被final修飾的靜態變量,如果值比較小,則在編譯后直接內嵌到字節碼中。如果值比較大,也是在編譯后直接放入常量池中。因此,準備階段結束后,final類型的靜態變量已經有了用戶自定義的值,而不是默認值。
2.3 解析
解析階段,主要是將class文件中常量池中的符號引用轉化為直接引用。
符號引用的含義:
可以直接理解為是一個字符串,用這個字符串來表示一個目標。就像博主的名字是SunAlwaysOnline,這個SunAlwaysOnline字符串就是一個符號引用,代表博主,但是現在不能通過名字直接找到我本人。
直接引用的含義:
直接引用是一個指向目標的指針,能夠通過直接引用定位到目標。比如
- Student s=new Student();
我們可以通過引用變量s直接定位到新創建出的Student對象實例。
將符號引用轉化為直接引用,就能將平淡無奇的字符串轉化為指向對象的指針。
3. 初始化
執行初始化,就是虛擬機執行類構造器
到了這個階段,類變量與類成員變量才會被賦予用戶自定義的值。
當然,一個類并不是被初始化多次,只有當對類的首次主動使用的時候才會導致類的初始化。主動使用包含以下幾種方式:
- 使用new語句創建類的對象訪問類靜態變量,或者對該靜態變量賦值調用類的靜態方法通過反射方式獲取對象實例有public static void main(String[] args)方法的類會首先被初始化初始化一個類時,如果父類還沒有被初始化,則首先會初始化父類,再初始化該類。
被動使用會發生呢?
- 當訪問一個靜態變量時時,只有真正聲明這個靜態變量的類才會被初始化。例如:通過子類引用父類的靜態變量,不會導致子類初始化。引用常量不會觸發此類的初始化(常量在編譯階段就內嵌進字節碼或存入調用類的常量池中)聲明并創建數組時,不會觸發類的初始化。例如Student array=new Student[2];
4. 類的初始化順序
現在有以下的代碼:
- class Father {
- public static int fatherA = 1;
- public static final int fatherB = 2;
- static {
- System.out.println("父類的靜態代碼塊");
- }
- {
- System.out.println("父類的非靜態代碼塊");
- }
- Father() {
- System.out.println("父類的構造方法");
- }
- }
- class Son extends Father {
- public static int sonA = 3;
- public static final int sonB = 4;
- static {
- System.out.println("子類的靜態代碼塊");
- }
- {
- System.out.println("子類的非靜態代碼塊");
- }
- Son() {
- System.out.println("子類的構造方法");
- }
- }
(1)Main方法中實例化子類:
- public class Main {
- public static void main(String[] args) {
- Son son = new Son();
- }
- }
首先可以確定的是,這屬于主動使用,父類先于子類初始化,因此會得到以下的輸出:

這里可以進行總結,程序執行的順序為:
父類的靜態域->子類的靜態域->父類的非靜態域->子類的非靜態域->父類的構造方法->子類的構造方法
這里的靜態域包括靜態變量與靜態代碼塊,靜態變量和靜態代碼塊的執行順序由編碼順序決定。
規律就是,靜態先于非靜態,父類先于子類,構造方法在最后。嗯給我背三遍
(2)Mian方法中輸出子類的sonA屬性
- public class Main {
- public static void main(String[] args) {
- System.out.println(Son.sonA);
- }
- }
這里只要輸出子類的靜態屬性sonA,因此需要初始化子類,但父類還沒有被初始化,因此先初始化父類。一般而言,靜態代碼塊會對靜態變量進行賦值,因此調用靜態屬性,在此之前虛擬機會調用靜態代碼塊。所以,輸出如下:

(3)Main方法輸出子類繼承而來的fatherA屬性
- public class Main {
- public static void main(String[] args) {
- System.out.println(Son.fatherA);
- }
- }
子類從父類繼承而來的屬性,因此這里屬于被動使用。只會執行靜態屬性真正存在的那個類的初始化,即只會初始化父類。因此,輸出:

(4)Main方法中聲明并創建一個子類類型的數組
- public class Main {
- public static void main(String[] args) {
- Son[] sons=new Son[10];
- }
- }
顯然,這屬于被動使用,不會初始化Son類。因此,沒有任何輸出。
(5)Main方法輸出子類被static final修飾的變量
- public class Main {
- public static void main(String[] args) {
- System.out.println(Son.sonB);
- }
- }
顯然,被static final修改的變量,也就是一個常量,在編譯器就放入類的常量池中了,不需要初始化類。因此,這里只輸出sonB的值,即為4。
(6)在聲明前使用一個靜態變量
- public class Main {
- static {
- c = 1;
- }
- public static int c;
- }
這樣的代碼,是可以運行的,小朋友,你是不是有大大的疑問?但容我自仔細分析來。
首先,在準備階段,為靜態變量c分配內存,然后賦予初始值0。等到初始化階段,執行類的靜態域,也就是執行此處的靜態代碼塊中c=1,c此時已經存在,也有了一個默認值0,此時可以修改c的值。
但是,如果我僅僅在c=1后使用c的話,如:
- public class Main {
- static {
- c = 1;
- System.out.println(c);
- }
- public static int c;
- }
此時編譯沒法通過,編輯器提示Illegal forward reference,即非法前向引用,似乎只能寫入c,不能讀取c。我們之前已經分析過了,此時在內存中是有這個c的,那為什么不能讀取c?
本來在正常的情況下,要想使用一個變量,變量首先需要聲明出來。當然,java做出了一種特許,允許在使用前不先聲明,但必須要滿足幾個條件,其中有一個條件是該變量只能出現在賦值表達式的左邊,即c=1可以,c=2可以,c+=1不可以(c+=1也就是c=c+1,違反了左值協定)。當然如果這里使用全限定名,也就是輸出Main.c時,則可以正常運行。
有的小伙伴可能還是有大大的疑問,不要緊,沒看懂的可以參考以下講解非法前向引用的文章
java報錯非法的前向引用問題
Java編譯時提示非法向前引用
Illegal forward Reference java issue
關于加載使用到的類加載器,雙親委派機制,如何自定義類加載器,可能需要另開篇幅。