淺談「String Pool」
概要
這是筆者早期的筆記了,想刪掉又覺得可惜,就決定整理一下,貼過來了。
字串在多數的程式語言中都是特別的存在,主要原因是它的使用頻率過於頻繁,所以字串通常都會具有一些特權功能,「Java」亦不例外,而「String Pool」就是其特權之一;「String Pool」翻譯為「字串池」,其特性與實現機制是不少面試官鍾愛的考題,而本文就是要來介紹它。
正文
目的
字串池機制存在的目的就是為了降低「當字串重覆使用」時所消耗的資源成本。
機制
在講機制之前,我們先談「字串」的一個重要特性:「Immutable」,意即當「字串」 一經生成,便不能改變。
什麼意思?先看下面這段程式碼:
String str = "A";
str += "B";
System.out.println(str); // AB
打印的結果是字串:「AB」,而「AB」就像是由原本的字串:「A」再加上字串:「B」後產生的結果。
但實際上在記憶體中並不是這樣運作的,由於「Java」的字串具有「不可改變」的特性,因此,上述程式片段的執行方式是先產生字串:「A」,然後再產生字串:「B」,經運算後,再產生字串:「AB」,並將「str」指向了「AB」。
換句話說,在上述代碼片段的運算過程中,有三個「字串」被產生,但僅有「AB」被指向,而另外兩字串理論上會被「GC」機制所回收。
由於「字串」在撰寫代碼的過程中,幾乎是最常被使用的「物件」之一;重點是「重覆使用率」也相當的高,如下程式碼:
String comboStr = "init";
comboStr += "A"; // line 2
comboStr += "A"; // line 3
comboStr += "A"; // line 4
comboStr += "A"; // line 5
System.out.println(comboStr); // initAAAA
在沒有「字串池」機制的情況下:
那麼「第二行」中所產生的常量字串:「A」,會在「第二行」結束時失去被指向,並等待「GC」,然後到了「第三行」,一個新的常量字串:「A」又被產生,並同樣在「第三行」結束時失去被指向,而等待「GC」,「第四行」也是如此⋯,直到程式結束;總共會有 4 個字串「A」正等待或是已經被「GC」的機制回收。
備註:本處僅討論常量字串:「A」,並假設「編譯優化」不存在。
因此,為了避免「重覆字串」在使用上的浪費,所以「JVM」實作了「字串池」的機制,其會在「Heap」中設置一塊「字串池」的區域,其專門用於存放「字串」,而在字串池裏的這些字串是允許被「重覆取用的」,如下圖:
然後將程式碼運行中所產生的字串放置「字串池內」,如下:
// Literal String
String s1 = "ABC";
String s2 = "ABC";
System.out.println(s1 == s2); // true
而記憶體配置如下:
但要注意的是,並不是所有被產生的字串都一定會進入「字串池」,實際上只有以「Literal」形式,也就是藉由「雙引號」所產生的字串會被放在「字串池」中,範例代碼如下:
// New String
String s1 = "ABC";
String s3 = new String("ABC");
System.out.println(s1 == s3); // false
而記憶體配置如下:
這也是為什麼我們不建議產生字串的時候用「new」,而是以「Literal」的形式。
來探討個有趣的例子,程式碼如下:
String s1 = "ABC"; // line 1
String literalStr = "A" + "B" + "C";
System.out.println(s1 == literalStr); // true
String a = "A";
String b = "B";
String c = "C";
String variableStr = a + b + c;
System.out.println(s1 == variableStr); // falseSystem.out.println(variableStr == literalStr); // false
結果是不是很詭異,其中「literalStr」與「variableStr」竟然是不相等的。
該問題要分為兩部分探討,首先是「literalStr」,官方文件的解釋如下:
簡單的說,這是與「編譯時期的優化」相關,其中,「literalStr」在編譯的過程中,會由:
String literalStr = "A" + "B" + "C";
被改成:
String literalStr = "ABC";
因此,對「JVM」而言,「literalStr」仍然是字串:「ABC」。
又因為「ABC」在上述代碼片段中的「line 1」就已經被創建,且置於「字串池」中,因此「literalStr」僅是將位址指向「字串池」中的「ABC」,換言之,「s1」與「literalStr」所指向的位址皆是「字串池」中的「ABC」,自然而然就是相同的。
至於為什麼「variableStr」與「s1」的指向不同,這必須牽扯到「Literal」的產生機制與方法:「String#intern()」,如下:
簡單說就是「intern()」會根據字串池是否有「相同字串」,若有,它就會將記憶體指向該位置,代碼如下:
String s1 = "ABC";
String s3 = new String("ABC");
String s4 = s3.intern();
System.out.println(s1 == s3); // false
System.out.println(s1 == s4); // false
記憶體狀況如下:
如果字串池沒有「相同字串」,則將「ABC」添加至字串池中。
回到我們稍早的問題,為什麼「variableStr」所指向的「ABC」並不是字串池中的「ABC」,代碼如下:
String a = "A";
String b = "B";
String c = "C";
String variableStr = a + b + c;
System.out.println( "ABC" == variableStr); // false
該問題的本質是,為什麼僅由以「常量字串」方式創建的字串才會進入「字串池」,而以「new」創建的字串不會進入「字串池」?
事實上,這跟定應有關,請參考「String Literals」,如下:
簡單的說就是,當「Literal」字串被創建時,就會直接執行「intern()」,用以確保其唯一性,而經由運算所產生的「字串」是屬於「新建」的字串,而「新字串」與「字串池中的相同內容字串」自然不是同一個物件,其原文敘述如下:
至於「static」字串的機制也大致相似的,代碼如下:
public class StringPool {
public static void main(String[] args) {
String s1 = "ABC";
System.out.println(staticS1 == s1); // true
System.out.println(staticS2 == s1); // false
System.out.println(staticS3 == s1); // true
System.out.println(staticS4 == s1); // false
}
static String staticS1 = "ABC";
static String staticS2 = new String("ABC");
static String staticS3 = "A" + "B" + "C";
static String staticSa = "A";
static String staticSb = "B";
static String staticSc = "C";
static String staticS4 = staticSa + staticSb + staticSc;
}
至於在更深入的部分,筆者就沒有過多研究了,若有興趣,可以參考「深入分析 String.intern 和 String 常量的实现原理」。
備註:本處為探討「Java 7」以後的字串池實現方式。
關於「String Pool」與「intern()」還有些故事可以說,為什麼筆者備註要聲明本文的探討是僅適用於「Java 7」以後的實現方式呢?
其具體原因是「字串池」的實現方式在「Java 7」之後有大幅度的改動;原本在「Java 6」之前,「字串池」在記憶體中的位置是在「perm gen」,而其大小是固定,若存在大量「internedString」,則會導致內存增加,效能變差,詳細資訊請參考下列文章:「JDK-7032129 」、「JDK-4642821」。
因此在「Java 7」之後,其將「字串池」的記憶體位置改至「Heap」中,詳細資訊請參考文章:「JDK-6962931」,並使得其大小是可配置的,詳細資訊請參考文章:「JDK-6962930」。
完整程式碼連結:My GitHub