標籤:sub 之間 情況 main方法 loader 死迴圈 oid art 動態語言
原文地址:http://blog.csdn.net/u013256816/article/details/50829596
看到這個題目,很多人會覺得我寫我的java代碼,至於類,JVM愛怎麼載入就怎麼載入,博主有很長一段時間也是這麼認為的。隨著編程經驗的日積月累,越來越感覺到瞭解虛擬機器相關要領的重要性。閑話不多說,老規矩,先來一段代碼吊吊胃口。
public class SSClass{ static { System.out.println("SSClass"); }} public class SuperClass extends SSClass{ static { System.out.println("SuperClass init!"); } public static int value = 123; public SuperClass() { System.out.println("init SuperClass"); }}public class SubClass extends SuperClass{ static { System.out.println("SubClass init"); } static int a; public SubClass() { System.out.println("init SubClass"); }}public class NotInitialization{ public static void main(String[] args) { System.out.println(SubClass.value); }}
運行結果
SSClassSuperClass init!123
答案答對了嚒?
也許有人會疑問:為什麼沒有輸出SubClass init。ok~解釋一下:對於靜態欄位,只有直接定義這個欄位的類才會被初始化,因此通過其子類來引用父類中定義的靜態欄位,只會觸發父類的初始化而不會觸發子類的初始化。
上面就牽涉到了虛擬機器類載入機制。如果有興趣,可以繼續看下去。
類載入過程
類從被載入到虛擬機器記憶體中開始,到卸載出記憶體為止,它的整個生命週期包括:載入(Loading)、驗證(Verification)、準備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸載(Unloading)7個階段。其中準備、驗證、解析3個部分統稱為串連(Linking)。。
載入、驗證、準備、初始化和卸載這5個階段的順序是確定的,類的載入過程必須按照這種順序按部就班地開始,而解析階段則不一定:它在某些情況下可以在初始化階段之後再開始,這是為了支援Java語言的運行時綁定(也稱為動態綁定或晚期綁定)。以下陳述的內容都已HotSpot為基準。
載入
在載入階段(可以參考java.lang.ClassLoader的loadClass()方法),虛擬機器需要完成以下3件事情:
- 通過一個類的全限定名來擷取定義此類的二進位位元組流(並沒有指明要從一個Class檔案中擷取,可以從其他渠道,譬如:網路、動態產生、資料庫等);
- 將這個位元組流所代表的靜態儲存結構轉化為方法區的運行時資料結構;
- 在記憶體中產生一個代表這個類的java.lang.Class對象,作為方法區這個類的各種資料的訪問入口;
載入階段和串連階段(Linking)的部分內容(如一部分位元組碼檔案格式驗證動作)是交叉進行的,載入階段尚未完成,串連階段可能已經開始,但這些夾在載入階段之中進行的動作,仍然屬於串連階段的內容,這兩個階段的開始時間仍然保持著固定的先後順序。
驗證
驗證是串連階段的第一步,這一階段的目的是為了確保Class檔案的位元組流中包含的資訊符合當前虛擬機器的要求,並且不會危害虛擬機器自身的安全。
驗證階段大致會完成4個階段的檢驗動作:
- 檔案格式驗證:驗證位元組流是否符合Class檔案格式的規範;例如:是否以魔術0xCAFEBABE開頭、主次版本號碼是否在當前虛擬機器的處理範圍之內、常量池中的常量是否有不被支援的類型。
- 中繼資料驗證:對位元組碼描述的資訊進行語義分析(注意:對比javac編譯階段的語義分析),以保證其描述的資訊符合Java語言規範的要求;例如:這個類是否有父類,除了java.lang.Object之外。
- 位元組碼驗證:通過資料流和控制流程分析,確定程式語義是合法的、符合邏輯的。
- 符號引用驗證:確保解析動作能正確執行。
- 二級制相容性的驗證:確保相互引用的類之間的協調一致。例如:在Worker類的gotowork()方法中會調用Car類的run()方法,java虛擬機器在驗證Worker類時,會檢查方法區內是否存在Car類的run()方法。假如不存
就回拋出NOSuchMethodError錯誤
驗證階段是非常重要的,但不是必須的,它對程式運行期沒有影響,如果所引用的類經過反覆驗證,那麼可以考慮採用-Xverifynone參數來關閉大部分的類驗證措施,以縮短虛擬機器類載入的時間。
準備
準備階段是正式為類變數分配記憶體並設定類變數初始值的階段,這些變數所使用的記憶體都將在方法區中進行分配。這時候進行記憶體配置的僅包括類變數(被static修飾的變數),而不包括執行個體變數,執行個體變數將會在對象執行個體化時隨著對象一起分配在堆中。其次,這裡所說的初始值“通常情況”下是資料類型的零值,假設一個類變數的定義為:
public static int value=123;
那變數value在準備階段過後的初始值為0而不是123.因為這時候尚未開始執行任何java方法,而把value賦值為123的putstatic指令是程式被編譯後,存放於類構造器()方法之中,所以把value賦值為123的動作將在初始化階段才會執行。
至於“特殊情況”是指:public static final int value=123,即當類欄位的欄位屬性是ConstantValue時,會在準備階段初始化為指定的值,所以標註為final之後,value的值在準備階段初始化為123而非0.
解析
解析階段是虛擬機器將常量池內的符號引用替換為直接引用的過程。解析動作主要針對類或介面、欄位、類方法、介面方法、方法類型、方法控制代碼和調用點限定符7類符號引用進行。
例如Worker類的gotoworker()方法中會引用Car類的run()方法
在Worker類的位元據中,包含了一個對Car類的run()方法的符號引用,它由run()方法的全名和相關描述組成。
在解析階段,java虛擬機器會把這個符號引用替換成一個指標,該指標指向Car類的run()方法在方法去的記憶體位置,這個指標就是直接引用。
public void gotoWork() { car.run();// 這段代碼在Worker類的位元據中表示為符號引用 }
初始化
類初始化階段是類載入過程的最後一步,到了初始化階段,才真正開始執行類中定義的java程式碼。在準備階段,變數已經賦過一次系統要求的初始值,而在初始化階段,則根據程式猿通過程式制定的主觀計划去初始化類變數和其他資源,或者說:初始化階段是執行類構造器<clinit>()方法的過程.
<clinit>()方法是由編譯器自動收集類中的所有類變數的賦值動作和靜態語句塊static{}中的語句合并產生的,編譯器收集的順序是由語句在源檔案中出現的順序所決定的,靜態語句塊只能訪問到定義在靜態語句塊之前的變數,定義在它之後的變數,在前面的靜態語句塊可以賦值,但是不能訪問。如下:
public class Test{ static { i=0; System.out.println(i);//這句編譯器會報錯:Cannot reference a field before it is defined(非法向前應用) } static int i=1;}
那麼去掉報錯的那句,改成下面:
public class Test{ static { i=0;// System.out.println(i); } static int i=1; public static void main(String args[]) { System.out.println(i); }}
輸出結果是什麼呢?當然是1啦~在準備階段我們知道i=0,然後類初始化階段按照順序執行,首先執行static塊中的i=0,接著執行static賦值操作i=1,最後在main方法中擷取i的值為1。
<clinit>()方法與執行個體構造器<init>()方法不同,它不需要顯示地調用父類構造器,虛擬機器會保證在子類<cinit>()方法執行之前,父類的<clinit>()方法已經執行完畢,回到本文開篇的舉例代碼中,結果會列印輸出:SSClass就是這個道理。
由於父類的<clinit>()方法先執行,也就意味著父類中定義的靜態語句塊要優先於子類的變數賦值操作。
<clinit>()方法對於類或者介面來說並不是必需的,如果一個類中沒有靜態語句塊,也沒有對變數的賦值操作,那麼編譯器可以不為這個類生產<clinit>()方法。
介面中不能使用靜態語句塊,但仍然有變數初始化的賦值操作,因此介面與類一樣都會產生<clinit>()方法。但介面與類不同的是,執行介面的<clinit>()方法不需要先執行父介面的<clinit>()方法。只有當父介面中定義的變數使用時,父介面才會初始化。另外,介面的實作類別在初始化時也一樣不會執行介面的<clinit>()方法。
虛擬機器會保證一個類的<clinit>()方法在多線程環境中被正確的加鎖、同步,如果多個線程同時去初始化一個類,那麼只會有一個線程去執行這個類的<clinit>()方法,其他線程都需要阻塞等待,直到活動線程執行<clinit>()方法完畢。如果在一個類的<clinit>()方法中有耗時很長的操作,就可能造成多個線程阻塞,在實際應用中這種阻塞往往是隱藏的。
package jvm.classload;public class DealLoopTest{ static class DeadLoopClass { static { if(true) { System.out.println(Thread.currentThread()+"init DeadLoopClass"); while(true) { } } } } public static void main(String[] args) { Runnable script = new Runnable(){ public void run() { System.out.println(Thread.currentThread()+" start"); DeadLoopClass dlc = new DeadLoopClass(); System.out.println(Thread.currentThread()+" run over"); } }; Thread thread1 = new Thread(script); Thread thread2 = new Thread(script); thread1.start(); thread2.start(); }}
運行結果:(即一條線程在死迴圈以類比長時間操作,另一條線程在阻塞等待)
Thread[Thread-0,5,main] startThread[Thread-1,5,main] startThread[Thread-0,5,main]init DeadLoopClass
需要注意的是,其他線程雖然會被阻塞,但如果執行<clinit>()方法的那條線程退出<clinit>()方法後,其他線程喚醒之後不會再次進入<clinit>()方法。同一個類載入器下,一個類型只會初始化一次。
將上面代碼中的靜態塊替換如下:
static { System.out.println(Thread.currentThread() + "init DeadLoopClass"); try { TimeUnit.SECONDS.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); } }
運行結果:
Thread[Thread-0,5,main] startThread[Thread-1,5,main] startThread[Thread-1,5,main]init DeadLoopClass (之後sleep 10s)Thread[Thread-1,5,main] run overThread[Thread-0,5,main] run over
虛擬機器規範嚴格規定了有且只有5中情況(jdk1.7)必須對類進行“初始化”(而載入、驗證、準備自然需要在此之前開始):
- 遇到new,getstatic,putstatic,invokestatic這失調位元組碼指令時,如果類沒有進行過初始化,則需要先觸發其初始化。產生這4條指令的最常見的Java代碼情境是:使用new關鍵字執行個體化對象的時候、讀取或設定一個類的靜態欄位(被final修飾、已在編譯器把結果放入常量池的靜態欄位除外)的時候,以及調用一個類的靜態方法的時候。
- 使用java.lang.reflect包的方法對類進行反射調用的時候,如果類沒有進行過初始化,則需要先觸發其初始化。
- 當初始化一個類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化。
- 當虛擬機器啟動時,使用者需要指定一個要執行的主類(包含main()方法的那個類),虛擬機器會先初始化這個主類。
- 當使用jdk1.7動態語言支援時,如果一個java.lang.invoke.MethodHandle執行個體最後的解析結果REF_getstatic,REF_putstatic,REF_invokeStatic的方法控制代碼,並且這個方法控制代碼所對應的類沒有進行初始化,則需要先出觸發其初始化。
開篇已經舉了一個範例:通過子類引用付了的靜態欄位,不會導致子類初始化。
這裡再舉兩個例子。
1. 通過數組定義來引用類,不會觸發此類的初始化:(SuperClass類已在本文開篇定義)
public class NotInitialization{ public static void main(String[] args) { SuperClass[] sca = new SuperClass[10]; }}
運行結果:(無)
2. 常量在編譯階段會存入調用類的常量池中,本質上並沒有直接引用到定義常量的類,因此不會觸發定義常量的類的初始化:
public class ConstClass{ static { System.out.println("ConstClass init!"); } public static final String HELLOWORLD = "hello world";}public class NotInitialization{ public static void main(String[] args) { System.out.println(ConstClass.HELLOWORLD); }}
運行結果:hello world
附:昨天從論壇上看到一個例子,很有意思,如下:
package jvm.classload;public class StaticTest{ public static void main(String[] args) { staticFunction(); } static StaticTest st = new StaticTest(); static { System.out.println("1"); } { System.out.println("2"); } StaticTest() { System.out.println("3"); System.out.println("a="+a+",b="+b); } public static void staticFunction(){ System.out.println("4"); } int a=110; static int b =112;}
問題是:請問輸出是什嗎?
程式跑一下就知道結果,如果想知道為什麼,請在下方留言~~
plus:這題的解釋已經整理好了,歡迎點擊Java虛擬機器類載入機制——案例分析
參考文獻:《深入理解java虛擬機器》周志明 著.
Java虛擬機器類載入機制