Java虛擬機器的類載入機制
一、引言
關於類的載入機制,我們先從面試題開始:
public class ClassLoaderProcess {
public static void main(String[] args) {
System.out.println(Singleton.count_1);
System.out.println(Singleton.count_2);
}
}
class Singleton {
private static Singleton singleton = new Singleton();
public static int count_1;
public static int count_2 = 0;
static {
count_1++;
count_2++;
}
private Singleton() {
count_1++;
count_2++;
}
public static Singleton getInstance() {
return singleton;
}
}
在上面的程式碼中,Singleton是個單例模式的類,該類中有兩個靜態變數,在靜態程式碼塊中對兩個靜態變數做自增運算,在私有構造方法中,再對兩個靜態變數做自增運算,最後打印出來的結果是多少呢?可以先思考一下。
如果你的答案是count_1=2,count_2=2,那很遺憾,你回答錯了。我們帶著這個問題,去分析虛擬機器是如何載入一個類的。
二、類載入機制
1、首先來看一下類的生命週期:
上圖表示了一個類的生命週期。類從被載入到虛擬機器的記憶體中開始,到卸載出記憶體為止,它的整個生命週期包括:載入、連線(驗證、準備、解析)、初始化、使用和解除安裝7個階段。
其中,載入、驗證、準備、初始化和解除安裝5個階段的順序是確定的,類的載入過程必須按照這種順序按部就班的開始。而解析階段則不一定,它在某些情況下可以在初始化階段之後再開始,這是為了支援Java語言的執行時繫結。下面來逐個分析各個過程。
(1)、 載入
我們知道,程式是要載入到記憶體中才可以執行,什麼情況下需要走載入這一步呢?Java虛擬機器規範中並沒有進行強制約定,這點可以交給虛擬機器的具體實現來自由把握。
在載入階段,Java虛擬機器需要完成以下3件事情。
1. 通過一個類的全限定名來獲取定義此類的二進位制位元組流
2. 將這個位元組流所代表的靜態儲存結構轉換為方法區的執行時資料結構
3. 在記憶體中(堆中)生成一個代表這個類的Class物件,用來封裝類在方法區裡的資料結構,作為方法區中這個類的各種資料訪問入口。
從這三個步驟中可以很明顯的看出,我們可以通過這個Class來獲取類的各種資料,它就像一面鏡子,可以反射出類的資訊來,所以也就明白了在用反射的時候為什麼要使用Class了。
(2)、 驗證
驗證是連線階段的第一步,這個階段的目的是為了確保Class檔案的位元組流中包含的資訊符合當前虛擬機器的要求,並且不會危害虛擬機器自身的安全。
一般我們都是通過Java檔案編譯生成的class檔案,這是沒什麼問題的,但是class檔案並不是一定要求使用Java原始碼編譯而來,也可以使用很多其他途徑,比如用十六進位制編譯器直接編寫來產生class檔案。虛擬機器如果不檢查輸入的位元組流,對其完全信任的話,可能就會因為載入了有害的位元組流而導致系統崩潰,所以驗證,是虛擬機器的一項重要的工作。
(3)、 準備
接下來就是連線的第二步:準備。準備階段是正式為類變數分配記憶體並設定類變數初始值的階段。這裡有兩個概念要搞清楚:
1. 類變數:即被static修飾的靜態變數。
2. 初始值:指的是該資料型別對應的“零”值。
所以也就是說,準備階段是為靜態變數分配記憶體,並且對其初始化為零值。但不包括靜態程式碼塊和例項變數。靜態程式碼塊在後面的初始化階段執行,例項變數將會在物件例項化的時候隨著物件一起分到Java堆中的。比如我舉個例子:
public static int value = 123;
在準備階段,value的值為0,並非123!!!!當然,如果是boolean型別,則為false。零值是針對具體的型別來說的。
(4)、 解析
解析階段是虛擬機器將常量池內的符號引用替換為直接引用的過程,這個符號引用和直接引用有啥關聯呢?
1. 符號引用:以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義的定位到目標即可。
符號引用與虛擬機器實現的記憶體佈局是無關的,引用的目標不一定載入到記憶體中。
2. 直接引用:指的是直接指向目標的指標、相對偏移量或者是一個能間接定位到目標的控制代碼。
直接引用是和虛擬機器實現的記憶體佈局相關的,引用的目標必定已經在記憶體中存在。
(5)、 初始化
初始化是類載入過程的最後一步,在前面的過程中,除了第一步載入階段使用者可以通過指定自定義類載入器參與外,其餘過程完全是由虛擬機器自己主導控制的。到了初始化階段,才真正開始執行類中定義的java程式程式碼了(或者說是位元組碼)。
由上面分析可知,在準備階段,靜態變數已經賦過一次值了,只不過是系統要求的初始值而已,而在初始化階段,為類的靜態變數賦予程式中指定的初始值,還有執行靜態程式碼塊中的程式。
關於類的初始化這個階段,可以再分析的深入一點,剛剛說初始化的階段是為類的靜態變數賦實際值的階段,我們也可以從另外的一個角度去表達:初始化階段是執行類構造器方法的過程(注意:不是我們平時說的類的構造方法)。構造器方法是<cinit>()方法,它是由編譯器自動收集類中所有的靜態變數的賦值動作和靜態程式碼塊中的語句合併產生的,所以也就清楚了,為啥初始化階段也可以叫做類構造器方法執行的過程。
這裡需要注意的是,編譯器收集的順序是由語句在程式中出現的順序所決定的,靜態程式碼塊中只能訪問到定義在靜態程式碼塊之前的變數,定義在它之後的變數,在前面的靜態程式碼塊中可以賦值,但是不能訪問。可以舉個例子:
public class Test {
static {
i = 0; //給變數賦值可以正常通過編譯
System.out.print(i); //但是不能訪問,這句編譯會提示非法向前引用
}
static int i = 1;
<cinit>()方法與類的建構函式不同,它不需要顯示的呼叫父類的構造器,虛擬機器會保證在子類的<cinit>()方法執行前,父類的<cinit>()方法已經執行完畢,所以在虛擬機器中第一個被執行的<cinit>()方法的類肯定是java.lang.Object。
由於父類的<cinit>()方法先執行,也就意味著父類中定義的靜態程式碼塊要優先於子類的靜態變數賦值操作,看一個例子:
public class CinitMethod {
static class Parent{
public static int A = 1;
static {
A = 2;
}
}
static class Sub extends Parent {
public static int B = A;
}
public static void main(String[] args) {
System.out.println(Sub.B);
}
}
這段程式中,在準備階段,先將A賦為0,B賦為0,在初始化階段,先執行父類的<cinit>()方法,所以會執行A=1;然後A=2,然後執行子類的<cinit>()方法,執行B=A,所以打印出來是2。
虛擬機器會保證一個類的<cinit>()方法在多執行緒環境中被正確的加鎖、同步,如果多個執行緒同時去初始化一個類,那麼只會有一個執行緒去執行這個類的<cinit>()方法,其它執行緒都需要阻塞等待,直到活動執行緒執行完該方法。
到這裡,一個類的載入過程就算完畢了,類載入的最終產品是位於堆中的Class物件,封裝了類在方法區內的資料結構,並向java程式設計師提供了訪問方法區內資料結構的介面。所以程式設計師就可以使用可以使用這個類去獲取與該類相關的資訊了。
要注意的是,這是類載入完畢了,跟類的物件是沒有關係的,到目前只能使用類的靜態變數和靜態方法,類的物件需要我們去產生的,有了物件才能操作其中的普通成員變數和方法。
現在再去看文章開頭的那段java程式碼應該很簡單了:
1. 在準備階段,Java虛擬機器將Singleton賦為空,count_1和count_2賦為0(count_2賦為0不是程式中的賦的0,而是int的預設值)。
2. 在初始化階段,Java虛擬機器按照順序執行static程式碼:首先例項化Singleton,執行構造方法中的程式碼,count_1和count_2變成1;
然後按順序執行static程式碼,count_1沒有賦值,還是1,count_2被賦值為0;最後執行靜態程式碼塊中的程式碼,
count_1和count_2各自增1,所以count_1=2,count_2=1.然後按順序執行static程式碼,count_1沒有賦值,還是1,
count_2被賦值為0;最後執行靜態程式碼塊中的程式碼,count_1和count_2各自增1,所以count_1=2,count_2=1.