JVM筆記6:JVM類載入機制
虛擬機器把描述類的資料從Class檔案載入到記憶體,並對資料進行校驗、轉換解析、初始化,最終形成可以被虛擬機器直接使用的Java型別,這就是虛擬機器的類載入機制
從類被載入到虛擬機器記憶體中開始,到卸載出記憶體為止,類的生命週期包括載入(Loading)、驗證(Verification)、準備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和解除安裝(Unloading)7個階段
其中驗證、準備和解析三部分稱為連線,在Java語言中,型別的載入和連線過程都是在程式執行期間完成的(Java可以動態擴充套件的語言特性就是依賴執行期動態載入、動態連線這個特點實現的),這樣會在類載入時稍微增加一些效能開銷,但是卻為Java應用程式提供高度的靈活性
載入、驗證、準備、初始化和解除安裝這5個階段的順序是固定的(即:載入階段必須在驗證階段開始之前開始,驗證階段必須在準備階段開始之前開始等。這些階段都是互相交叉地混合式進行的,通常會在一個階段的執行過程中呼叫或啟用另一個階段),解析階段則不一定,在某些情況下,解析階段有可能在初始化階段結束後開始,以支援Java的動態繫結
載入(Loading)
start time:
“載入”階段是“類載入過程”的一個階段,虛擬機器規範並沒有強制約束什麼時候開始類載入過程的第一個階段:載入,而是交由虛擬機器的具體實現自由把握,但是虛擬機器規範嚴格規定有且只有4種情況(這4種情況將在後面初始化部分進行介紹)必須立即對類進行初始化,相應的載入、驗證和準備階段自然要在初始化階段開始之前開始
task:
在載入階段,虛擬機器需要完成以下三件事(虛擬機器規範對這三件事的要求並不具體,因此虛擬機器實現與具體應用的靈活度相當大):
1,通過一個類的全限定名獲取定義這個類的二進位制流
可以從jar包、ear包、war包中獲取,可以從網路中獲取(Applet),可以執行時生成(動態代理),可以通過其它檔案生成(Jsp)等
虛擬機器規範並沒有指明二進位制流要從從一個Class檔案中獲取,準確的說是沒有指明從哪裡獲取及如何獲取(完全可以通過使用自己實現的類載入器讀入一二進位制流來完成載入階段,但是一般情況下二進位制流的來源還是Class檔案,jar中儲存的是Class檔案,Jsp也是首先被編譯成Class檔案)
2,將這個位元組流代表的靜態儲存結構轉化為方法區的執行時資料結構
虛擬機器規範並未規定方法區儲存資料的具體資料結構,資料儲存格式由虛擬機器實現自行定義
3,在Java堆中生成一個代表這個類的java.lang.Class物件,作為方法區這些資料的訪問入口
驗證(Verification)
start time:
載入階段與連線階段的部分內容(如一部分位元組碼檔案格式驗證動作)是交叉進行的,載入階段還未結束,連線階段可能已經開始,這部分夾雜在載入階段進行的動作,依然屬於連線階段的內容,並且載入階段必定早於連線階段開始
task:
連線階段的第一步,確保Class檔案的位元組流中包含的資訊符合當前虛擬機器的要求,並且不會危害虛擬機器自身的安全
Java語言本身是相對安全的語言(相對於C/C++),使用純粹的Java程式碼無法做到諸如訪問陣列邊界之外的資料、將一個物件轉型為它未實現的資料型別、跳轉到不存在的程式碼行等,如果這樣做了,編譯器將拒絕編譯,但是Class檔案不一定由Java原始碼編譯而來,完全可以使用任何途徑,如:用十六進位制編輯器直接編寫來產生Class檔案,在位元組碼層面上,上述Java程式碼無法做到的事情都是可以實現的,此時虛擬機器如果不檢查輸入的位元組流,很有可能因為載入了有害的位元組流而導致系統崩潰,所以驗證時虛擬機器對自身保護的一項重要工作
虛擬機器規範對該階段的規定非常籠統,僅要求如果驗證到輸入的位元組流不符合Class檔案的儲存格式,就丟擲一個java.lang.VerifyError異常(JDK1.6的API文件對該異常類的描述為:當“校驗器”檢測到一個類檔案雖然格式正確,但包含著一些內部不一致問題或安全性問題時,丟擲該錯誤)或其子類異常,具體檢查哪些方面、如何檢查、何時檢查,都未做強制要求或明確說明,故不同的虛擬機器對驗證的實現有所不同,但大致都會完成如下4個階段的檢查過程:
1,檔案格式驗證
驗證位元組流是否符合Class檔案格式的規範,是否能被當前版本的虛擬機器處理(如:是否以魔數0xCAFEBABE開頭、主次版本號是否在當前虛擬機器的處理範圍之內等)
該階段的主要目的是保證輸入的位元組流能被正確的解析並存儲於方法區內,格式上符合描述一個Java型別資訊的要求
該階段的驗證是基於位元組流的,經過這個階段的驗證後,位元組流才會進入記憶體的方法區中進行儲存,故後面的三個驗證階段是基於方法區的儲存結構進行的
2,元資料驗證
對位元組碼描述的資訊(即類的元資料資訊)進行語義分析,以保證其描述的資訊符合Java語言規範的要求(如:該類是否有父類、是否繼承了不允許被繼承的類等)
3,位元組碼驗證
進行資料流和控制流分析,即對類的方法體進行校驗分析以保證被校驗類的方法在執行時不會做出危害虛擬機器安全的行為(如:保證跳轉指令不會跳轉到方法體以外的位元組碼指令上等)
即使一個方法通過了位元組碼驗證,也不能說明其一定是安全的(通過程式去校驗程式邏輯是無法做到絕對準確的)
JDK1.6之後的Javac編譯器進行了一項優化,給方法體的Code屬性的屬性表中增加了一項新屬性:StackMapTable,該屬性儲存了方法體中的型別資訊,可以使位元組碼驗證時的型別推導變為型別檢查從而節省時間。在JDK1.6的HotSpot虛擬機器中提供了-XX:-UseSplitVerifier選項來關閉這項優化,或者使用-XX:+FailOverToOldVerifier選項在型別檢查失敗時退回到使用型別推導方式進行校驗
4,符號引用驗證
對類自身以外的資訊進行匹配性校驗(如:符號引用中通過字串描述的全限定名是否能找到對應的類、指定的類中是否存在符合描述符與簡單名稱描述的方法與欄位)
該校驗發生於虛擬機器將符號引用轉化為直接引用的時候,該轉化動作發生於連線的第三個階段:解析階段,確保解析動作能夠正常執行,如果無法通過符號引用驗證,將會丟擲一個java.lang.IncompatibleClassChangeError異常(特別要注意其繼承自java.lang.Error而不是java.lang.Exception)的子類,其子類均為通常由編譯器捕獲的錯誤
如果執行的全部程式碼都已經被反覆使用和驗證過,在實施階段可以使用-Xverify:none引數來關閉大部分的類驗證措施以縮短虛擬機器類載入的時間
準備(Preparation)
task:
虛擬機器在準備階段為類變數(static修飾的變數)分配記憶體,並設定類變數初始值。這些記憶體都將在方法區分配
有2個方面需要特別強調:
1,該階段進行記憶體分配的僅包括類變數,不包括例項變數,例項變數將在物件初始化時隨物件一起分配在堆記憶體中
2,這裡所說的初始值“通常情況下”是指資料型別的零值,如一個類變數定義為:public static int a =1;,變數a在準備階段之後的值為0而不是1
程式編譯後產生將a賦值為1的putstatic指令,並將該條指令存放在類構造器<clinit>()中,故a賦值為1的動作將在初始化階段完成
如果類欄位的欄位屬性表中包含ConstantValue屬性,那在準備階段變數就會被初始化為ConstantValue屬性所指定的值,即如果a變數定義變為public final static int a = 1;,編譯時javac會為a生成ConstantValue屬性,準備階段虛擬機器就會根據ConstantValue的設定將a的值置為1
基本資料型別零值如下:
資料型別 |
零值 |
byte |
(byte)0 |
char |
‘\u0000’ |
short |
(short)0 |
int |
0 |
long |
0L |
float |
0.0f |
double |
0.0d |
boolean |
false |
reference |
null |
解析(Resolution)
start time:
虛擬機器規範並未規定解析動作發生的具體時間,僅要求在執行anewarray、checkcast、getfield、getstatic、instanceof、invokeinterface、invokespecial、invokestatic、invokevirtual、multianewarray、new、putfield和putstatic這13個用於操作符號引用的位元組碼指令之前,先對它們所使用的符號引用進行解析
task:
解析階段是虛擬機器將常量池中的符號引用(見備註一)替換為直接引用(見備註二)的過程
對同一個符號引用進行多次解析請求是很常見的,虛擬機器實現可能會對第一次解析的結果進行快取(將直接引用儲存在執行時常量池中),無論是否真正執行了多次解析動作,虛擬機器實現必須保證在同一個實體中,如果一個符號引用之前已經被成功解析過,後續的引用解析請求就應當一直成功,反之亦然
解析動作主要針對類或介面、欄位、類方法、介面方法四類符號引用進行,分別對應於常量池中CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTAN_Methodref_info、CONSTANT_InterfaceMethodref_info四種類型常量
對欄位、類方法、介面方法符號引用解析前,首選會對其所在類的符號引用進行解析(詳見常量池常量結構,CONSTANT_Fieldref_info、CONSTAN_Methodref_info、CONSTANT_InterfaceMethodref_info型別常量中均儲存了宣告該欄位、類方法、介面方法的類或介面的描述符的索引:一個CONSTANT_Class_info型別常量的索引)
初始化(Initialization)
start time:
有且只有4種情況必須立即對類進行初始化:
1,遇到new(使用new關鍵字例項化物件)、getstatic(獲取一個類的靜態欄位,final修飾符修飾的靜態欄位除外)、putstatic(設定一個類的靜態欄位,final修飾符修飾的靜態欄位除外)和invokestatic(呼叫一個類的靜態方法)這4條位元組碼指令時,如果類還沒有初始化,則必須首先對其初始化
2,使用java.lang.reflect包中的方法對類進行反射呼叫時,如果類還沒有初始化,則必須首先對其初始化
3,當初始化一個類時,如果其父類還沒有初始化,則必須首先初始化其父類
4,當虛擬機器啟動時,需要指定一個主類(main方法所在的類),虛擬機器會首選初始化這個主類
這4種行為稱為對一個類進行主動引用,除此之外所有引用類的方式,都不會觸發初始化,稱為被動引用
package com.test;
public class SuperClass {
static{
System.out.println("super class init!");
}
public static int a = 1;
public final static int b = 1;
}
package com.test;
public class SubClass extends SuperClass{
static{
System.out.println("super class init!");
}
}
測試一:
//-XX:+TraceClassLoading
public class Test {
static{
System.out.println("test class init!");
}
public static void main(String[] args){
System.out.println(SubClass.a);
}
}
執行結果為:
......
[Loaded com.test.Test from file:/D:/eclipseProject/1/bin/]
test class init!
[Loaded com.test.SuperClass from file:/D:/eclipseProject/1/bin/]
[Loaded com.test.SubClass from file:/D:/eclipseProject/1/bin/]
super class init!
1
......
對於靜態欄位,只有直接定義這個欄位的類才會被初始化,故只有父類會被初始化,子類不會被初始化(子類的呼叫方式不符合4種直接引用中的任何一種);Test類為程式入口,故也會進行初始化
是否會觸發子類的載入和驗證,虛擬機器規範沒有明確規定,視虛擬機器具體實現而定,對Hotspot虛擬機器,可通過-XX:+TraceClassLoading引數看到此操作會導致子類的載入
測試二:
public class Test {
public static void main(String[] args){
SuperClass[] temp = new SuperClass[10];
}
}
輸出結果為空
測試三:
public class Constant {
static{
System.out.println("constant class init!");
}
public final static int a = 1;
}
public class Test {
public static void main(String[] args){
System.out.println(Constant.a);
}
}
執行結果:
1
在編譯階段,常量a的值1將會放入Test類的常量池中,對常量Constant.a的引用實際上被轉化為Test類對自身常量池的引用,這2個類在編譯為Class檔案後就沒有任何關係了
task:
類初始化階段是“類載入過程”中最後一步,在之前的階段,除了載入階段使用者應用程式可以通過自定義類載入器參與,其它階段完全由虛擬機器主導和控制,直到初始化階段,才真正開始執行類中定義的Java程式程式碼
在準備階段,變數已經賦過一次初始值,在初始化階段,則是根據程式設計師通過程式制定的主觀計劃去初始化類變數和其它資源,簡單說,初始化階段即虛擬機器執行類構造器<clinit>()方法的過程,下面詳細介紹下<clinit>方法:
<clinit>由編譯器自動收集類中所有類變數的賦值動作和靜態語句塊中的語句合併產生的,編譯器收集的順序由語句在原始檔中出現的順序決定,特別注意的是,靜態語句塊只能訪問到定義在它之前的類變數,定義在它之後的類變數只能賦值,不能訪問
public class Test {
public static int a = 1;
static{
a += 1;
System.out.println(a);
b = 2;
//Cannot reference a field before it is defined
//b += 1;
//System.out.println(b);
}
public static int b = 1;
public static void main(String[] args) {
Test test = new Test();
System.out.println(Test.b);
}
}
執行結果為(收集順序由其在原檔案中的順序決定,故這裡b的值為1而不是2):
2
1
與例項構造器<init>()方法不同,<clinit>方法不需要顯式的呼叫父類的<clinit>()方法,虛擬機器會自動保證在子類的<clinit>()方法執行之前,父類的<clinit>()方法已經執行結束,虛擬機器中第一個執行<clinit>()方法的類為java.lang.Object
public class SuperClass {
static{
System.out.println("super class init!");
}
}
public class SubClass extends SuperClass{
static{
System.out.println("sub class init!");
}
public static void main(String[] args) {
SubClass sub = new SubClass();
}
}
執行結果為:
super class init!
sub class init!
<clinit>()方法對於類或介面不是必須的,如果一個類中不包含靜態語句塊,也沒有對類變數的賦值操作,編譯器可以不為該類生成<clinit>()方法
介面中不可以使用靜態語句塊,但是可以有類變數的賦值操作,故編譯器也會對介面生成<clinit>()方法,執行介面的<clinit>()方法之前不要求首先執行父介面的<clinit>()方法,除非介面中使用了父介面中初始化的類變數,同理,介面實現類在初始化時也不一定要求首先執行介面的<clinit>()方法
虛擬機器會保證一個類的<clinit>()方法在多執行緒環境下被正確的加鎖和同步,如果多個執行緒同時初始化一個類,只會有一個執行緒執行這個類的<clinit>()方法,其它執行緒都會阻塞等待,直到活動執行緒執行<clinit>()方法完畢
public class A {
static{
System.out.println(Thread.currentThread().getName()
+" "+new Date());
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public class T implements Runnable {
public void run() {
A a = new A();
System.out.println(Thread.currentThread().getName()
+" "+new Date());
}
public static void main(String[] args){
T t1 = new T();
T t2 = new T();
Thread thread1 = new Thread(t1);
Thread thread2 = new Thread(t2);
thread1.start();
thread2.start();
}
}
執行結果(只有一個執行緒執行了A類中靜態語句塊中的程式碼):
Thread-0 Fri Dec 06 10:25:00 CST 2013
Thread-1 Fri Dec 06 10:25:05 CST 2013
Thread-0 Fri Dec 06 10:25:05 CST 2013
PS:
1,符號引用:以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時可以無歧義的定位到目標即可。符號引用與虛擬機器實現的記憶體佈局無關,引用目標並不一定已經載入到記憶體中
2,直接引用:直接指向目標的指標、相對偏移量或一個能間接定位到目標的控制代碼,直接引用與虛擬機器實現的記憶體佈局相關,如果有了直接引用,引用目標必定已經載入到記憶體中