1. 程式人生 > >Class檔案載入及其初始化過程

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{       num = 5;  //賦值操作,這是合法的,尷尬 }

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>函式首先會初始化類中成員變數或者執行非靜態程式碼塊(這二者執行的先後順序依賴於在原始檔中出現的順序)然後再呼叫建構函式。