導航
JVM的生命週期
- 程序正常執行結束
- 調用System.exit()方法
- 程序執行過程中出現異常而終止(沒有使用try...catch捕獲異常,致使異常最終被拋給JVM)
- 由於操作系統出現錯誤而導致JVM進程終止
類加載機制
JVM把描述類的數據從Class文件加載到內存,並對數據進行校驗、轉換解析和初始化,最終形成可以被JVM直接使用的Java類型,這就是JVM的類加載機制。
類的生命週期
類從被JVM加載到內存中開始,到卸載出內存爲止,它的整個生命週期包括:加載、驗證、準備、解析、初始化、使用和卸載7個階段。其中驗證、準備、解析3個階段統稱爲連接。具體可以參考下圖:
本文主要講述類的加載、連接和初始化3個階段。
類的加載、連接、初始化
類的加載、連接、初始化就是類加載的全過程,而這些都是在程序的運行期完成的。
- 加載:將類的Class文件加載到內存中
- 連接:
- 驗證:確保加載Class文件的正確性,沒有被篡改
- 準備:爲類的靜態變量分配內存,並將其初始化爲默認值
- 解析:將類中的符號引用替換爲直接引用
- 初始化:爲類中的靜態變量賦予給定的初始值
類初始化階段的爲靜態變量賦初始值,有兩種方式:
1、顯式直接爲靜態變量賦值
class Demo {
static String str = "hello";
}
2、在靜態代碼塊中爲靜態變量賦值
class Demo {
static String str;
static {
str = "hello";
}
}
根據上述的第二種情況,我們可以推導出:執行了static代碼塊中的代碼就可以證明該類初始化了。
類的使用方式
java程序對類的使用可以分爲兩種方式:主動使用和被動使用。
主動使用
- 創建類的實例
- 訪問某個類、接口的靜態變量,或者對該靜態變量賦值
- 調用類的靜態方法
- 反射
- 初始化一個類的子類
- JVM啓動時被標記爲啓動類的類(包含main()方法)
- JDK1.7開始提供動態語言支持(很少使用)
被動使用
除了上述七種情況,其餘使用java類的方式都可以視爲對類的被動使用,被動使用不會導致類的初始化。注意,不初始化一個類,並不意味着不會加載這個類的Class文件。
例如通過子類訪問父類的靜態變量,不會初始化子類。例1:
public class Test{
public static void main(String[] args) {
System.out.println(Children.str);
}
}
class Parent {
public static String str = "hello";
static {
System.out.println("Parent init");
}
}
class Children extends Parent{
static {
System.out.println("Children init");
}
}
上面例子會打印“Parent init”,而不會打印“Children init”。這個例子從側面說明了主動使用的第二種情況——訪問某個類、接口的靜態變量,或者對該靜態變量賦值——初始化的是定義這個靜態變量的類。
加載
類的加載指的是將類的Class文件中的二進制數據讀入到內存中,將其放在運行時數據區的方法區,然後在內存中創建一個java.lang.Class對象(規範並未說明Class對象位於什麼區域,例如HotSpot虛擬機將其放在方法區中),用於封裝類在方法區內的數據結構。類的加載的最終產品是Class對象,而該Class對象正是反射的基礎。
加載Class文件的方式
- 從本地(磁盤)直接加載
- 從網絡下載加載
- 從zip、jar等歸檔文件中加載
- 從專有數據庫中提取加載
- 將java源文件動態編譯
查看類加載信息
這裏我們需要用到一個JVM的參數:-XX:+TraceClassLoading(追蹤類的加載信息並打印出來)。關於JVM的參數格式是有規律可循的,具體來說就是下面三種:
- -XX:+<option> 開啓option(+表示true,開啓的意思)
- -XX:-<option> 關閉option(-表示false,關閉的意思)
- -XX:<option>=<value> 將option的值設置爲value
我使用的IDE是eclipse,在當前使用的啓動類(這裏是Test類)的java代碼中右擊->Run As->Run Configurations...,在Run Configurations窗口的VM arguments中就可以配置JVM參數。具體可以參考下圖:
當然,你也可以設置默認的JVM參數,這樣就不用分別爲每個啓用類設置JVM參數。選中菜單欄上的Window->Preferences->Java->Installed JREs,選中當前使用的JRE->Edit...,在Edit JRE窗口的Default VM arguments中就可以配置JVM參數。具體可以參考下圖:
完成上面任意一種JVM參數配置,就可以在控制檯中看到JVM加載的全部類信息的打印結果。
加載時機的不確定
什麼時候會開始類加載過程中的第一個階段:加載?在虛擬機規範中並沒有進行強制約束,這點交由虛擬機的具體實現來自由把握。
在上面談及被動使用時,有過這樣的描述:不初始化一個類,並不意味着不會加載這個類的Class文件。這句話所表達的含義其實就暗含了加載時機的不確定性。
完成配置JVM參數配置之後,這裏繼續使用例1的代碼,運行程序。我們可以在打印結果中看到Children類的加載信息:
[Loaded org.hu.jvm.Parent from file:/C:/Users/hunan/workspace/JVM/build/classes/]
[Loaded org.hu.jvm.Children from file:/C:/Users/hunan/workspace/JVM/build/classes/]
雖然Children類沒有被初始化,但是該類依然被JVM加載了。這也就證實了類的加載時機是由虛擬機的具體實現來自由把握的。
然而雖然我們無法確定JVM什麼時候會加載一個類,但是我們可以確定什麼時候JVM不會加載一個類。例2:
public class Test{
public static void main(String[] args) {
System.out.println(FinalClass.str);
}
}
class FinalClass {
public static final String str = "hello";
static {
System.out.println("FinalClass init");
}
}
在打印結果中,我們不會看到FinalClass類的加載信息的,甚至將FinalClass類的Class文件刪除,這個程序依然可以運行。
eclipse編譯的Class文件默認存放在build文件夾下。在Quick Access輸入框中輸入Navigator以打開Navigator窗口,在Navigator窗口中就可以看到build文件夾以及編譯的Class文件。具體可以參考下圖:
把build文件夾下的FinalClass.class文件刪除,再次運行程序,你會發現程序可以正常運行,沒有任何報錯信息。
這是因爲Test類中訪問了FinalClass類中的常量str,而在編譯階段就已經將此常量的值“hello”存放到Test類的常量池中。之後Test類對常量str的訪問,實際上都是對自身常量池的訪問。換而言之,兩個類在編譯成Class文件之後就不存在任何聯繫了。所以引用常量並不會導致定義常量的類的初始化。
初始化
上面提到類的加載時機是不確定的,但是類的初始化的機我們是可以確定的:每個類或者接口只有被Java 程序首次主動使用使用時,JVM纔會初始化它。
從上面的對類初始化時機的描述中,我們可以得出這樣的結論:
- 首次使用的類纔會被初始化
- 主動使用的類纔會被初始化
- 類的初始化只會進行一次
反編譯與字節碼指令
現在我們來試着反編譯Class文件,看看Class文件中記錄的數據。這裏繼續使用例2的代碼。
在Navigator窗口選中要反編譯的Class文件所在的文件夾,然後點擊Terminal,在彈出的對話框中選擇“OK”就可以看到Terminal窗口。我們可以發現,Terminal中的命令行已經定位到Class文件所在的文件夾,在命令行中輸入javap -c 類名(這裏是Test),就可以查看Test類的Class文件中記錄的字節碼指令。具體可以參考下圖:
反編譯Test.class後字節碼指令如下圖所示:
在JVM中有很多字節碼指令助記符,我在文章中就講解一些碰到的助記符含義:
- getstatic:訪問靜態變量
- ldc:int,float,String 類型常量值從常量池推送到棧頂
- invokevirtual:調用實例方法
接口初始化
大家應該都知道接口中的變量都是public static final的,所以當我們在啓動類中引用接口中的變量時,就變成了例2所討論的情況——啓動類和接口在編譯成Class文件之後就不存在任何聯繫了。例3:
public class Test{
public static void main(String[] args) {
System.out.println(Inter.num);
}
}
interface Inter {
int num = 1; // 完整的樣子:public static final num = 1;
}
運行上面的程序,在控制檯打印的類加載信息中,我們不會看到Inter接口的加載信息的。既然如此,那麼問題來了——怎麼樣才能讓接口被初始化呢?
在例2和例3中,我們都給常量賦予了一個確定的值,所以在編譯期該常量會被存放到引用該常量的類的常量池中。那麼如果我們給常量賦予的值在編譯期無法確定呢?例4:
public class Test{
public static void main(String[] args) {
System.out.println(Inter.thread);
}
}
interface Inter {
Thread thread = new Thread() {
{ // 構造代碼塊,創建對象時執行
System.out.println("Inter init");
}
};
}
在上面的例子中,接口成員變量thread的值在編譯期是無法確定的。只有在運行期,Thread對象纔會被創建出來,當Test類訪問thread時,變量thread纔會被賦值,接口就會被初始化。打印結果:“Inter init”,證明接口Inter確實被初始化了。
通過例4我們可以發現之前從例2得出的推論——引用常量並不會導致定義常量的類的初始化——是不完善的,現在我們可以將這個結論完善一下:引用在編譯期可以確定值的常量並不會導致定義常量的類的初始化。
接口與類初始化的不同
下面的例子是java程序對類的主動使用,相信大家應該都知道打印的結果。例5:
public class Test{
public static void main(String[] args) {
System.out.println(Children.str2);
}
}
class Parent {
static {
System.out.println("Parent init");
}
}
class Children extends Parent{
public static String str2 = "welcome";
static {
System.out.println("Children init");
}
}
打印結果:“Parent init”,“Children init”,“welcome”。這個打印結果是符合我們預期的。例5所證明的是主動使用的第五種情況——初始化一個類的子類,該類會被初始化。但這條規則不適用於接口,具體體現在下面兩點:
- 在初始化一個接口的時候,並不會先初始化它的父接口
- 在初始化一個類的時候,並不會先初始化它所實現的接口
我們先來看第一種情況,例6:
public class Test{
public static void main(String[] args) throws Exception{
System.out.println(Children.num1);
}
}
interface Parent {
int num = new Random().nextInt(2);
Thread thread = new Thread() {
{
System.out.println("Parent init");
}
};
}
interface Children extends Parent{
int num1 = new Random().nextInt(2);
Thread thread1 = new Thread() {
{
System.out.println("Children init");
}
};
}
打印結果:“Children init”,1(此值以輸出情況爲準)。接着將main()方法中語句改爲:
System.out.println(Children.num);
我們可以發現打印結果變成了這樣:“Parent init”,0(此值以輸出情況爲準)。通過這個例子,我們可以對第一種情況進行補充:初始化一個接口時,並不要求其父接口都完成了初始化,只有在真正使用到父接口的時候(如引用接口中所定義的常量時),纔會初始化父接口。
再來看第二種情況,例7:
public class Test{
public static void main(String[] args) {
System.out.println(Implemention.str);
}
}
interface Inter {
Thread thread = new Thread() {
{
System.out.println("Inter init");
}
};
}
class Implemention implements Inter {
public static String str = "hello";
static {
System.out.println("Implemention init");
}
}
打印結果:“Implemention init”,“hello”。
從例6和例7兩個例子中不難發現,在對類和接口初始化的時候,類與接口、接口與接口之間的關聯很弱,甚至可以說沒有什麼關聯——類的初始化不會影響到該類繼承的接口、子接口的初始化不會影響父接口。
初始化的順序
看這樣一個例子,例8:
public class Test{
public static void main(String[] args) {
Singleton singleton = Singleton.getInstance();
System.out.println(singleton.count1);
System.out.println(singleton.count2);
}
}
class Singleton {
public static int count1;
public static int count2 = 0;
public static Singleton singleton = new Singleton();
private Singleton() {
count1++;
count2++;
}
public static Singleton getInstance() {
return singleton;
}
}
運行上面的程序,打印的結果是什麼呢?相信你一定能猜出來,打印結果是:1,1。你可能會覺得奇怪,這麼簡單的程序有什麼好舉例的。那麼我稍微修改一下這個程序,你還能猜出打印的結果嗎?例9:
public class Test{
public static void main(String[] args) {
Singleton singleton = Singleton.getInstance();
System.out.println(singleton.count1);
System.out.println(singleton.count2);
}
}
class Singleton {
public static int count1;
public static Singleton singleton = new Singleton();
private Singleton() {
count1++;
count2++;
}
public static int count2 = 0; // 將count2的位置調換到這裏
public static Singleton getInstance() {
return singleton;
}
}
打印結果:1,0。怎麼樣,猜對了嗎?爲什麼僅僅調換了count2的位置,程序打印結果就發生了這麼大的變化呢?
在JVM完成Singleton類的加載和連接兩個步驟之後,相信大家都是知道變量count1和count2的值的,它們都被賦予了對應數據類型的默認值——count1=0,count2=0。接着開始初始化,在爲變量singleton賦初值的時候調用了Singleton的構造方法,此時變量count1和count2的值變成了count1=1,count2=1。在此之後,變量count2才被賦予給定的初始值0,於是最後變量count1和count2的值爲count1=1,count2=0。具體可以參考下圖:
通過這個例子想要說明的是:初始化是自上而下順序執行的。
數組與被動使用
關於被動使用,還有一種情況需要着重討論。例10:
public class Test{
public static void main(String[] args) {
ArrayClass[] arr1 = new ArrayClass[1];
System.out.println(arr1.getClass()); // 獲取Class對象
System.out.println(arr1.getClass().getSuperclass()); // 獲取父類Class對象
System.out.println("========================");
ArrayClass[][] arr2 = new ArrayClass[5][6];
System.out.println(arr2.getClass());
System.out.println(arr2.getClass().getSuperclass());
System.out.println("========================");
char[] arr3 = new char[128];
System.out.println(arr3.getClass());
System.out.println(arr3.getClass().getSuperclass());
}
}
class ArrayClass {
static {
System.out.println("ArrayClass init");
}
}
打印結果如下:
class [Lorg.hu.jvm.ArrayClass;
class java.lang.Object
========================
class [[Lorg.hu.jvm.ArrayClass;
class java.lang.Object
========================
class [I
class java.lang.Object
打印結果中並沒有出現“ArrayC init”,說明ArrayClass類並沒有被初始化。這是因爲對於數組實例而言,其類型是由JVM在運行期動態生成的。對於這種動態生成的類型,其父類型是Object。JavaDoc經常將構成數組的元素稱爲Component,實際上就是將數組降低一個維度後的類型。
數組的類型分爲兩類:基本類型數組和引用類型數組。基本類型數組的類型表示爲:數組維度個[ + L + 數據類型對應的大寫字母,引用類型數組的類型表示爲:數組維度個[ + L + 類全名。
反編譯Test.class,從打印結果中可以看到出現了新的助記符:
- iconst_1:將整形常量1壓入棧(對於整型常量-1~5,JVM 採用iconst_m1、iconst_0、iconst_1、iconst_2、iconst_3、iconst_4、iconst_5指令將常量壓入棧中)
- astore_1:將棧頂引用類型值保存到局部變量1中(除此之外還有astore_0,astore_2,astore_3)
- bipush:將單字節(-128 ~ 127)的常量值從常量池中推至棧頂
- sipush:將一個短整型(-32768 ~ 32767)的常量值從常量池中推至棧頂
- anewarray:創建一個一維引用類型數組,並將其引用值壓入棧頂
- newarray:創建一個一維基本類型數組,並將其引用值壓入棧頂
- multianewarray:創建一個多維數組(基本類型和引用類型數組均使用此指令)。
參考: