String的intern()方法深入分析

String,是 Java 中除了基本數據類型以外,最爲重要的一個類型了。很多人會認爲他比較簡單。但是和 String 有關的面試題有很多,下面我隨便找兩道面試題,看看你能不能都答對:

  • Q1:String s = new String("hollis");定義了幾個對象。
  • Q2:如何理解 String 的intern方法?

上面這兩個是面試題和 String 相關的比較常考的,很多人一般都知道答案。

  • A1:若常量池中已經存在 “hollis”,則直接引用,也就是此時只會創建一個對象,如果常量池中不存在 “hollis”,則先創建後引用,也就是有兩個。
  • A2:當一個 String 實例調用intern()方法時,JVM 會查找常量池中是否有相同 Unicode 的字符串常量,如果有,則返回其的引用,如果沒有,則在常量池中增加一個 Unicode 等於 str 的字符串並返回它的引用;

兩個答案看上去沒有任何問題,但是,仔細想想好像哪裏不對呀。

按照上面的兩個面試題的回答,就是說 new String 會檢查常量池,如果有的話就直接引用,如果不存在就要在常量池創建一個,那麼還要 intern 幹啥?難道以下代碼是沒有意義的嗎?

    String s = new String("Hollis").intern();
  • 1

如果,每當我們使用 new 創建字符串的時候,都會到字符串池檢查,然後返回。那麼以下代碼也應該輸出結果都是 true?

    String s1 = "Hollis";
    String s2 = new String("Hollis");
    String s3 = new String("Hollis").intern();

    System.out.println(s1 == s2);
    System.out.println(s1 == s3);
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

但是,以上代碼輸出結果爲(base jdk1.8.0_73):

    false
    true
  • 1
  • 2

不知道聰明的讀者看完這段代碼之後,是不是有點被搞蒙了,到底是怎麼回事兒?

別急,且聽我慢慢道來。

字面量和運行時常量池

JVM 爲了提高性能和減少內存開銷,在實例化字符串常量的時候進行了一些優化。爲了減少在 JVM 中創建的字符串的數量,字符串類維護了一個字符串常量池。

在 JVM 運行時區域的方法區中,有一塊區域是運行時常量池,主要用來存儲編譯期生成的各種字面量符號引用

瞭解 Class 文件結構或者做過 Java 代碼的反編譯的朋友可能都知道,在 java 代碼被 javac 編譯之後,文件結構中是包含一部分 Constant pool 的。比如以下代碼:

public static void main(String[] args) {
    String s = "Hollis";
}
  • 1
  • 2
  • 3

經過編譯後,常量池內容如下:

 Constant pool:
   #1 = Methodref          #4.#20         // java/lang/Object."<init>":()V
   #2 = String             #21            // Hollis
   #3 = Class              #22            // StringDemo
   #4 = Class              #23            // java/lang/Object
   ...
   #16 = Utf8               s
   ..
   #21 = Utf8               Hollis
   #22 = Utf8               StringDemo
   #23 = Utf8               java/lang/Object
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

上面的 Class 文件中的常量池中,比較重要的幾個內容:

   #16 = Utf8               s
   #21 = Utf8               Hollis
   #22 = Utf8               StringDemo
  • 1
  • 2
  • 3

上面幾個常量中,s 就是前面提到的符號引用,而 Hollis 就是前面提到的字面量。而 Class 文件中的常量池部分的內容,會在運行期被運行時常量池加載進去。關於字面量,詳情參考 Java SE Specifications。

new String創建了幾個對象

下面,我們可以來分析下 String s = new String("Hollis"); 創建對象情況了。

這段代碼中,我們可以知道的是,在編譯期,符號引用 s 和字面量 Hollis 會被加入到 Class 文件的常量池中,然後在類加載階段,這兩個常量會進入常量池。

但是,這個“進入”過程,並不會直接把所有類中定義的常量全部都加載進來,而是會做個比較,如果需要加到字符串常量池中的字符串已經存在,那麼就不需要再把字符串字面量加載進來了。

所以,當我們說<若常量池中已經存在 “hollis”,則直接引用,也就是此時只會創建一個對象>說的就是這個字符串字面量在字符串池中被創建的過程。

說完了編譯期的事兒了,該到運行期了,在運行期,new String("Hollis");執行到的時候,是要在 Java 堆中創建一個字符串對象的,而這個對象所對應的字符串字面量是保存在字符串常量池中的。但是,String s = new String("Hollis");對象的符號引用 s 是保存在Java虛擬機棧上的,他保存的是堆中剛剛創建出來的的字符串對象的引用

所以,你也就知道以下代碼輸出結果爲 false 的原因了。

String s1 = new String("Hollis");
String s2 = new String("Hollis");
System.out.println(s1 == s2);
  • 1
  • 2
  • 3

因爲,== 比較的是 s1 和 s2 在堆中創建的對象的地址,當然不同了。但是如果使用 equals,那麼比較的就是字面量的內容了,那就會得到 true。
這裏寫圖片描述

在不同版本的JDK中,Java堆和字符串常量池之間的關係也是不同的,這裏爲了方便表述,就畫成兩個獨立的物理區域了。具體情況請參考Java虛擬機規範。

上圖中 s1 和 s2 是兩個完全不同的對象,在堆中有自己的內存空間,當然不相等了。

所以,String s = new String("Hollis");創建幾個對象的答案你也就清楚了。

常量池中的“對象”是在編譯期就確定好了的,在類被加載的時候創建的,如果類加載時,該字符串常量在常量池中已經有了,那這一步就省略了。堆中的對象是在運行期才確定的,在代碼執行到 new 的時候創建的。

運行時常量池的動態擴展

編譯期生成的各種字面量符號引用是運行時常量池中比較重要的一部分來源,但是並不是全部。那麼還有一種情況,可以在運行期像運行時常量池中增加常量。那就是 String 的intern方法。

當一個String實例調用intern()方法時,JVM 會查找常量池中是否有相同 Unicode 的字符串常量,如果有,則返回其的引用,如果沒有,則在常量池中增加一個 Unicode 等於 str 的字符串並返回它的引用;

intern()有兩個作用,第一個是將字符串字面量放入常量池(如果池沒有的話),第二個是返回這個常量的引用。

我們再來看下開頭的那個讓人產生疑惑的例子:

    String s1 = "Hollis";
    String s2 = new String("Hollis");
    String s3 = new String("Hollis").intern();

    System.out.println(s1 == s2);
    System.out.println(s1 == s3);
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

你可以簡單的理解爲String s1 = "Hollis";String s3 = new String("Hollis").intern();做的事情是一樣的(但實際有些區別,這裏暫不展開)。都是定義一個字符串對象,然後將其字符串字面量保存在常量池中,並把這個字面量的引用返回給定義好的對象引用。如下圖:

這裏寫圖片描述

對於String s3 = new String("Hollis").intern();,在不調intern情況,s3 指向的是 JVM 在堆中創建的那個對象的引用的(如圖中的s2)。但是當執行了 intern 方法時,s3 將指向字符串常量池中的那個字符串常量。

由於 s1 和 s3 都是字符串常量池中的字面量的引用,所以 s1==s3。但是,s2 的引用是堆中的對象,所以 s2!=s1。

intern 的正確用法

不知道,你有沒有發現,在String s3 = new String("Hollis").intern();中,其實 intern 是多餘的?

因爲就算不用 intern,Hollis 作爲一個字面量也會被加載到 Class 文件的常量池,進而加入到運行時常量池中,爲啥還要多此一舉呢?到底什麼場景下才會用到 intern 呢?

在解釋這個之前,我們先來看下以下代碼:

    String s1 = "Hollis";
    String s2 = "Chuang";
    String s3 = s1 + s2;
    String s4 = "Hollis" + "Chuang";
  • 1
  • 2
  • 3
  • 4

