Java物件初始化詳解

發表於2013-04-10

來源:MySun

在Java中,一個物件在可以被使用之前必須要被正確地初始化,這一點是Java規範規定的。本文試圖對Java如何執行物件的初始化做一個詳細深入地介紹(與物件初始化相同,類在被載入之後也是需要初始化的,本文在最後也會對類的初始化進行介紹,相對於物件初始化來說,類的初始化要相對簡單一些)。

1.Java物件何時被初始化

Java物件在其被建立時初始化,在Java程式碼中,有兩種行為可以引起物件的建立。其中比較直觀的一種,也就是通常所說的顯式物件建立,就是通過new關鍵字來呼叫一個類的建構函式,通過建構函式來建立一個物件,這種方式在java規範中被稱為“由執行類例項建立表示式而引起的物件建立”。
當然,除了顯式地建立物件,以下的幾種行為也會引起物件的建立,但是並不是通過new關鍵字來完成的,因此被稱作隱式物件建立,他們分別是:

● 載入一個包含String字面量的類或者介面會引起一個新的String物件被建立,除非包含相同字面量的String物件已經存在與虛擬機器內了(JVM會在記憶體中會為所有碰到String字面量維護一份列表,程式中使用的相同字面量都會指向同一個String物件),比如,

● 自動裝箱機制可能會引起一個原子型別的包裝類物件被建立,比如,

● String連線符也可能會引起新的String或者StringBuilder物件被建立,同時還可能引起原子型別的包裝物件被建立,比如(本人試了下,在mac ox下1.6.0_29版本的javac,對待下面的程式碼會通過StringBuilder來完成字串的連線,並沒有將i包裝成Integer,因為StringBuilder的append方法有一個過載,其方法引數是int),

2.Java如何初始化物件

當一個物件被建立之後,虛擬機器會為其分配記憶體,主要用來存放物件的例項變數及其從超類繼承過來的例項變數(即使這些從超類繼承過來的例項變數有可能被隱藏也會被分配空間)。在為這些例項變數分配記憶體的同時,這些例項變數也會被賦予預設值。

引用

關於例項變數隱藏

上面的程式碼中,Foo和Bar中都定義了變數i,在main方法中,我們用Foo引用一個Bar物件,如果例項變數與方法一樣,允許被覆蓋,那麼列印的結果應該是1,但是實際的結果確是0。
但是如果我們在Bar的方法中直接使用i,那麼用的會是Bar物件自己定義的例項變數i,這就是隱藏,Bar物件中的i把Foo物件中的i給隱藏了,這條規則對於靜態變數同樣適用。

在記憶體分配完成之後,java的虛擬機器就會開始對新建立的物件執行初始化操作,因為java規範要求在一個物件的引用可見之前需要對其進行初始化。在Java中,三種執行物件初始化的結構,分別是例項初始化器、例項變數初始化器以及建構函式。

2.1. Java的建構函式

每一個Java中的物件都至少會有一個建構函式,如果我們沒有顯式定義建構函式,那麼Java編譯器會為我們自動生成一個建構函式。建構函式與類中定義的其他方法基本一樣,除了建構函式沒有返回值,名字與類名一樣之外。在生成的位元組碼中,這些建構函式會被命名成<init>方法,引數列表與Java語言書寫的建構函式的引數列表相同(<init>這樣的方法名在Java語言中是非法的,但是對於JVM來說,是合法的)。另外,建構函式也可以被過載。

Java要求一個物件被初始化之前,其超類也必須被初始化,這一點是在建構函式中保證的。Java強制要求Object物件(Object是Java的頂層物件,沒有超類)之外的所有物件建構函式的第一條語句必須是超類建構函式的呼叫語句或者是類中定義的其他的建構函式,如果我們即沒有呼叫其他的建構函式,也沒有顯式呼叫超類的建構函式,那麼編譯器會為我們自動生成一個對超類建構函式的呼叫指令,比如,

對於上面程式碼中定義的類,如果觀察編譯之後的位元組碼,我們會發現編譯器為我們生成一個建構函式,如下,

上面程式碼的第二行就是呼叫Object物件的預設建構函式的指令。

正因為如此,如果我們顯式呼叫超類的建構函式,那麼呼叫指令必須放在建構函式所有程式碼的最前面,是建構函式的第一條指令。這麼做才可以保證一個物件在初始化之前其所有的超類都被初始化完成。

如果我們在一個建構函式中呼叫另外一個建構函式,如下所示,

對於這種情況,Java只允許在ConstructorExample(int i)內出現呼叫超類的建構函式,也就是說,下面的程式碼編譯是無法通過的,

或者,

Java對建構函式作出這種限制,目的是為了要保證一個類中的例項變數在被使用之前已經被正確地初始化,不會導致程式執行過程中的錯誤。但是,與C或者C++不同,Java執行建構函式的過程與執行其他方法並沒有什麼區別,因此,如果我們不小心,有可能會導致在物件的構建過程中使用了沒有被正確初始化的例項變數,如下所示,

