Java 何時會觸發一個類的初始化?
- 使用
new
關鍵字創建對象 - 訪問類的靜態成員變量 或 對類的靜態成員變量進行賦值
- 調用類的靜態方法
- 反射調用類時,如
Class.forName()
- 初始化子類時,會先初始化其父類(如果父類還沒有進行過初始化的話)
- 遇到啟動類時,如果一個類被標記為啟動類(即包含
main
方法),虛擬機會先初始化這個主類。 - 實現帶有默認方法的接口的類被初始化時(擁有被
default
關鍵字修飾的接口方法的類) - 使用 JDK7 新加入的動態語言支持時
MethodHandle
虛擬機在何時加載類
關于在什么情況下需要開始類加載的第一個階段,《Java虛擬機規范》中并沒有進行強制約束,留給虛擬機自由發揮。但對于初始化階段,虛擬機規范則嚴格規定:當且僅當出現以下六種情況時,必須立即對類進行初始化,而加載、驗證、準備自然需要在此之前進行。虛擬機規范中對這六種場景中的行為稱為對一個類型進行主動引用。除此之外,所有引用類型的方式都不會觸發初始化,稱為被動引用。
1. 遇到指定指令時
在程序執行過程中,遇到 new、getstatic、putstatic、invokestatic 這4條字節碼執行時,如果類型沒有初始化,則需要先觸發其初始化階段。
new
這沒什么好說的,使用new
關鍵字創建對象,肯定會觸發該類的初始化。
getstatic 與 putstatic
當訪問某個類或接口的靜態變量,或對該靜態變量進行賦值時,會觸發類的初始化。首先來看第一個例子:
// 示例1
public class Demo {public static void main(String[] args) {System.out.println(Bird.a);}
}class Bird {static int a = 2;// 在類初始化過程中不僅會執行構造方法,還會執行類的靜態代碼塊// 如果靜態代碼塊里的語句被執行,說明類已開始初始化static {System.out.println("bird init");}
}
執行后會輸出:
bird init
2
同樣地,如果直接給Bird.a
進行賦值,也會觸發Bird
類的初始化:
public class Demo {public static void main(String[] args) {Bird.a = 2;}
}class Bird {static int a;static {System.out.println("bird init");}
}
執行后會輸出:
bird init
接著再看下面的例子:
public class Demo {public static void main(String[] args) {Bird.a = 2;}
}class Bird {// 與前面的例子不同的是,這里使用 final 修飾static final int a = 2;static {System.out.println("bird init");}
}
執行后不會有輸出。
本例中,a
不再是一個靜態變量,而變成了一個常量,運行代碼后發現,并沒有觸發Bird
類的初始化流程。常量在編譯階段會存入到調用這個常量的方法所在類的常量池中。本質上,調用類并沒有直接引用定義常量的類,因此并不會觸發定義常量的類的初始化。即這里已經將常量a=2
存入到Demo
類的常量池中,這之后,Demo
類與Bird
類已經沒有任何關系,甚至可以直接把Bird
類生成的class
文件刪除,Demo
仍然可以正常運行。使用javap
命令反編譯一下字節碼:
// 前面已省略無關部分public static void main(java.lang.String[]);Code:0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;3: iconst_24: invokevirtual #4 // Method java/io/PrintStream.println:(I)V7: return
}
從反編譯后的代碼中可以看到:Bird.a
已經變成了助記符iconst_2
(將int
類型2
推送至棧頂),和Bird
類已經沒有任何聯系,這也從側面證明,只有訪問類的靜態變量才會觸發該類的初始化流程,而不是其他類型的變量。
關于Java助記符,如果將上面一個示例中的常量修改為不同的值,會生成不同的助記符,比如:
// bipush 20
static int a = 20;
// 3: sipush 130
static int a = 130
// 3: ldc #4 // int 327670
static int a = 327670;
其中:
iconst_n
:將int
類型數字n
推送至棧頂,n
取值0~5
lconst_n
:將long
類型數字n
推送至棧頂,n
取值0,1
,類似的還有fconst_n
、dconst_n
bipush
:將單字節的常量值(-128~127
) 推送至棧頂
sipush
:將一個短整類型常量值(-32768~32767
) 推送至棧頂
ldc
:將int
、float
或String
類型常量值從常量池中推送至棧頂
再看下一個實例:
public class Demo {public static void main(String[] args) {System.out.println(Bird.a);}
}class Bird {static final String a = UUID.randomUUID().toString();static {System.out.println("bird init");}
}
執行后會輸出:
bird init
d01308ed-8b35-484c-b440-04ce3ecb7c0e
在本例中,常量a
的值在編譯時不能確定,需要進行方法調用,這種情況下,編譯后會產生getstatic
指令,同樣會觸發類的初始化,所以才會輸出bird init
。看下反編譯字節碼后的代碼:
// 已省略部分無關代碼
public static void main(java.lang.String[]);Code:0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;3: getstatic #3 // Field com/hicsc/classloader/Bird.a:Ljava/lang/String;6: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V9: return
invokestatic
調用類的靜態方法時,也會觸發該類的初始化。比如:
public class Demo {public static void main(String[] args) {Bird.fly();}
}class Bird {static {System.out.println("bird init");}static void fly() {System.out.println("bird fly");}
}
執行后會輸出:
bird init
bird fly
通過本例可以證明,調用類的靜態方法,確實會觸發類的初始化。
2. 反射調用時
使用java.lang.reflect
包的方法對類型進行反射調用的時候,如果類型沒有進行過初始化,則需要先觸發其初始化。來看下面的例子:
ublic class Demo {public static void main(String[] args) throws Exception {ClassLoader loader = ClassLoader.getSystemClassLoader();Class clazz = loader.loadClass("com.hicsc.classloader.Bird");System.out.println(clazz);System.out.println("——————");clazz = Class.forName("com.hicsc.classloader.Bird");System.out.println(clazz);}
}class Bird {static {System.out.println("bird init");}
}
執行后輸出結果:
class com.hicsc.classloader.Bird
------------
bird init
class com.hicsc.classloader.Bird
本例中,調用ClassLoader
方法load
一個類,并不會觸發該類的初始化,而使用反射包中的forName
方法,則觸發了類的初始化。
3. 初始化子類時
當初始化類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化。比如:
public class Demo {public static void main(String[] args) throws Exception {Pigeon.fly();}
}class Bird {static {System.out.println("bird init");}
}class Pigeon extends Bird {static {System.out.println("pigeon init");}static void fly() {System.out.println("pigeon fly");}
}
執行后輸出:
bird init
pigeon init
pigeon fly
本例中,在main
方法調用Pigeon
類的靜態方法,最先初始化的是父類Bird
,然后才是子類Pigeon
。因此,在類初始化時,如果發現其父類并未初始化,則會先觸發父類的初始化。
對子類調用父類中存在的靜態方法,只會觸發父類初始化而不會觸發子類的初始化。
看下面的例子,可以先猜猜運行結果:
public class Demo {public static void main(String[] args) {Pigeon.fly();}
}class Bird {static {System.out.println("bird init");}static void fly() {System.out.println("bird fly");}
}class Pigeon extends Bird {static {System.out.println("pigeon init");}
}
輸出:
bird init
bird fly
本例中,由于fly
方法是定義在父類中,那么方法的擁有者就是父類,因而,使用Pigeno.fly()
并不是表示對子類的主動引用,而是表示對父類的主動引用,所以,只會觸發父類的初始化。
4. 遇到啟動類時
當虛擬機啟動時,如果一個類被標記為啟動類(即:包含main
方法),虛擬機會先初始化這個主類。比如:
public class Demo {static {System.out.println("main init");}public static void main(String[] args) throws Exception {Bird.fly();}
}class Bird {static {System.out.println("bird init");}static void fly() {System.out.println("bird fly");}
}
執行后輸出:
main init
bird init
bird fly
5. 實現帶有默認方法的接口的類被初始化時
當一個接口中定義了 JDK8 新加入的默認方法(被default
關鍵字修飾的接口方法) 時,如果有這個接口的實現類發生了初始化,那該接口要在其之前被初始化。
由于接口中沒有static{}
代碼塊,怎么判斷一個接口是否初始化?來看下面這個例子:
public class Demo {public static void main(String[] args) throws Exception {Pigeon pigeon = new Pigeon();}
}interface Bird {// 如果接口被初始化,那么這句代碼一定會執行// 那么Intf類的靜態代碼塊一定會被執行public static Intf intf = new Intf();default void fly() {System.out.println("bird fly");}
}class Pigeon implements Bird {static {System.out.println("pigeon init");}
}class Intf {{System.out.println("interface init");}
}
執行后輸出:
interface init
pigeon init
可知,接口確實已被初始化,如果把接口中的default
方法去掉,那么不會輸出interface init
,即接口未被初始化。
6. 使用JDK7新加入的動態語言支持時
當使用JDK7新加入的動態類型語言支持時,如果一個java.lang.invoke.MethodHandle
實例最后的解析結果為REF_getStatic
、REF_putStatic
、REF_invokeStatic
、REF_newInvokeSpecial
四種類型的方法句柄,并且這個方法句柄對應的類沒有進行過初始化,則需要先觸發其初始化。
簡單點來說,當初次調用MethodHandle
實例時,如果其指向的方法所在類沒有進行過初始化,則需要先觸發其初始化。
什么是動態類型語言:
- 動態類型語言的關鍵特性是它的類型檢查的主體過程是在運行期進行的,常見的語言比如:JavaScript、PHP、Python等,相對地,在編譯期進行類型檢查過程的語言,就是靜態類型語言,比如 Java 和 C# 等。
- 簡單來說,對于動態類型語言,變量是沒有類型的,變量的值才具有類型,在編譯時,編譯器最多只能確定方法的名稱、參數、返回值這些,而不會去確認方法返回的具體類型以及參數類型。
- 而Java等靜態類型語言則不同,你定義了一個整型的變量x,那么x的值也只能是整型,而不能是其他的,編譯器在編譯過程中就會堅持定義變量的類型與值的類型是否一致,不一致編譯就不能通過。因此,「變量無類型而變量值才有類型」是動態類型語言的一個核心特征。
關于MethodHandle
與反射的區別,可以參考周志明著「深入理解Java虛擬機」第8.4.3小節,這里引用部分內容,方便理解。
- Reflection 和 MethodHandle 機制本質上都是在模擬方法調用,但是 Reflection 是在模擬 Java 代碼層次的方法調用,而 MethodHandle 是在模擬字節碼層次的方法調用。
- 反射中的 Method 對象包含了方法簽名、描述符以及方法屬性列表、執行權限等各種信息,而 MethodHandle 僅包含執行該方法的相關信息,通俗來講:Reflection 是重量級,而 MethodHandle 是輕量級。
總的來說,反射是為 Java 語言服務的,而 MethodHandle
則可為所有 Java 虛擬機上的語言提供服務。
來看一個簡單的示例:
public class Demo {public static void main(String[] args) throws Exception {new Pigeon().fly();}
}class Bird {static {System.out.println("bird init");}static void fly() {System.out.println("bird fly");}
}class Pigeon {void fly() {try {MethodHandles.Lookup lookup = MethodHandles.lookup();// MethodType.methodType 方法的第一個參數是返回值// 然后按照目標方法接收的參數的順序填寫參數類型// Bird.fly() 方法返回值是空, 沒有參數MethodType type = MethodType.methodType(void.class);MethodHandle handle = lookup.findStatic(Bird.class, "fly", type);handle.invoke();} catch (Throwable a) {a.printStackTrace();}}
}
在Pigeon
類中,使用MethodHandle
來調用Bird
類中的靜態方法fly
,按照前面所述,初次調用MethodHandle
實例時,如果其指向的方法所在類沒有進行過初始化,則需要先觸發其初始化。所以,這里一定會執行Bird
類中的靜態代碼塊。而最終的運行結果也與我們預計的一致:
bird init
bird fly
虛擬機如何加載類 - 類的加載過程
類的加載全過程包括:加載、驗證、準備、解析和初始化 5 個階段,是一個非常復雜的過程。
加載 Loading
Loading 階段主要是找到類的class文件,并把文件中的二進制字節流讀取到內存,然后在內存中創建一個java.lang.Class
對象。
加載完成后,就進入連接階段,但需要注意的是,加載階段與連接階段的部分動作(如一部分字節碼文件格式驗證動作)是交叉進行的,加載階段尚未完成,連接階段可能已經開始,但這些夾在加載階段之中進行的動作,仍然屬于連接階段的一部分,這兩個階段的開始時間仍然保持著固定的先后順序,也就是只有加載階段開始后,才有可能進入連接階段。
驗證 Verification
驗證是連接階段的首個步驟,其目的是確保被加載的類的正確性,即要確保加載的字節流信息要符合《Java虛擬機規范》的全部約束要求,確保這些信息被當做代碼運行后不會危害虛擬機自身的安全。
其實,Java 代碼在編譯過程中,已經做了很多安全檢查工作,比如,不能將一個對象轉型為它未實現的類型、不能使用未初始化的變量(賦值除外)、不能跳轉到不存在的代碼行等等。但 JVM 仍要對這些操作作驗證,這是因為 Class 文件并不一定是由 Java 源碼編譯而來,甚至你都可以通過鍵盤自己敲出來。如果 JVM 不作校驗的話,很可能就會因為加載了錯誤或有惡意的字節流而導致整個系統受到攻擊或崩潰。所以,驗證字節碼也是 JVM 保護自身的一項必要措施。
整個驗證階段包含對文件格式、元數據、字節碼、符號引用等信息的驗證。
準備 Preparation
這一階段主要是為類的靜態變量分配內存,并將其初始化為默認值。這里有兩點需要注意:
- 僅為類的靜態變量分配內存并初始化,并不包含實例變量
- 初始化為默認值,比如
int
為0
,引用類型初始化為null
需要注意的是,準備階段的主要目的并不是為了初始化,而是為了為靜態變量分配內存,然后再填充一個初始值而已。就比如:
// 在準備階段是把靜態類型初始化為 0,即默認值
// 在初始化階段才會把 a 的值賦為 1
public static int a = 1;
來看一個實例加深印象,可以先考慮一下運行結果。
public class StaticVariableLoadOrder {public static void main(String[] args) {Singleton singleton = Singleton.getInstance();System.out.println("counter1:" + Singleton.counter1);System.out.println("counter2:" + Singleton.counter2);}
}class Singleton {public static Singleton instance = new Singleton();private Singleton() {counter1++;counter2++;System.out.println("構造方法里:counter1:" + counter1 + ", counter2:" + counter2);}public static int counter1;public static int counter2 = 0;public static Singleton getInstance() {return instance;}
}
其運行結果是:
構造方法里:counter1:1, counter2:1
counter1:1
counter2:0
在準備階段,counter1
和counter2
都被初始化為默認值0
,因此,在構造方法中自增后,它們的值都變為1
,然后繼續執行初始化,僅為counter2
賦值為0
,counter1
的值不變。
如果你理解了這段代碼,再看下面這個例子,想想會輸出什么?
// main 方法所在類的代碼不變
// 修改了 counter1 的位置,并為其初始化為 1
class Singleton {public static int counter1 = 1;public static Singleton instance = new Singleton();private Singleton() {counter1++;counter2++;System.out.println("構造方法里:counter1:" + counter1 + ", counter2:" + counter2);}public static int counter2 = 0;public static Singleton getInstance() {return instance;}
}
運行后輸出:
構造方法里:counter1:2, counter2:1
counter1:2
counter2:0
counter2
并沒有任何變化,為什么counter1
的值會變成2
?其實是因為類在初始化的時候,是按照代碼的順序來的,就比如上面的示例中,為counter1
賦值以及執行構造方法都是在初始化階段執行的,但誰先誰后呢?按照順序來,因此,在執行構造方法時,counter1
已經被賦值為1
,執行自增后,自然就變為2
了。
解析 Resolution
解析階段是將常量池類的符號引用替換為直接引用的過程。在編譯時,Java 類并不知道所引用的類的實際地址,只能使用符號引用來代替。符號引用存儲在class
文件的常量池中,比如類和接口的全限定名、類引用、方法引用以及成員變量引用等,如果要使用這些類和方法,就需要把它們轉化為 JVM 可以直接獲取的內存地址或指針,即直接引用。
因此,解析的動作主要是針對類或接口、字段、類方法、接口方法、方法類型、方法句柄、調用點限定符這 7 類符號引用進行的。
初始化 Initialization
在準備階段我們只是給靜態變量設置了類似0
的初值,在這一階段,則會根據我們的代碼邏輯去初始化類變量和其他資源。
更直觀的說初始化過程就是執行類構造器<clinit>
方法的過程。
類的初始化是類加載過程的最后一個步驟,直到這一個步驟,JVM 才真正開始執行類中編寫的 Java 代碼。初始化完也就差不多是類加載的全過程了,什么時候需要初始化也就是我們最前面講到的幾種情況。
類初始化是懶惰的,不會導致類初始化的情況,也就是前面講到的被動引用類型,再講全一點:
- 訪問類的
static final
靜態常量(基本類型和字符串)不會觸發初始化 - 訪問類對象
.class
不會觸發初始化 - 創建該類的數組不會觸發初始化
- 執行類加載器的
loadClass
方法不會觸發初始化 Class.forName
(反射)的參數2為false
時(為true
才會初始化)
在編譯生成class
文件時,編譯器會產生兩個方法加于class
文件中,一個是類的初始化方法clinit
, 另一個是實例的初始化方法init
。
1. 類初始化方法:<clinit>()
- Java 編譯器在編譯過程中,會自動收集類中所有靜態變量賦值語句、靜態代碼塊中的語句,將其合并到類構造器
<clinit>()
方法,收集的順序由源代碼文件中出現的順序決定。類初始化方法一般在類初始化階段執行。 - 如果兩個類存在父子關系,那么在執行子類的
<clinit>()
方法之前,會確保父類的方法已執行完畢,因此,父類的靜態代碼塊會優先于子類的靜態代碼塊。
例子:
public class ClassDemo {static {i = 20;}static int i = 10;static {i = 30;}// init 方法收集后里面的代碼就是這個,當然你是看不到該方法的init() {i = 20;i = 10;i = 30;}
}
<clinit>()
方法不需要顯示調用,類解析完了會立即調用,且父類的<clinit>()
永遠比子類的先執行,因此在jvm中第一個執行的肯定是Object
中的<clinit>()
方法。<clinit>()
方法不是必須的,如果沒有靜態代碼塊和變量賦值就沒有- 接口也有變量復制操作,因此也會生成
<clinit>()
,但是只有當父接口中定義的變量被使用時才會初始化。
這里有一點需要特別強調,JVM 會保證一個類的<clinit>()
方法在多線程環境中被正確的加鎖同步,如果多個線程同時去初始化一個類,那么只會有其中一個線程去執行這個類的<clinit>()
方法,其它線程都需要等待,直到<clinit>()
方法執行完畢。如果在一個類的<clinit>()
方法中有耗時很長的操作,那么可能會造成多個線程阻塞,在實際應用中這種阻塞往往是很隱蔽的。因此,在實際開發過程中,我們都會強調,不要在類的構造方法中加入過多的業務邏輯,甚至是一些非常耗時的操作。
另外,靜態代碼塊中只能訪問定義它之前的變量,定義在它之后的變量可以賦值但不能訪問:
class Class{static {c = 2; // 賦值操作可以正常編譯通過System.out.println(c);//編譯器提示 Illegal forward reference,非法向前引用}static int c = 1;
}
2. 對象初始化方法:init()
init()
是實例對象自動生成的方法。編譯器會按照從上至下的順序,收集 「類成員變量」 的賦值語句、普通代碼塊,最后收集構造函數的代碼,最終組成對象初始化方法。對象初始化方法一般在實例化類對象的時候執行。
例子:
public class ClassDemo {int a = 1;{a = 2;System.out.println(2);}{b = "b2";System.out.println("b2");}String b = "b1";public ClassDemo(int a, String b) {System.out.println("構造器賦值前:"+this.a+" "+this.b);this.a = a;this.b = b;}public static void main(String[] args) {ClassDemo demo = new ClassDemo(3, "b3");System.out.println("構造結束后:"+demo.a+" "+demo.b);
// 2
// b2
// 構造器賦值前:2 b1
// 構造結束后:3 b3}
}
上面的代碼的init()
方法實際為:
public init(int a, String b){super(); // 不要忘記在底層還會加上父類的構造方法this.a = 1;this.a = 2;System.out.println(2);this.b = "b2";System.out.println("b2");this.b = "b1";System.out.println("構造器賦值前:" + this.a + " " + this.b); // 構造方法在最后this.a = a;this.b = b;
}
類執行過程小結:
- 確定類變量的初始值。在類加載的準備階段,JVM 會為「類變量」初始化默認值,這時候類變量會有一個初始的零值。如果是被 final 修飾的類變量,則直接會被初始成用戶想要的值。
- 初始化入口方法。當進入類加載的初始化階段后,JVM 會尋找整個 main 方法入口,從而初始化 main 方法所在的整個類。當需要對一個類進行初始化時,會首先初始化類構造器,之后初始化對象構造器。
- 初始化類構造器。JVM 會按順序收集「類變量」的賦值語句、靜態代碼塊,將它們組成類構造器,最終由 JVM 執行。
- 初始化對象構造器。JVM 會按順序收集「類成員變量」的賦值語句、普通代碼塊,最后收集構造方法,將它們組成對象構造器,最終由 JVM 執行。
如果在初始化 「類變量」時,類變量是一個其他類的對象引用,那么就先加載對應的類,然后實例化該類對象,再繼續初始化其他類變量。
參考:
- 深入理解JVM類加載機制
- jvm深入理解類加載機制