Class檔案載入及其初始化過程
該博文介紹位元組碼檔案裝載過程中的各個階段。。。
重點需要掌握的是每個階段中JVM需要做的工作。。。
圖覽全域性----Class檔案裝載經歷的各個階段:
在java應用程式開發中,只有被java虛擬機器裝載的Class型別才能在程式中使用。只要生成的位元組碼符合java虛擬機器的指令集和檔案格式,就可以在JVM上執行,這為java的跨平臺性提供條件。
位元組碼檔案的裝載過程:載入 、 連線(包括三個步驟:驗證 準備 解析) 、初始化,如圖所示
-------------------------------------------------------------------------------------------------
類裝載的條件:
Java虛擬機器不會無條件的裝載Class型別。
Java虛擬機器規定:一個類或者介面在初次使用時,必須進行初始化。
這裡的使用指的是主動使用,主動使用有以下幾種情況:
- 當建立一個類的例項時,比如使用new關鍵字,或者通過反射、克隆、反序列化方式。
- 當呼叫類的靜態方法時,即當使用了位元組碼invokestatic指令
- 當使用類或者介面的靜態欄位時(final常量除外,此種情況只會載入類而不會進行初始化),即使用getstatic或者putstatic指令(可以使用jclasslib軟體檢視生成的位元組碼檔案)
- 當使用java.lang.reflect包中的方法反射類的方法時
- 當初始化子類時,必須先初始化父類
- 作為啟動虛擬機器、含有main方法的那個類
除了以上情況屬於主動使用外,其他情況均屬於被動使用,被動使用不會引起類的初始化,只是載入了類卻沒有初始化。
例1:主動使用(這是三個class檔案,而不是一個,此處為方便寫在一起。多說一點:因為一個Class檔案只能有一個public類和檔名一樣,其餘類修飾符只能是非pubic)
public class Parent{ static{ System.out.println("Parent init"); } } public class Child{ static{ System.out.println("Child init"); } } public class InitMain{ public static void main(String[] args){ Child c = new Child(); } }
以上聲明瞭3個類:Parent Child InitMain,Child類為Parent類的子類。若Parent類被初始化,將會執行static塊,會列印"Parent init",若Child類被初始化,則會列印"Child init"。(類的載入先於初始化,故執行靜態程式碼塊後(<cinit>),就表明類已經載入了)
執行InitMain,結果為:
Parent init
Child init
由此可知,系統首先裝載Parent類,接著裝載Child類。
符合主動裝載中的兩個條件:使用new關鍵字建立類的例項會裝載相關的類,以及在初始化子類時,必須先初始化父類。
例2 :被動裝載
public class Parent{
static{
System.out.println("Parent init ");
}
public static int v = 100; //靜態欄位
}
public class Child extends Parent{
static{
System.out.println("Child init");
}
}
public class UserParent{
public static void main(String[] args){
System.out.println(Child.v);
}
}
Parent中有靜態變數v,並且在UserParent中,使用其子類Child去呼叫父類中的變數。
執行程式碼:
Parent init
100
雖然在UserParent中,直接訪問了子類物件,但是Child子類並未初始化,僅僅載入了Child類,只有Parent類進行初始化。所以,在引用一個欄位時,只有直接定義該欄位的類,才會被初始化。
注意:雖然Child類沒有被初始化,但是,此時Child類已經被系統載入,只是沒有進入初始化階段。
可以使用-XX:+ThraceClassLoading 引數執行這段程式碼,檢視日誌,便可以看到Child類確實被載入了,只是初始化沒有進行
例3 :引用final常量
public class FinalFieldClass{
public static final String constString = "CONST";
static{
System.out.println("FinalFieldClass init");
}
}
public class UseFinalField{
public static void main(String[] args){
System.out.println(FinalFieldClass.constString);
}
}
執行程式碼:CONST
FinalFieldClass類沒有因為其常量欄位constString被引用而進行初始化,這是因為在Class檔案生成時,final常量由於其不變性,做了適當的優化。驗證完位元組碼檔案無誤後,在準備階段就會為常量初始化為指定的值。
分析UseFinalField類生成的Class檔案,可以看到main函式的位元組碼為:
在位元組碼偏移3的位置,通過Idc將常量池第22項入棧,在此Class檔案中常量池第22項為:
#22 = String #23 //CONST
#23 = UTF8 CONST
由此可以看出,編譯後的UseFinalField.class中,並沒有引用FinalFieldClass類,而是將FinalFieldClass類中final常量欄位直接存放在自己的常量池中,所以,FinalFiledClass類自然不會被載入。(javac在編譯時,將常量直接植入目標類,不再使用被引用類)通過捕獲類載入日誌(部分日誌)可以看出:(並沒有載入FinalFiledClass類日誌)
注意:並不是在程式碼中出現的類,就一定會被載入或者初始化,如果不符合主動使用的條件,類就不會被載入或者進一步初始化。
詳解類裝載的整個過程
1)載入類:處於類裝載的第一個階段。
載入類時,JVM必須完成:
- 通過類的全名,獲取類的二進位制資料流
- 解析類的二進位制資料流為方法區內的資料結構,也就是將類檔案放入方法區中
- 建立java.lang.Class類的例項,表示該型別
2)連線
驗證位元組碼檔案:當類被載入到系統後,就開始連線操作,驗證是連線的第一步。
主要目的是保證載入的位元組碼是符合規範的。
驗證的步驟如圖:
準備階段
當一個類驗證通過後,虛擬機器就會進入準備階段。準備階段是正式為類變數(static修飾的變數)分配記憶體並設定類變數初始值,這些記憶體都將在方法區進行分配。這個時候進行記憶體分配的僅是類變數,不包括例項變數,例項變數將會在物件例項化時隨著物件一起分配在堆上。為類變數設定初始值是設為其資料型別的“零值”。
比如 public static int num = 12; 這個時候就會為num變數賦值為0
java虛擬機器為各種型別變數預設的初始值如表:
型別 | 預設初始值 |
int | 0 |
long | 0L |
short | (short)0 |
char | \u0000 |
boolean | false |
reference | null |
float | 0f |
double | 0f |
注意:java並不支援boolean型別,對於boolean型別,內部實現是Int,由於int的預設值是0,故對應的,boolean的預設值是false
如果類中屬於常量的欄位,那麼常量欄位也會在準備階段被附上正確的值,這個賦值屬於java虛擬機器的行為,屬於變數的初始化。在準備階段,不會有任何java程式碼被執行。
解析類
在準備階段完成後,就進入瞭解析階段。
解析階段的任務就是將類、介面、欄位和方法的符號引用轉為直接引用。
符號引用就是一些字面量的引用。比較容易理解的就是在Class類檔案中,通過常量池進行大量的符號引用。
具體可以使用JclassLib軟體檢視Class檔案的結構:::
下面通過一個簡單函式的呼叫來講解下符號引用是如何工作的。。。
例如:System.out.println();
生成的位元組碼指令:invokevirtual #24 <java/io/PrintStream.println>
這裡使用了常量池第24項,檢視並分析該常量池,可以檢視到如圖的結構:
常量池第24項被invokevirtual使用,順著CONSTANT_Methodref #24的引用關係繼續在常量池中查詢,發現所有對於Class以及NameAndType型別的引用都是基於字串的,因此,可以認為Invokevirtual的函式呼叫通過字面量的引用描述已經表達清楚了,這就是符號引用。
但是隻有符號引用是不夠的,當println()方法被呼叫時,系統需要明確知道方法的位置。java虛擬機器會為每個類準備一張方法表,將其所有的方法都列在表中,當需要呼叫一個類的方法時,只要知道這個方法在表中的偏移量就可以了。通過解析操作,符號引用就可以轉變為目標方法在類中方法表的位置,從而使方法被成功呼叫。
所以,解析的目的就是將符號引用轉變為直接引用,就是得到類或者欄位、方法在記憶體中的指標或者偏移量。如果直接引用存在,那麼系統中肯定存在類、方法或者欄位,但只存在符號引用,不能確定系統中一定存在該物件。
3)類初始化
如果前面的步驟沒有出現問題,那麼表示類可以順利裝載到系統中。此時,才會開始執行java位元組碼。
初始化階段的重要工作是執行類的初始化方法<clinit>()。其特點:
- <clinit>()方法是由編譯器自動生成的,它是由類靜態成員的賦值語句以及static語句塊合併產生的。編譯器收集的順序是由語句在原始檔中出現的順序決定的,靜態語句塊中只能訪問到定義在靜態語句塊之前的類變數,定義在其之後的類變數,只能被賦值,不能被訪問。比如:
static int num = 12;
------------------------------------------------------------
static{
System.out.println(num); //不合法訪問
}
static int num = 12;
例如:
public class SimpleStatic{
public static int id = 1;
public static int number;
static{
number = 4;
}
}
java編譯器為這段程式碼生成如下的<clinit>:
0 iconst_1
1 putstatic #2 <Demo.id>
4 iconst_4
5 putstatic #3 <Demo.number>
8 return
<clinit>函式中,整合了SimpleStatic類中的static賦值語句以及static語句塊
改段JVM指令程式碼表示:先後對id和number兩個成員變數進行賦值
- <clinit>()方法與類的構造器函式<init>()方法不同,它不需要顯示的呼叫父類的<clinit>()方法,虛擬機器會保證在子類的<clinit>()方法執行之前,父類的<clinit>()方法已經執行完畢。故父類的靜態語句塊會先於子類的靜態語句塊執行。
public class ChildStatic extends SimpleStatic
{
static{
number = 2;
}
public static void main(String[] args){
System.out.println(number);
}
}
執行程式碼:
2
表明父類的<clinit>總是在子類<clinit>之前被呼叫。
注意:java編譯器並不是為所有的類都產生<clinit>初始化函式,如果一個類既沒有類變數賦值語句,也沒有static語句塊,那麼生成的<clinit>函式就應該為空,因此,編譯器就不會為該類插入<clinit>函式
例如:
public class StaticFinalClass{
public static final int i=1;
public static final int j=2;
}
由於StaticFinalClass只有final常量,而final常量在準備階段被賦值,而不在初始化階段處理,因此對於StaticFinalClass類來說,<clinit>就無事可做,因此,在產生的class檔案中沒有該函式存在。
- 虛擬機器保證一個類的<clinit>()方法在多執行緒環境中被正確的加鎖和同步,如果多個執行緒同時去初始化一個類,只有一個執行緒去執行這個類的<clinit>()方法,其他執行緒都會被阻塞,直到指定執行緒執行完<clinit>()方法。
--------------------------------------------------------------------------------------------------------------------------------------------------
趁著意猶未盡,來看看物件初始化流程:包括成員變數和構造器呼叫的先後順序,子類構造器和父類之間的先後順序等等。通過位元組碼檔案指令直接的展示這個過程:
編輯幾個類,包括一個子類一個父類,其中子類和父類中都包含了成員變數、非靜態程式碼塊、構造器函式以及前面講到的靜態程式碼塊和靜態變數:
package com.classextends;
public class FuZiDemo {
public static void main(String[] args) {
new ZiClass();//測試類,建立子類物件
}
}
class FuClass {
int fuOwer = 120; //成員變數一
static{
System.out.println("Fu clinit()"); //靜態程式碼塊
}
static int num = 22; //靜態變數
{ //非靜態程式碼塊
fuName = "tempValue";
System.out.println(fuOwer);
int c = 23;
}
String fuName = "dali"; //成員變數二
FuClass(){ //父類建構函式
System.out.println("Fu init()");
fuOwer = 100;
}
}
class ZiClass extends FuClass {
int ziOwer = 82; //成員變數一
static{ //靜態程式碼塊
System.out.println("Zi clinit()");
}
static int num = 2; //靜態變數
{ //非靜態程式碼塊
ziName = "tempValue";
System.out.println(ziOwer);
int c = 23; //區域性變數
}
String ziName = "urocle"; //成員變數二
ZiClass(){ //子類建構函式
ziOwer = 23;
System.out.println("Zi init()");
}
}
分析:
一、類的載入和初始化
首先FuziDemo這個測試類要載入,然後執行main指令時會new 子類物件,故要去載入子類的位元組碼檔案,但是會發現子類有一個直接繼承類FuClass,於是就會先去載入FuClass的位元組碼檔案,接著會初始化父類,執行FuClass類的<clinit>方法:執行輸出語句以及為靜態成員賦值,其位元組碼指令為:
0 getstatic #13 <java/lang/System.out>
3 ldc #19 <Fu clinit()>
5 invokevirtual #21 <java/io/PrintStream.println>
8 bipush 22
10 putstatic #27 <com/classextends/FuClass.num>
13 return
完成父類的初始化工作之後,緊接著載入子類的位元組碼檔案並且執行其<clinit>()方法。其位元組碼指令類似於父類的:
0 getstatic #13 <java/lang/System.out>
3 ldc #19 <Zi clinit()>
5 invokevirtual #21 <java/io/PrintStream.println> //呼叫println()方法輸出 #19也就是 Zi clinit()
8 iconst_2
9 putstatic #27 <com/classextends/ZiClass.num> //為靜態變數賦值
12 return
二、子類和父類成員變數初始化,以及建構函式執行順序
測試類main函式的位元組碼指令:
0 new #16 <com/classextends/ZiClass>
3 invokespecial #18 <com/classextends/ZiClass.<init>> //呼叫子類的初始化函式
6 return
下面看看子類ZiClass的<init>()函式的位元組碼指令:
0 aload_0
1 invokespecial #32 <com/classextends/FuClass.<init>> //首先會去呼叫父類的<init>()函式
4 aload_0
5 bipush 82
7 putfield #34 <com/classextends/ZiClass.ziOwer> //為成員變數 ziOwer賦值為82
10 aload_0
11 ldc #36 <tempValue>
13 putfield #38 <com/classextends/ZiClass.ziName> //執行非靜態程式碼塊,臨時為成員變數ziName賦值
16 getstatic #13 <java/lang/System.out> //呼叫System.out輸出函式
19 aload_0
20 getfield #34 <com/classextends/ZiClass.ziOwer> //獲取成員變數 ziOwer的值
23 invokevirtual #40 <java/io/PrintStream.println> //列印輸出
26 bipush 23
28 istore_1
29 aload_0
30 ldc #43 <urocle>
32 putfield #38 <com/classextends/ZiClass.ziName> //為成員變數ziName賦值為urocle
35 aload_0
36 bipush 23
//取出 23 ,意味著例項初始化過程中先初始化成員變數及執行非靜態程式碼塊,最後執行構造
38 putfield #34 <com/classextends/ZiClass.ziOwer> //為成員變數ziOwer賦值為23
41 getstatic #13 <java/lang/System.out>
44 ldc #45 <Zi init()>
46 invokevirtual #21 <java/io/PrintStream.println>
49 return
同樣FuClass類的例項初始化函式<init>()如下,此處不再解釋:
0 aload_0
1 invokespecial #32 <java/lang/Object.<init>>
4 aload_0
5 bipush 120
7 putfield #34 <com/classextends/FuClass.fuOwer>
10 aload_0
11 ldc #36 <tempValue>
13 putfield #38 <com/classextends/FuClass.fuName>
16 getstatic #13 <java/lang/System.out>
19 aload_0
20 getfield #34 <com/classextends/FuClass.fuOwer>
23 invokevirtual #40 <java/io/PrintStream.println>
26 bipush 23
28 istore_1
29 aload_0
30 ldc #43 <dali>
32 putfield #38 <com/classextends/FuClass.fuName>
35 getstatic #13 <java/lang/System.out>
38 ldc #45 <Fu init()>
40 invokevirtual #21 <java/io/PrintStream.println>
43 aload_0
44 bipush 100
46 putfield #34 <com/classextends/FuClass.fuOwer>
49 return
三 給出程式執行的結果
Fu clinit()
Zi clinit() //靜態程式碼塊輸出
120 //非靜態程式碼塊輸出
Fu init() //建構函式輸出
82
Zi init()
總結:
(1)父類載入初始化先於子類,父類的<clinit>優先於子類的<clinit>函式執行
(2)如果建立一個子類物件,父類建構函式<init>呼叫先於子類構造器<init>函式呼叫。在執行構造器<init>函式首先會初始化類中成員變數或者執行非靜態程式碼塊(這二者執行的先後順序依賴於在原始檔中出現的順序),然後再呼叫建構函式。