如果執行上面這段程式碼,會發現列印出來的結果既不是1,也不是2,而是0。根本原因就是Bar過載了Foo中的getValue方法。在執行Bar的建構函式是,編譯器會為我們在Bar建構函式開頭插入呼叫Foo的建構函式的程式碼,而在Foo的建構函式中呼叫了getValue方法。由於Java對建構函式的執行沒有做特殊處理,因此這個getValue方法是被Bar過載的那個getValue方法,而在呼叫Bar的getValue方法時,Bar的建構函式還沒有被執行,這個時候j的值還是預設值0,因此我們就看到了列印出來的0。

2.2. 例項變數初始化器與例項初始化器

我們可以在定義例項變數的同時,對例項變數進行賦值,賦值語句就時例項變數初始化器了,比如,

如果我們以這種方式為例項變數賦值,那麼在建構函式執行之前會先完成這些初始化操作。

我們還可以通過例項初始化器來執行物件的初始化操作,比如,

上面程式碼中花括號內程式碼,在Java中就被稱作例項初始化器,其中的程式碼同樣會先於建構函式被執行。

如果我們定義了例項變數初始化器與例項初始化器,那麼編譯器會將其中的程式碼放到類的建構函式中去,這些程式碼會被放在對超類建構函式的呼叫語句之後(還記得嗎?Java要求建構函式的第一條語句必須是超類建構函式的呼叫語句),建構函式本身的程式碼之前。我們來看下下面這段Java程式碼被編譯之後的位元組碼,Java程式碼如下,

編譯之後的位元組碼如下,

上面的位元組碼,第4,5行是執行的是原始碼中i=1的操作,第6,7行執行的原始碼中j=2的操作,第8-11行才是建構函式中i=3和j=4的操作。

Java是按照程式設計順序來執行例項變數初始化器和例項初始化器中的程式碼的,並且不允許順序靠前的例項初始化器或者例項變數初始化器使用在其後被定義和初始化的例項變數,比如,

上面的這些程式碼都是無法通過編譯的,編譯器會抱怨說我們使用了一個未經定義的變數。之所以要這麼做,是為了保證一個變數在被使用之前已經被正確地初始化。但是我們仍然有辦法繞過這種檢查,比如,

如果我們執行上面這段程式碼,那麼會發現列印的結果是0。因此我們可以確信,變數j被賦予了i的預設值0,而不是經過例項變數初始化器和建構函式初始化之後的值。

引用

一個例項變數在物件初始化的過程中會被賦值幾次?

在本文的前面部分,我們提到過,JVM在為一個物件分配完記憶體之後,會給每一個例項變數賦予預設值,這個時候例項變數被第一次賦值,這個賦值過程是沒有辦法避免的。

如果我們在例項變數初始化器中對某個例項x變數做了初始化操作,那麼這個時候,這個例項變數就被第二次賦值了。

如果我們在例項初始化器中,又對變數x做了初始化操作,那麼這個時候,這個例項變數就被第三次賦值了。

如果我們在類的建構函式中,也對變數x做了初始化操作,那麼這個時候,變數x就被第四次賦值。

也就是說,一個例項變數,在Java的物件初始化過程中,最多可以被初始化4次。

2.3. 總結

通過上面的介紹,我們對Java中初始化物件的幾種方式以及通過何種方式執行初始化程式碼有了瞭解,同時也對何種情況下我們可能會使用到未經初始化的變數進行了介紹。在對這些問題有了詳細的瞭解之後,就可以在編碼中規避一些風險,保證一個物件在可見之前是完全被初始化的。

3.關於類的初始化

Java規範中關於類在何時被初始化有詳細的介紹,在3.0規範中的12.4.1節可以找到,這裡就不再多說了。簡單來說,就是當類被第一次使用的時候會被初始化,而且只會被一個執行緒初始化一次。我們可以通過靜態初始化器和靜態變數初始化器來完成對類變數的初始化工作,比如,

上面通過兩種方式對類變數i進行了賦值操作,分別通過靜態變數初始化器(程式碼第2行)以及靜態初始化器(程式碼第5-6行)完成。

靜態變數初始化器和靜態初始化器基本同例項變數初始化器和例項初始化器相同,也有相同的限制(按照編碼順序被執行,不能引用後定義和初始化的類變數)。靜態變數初始化器和靜態初始化器中的程式碼會被編譯器放到一個名為static的方法中(static是Java語言的關鍵字,因此不能被用作方法名,但是JVM卻沒有這個限制),在類被第一次使用時,這個static方法就會被執行。上面的Java程式碼編譯之後的位元組碼如下,我們看到其中的static方法,

在第2節中,我們介紹了可以通過特殊的方式來使用未經初始化的例項變數,對於類變數也同樣適用,比如,

上面這段程式碼的列印結果是0,類變數的值是i的預設值0。但是,由於靜態方法是不能被覆寫的,因此第2節中關於建構函式呼叫被覆寫方法引起的問題不會在此出現。

 

相關文章