在Java中,一個對象在可以被使用之前必須要被正確地初始化,這一點是Java規範規定的。本文試圖對Java如何執行對象的初始化做一個詳細深入地介紹(與對象初始化相同,類在被加載之後也是需要初始化的,本文在最後也會對類的初始化進行介紹,相對於對象初始化來說,類的初始化要相對簡單一些)。
1.Java對象何時被初始化
Java對象在其被創建時初始化,在Java代碼中,有兩種行爲可以引起對象的創建。其中比較直觀的一種,也就是通常所說的顯式對象創建,就是通過new關鍵字來調用一個類的構造函數,通過構造函數來創建一個對象,這種方式在java規範中被稱爲“由執行類實例創建表達式而引起的對象創建”。
當然,除了顯式地創建對象,以下的幾種行爲也會引起對象的創建,但是並不是通過new關鍵字來完成的,因此被稱作隱式對象創建,他們分別是:
● 加載一個包含String字面量的類或者接口會引起一個新的String對象被創建,除非包含相同字面量的String對象已經存在與虛擬機內了(JVM會在內存中會爲所有碰到String字面量維護一份列表,程序中使用的相同字面量都會指向同一個String對象),比如,
class StringLiteral {
private String str = "literal";
private static String sstr = "s_literal";
}
● 自動裝箱機制可能會引起一個原子類型的包裝類對象被創建,比如,
class PrimitiveWrapper {
private Integer iWrapper = 1;
}
● String連接符也可能會引起新的String或者StringBuilder對象被創建,同時還可能引起原子類型的包裝對象被創建,比如(本人試了下,在mac ox下1.6.0_29版本的javac,對待下面的代碼會通過StringBuilder來完成字符串的連接,並沒有將i包裝成Integer,因爲StringBuilder的append方法有一個重載,其方法參數是int),
public class StringConcatenation {
private static int i = 1;
public static void main(String... args) {
System.out.println("literal" + i);
}
}
2.Java如何初始化對象
當一個對象被創建之後,虛擬機會爲其分配內存,主要用來存放對象的實例變量及其從超類繼承過來的實例變量(即使這些從超類繼承過來的實例變量有可能被隱藏也會被分配空間)。在爲這些實例變量分配內存的同時,這些實例變量也會被賦予默認值。
引用
關於實例變量隱藏
class Foo {
int i = 0;
}
class Bar extends Foo {
int i = 1;
public static void main(String... args) {
Foo foo = new Bar();
System.out.println(foo.i);
}
}
上面的代碼中,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的頂層對象,沒有超類)之外的所有對象構造函數的第一條語句必須是超類構造函數的調用語句或者是類中定義的其他的構造函數,如果我們即沒有調用其他的構造函數,也沒有顯式調用超類的構造函數,那麼編譯器會爲我們自動生成一個對超類構造函數的調用指令,比如,
public class ConstructorExample {
}
對於上面代碼中定義的類,如果觀察編譯之後的字節碼,我們會發現編譯器爲我們生成一個構造函數,如下,
aload_0
invokespecial #8;
//Method java/lang/Object."<init>":()V
return
上面代碼的第二行就是調用Object對象的默認構造函數的指令。
正因爲如此,如果我們顯式調用超類的構造函數,那麼調用指令必須放在構造函數所有代碼的最前面,是構造函數的第一條指令。這麼做纔可以保證一個對象在初始化之前其所有的超類都被初始化完成。
如果我們在一個構造函數中調用另外一個構造函數,如下所示,
public class ConstructorExample {
private int i;
ConstructorExample() {
this(1);
}
ConstructorExample(int i) {
....
this.i = i;
....
}
}
對於這種情況,Java只允許在ConstructorExample(int i)內出現調用超類的構造函數,也就是說,下面的代碼編譯是無法通過的,
public class ConstructorExample {
private int i;
ConstructorExample() {
super();
this(1);
....
}
ConstructorExample(int i) {
....
this.i = i;
....
}
}
或者,
public class ConstructorExample {
private int i;
ConstructorExample() {
this(1);
super();
....
}
ConstructorExample(int i) {
....
this.i = i;
....
}
}
Java對構造函數作出這種限制,目的是爲了要保證一個類中的實例變量在被使用之前已經被正確地初始化,不會導致程序執行過程中的錯誤。但是,與C或者C++不同,Java執行構造函數的過程與執行其他方法並沒有什麼區別,因此,如果我們不小心,有可能會導致在對象的構建過程中使用了沒有被正確初始化的實例變量,如下所示,
class Foo {
int i;
Foo() {
i = 1;
int x = getValue();
System.out.println(x);
}
protected int getValue() {
return i;
}
}
class Bar extends Foo {
int j;
Bar() {
j = 2;
}
@Override
protected int getValue() {
return j;
}
}
public class ConstructorExample {
public static void main(String... args) {
Bar bar = new Bar();
}
}
如果運行上面這段代碼,會發現打印出來的結果既不是1,也不是2,而是0。根本原因就是Bar重載了Foo中的getValue方法。在執行Bar的構造函數是,編譯器會爲我們在Bar構造函數開頭插入調用Foo的構造函數的代碼,而在Foo的構造函數中調用了getValue方法。由於Java對構造函數的執行沒有做特殊處理,因此這個getValue方法是被Bar重載的那個getValue方法,而在調用Bar的getValue方法時,Bar的構造函數還沒有被執行,這個時候j的值還是默認值0,因此我們就看到了打印出來的0。
2.2. 實例變量初始化器與實例初始化器
我們可以在定義實例變量的同時,對實例變量進行賦值,賦值語句就時實例變量初始化器了,比如,
public class InstanceVariableInitializer {
private int i = 1;
private int j = i + 1;
}
如果我們以這種方式爲實例變量賦值,那麼在構造函數執行之前會先完成這些初始化操作。
我們還可以通過實例初始化器來執行對象的初始化操作,比如,
public class InstanceInitializer {
private int i = 1;
private int j;
{
j = 2;
}
}
上面代碼中花括號內代碼,在Java中就被稱作實例初始化器,其中的代碼同樣會先於構造函數被執行。
如果我們定義了實例變量初始化器與實例初始化器,那麼編譯器會將其中的代碼放到類的構造函數中去,這些代碼會被放在對超類構造函數的調用語句之後(還記得嗎?Java要求構造函數的第一條語句必須是超類構造函數的調用語句),構造函數本身的代碼之前。我們來看下下面這段Java代碼被編譯之後的字節碼,Java代碼如下,
public class InstanceInitializer {
private int i = 1;
private int j;
{
j = 2;
}
public InstanceInitializer() {
i = 3;
j = 4;
}
}
編譯之後的字節碼如下,
aload_0
invokespecial #11;
//Method java/lang/Object."<init>":()V
aload_0
iconst_1
putfield #13;
//Field i:I
aload_0
iconst_2
putfield #15;
//Field j:I
aload_0
iconst_3
putfield #13;
//Field i:I
aload_0
iconst_4
putfield #15;
//Field j:I
return
上面的字節碼,第4,5行是執行的是源代碼中i=1的操作,第6,7行執行的源代碼中j=2的操作,第8-11行纔是構造函數中i=3和j=4的操作。
Java是按照編程順序來執行實例變量初始化器和實例初始化器中的代碼的,並且不允許順序靠前的實例初始化器或者實例變量初始化器使用在其後被定義和初始化的實例變量,比如,
public class InstanceInitializer {
{
j = i;
}
private int i = 1;
private int j;
}
public class InstanceInitializer {
private int j = i;
private int i = 1;
上面的這些代碼都是無法通過編譯的,編譯器會抱怨說我們使用了一個未經定義的變量。之所以要這麼做,是爲了保證一個變量在被使用之前已經被正確地初始化。但是我們仍然有辦法繞過這種檢查,比如,
public class InstanceInitializer {
private int j = getI();
private int i = 1;
public InstanceInitializer() {
i = 2;
}
private int getI() {
return i;
}
public static void main(String[] args) {
InstanceInitializer ii = new InstanceInitializer();
System.out.println(ii.j);
}
}
如果我們執行上面這段代碼,那麼會發現打印的結果是0。因此我們可以確信,變量j被賦予了i的默認值0,而不是經過實例變量初始化器和構造函數初始化之後的值。
引用
一個實例變量在對象初始化的過程中會被賦值幾次?
在本文的前面部分,我們提到過,JVM在爲一個對象分配完內存之後,會給每一個實例變量賦予默認值,這個時候實例變量被第一次賦值,這個賦值過程是沒有辦法避免的。
如果我們在實例變量初始化器中對某個實例x變量做了初始化操作,那麼這個時候,這個實例變量就被第二次賦值了。
如果我們在實例初始化器中,又對變量x做了初始化操作,那麼這個時候,這個實例變量就被第三次賦值了。
如果我們在類的構造函數中,也對變量x做了初始化操作,那麼這個時候,變量x就被第四次賦值。
也就是說,一個實例變量,在Java的對象初始化過程中,最多可以被初始化4次。
2.3. 總結
通過上面的介紹,我們對Java中初始化對象的幾種方式以及通過何種方式執行初始化代碼有了瞭解,同時也對何種情況下我們可能會使用到未經初始化的變量進行了介紹。在對這些問題有了詳細的瞭解之後,就可以在編碼中規避一些風險,保證一個對象在可見之前是完全被初始化的。
3.關於類的初始化
Java規範中關於類在何時被初始化有詳細的介紹,在3.0規範中的12.4.1節可以找到,這裏就不再多說了。簡單來說,就是當類被第一次使用的時候會被初始化,而且只會被一個線程初始化一次。我們可以通過靜態初始化器和靜態變量初始化器來完成對類變量的初始化工作,比如,
public class StaticInitializer {
static int i = 1;
static {
i = 2;
}
}
上面通過兩種方式對類變量i進行了賦值操作,分別通過靜態變量初始化器(代碼第2行)以及靜態初始化器(代碼第5-6行)完成。
靜態變量初始化器和靜態初始化器基本同實例變量初始化器和實例初始化器相同,也有相同的限制(按照編碼順序被執行,不能引用後定義和初始化的類變量)。靜態變量初始化器和靜態初始化器中的代碼會被編譯器放到一個名爲static的方法中(static是Java語言的關鍵字,因此不能被用作方法名,但是JVM卻沒有這個限制),在類被第一次使用時,這個static方法就會被執行。上面的Java代碼編譯之後的字節碼如下,我們看到其中的static方法,
static {};
Code:
Stack=1, Locals=0, Args_size=0
iconst_1
putstatic #10;
//Field i:I
iconst_2
putstatic #10;
//Field i:I
return
在第2節中,我們介紹了可以通過特殊的方式來使用未經初始化的實例變量,對於類變量也同樣適用,比如,
public class StaticInitializer {
static int j = getI();
static int i = 1;
static int getI () {
return i;
}
public static void main(String[] args) {
System.out.println(StaticInitializer.j);
}
}
上面這段代碼的打印結果是0,類變量的值是i的默認值0。但是,由於靜態方法是不能被覆寫的,因此第2節中關於構造函數調用被覆寫方法引起的問題不會在此出現。