在經過反編譯後,得到代碼如下:

    String s1 = "Hollis";
    String s2 = "Chuang";
    String s3 = (new StringBuilder()).append(s1).append(s2).toString();
    String s4 = "HollisChuang";
  • 1
  • 2
  • 3
  • 4

可以發現,同樣是字符串拼接,s3 和s4 在經過編譯器編譯後的實現方式並不一樣。s3 被轉化成 StringBuilderappend,而 s4 被直接拼接成新的字符串。

如果你感興趣,你還能發現,String s4 = s1 + s2; 經過編譯之後,常量池中是有兩個字符串常量的分別是 Hollis、Chuang(其實 Hollis 和 Chuang 是String s1 = "Hollis";String s2 = "Chuang";定義出來的),拼接結果HollisChuang 並不在常量池中。

如果代碼只有String s4 = "Hollis" + "Chuang";,那麼常量池中將只有 HollisChuang 而沒有 Hollis 和 Chuang。

究其原因,是因爲常量池要保存的是已確定的字面量值。也就是說,對於字符串的拼接,純字面量和字面量的拼接,會把拼接結果作爲常量保存到字符串。

如果在字符串拼接中,有一個參數是非字面量,而是一個變量的話,整個拼接操作會被編譯成StringBuilder.append,這種情況編譯器是無法知道其確定值的。只有在運行期才能確定。

那麼,有了這個特性了,intern 就有用武之地了。那就是很多時候,我們在程序中用到的字符串是只有在運行期才能確定的,在編譯期是無法確定的,那麼也就沒辦法在編譯期被加入到常量池中。

這時候,對於那種可能經常使用的字符串,使用 intern 進行定義,每次 JVM 運行到這段代碼的時候,就會直接把常量池中該字面值的引用返回,這樣就可以減少大量字符串對象的創建了。

如一美團點評團隊的《深入解析String#intern》文中舉的一個例子:

static final int MAX = 1000 * 10000;
static final String[] arr = new String[MAX];

public static void main(String[] args) throws Exception {
    Integer[] DB_DATA = new Integer[10];
    Random random = new Random(10 * 10000);
    for (int i = 0; i < DB_DATA.length; i++) {
        DB_DATA[i] = random.nextInt();
    }
    for (int i = 0; i < MAX; i++) {
         arr[i] = new String(String.valueOf(DB_DATA[i % DB_DATA.length])).intern();
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

在以上代碼中,我們明確的知道,會有很多重複的相同的字符串產生,但是這些字符串的值都是只有在運行期才能確定的。所以,只能我們通過intern顯示的將其加入常量池,這樣可以減少很多字符串的重複創建。

總結

我們再回到文章開頭那個疑惑:按照上面的兩個面試題的回答,就是說new String也會檢查常量池,如果有的話就直接引用,如果不存在就要在常量池創建一個,那麼還要intern幹啥?難道以下代碼是沒有意義的嗎?

String s = new String("Hollis").intern();
  • 1

new String 所謂的“如果有的話就直接引用”,指的是Java堆中創建的String對象中包含的字符串字面量直接引用字符串池中的字面量對象。也就是說,還是要在堆裏面創建對象的。

intern中說的“如果有的話就直接返回其引用”,指的是會把字面量對象的引用直接返回給定義的對象。這個過程是不會在 Java 堆中再創建一個 String 對象的。

的確,以上代碼的寫法其實是使用 intern 是沒什麼意義的。因爲字面量 Hollis 會作爲編譯期常量被加載到運行時常量池。

之所以能有以上的疑惑,其實是對字符串常量池、字面量等概念沒有真正理解導致的。有些問題其實就是這樣,單個問題,自己都知道答案,但是多個問題綜合到一起就蒙了。歸根結底是知識的理解還停留在點上,沒有串成面。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章