淺談Java的常數池

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
/*
* What is the output of this program ?
* 猜猜看,這個程式的輸出是什麼?
*/


public class ConstantPool {

public static void main(String[] args) {

ConstantPool cp = new ConstantPool();
cp.aboutString();
}

public void aboutString(){

String s1 = "Hello";
String s2 = "Hello";
String s3 = new String("Hello");
String s4 = "Hel" + "lo";

String s5 = "Hel" + new String("lo");
String s7 = "He";

String s8 = "llo";
String s9 = s7+s8;
String s10 = new String("Hello");

/*
* Try this :)
*/


System.out.println(s1==s2);
System.out.println(s1==s3);
System.out.println(s1==s4);
System.out.println(s1==s5);
System.out.println(s1==s9);
System.out.println(s3==s10);
}

}

Answer

1
2
3
4
5
6
true
false
true
false
false
false

這是什麼巫術 ?

首先,在Java中的==有兩種意涵:

對於基本資料型態,比對兩者的數值
對於物件,則是比對兩者存放的位置

String是個物件,所以對String使用==,我們其實是在比較物件的記憶體位置,這就解決了最後一個問題,s3與s10都是經過new配置而成,實際上new傳回了兩個String,只不過他們的字串值都叫做Hello而已,比對兩者的位置當然會得出false。

stack heap
s3 -> “Hello”
s10-> “Hello”

如果明白了最後一題在搞什麼花樣,問題來了,為什麼第一題是true?

常數池

讓我們從答案往回推,既然第一題是true,也就代表s1與s2所指向的是同一個物件,而為何會指向同一個物件呢?

其實是Java存在一個特殊空間,把編譯時期就已經確定的常數(constant)包進.class,執行時再從這個空間統一拿出。這個特殊空間稱作常數池或常量池(constant pool),它主要保管這兩種東西:

  • 字面量(Literal)
    包含了常數以及既定字串。

  • Symbolic References
    主要是類別和介面的Fully qualified name,欄位和方法的名稱。

每當編譯完成,編譯器會將上述兩者包進.class中。原始碼中每一個字面量會被統一構成一張常數表,並透過索引的型式參照它。而當JVM載入這份.class檔時,會特別為常數池在方法區建立一個資料結構,並把常數表中所有的字面量在Heap上生成一個實例(即是String.intern()的工作內容)。

除了String,Java對doublefloat以外的6個基本類別在區間-128~127都實作了常數池,也就是說

1
2
3
4
5
6
Integer i1 = 1;
Integer i2 = 1;
Integer i3 = 128;
Integer i4 = 128;
System.out.println(i1==i2);//true
System.out.println(i3==i4);//false

關於這點,我們也可從Integer類別中valueOf的實作得到證實:

1
2
3
4
5
6
7
8
9
//IntegerCache.low  預設為 -128
//IntegerCache.high 預設為 127

public static Integer valueOf(int i) {
assert IntegerCache.high >= 127;
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}
P.S
我會提到valueOf是因為Integer i1 = 1其實是個編譯器蜜糖,編譯器會將上述程式碼展開成Integer i1 = valueOf(1),對於這部分更深入的細節,可以餵食Google「Java裝箱拆箱」。

一般來說,常數池是這麼運作的(以string及題目為例):

  1. 編譯時發現字串,若常數池中已存在,則將現有的(先前找到的)位置賦予它,否則將該字串壓入常數池,並紀錄位置
  2. 當載入class時,JVM會尋找.class的常數表,發現了字串常量Hello,在heap中生成對應的實例。
  3. 接著JVM會對常數池的入口位置進行解析,首先會找到字串s1的字面量Hello
  4. 由於常數池中已經存在Hello,所以提供常數池的引用給s1
  5. 之後JVM找到s2的字面量Hello
  6. 由於常數池中已經存在Hello,所以仍舊將s2指向已經生成好的Hello,而非再產生一個Hello

由此我們也能發現常數池的優點,它達到了共享的效果,避免資源頻繁產生或釋放造成不必要的浪費。

關於第二題 (s1==s3) 為false,是因為new是動態產生新的物件,其跟s1接收到的Hello自然是不同的,但是要注意,new 仍舊會觸發常數池存取,如果今天s3 = new String("Hello");是第一行,常數池的處理會變成這樣:

  1. 當載入class時,JVM會尋找.class的常數表,發現了字串常量Hello,在heap中生成對應的實例。
  2. 因為 s3 使用的是 new ,所以其不引用常數池,而是在heap生成一個新的String物件
  3. 所以此時heap實際上存在兩份物件,一個是讓常數池提供引用的,另一個是s3持有的。

直觀來看,= 與 new 都會進行常數池的檢查,就像先前說的,編譯器找的是原始碼中的字面量,它才不會管你是 new 還是 = 。不過在執行時, = 會引用常數池,new則是另外生成新的物件。

關於第三題 (s1==s4) 為true,是Java的優化功能之一,編譯器會將"Hel"+"lo"組合成"Hello",因此道理和(s1==s2)相同。

關於第四題,近似於第二題的概念,具體來說String s5 = "Hel" + new String("lo");做了三件事:

  1. 常數池存放了 “Hel” 及 “lo”,並產生對應的實例
  2. heap 再另外new出一個新的 “lo”
  3. 使用String方法.append()合併”Hel”與”lo”,傳回一個新字串給s5

第五題則是和第三題的類比,會回傳false可不是因為它是拆成He與llo,而是跟先前提到的優化有關,它只會進行常數的優化,這是因為編譯器不知道我們在String s9 = s7+s8;這行前,會不會對s7s8的值進行修改,若貿然的假設s7仍是He或s8仍是llo,反而可能導致預期外的錯誤,這風險可不是編譯器能承擔的,自然也不會負責這個部分。