類變量的初始化
類變量是之類中的static變量,在Java程序運行時它存儲於方法區中,可以被認爲是類信息(java.lang.Class對象)的一部分。
《Java編程思想》中描述了static變量的初始化時機:創建類的第一個對象時,或者是訪問static域或者static方法時。
上述語句是有依據的,在Java虛擬機規範中嚴格規定了有且只有5種情況必須立即對類進行初始化:
1)遇到new、getstatic、putstatic或invokestatic這4條字節碼指令時,如果類沒有進行過初始化,則需要先觸發其初始化。生成這4條指令的最常見的Java代碼場景是:使用new關鍵字實例化對象的時候、讀取或設置一個類的靜態字段(被final修飾、已在編譯期把結果放入常量池的靜態字段除外)的時候,以及調用一個類的靜態方法的時候。
2)使用java.lang.reflect包的方法對類進行反射調用的時候,如果類沒有進行過初始化,則需要先觸發其初始化。
3)當初始化一個類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化。
4)當虛擬機啓動時,用戶需要指定一個要執行的主類(包含main()方法的那個類),虛擬機會先初始化這個主類。
5)當使用JDK 1.7的動態語言支持時,如果一個java.lang.invoke.MethodHandle實例最後的解析結果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,並且這個方法句柄所對應的類沒有進行過初始化,則需要先觸發其初始化。
這裏1)、3)、4)條與本文直接相關,其中第一條解釋《Java編程思想》中的那句話,new時創建對象,getstatic、putstatic是讀取有關的static變量,invokestatic是調用靜態方法。而第三條其實可以歸納到第一條,因爲main方法實際上是主類的靜態方法(static void main函數)。而第三條表示初始化時有向上遞歸初始化父類的過程。
這裏要提醒一點的是,許多人會把加載過程(Loading)和類加載(Class Loading)混在一起,這裏要區分開來。類加載(Class Loading)包括加載(Loading)、驗證(Verification)、準備(Preparation)、解析(Resolution)、初始化(Initializa-tion)、使用(Using)和卸載(Unloading)7個階段。其中加載(Loading)階段,虛擬機規範中規定了要完成3件事:
1)通過一個類的全限定名來獲取定義此類的二進制字節流。
2)將這個字節流所代表的靜態存儲結構轉化爲方法區的運行時數據結構。
3)在內存中生成一個代表這個類的java.lang.Class對象,作爲方法區這個類的各種數據的訪問入口。
而此時類還沒有進行初始化,一般虛擬機只有在碰到上面說的的5種情況才進行初始化。但此時類變量的值也不是莫名其妙的值,因爲加載時會將類變量賦值爲默認的0值(基本數據類型爲0,引用類型爲null)。
事實上javac編譯器會把靜態變量的初始化(包括靜態塊中的初始化和直接在定義時賦值初始化)收集到一個名爲<clinit>方法中,該方法不能被用戶調用,但它會在類需要被初始化時被虛擬機自動調用。
此外所有的類只能被初始化一次!
講了這麼多,我們來看一個例子:
class Animal {
public static String type="animal";
static {
System.out.println("animal static init!");
}
}
class Cat extends Animal {
public static String catType="cat";
static {
System.out.println("cat static init!");
}
}
public class Main {
public static void main(String[] args) {
System.out.println(Cat.type);
}
}
輸出結果:
animal static init! animal
上述代碼運行之後,只會輸出“animal static init!”,而不會輸出"cat static init!"。對於靜態字段,只有直接定義這個字段的類纔會被初始化,因此通過其子類來引用父類中定義的靜態字段,只會觸發父類的初始化而不會觸發子類的初始化。
簡單描述下虛擬機中上述運行過程,要輸出Cat.type,則需要調用getstatic方法,因此需要對其類進行初始化,那麼初始化的到底時Animal類還時Cat類呢?反編譯Main(javap -verbose Main.class)的Class文件後我們發現get static後面跟時常量池中的一個Fieldref常量,它指向的是Cat類,但是在運行getstatic指令時,會去Cat的類對象(Java.lang.class類對象)中找名爲type的變量,找到後發現type指向的類是Animal類(直接定義這個字段的類),因此最後我們初始化的類對象是Animal類。
我們繼續改變Main函數,代碼如下(Animal和Cat代碼不變):
public class Main {
public static void main(String[] args) {
System.out.println(Cat.catType);
}
}
輸出結果:
animal static init! cat static init! cat
可以看到這次animal類和cat類均被初始化。這正是應了虛擬機規範中的第三條:3)當初始化一個類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化。
在虛擬機實現中,當調用子類的<clinit>方法時我們先回調用其父類的<clinit>方法,這樣一步步遞歸向上,直至最上層的類被初始化。
然後我們繼續改變Cat類,其餘類代碼不變,將catType改爲static final,代碼如下:
class Cat extends Animal {
public static final String catType="cat";
static {
System.out.println("cat static init!");
}
}
public class Main {
public static void main(String[] args) {
System.out.println(Cat.catType);
}
}
輸出結果:
cat
這個時候只輸出了cat,爲什麼呢?我們反編譯Main.class文件
可以看到Main函數的Cat.catType沒有用getstatic指令,而時用了ldc(從常量池中去數據指令)從常量池中取出了cat字符串。這是爲什麼呢?我們看規範中的第一條有這麼一句話:讀取或設置一個類的靜態字段(被final修飾、已在編譯期把結果放入常量池的靜態字段除外)的時候,以及調用一個類的靜態方法的時候。這是一個常量傳播優化問題,雖然Java源碼中引用了Cat類中的常量catType,但在javac編譯階段通過常量傳播優化,已經將此常量的值“ca't”存儲到了Main類的常量池中,以後Main對常量Cat.catType的引用實際都被轉化爲Main類對自身常量池的引用了。
實例變量的初始化
實例變量的初始化時調用構造器(即使你的源碼中沒有爲你的類寫構造器,也沒有定義時賦值初始化,編譯器也會自動添加一個空構造器),若是在定義時賦值初始化,比如public int a=1;編譯器會將該語句自動放入構造器中。
Java中用這樣的語句進行初始化:A a=new A(); 這樣的語句其實具有迷惑性,因爲Java編譯器會將該語句編譯成四條字節碼指令,分成三步來完成(插一句,這也是爲什麼單例模式的雙校驗鎖要加volatie的原因),我們看一下反編譯以後的代碼:
先解釋一下,第一步時new指令,會去虛擬機堆中申請一塊A對象大小的內存,然後將對象地址放到虛擬機棧上,dup指令在虛擬機棧上重新複製一塊對象地址,運行invokespecial指令調用A的構造函數,最後astore把對象地址給局部變量a。
我們拋開dup指令(該指令是因爲調用invokespecial指令會彈出對象地址,如果不復制一份,astore指令無法將對象地址給局部變量a),可以將其歸納成三步:1.申請堆空間2.調用構造函數3對象地址給引用。
這裏我們要注意有兩次初始化,第一次是在new指令中,虛擬機申請堆空間後,會將實例變量刷成默認的0值(基本數據類型爲0,引用類型爲null)。第二次是在invokespecial調用構造函數中,對實例變量按照構造器進行初始化。
第一次的初始化使得Java語言保證了安全性,即使某個實例變量忘記初始化也不會有莫名其妙的值出現。
例子可以在下面的綜合與構造器中的多態的章節中看到。
如果有繼承關係的話,子類的構造器會先調用父類的構造器,但要注意這不是由虛擬機來保證的,而是由javac的編譯器來保證的。我們看這樣一個例子:
class A{}
class B extends A{}
public class Test {
public static void main(String[] args) {
new B();
}
}
對程序進行反編譯,我們可以看到:
Test中調用了B的構造方法:
構造器B中調用A的構造方法:
構造器A中調用Object類的構造方法:
初始化順序
在類的內部,變量定義的先後順序決定了初始化的順序,例子如下(《Java編程思想》第四版P94):
class Door{
Door(int marker){
System.out.println("Door("+marker+")");
}
}
class House{
Door d1=new Door(1); //定義發生在構造器調用之前
House(){
System.out.println("House()");
d3=new Door(8); //對d3的重新定義
}
Door d2=new Door(2); //定義發生在構造器調用之後
void f(){
System.out.println("f()");
}
Door d3=new Door(3); //定義發生在最後
}
public class Order {
public static void main(String args[])
{
House h=new House();
h.f();
}
}
輸出結果:
Door(1) Door(1) Door(1) House() Door(8) f()
但我們也要注意構造塊會優先於構造函數執行,我們看這樣一個例子:
public class ConstructBlock {
ConstructBlock(){
a=3;
}
{
a=2;
}
public int a=1;
public static void main(String[] args) {
System.out.println(new ConstructBlock().a);
}
}
輸出結果:
3
我們看一下反編譯的結果:
構造器中實際初始化順序爲a先=2在=1最後=3。
綜合
我們常常會看到書中寫:初始化的順序是先靜態變量(如果它們尚未因前面的對象創建而被初始化),而後是“非靜態”變量。
現在我們知道爲什麼這樣了, new A()這條語句會先執行new指令,若類還未被初始化的話,碰到new指令會先初始化類變量,然後調用invokespecial指令運行構造函數。
下面我們看一個有繼承,有類變量和實例變量初始化的例子(《Java編程思想》第四版P146):
class Insect {
private int i = 9;
protected int j;
Insect() {
System.out.println("i = " + i + ", j = " + j);
j = 39;
}
private static int x1 =
printInit("static Insect.x1 initialized");
static int printInit(String s) {
System.out.println(s);
return 47;
}
}
public class Beetle extends Insect {
private int k = printInit("Beetle.k initialized");
public Beetle() {
System.out.println("k = " + k);
System.out.println("j = " + j);
}
private static int x2 =
printInit("static Beetle.x2 initialized");
public static void main(String[] args) {
System.out.println("Beetle constructor");
Beetle b = new Beetle();
}
}
輸出結果:
static Insect.x1 initialized
static Beetle.x2 initialized
Beetle constructor
i = 9, j = 0
Beetle.k initialized
k = 47
j = 39
解釋一下:
在Java上運行Beetle時,所發生的第一件事情就是試圖訪問Beetle.main()(一個static方法)(上面5條情況中的第四條:當虛擬機啓動時,用戶需要指定一個要執行的主類(包含main()方法的那個類),虛擬機會先初始化這個主類),於是加載器開始啓動並找出Beetle類的編譯代碼(在名爲Beetle.class的文件之中)。在對它進行加載的過程中,編譯器注意到它有一個基類(這是由關鍵字extends得知的),於是它繼續進行加載。不管你是否打算產生一個該基類的對象,這都要發生。
如果該基類還有其自身的基類,那麼第二個基類就會被加載,以此類推。接下來,根基類的static初始化(在此例中爲Insect)即會被執行,然後是下一個導出類,以此類推。這種方式很重要,因爲導出類的static初始化可能會依賴於基類成員能否被正確初始化。
至此爲止,必要的類都已經加載完畢,對象就可以被創建了。首先,對象中所有的基本類型都會被設爲默認值,對象引用被設爲null----這是通過將對象內存設爲二進制零值而一舉生成的。然後,基類的構造器會被調用。在本例中,它是被自動調用的。但也可以用super來指定對基類構造器的調用(正如**Beetle()**構造器中的第一部操作)。基類構造器和導出類的構造器一樣,以相同的順序來經歷相同的過程。在基類構造器完成之後,實例變量按其順序被初始化。最後,構造器的其餘部分被執行。
進階
數組初始化
class Animal {
static {
System.out.println("animal static init!");
}
}
public class Main {
public static void main(String[] args) {
Animal[] x=new Animal[4];
}
}
運行結果沒有任何輸出。明明有new,爲什麼沒有初始化Animal類呢?我們看一下反編譯結果:
其實並沒有運行new指令,運行的時anewarray指令,這裏面觸發了另外一個名爲“[LAnimal”的類的初始化階段,對於用戶代碼來說,這並不是一個合法的類名稱,它是一個由虛擬機自動生成的、直接繼承於java.lang.Object的子類。這個類代表了一個元素類型爲Animal的一維數組,數組中應有的屬性和方法(用戶可直接使用的只有被修飾爲public的length屬性和clone()方法)都實現在這個類裏。但是該指令並不會初始Animal類。
構造器中的多態
如果在一個構造器的內部調用正在構造的對象的某個動態綁定方法,會發生什麼情況呢?我們看如下的例子(《Java編程思想》第四版P163):
public class PloyConstructors {
public static void main(String[] args) {
new RoundGlyph(4);
}
}
class Glyph{
void draw(){
System.out.println("Glyph draw: ");
}
Glyph(){
System.out.println("Glyph before draw()");
//考慮動態綁定的問題在父類中如果某種情況下
//調用被子類覆寫的方法,會發生什麼?
draw();
System.out.println("Glyph after draw()");
}
}
class RoundGlyph extends Glyph{
private int radius = 1;
RoundGlyph(int r){
//super();
radius = r;
System.out.println("RoundGlyph.RoundGlyph,radius " + radius);
}
//通常情況下子類覆寫父類方法獲得多態性
@Override
void draw(){
System.out.println("RoundGlyph.draw(),radius " + radius);
}
}
輸出結果:
Glyph before draw() RoundGlyph.draw(),radius 0 Glyph after draw() RoundGlyph.RoundGlyph,radius 4
初始化的實際過程: 1.存儲空間被分配出來準備創建一個類實例,存儲空間被初始化爲0 2.調用父類構造器,初始化父類對象,這時會調用被覆蓋的draw()方法,由於步驟1,此時radius=0 3.調用成員的初始化部分 4.調用子類的構造器
但是爲什麼對象還在構造階段就能實現多態呢?
這就要牽扯到invokevirtual方法,Java的動態綁定是依靠該指令實現的,方法最後會在運行invokevirtual指令時棧頂的對象所對應的類中找方法(在這裏new 的是RoundGlyph對象,就會在RoundGlyph類中找)中找方法。上面的程序中父類構造器中調用了draw(),會調用invokevirtual指令,然後在棧頂RoundGlyph對象所在的RoundGlyph類中找方法(找方法的過程是先找當前類,若當前類沒有該方法去找父類),因此必然調用的是子類的方法。