JVM(一) 類加載

導航

JVM的生命週期

類加載機制

類的生命週期

類的加載、連接、初始化

類的使用方式

主動使用

被動使用

加載

加載Class文件的方式

查看類加載信息

加載時機的不確定

初始化

反編譯與字節碼指令

接口初始化

接口與類初始化的不同

初始化的順序

數組與被動使用


JVM的生命週期

  • 程序正常執行結束
  • 調用System.exit()方法
  • 程序執行過程中出現異常而終止(沒有使用try...catch捕獲異常,致使異常最終被拋給JVM)
  • 由於操作系統出現錯誤而導致JVM進程終止

 

類加載機制

JVM把描述類的數據從Class文件加載到內存,並對數據進行校驗、轉換解析和初始化,最終形成可以被JVM直接使用的Java類型,這就是JVM的類加載機制。

 

類的生命週期

類從被JVM加載到內存中開始,到卸載出內存爲止,它的整個生命週期包括:加載、驗證、準備、解析、初始化、使用和卸載7個階段。其中驗證、準備、解析3個階段統稱爲連接。具體可以參考下圖:

本文主要講述類的加載、連接和初始化3個階段。

 

類的加載、連接、初始化

類的加載、連接、初始化就是類加載的全過程,而這些都是在程序的運行期完成的。

  • 加載:將類的Class文件加載到內存中
  • 連接
    1. 驗證:確保加載Class文件的正確性,沒有被篡改
    2. 準備:爲類的靜態變量分配內存,並將其初始化爲默認值
    3. 解析:將類中的符號引用替換爲直接引用
  • 初始化:爲類中的靜態變量賦予給定的初始值

類初始化階段的爲靜態變量賦初始值,有兩種方式:

1、顯式直接爲靜態變量賦值

class Demo {
    static String str = "hello";
}

2、在靜態代碼塊中爲靜態變量賦值

class Demo {
	static String str;
	
	static {
		str = "hello";
	}
}

根據上述的第二種情況,我們可以推導出:執行了static代碼塊中的代碼就可以證明該類初始化了

 

類的使用方式

java程序對類的使用可以分爲兩種方式:主動使用和被動使用。

主動使用

  1. 創建類的實例
  2. 訪問某個類、接口的靜態變量,或者對該靜態變量賦值
  3. 調用類的靜態方法
  4. 反射
  5. 初始化一個類的子類
  6. JVM啓動時被標記爲啓動類的類(包含main()方法)
  7. 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纔會初始化它

從上面的對類初始化時機的描述中,我們可以得出這樣的結論:

  1. 首次使用的類纔會被初始化
  2. 主動使用的類纔會被初始化
  3. 類的初始化只會進行一次

反編譯與字節碼指令

現在我們來試着反編譯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所證明的是主動使用的第五種情況——初始化一個類的子類,該類會被初始化。但這條規則不適用於接口,具體體現在下面兩點:

  1. 在初始化一個接口的時候,並不會先初始化它的父接口
  2. 在初始化一個類的時候,並不會先初始化它所實現的接口

我們先來看第一種情況,例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:創建一個多維數組(基本類型和引用類型數組均使用此指令)。

 

參考:

https://juejin.im/post/5a810b0e5188257a5c606a85

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章