我們都知道將java源文件通過javac命令編譯後得到的是.class文件,它是真實存儲在磁盤上的,那麼Java虛擬機是如何將其讀入內存,最終形成虛擬機直接使用的Java類型的呢?這一切都要歸功於虛擬機類加載機制。
-
虛擬機類加載機制可以分爲如下幾個階段:
- 加載
- 連接:
- 驗證
- 準備
- 解析
- 初始化
- 使用
- 卸載
總的來說,分爲7個階段,其中驗證、準備和解析可以統稱爲連接。
-
“加載”階段:
首先要區別“加載階段”和類加載這兩個詞的含義,“加載”是整個類加載過程(機制)的一個階段。在加載階段,虛擬機需要完成以下3件事情:
- 通過一個類的全限定名來獲取定義此類的二進制字節流
- 將這個字節流所代表的靜態存儲結構轉化爲方法區的運行時數據結構
- 在內存中生成一個代表這個類的java.lang.Class對象,作爲方法區這個類的各種數據的訪問入口。至於這個Class對象存放在虛擬機的哪個運行時內存區域,JVM規範並沒有做強制性要求,我們熟悉的HotSpot虛擬機就將其存放在方法區。
簡而言之,加載階段就是將class文件讀入內存,這裏的class文件有可能來自於本地文件系統,也有可能來自於專有數據庫,亦或是zip、jar等歸檔文件,還有可能來自於網絡。那麼自然而然這裏面會涉及IO操作,以及上面提到的轉化,最終形成的產物
我們熟悉的Java類加載器就是從這個階段開始工作。關於類加載器和雙親委託機制我們放到後面的系列文章做專門介紹。
-
驗證階段:
對於讀取到內存的class文件,我們並不能確保其合法性,所以就需要對讀取到的字節碼文件進行校驗。這和我們平常開發中是一樣的,“Input is Evil”,忘了是哪個國外大佬說的,輸入總是邪惡的。所以對於程序輸入的內容,我們都需要進行合法性校驗,無論是前端還是後臺開發者。至於驗證的內容包括以下幾個方面:
- 文件格式驗證
- 元數據驗證
- 字節碼驗證
- 符號引用驗證
驗證的內容很多,我這裏就舉一個簡單的例子:我們都知道Java泛型以及自動裝箱/拆箱以及增強for循環,這些特性都是JDK1.5中才引入的,如果我們的字節碼文件是基於JDK1.5生成的,假設當前的JVM是基於1.4版本,那麼自然而然是有問題的。
不知道大家有沒有這樣一個疑問:前面在加載階段,我們已經提到,加載階段最終會生成一個Class對象。既然都生成了Class對象,那爲什麼在驗證階段再做這些校驗有什麼意義呢?原來:
加載階段與連接階段的部分內容是交叉進行的,加載階段尚未完成,連接階段可能已經開始,但這些夾在加載階段之中進行的動作,仍然屬於連接階段的內容,這兩個階段的開始時間仍然保持着固定的先後順序。
-
準備階段:
在這個階段,JVM就會爲類的靜態變量分配空間,並將其初始化爲默認值。這個地方所說的默認值要和我們顯式指定的默認值做下區分。例如:
private static int flag = 25;
那麼在準備階段,會爲flag變量分配內存空間,並且初始化爲0,而不是25。被賦值成25是在後面的初始化階段。這裏我們可以通過一段代碼進行驗證:
public class MyTest { public static void main(String[] args) { System.out.println("in main: " + Singleton.count); } } class Singleton { private static final Singleton instance = new Singleton(); public static int count = 0; private Singleton() { count++; System.out.println("Singleton constructor: " + count); } public static Singleton getInstance(){ return instance; } }
我們在這裏實現了一個餓漢式單例。這樣,我們直接看輸出結果:
Singleton constructor: 1
in main: 0
可以發現,我們在構造器中確確實實是將count自增了一次,變成了1.但是在main方法裏訪問的時候,count的值爲0。爲了更好、更體系地解釋這其中的原理,我們在介紹完初始化階段。
-
解析階段:
把類中的符號引用轉換成直接引用。之所以會有符號引用和直接引用,是因爲Java是跨平臺語言,在編譯期間並沒有確定具體引用的內存地址空間。以下摘自《深入理解JVM虛擬機》
- 符號引用:符號引用以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可。符號引用與虛擬機實現的內存佈局無關,引用的目標並不一定加載到內存中。各種虛擬機實現的內存佈局可以各不相同,但是它們能接受的符號引用必須是一致的,因爲符號引用的字面量形式明確定義在Java虛擬機規範的Class文件格式中。
- 直接引用:直接引用可以是直接指向目標的指針、相對偏移量或是一個能間接定位到目標的句柄。直接引用是和虛擬機實現的內存佈局相關的,同一個符號引用在不同虛擬機實例上翻譯出來的直接引用一般不會相同。如果有了直接引用,那引用的目標必定已經在內存中存在。
-
初始化階段:
Java程序對類的使用方式可分爲兩種:
- 主動使用
- 被動使用
所有的Java虛擬機實現必須在每個類或接口被Java程序”首次主動使用“時才初始化它們。有哪些情況會被視爲是對類的主動使用呢?
- 創建類的實例
- 訪問某個類或接口的靜態變量,或者對該靜態變量賦值
- 調用類的靜態方法
- 反射,如Class.forName(“xxx…xxx.xx”)
- 初始化一個類的子類
- Java虛擬機啓動時被標明爲啓動類的類
- JDK1.7開始提供動態語言支持,MethodHandle實例解析的結果REF_getStatic、REF_putStatic、REF_invokeStatic句柄對應的類沒有初始化
- 當一個接口中定義了JDK 8新加入的默認方法(被default關鍵字修飾的接口方法)時,如果有
這個接口的實現類發生了初始化,那該接口要在其之前被初始化。
前面的6種情況都是我們日常開發中常見的。下面我們先解釋之前遺留下來的問題解釋清楚,然後在分別舉例說明前面6種情況對於類的主動使用。
public class MyTest { public static void main(String[] args) { System.out.println("in main: " + Singleton.count); } } class Singleton { private static final Singleton instance = new Singleton(); public static int count = 0; private Singleton() { count++; System.out.println("Singleton constructor: " + count); } public static Singleton getInstance(){ return instance; } }
由於我們訪問了Singleton的靜態變量count,被視爲主動使用,並且是首次主動使用,所以會觸發類的初始化。而在準備階段,已經爲Singleton的靜態變量instance以及count變量分配空間,並且初始化爲默認值null以及0。到了初始化階段,會按照代碼中的先後順序執行初始化(實際上通過反編譯可以得知,他們都放置在):
- 對於instance變量的初始化:創建Singleton類的實例(這也是對類的主動使用,但是不是首次),從而會執行Singleton的構造方法,使得count自增爲1,並且打印:Singleton constructor: 1
- 對於count變量的初始化:將count賦值爲0
這樣是不是就已經回答我們之前遺留下來的問題了。在準備階段,會爲類的靜態變量分配內存空間,並且初始化爲默認值,這個時候的默認值是和我們的開發代碼無關的,是已經規定好了的:
類型 默認值 byte (byte)0 short (short)0 int 0 long 0L float 0.0f double 0.0d boolean false char ‘\u0000’ reference null ok.那麼我們來驗證上面提到的被視爲是對類的主動使用的六種情形:
-
創建類的實例
public class MyTest2 { public static void main(String[] args) { new Person(); new Person(); } } class Person { static { System.out.println("person init block."); } }
輸出結果爲:
person init block.
- 通過靜態代碼塊來反映是否執行了類的初始化;
- 只有對於類的首次主動使用纔會觸發類的初始化,這裏我們new兩個Person對象,但是static代碼塊只執行了一次。
-
訪問某個類或接口的靜態變量,或者對它們進行賦值
public class MyTest3 { public static void main(String[] args) { System.out.println(Human.name); } } class Human{ public static String name = "David"; static { System.out.println("in Human static block."); } }
輸出結果:
in Human static block.
David -
調用類的靜態方法
public class MyTest4 { public static void main(String[] args) { Student.introduce("David"); } } class Student { public static void introduce(String name) { System.out.println("Hello, I'm " + name); } static { System.out.println("in Student's static block."); } }
輸出結果如下:
in Student’s static block.
Hello, I’m David -
反射,如Class.forName()
public class MyTest5 { public static void main(String[] args) throws ClassNotFoundException { Class.forName("com.xlh.jvm.classloader2.Student"); } } class Student { public static void introduce(String name) { System.out.println("Hello, I'm " + name); } static { System.out.println("in Student's static block."); } }
輸出結果:
in Student’s static block.
-
訪問某個類的子類
public class MyTest6 { public static void main(String[] args) { new Dog(); } } class Animal{ static { System.out.println("Animal's static block."); } } class Dog extends Animal{ }
輸出結果爲:
Animal’s static block.
-
被Java虛擬機標記爲啓動類的類
public class MyTest7 { public static void main(String[] args) { } static { System.out.println("Main class static"); } }
輸出結果爲:
Main class static
可以看到,我們通過實例來證明了前面說到的七種情況。
-
幾種易混淆點:
-
對於靜態常量的處理:
public class MyTest6 { public static void main(String[] args) { System.out.println(Animal.name); } } class Animal{ public static final String name = "Animal"; static { System.out.println("Animal's static block."); } }
輸出結果爲:
Animal
從輸出結果來看,Animal類的靜態代碼塊並沒有執行。換句話說,對於Animal類中的常量name的訪問,並沒有觸發Animal類的初始化。我們前面提到:”訪問某個類或接口的靜態變量,或者對它們進行賦值“,但是加了final關鍵字後變成常量,編譯期對於其處理就發生了改變:常量在編譯階段會被存入到調用這個常量的方法所在類的常量池中。從本質上來說,調用類並沒喲直接引用到定義常量的類。比如我們這裏的Animal.name,編譯期間就會把它存放到MyTest6類的常量池中,在這之後,MyTest6和Animal類就沒有任何關係了。下面我們來看看反編譯的結果:
我們再把final關鍵字去掉:
很明顯,助記符由ldb變成了getstatic。
ldc:Push item from run-time constant pool
getstatic: Get
static
field from class。而且,加了final關鍵字的反編譯結果中,看不到Animal類信息。由此,我們可以嘗試將Animal.class文件刪除:
再次運行MyTest6程序,會發現並沒有報錯,輸出結果也是隻有一個”Animal“。
ok,如果我們現在稍微將name的賦值行爲修改一下:
public class MyTest6 { public static void main(String[] args) { System.out.println(Animal.name); } } class Animal { public static final String name = UUID.randomUUID().toString(); static { System.out.println("Animal's static block."); } }
再次運行程序:
Animal’s static block.
6946150f-92db-467f-8776-c78449d66f30我們會發現,此時Animal類的靜態代碼塊得到了執行,也就是說Animal類執行了初始化。同樣是加了final關鍵字,這裏表現出來的行爲和我們上面的得出的結論恰恰相反。
兩者的唯一區別在於:同樣是常量,前者的值在編譯期間就可以確定,稱之爲編譯期常量;後者的值在編譯期間是無法確定的,需要在運行期間才得以確定,稱之爲運行期常量,既然是運行期纔可以確定,那麼我們自然無法在編譯期間就將其放置到調用類的常量池中。
-
對於靜態字段,只有直接定義這個字段的類纔會被初始化。
public class MyTest6 { public static void main(String[] args) { System.out.println(Dog.name); } } class Animal { public static String name = "David"; static { System.out.println("Animal's static block."); } } class Dog extends Animal { static { System.out.println("Dog's static block."); } }
還是前面的Animal類例子,我們把final關鍵字去掉,然後通過Dog.name的形式來訪問定義在Animal類的靜態屬性。我們來看輸出結果:
Animal’s static block.
David
我們可以發現,Dog類並沒有被初始化,反而是Animal類被初始化了。這也就驗證了,我們這條的結論:對於靜態字段,只有直接定義這個字段的類纔會被初始化。
-
對數組類型的處理
public class MyTest8 { public static void main(String[] args) { Item[] items = new Item[5]; } } class Item { static { System.out.println("in Item's static block."); } }
如果運行這段程序,輸出結果是怎樣的呢?答案是控制檯沒有任何輸出信息。
對於數組實例而言,其類型是由JVM在運行期生成的,數組類本身不通過類加載器創建;對於某個類的數組類型的主動使用,並不會導致該元素類的初始化。這裏我們用的是一維數組舉例的換成二維數組也是一樣。但是對於引用數據類型而言,儘管其對應的數組類本身是不同類加載器創建,但是該引用數據類型還是需要通過類加載器創建。例如此處再添加一行:
items[0] = new Item();
那麼肯定會觸發Item類的初始化。
而對於原生數據類型而言,其對應的數組類會被標記爲與引導類加載器關聯。
-
-
總結:
-
虛擬機類加載機制的幾個階段:加載、連接(驗證、準備、解析)、初始化、使用以及卸載。
-
對於類的首次主動使用,纔會觸發類的初始化,具體可被視爲主動使用的情形有:
- 創建類的實例(對應的助記符爲new)
- 訪問某個類或者接口的靜態變量,或者對該靜態變量賦值(常量除外)(助記符:getstatic、putstatic)
- 調用某個類的靜態方法(invokestatic)
- 使用java.lang.reflect包對某個類型進行反射調用,如Class.forName(“xxx.xxx.xxx”);
- 初始化一個類的子類
- 被標記爲啓動類的類
- JDK1.7開始提供動態語言支持,MethodHandle實例解析的結果REF_getStatic、REF_putStatic、REF_invokeStatic句柄對應的類沒有初始化
- 當一個接口中定義了JDK 8新加入的默認方法(被default關鍵字修飾的接口方法)時,如果有
這個接口的實現類發生了初始化,那該接口要在其之前被初始化。
有且只有這些情形被視爲類的主動使用
-
對幾種易混淆點的處理
- 編譯期常量與運行期常量所表現出來的不同行爲
- 對於類的靜態變量,只有直接定義該靜態變量的類纔會被初始化
- 對於數組類型的處理,數組類型本身是由JVM在運行期間動態產生的,對於某個類的數組類型的主動使用,並不會觸發該類的初始化。
-
參考資源:《深入理解Java虛擬機第三版》