從字串到常量池,一文看懂String類設計

程式設計師DMZ發表於2020-06-17

從一道面試題開始

看到這個標題,你肯定以為我又要講這道面試題了

//  這行程式碼建立了幾個物件?
String s3 = new String("1");

是的,沒錯,我確實要從這裡開始

image-20200615221408500

這道題就算你沒做過也肯定看到,總所周知,它建立了兩個物件,一個位於堆上,一個位於常量池中。

這個答案粗看起來是沒有任何問題的,但是仔細思考確經不起推敲。

如果你覺得我說的不對的話,那麼可以思考下面這兩個問題

  1. 你說它建立了兩個物件,那麼這兩個物件分別是怎樣建立的呢?我們回顧下Java建立物件的方式,一共就這麼幾種

    • 使用new關鍵字建立物件
    • 使用反射建立物件(包括Class類的newInstance方法,以及Constructor類的newInstance方法)
    • 使用clone複製一個物件
    • 反序列化得到一個物件

    你說它建立了兩個物件,那你告訴我除了new出來那個物件外,另外一個物件怎麼建立出來的?

  2. 堆跟常量池到底什麼關係?不是說在JDK1.7之後(含1.7版本)常量池已經移到了堆中了嗎?如果說常量池本身就位於堆中的話,那麼這種一個物件在堆中,一個物件在常量池的說法還準確嗎?

如果你也產生過這些疑問的話,那麼請耐心看完這篇文章!要解釋上面的問題首先我們得對常量池有個準確的認知。

image-20200615221502268

常量池

通常來說,我們提到的常量池分為三種

  • class檔案中的常量池
  • 執行時常量池
  • 字串常量池

對於這三種常量池,我們需要搞懂下面幾個問題?

  1. 這個常量池在哪裡?
  2. 這個常量池用來幹什麼呢?
  3. 這三者有什麼關係?

接下來,我們帶著這些問題往下看

class檔案中的常量池

位置在哪?

顧名思義,class檔案中的常量池當然是位於class檔案中,而class檔案又是位於磁碟上

用來幹什麼的?

在學習class檔案中的常量池前,我們首選需要對class檔案的結構有一定了解

Class檔案是一組以8個位元組為基礎單位的二進位制流,各個資料專案嚴格按照順序緊湊地排列在文

件之中,中間沒有新增任何分隔符,這使得整個Class檔案中儲存的內容幾乎全部是程式執行的必要數

據,沒有空隙存在。

​ ------------《深入理解Java虛擬機器》

整個class檔案的組成可以用下圖來表示

image-20200615225016604

對本文而言,我們只關注其中的常量池部分,常量池可以理解為class檔案中資源倉庫,它是class檔案結構中與其它專案關聯最多的資料型別,主要用於存放編譯器生成的各種字面量(Literal)和符號引用(Symbolic References)
字面量就是我們所說的常量概念,如文字字串、被宣告為final的常量值等
符號引用是一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可(它與直接引用區分一下,直接引用一般是指向方法區的本地指標,相對偏移量或是一個能間接定位到目標的控制程式碼)。一般包括下面三類常量:

  • 類和介面的全限定名
  • 欄位的名稱和描述符
  • 方法的名稱和描述符

現在我們知道了class檔案中常量池的作用:存放編譯器生成的各種字面量(Literal)和符號引用(Symbolic References)。很多時候知道了一個東西的概念並不能說你會了,對於程式設計師而言,如果你說你已經會了,那麼最好的證明是你能夠通過程式碼將其描述出來,所以,接下來,我想以一種直觀的方式讓大家感受到常量池的存在。通過分析一段簡單程式碼的位元組碼,讓大家能更好感知常量池的作用。

talk is cheap ,show me code

我們以下面這段程式碼為例,通過javap來檢視class檔案中的具體內容,程式碼如下:

/**
 * @author 程式設計師DMZ
 * @Date Create in 22:59 2020/6/15
 * @公眾號 微信搜尋:程式設計師DMZ
 */
public class Main {
    public static void main(String[] args) {
        String name = "dmz";
    }
}

進入Main.java檔案所在目錄,執行命令:javac Main.java ,那麼此時會在當前目錄下生成對應的Main.class檔案。再執行命令:javap -v -c Main.class,此時會得到如下的解析後的位元組碼資訊

public class com.dmz.jvm.Main
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
// 這裡就是常量池了
Constant pool:
   #1 = Methodref          #4.#20         // java/lang/Object."<init>":()V
   #2 = String             #21            // dmz
   #3 = Class              #22            // com/dmz/jvm/Main
   #4 = Class              #23            // java/lang/Object
   #5 = Utf8               <init>
   #6 = Utf8               ()V
   #7 = Utf8               Code
   #8 = Utf8               LineNumberTable
   #9 = Utf8               LocalVariableTable
  #10 = Utf8               this
  #11 = Utf8               Lcom/dmz/jvm/Main;
  #12 = Utf8               main
  #13 = Utf8               ([Ljava/lang/String;)V
  #14 = Utf8               args
  #15 = Utf8               [Ljava/lang/String;
  #16 = Utf8               name
  #17 = Utf8               Ljava/lang/String;
  #18 = Utf8               SourceFile
  #19 = Utf8               Main.java
  #20 = NameAndType        #5:#6          // "<init>":()V
  #21 = Utf8               dmz
  #22 = Utf8               com/dmz/jvm/Main
  #23 = Utf8               java/lang/Object
 // 下面是方法表                           
{
  public com.dmz.jvm.Main();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 7: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lcom/dmz/jvm/Main;

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=1, locals=2, args_size=1
         // 可以看到方法表中的指令引用了常量池中的常量,這也是為什麼說常量池是資源倉庫的原因
         // 因為它會被class檔案中的其它結構引用         
         0: ldc           #2                  // String dmz
         2: astore_1
         3: return
      LineNumberTable:
        line 9: 0
        line 10: 3
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       4     0  args   [Ljava/lang/String;
            3       1     1  name   Ljava/lang/String;
}
SourceFile: "Main.java"

在上面的位元組碼中,我們暫且關注常量池中的內容即可。主要看這兩行

#2 = String             #14            // dmz
#14 = Utf8               dmz

如果要看懂這兩行程式碼,我們需要對常量池中String型別常量的結構有一定了解,其結構如下:

CONSTANT_String_info tag 標誌常量型別的標籤
index 指向字串字面量的索引

對應到我們上面的位元組碼中,tag=String,index=#14,所以我們可以知道,#2是一個字面量為#14的字串型別常量。而#14對應的字面量資訊(一個Utf8型別的常量)就是dmz

常量池作為資源倉庫,最大的用處在於被class檔案中的其它結構所引用,這個時候我們再將注意力放到main方法上來,對應的就是這三條指令

0: ldc           #2                  // String dmz
2: astore_1
3: return

ldc:這個指令的作用是將對應的常量的引用壓入運算元棧,在執行ldc指令時會觸發對它的符號引用進行解析,在上面例子中對應的符號引用就是#2,也就是常量池中的第二個元素(這裡就能看出方法表中就引用了常量池中的資源)

astore_1:將運算元棧底元素彈出,儲存到區域性變數表中的1號元素

return:方法返回值為void,標誌方法執行完成,將方法對應棧幀從棧中彈出

下面我用畫圖的方式來畫出整個流程,主要分為四步

  1. 解析ldc指令的符號引用(#2

  2. #2對應的常量的引用壓入到運算元棧頂

  3. 將運算元棧的元素彈出並儲存到區域性變數表中

  4. 執行return指令,方法執行結束,彈出棧區該方法對應的棧幀

第一步:

在解析#2這個符號引用時,會先到字串常量池中查詢是否存在對應字串例項的引用,如果有的話,那麼直接返回這個字串例項的引用,如果沒有的話,會建立一個字串例項,那麼將其新增到字串常量池中(實際上是將其引用放入到一個雜湊表中),之後再返回這個字串例項物件的引用。

到這裡也能回答我們之前提出的那個問題了,一個物件是new出來的,另外一個是在解析常量池的時候JVM自動建立的

第二步:

image-20200616215838415

將第一步得到的引用壓入到運算元棧,此時這個字串例項同時被運算元棧以及字串常量池引用。

第三步:

image-20200616220344102

運算元棧中的引用彈出,並賦值給區域性變數表中的1號位置元素,到這一步其實執行完了String name = "dmz"這行程式碼。此時區域性變數表中儲存著一個指向堆中字串例項的引用,並且這個字串例項同時也被字串常量池引用。

第四步:

這一步我就不畫圖了,就是方法執行完成,棧幀彈出,非常簡單。


在上文中,我多次提到了字串常量池,它到底是個什麼東西呢?我們還是分為兩部分討論

  1. 位置在哪?
  2. 用來幹什麼的?

字串常量池

位置在哪?

字串常量池比較特殊,在JDK1.7之前,其存在於永久代中,到JDK1.7及之後,已經中永久代移到了堆中。當然,如果你非要說永久代也是堆的一部分那我也沒辦法。

另外還要說明一點,經常有同學會將方法區元空間永久代(permgen space)的概念混淆。請注意

  1. 方法區JVM在記憶體分配時需要遵守的規範,是一個理論,具體的實現可以因人而異
  2. 永久代hotspot jdk1.8以前對方法區的實現,使用jdk1.7的老司機肯定以前經常遇到過java.lang.OutOfMemoryError: PremGen space異常。這裡的PermGen space其實指的就是方法區。不過方法區和PermGen space又有著本質的區別。前者是JVM的規範,而後者則是JVM規範的一種實現,並且只有HotSpot才有PermGen space
  3. 元空間jdk1.8對方法區的實現,jdk1.8徹底移除了永久代,其實,移除永久代的工作從JDK 1.7就開始了。JDK 1.7中,儲存在永久代的部分資料就已經轉移到Java Heap或者Native Heap。但永久代仍存在於JDK 1.7中,並沒有完全移除,譬如符號引用(Symbols)轉移到了native heap;字面量(interned strings)轉移到了Java heap;類的靜態變數(class statics)轉移到了Java heap。到jdk1.8徹底移除了永久代,將JDK7中還剩餘的永久代資訊全部移到元空間,元空間相比對永久代最大的差別是,元空間使用的是本地記憶體(Native Memory)

用來幹什麼的?

字串常量池,顧名思義,肯定就是用來儲存字串的嘛,準確來說儲存的是字串例項物件的引用。我查閱了很多部落格、資料,它們都會說,字串常量池中儲存的就是字串物件。其實我們可以類比下面這段程式碼:

HashSet<Person> persons = new HashSet<Person>;

persons這個集合中,儲存的是Person物件還是Person物件對應的引用呢?

所以,請大聲跟我念三遍

字串常量池儲存的是字串例項物件的引用!

字串常量池儲存的是字串例項物件的引用!

字串常量池儲存的是字串例項物件的引用!

下面我們來看R大博文下評論的一段話:

簡單來說,HotSpot VM裡StringTable是個雜湊表,裡面存的是駐留字串的引用(而不是駐留字串例項自身)。也就是說某些普通的字串例項被這個StringTable引用之後就等同被賦予了“駐留字串”的身份。這個StringTable在每個HotSpot VM的例項裡只有一份,被所有的類共享。類的執行時常量池裡的CONSTANT_String型別的常量,經過解析(resolve)之後,同樣存的是字串的引用;解析的過程會去查詢StringTable,以保證執行時常量池所引用的字串與StringTable所引用的是一致的。

​ ------R大部落格

從上面我們可以知道

  1. 字串常量池本質就是一個雜湊表
  2. 字串常量池中儲存的是字串例項的引用
  3. 字串常量池在被整個JVM共享
  4. 在解析執行時常量池中的符號引用時,會去查詢字串常量池,確保執行時常量池中解析後的直接引用跟字串常量池中的引用是一致的

為了更好理解上面的內容,我們需要去分析String中的一個方法-----intern()

intern方法分析

/** 
 * Returns a canonical representation for the string object. 
 * <p> 
 * A pool of strings, initially empty, is maintained privately by the 
 * class <code>String</code>. 
 * <p> 
 * When the intern method is invoked, if the pool already contains a 
 * string equal to this <code>String</code> object as determined by 
 * the {@link #equals(Object)} method, then the string from the pool is 
 * returned. Otherwise, this <code>String</code> object is added to the 
 * pool and a reference to this <code>String</code> object is returned. 
 * <p> 
 * It follows that for any two strings <code>s</code> and <code>t</code>, 
 * <code>s.intern()&nbsp;==&nbsp;t.intern()</code> is <code>true</code> 
 * if and only if <code>s.equals(t)</code> is <code>true</code>. 
 * <p> 
 * All literal strings and string-valued constant expressions are 
 * interned. String literals are defined in section 3.10.5 of the 
 * <cite>The Java&trade; Language Specification</cite>. 
 * 
 * @return  a string that has the same contents as this string, but is 
 *          guaranteed to be from a pool of unique strings. 
 */  
public native String intern();  

String#intern方法中看到,這個方法是一個 native 的方法,但註釋寫的非常明瞭。“如果常量池中存在當前字串, 就會直接返回當前字串. 如果常量池中沒有此字串, 會將此字串放入常量池中後, 再返回”。

關於其詳細的分析可以參考:美團:深入解析String#intern

珠玉在前,所以本文著重就分析下intern方法在JDK不同版本下的差異,首先我們要知道引起差異的原因是因為JDK1.7及之後將字串常量池從永久代挪到了堆中。

我這裡就以美團文章中的示例程式碼來進行分析,程式碼如下:

public static void main(String[] args) {
    String s = new String("1");
    s.intern();
    String s2 = "1";
    System.out.println(s == s2);

    String s3 = new String("1") + new String("1");
    s3.intern();
    String s4 = "11";
    System.out.println(s3 == s4);
}

列印結果是

  • jdk6 下false false
  • jdk7 下false true

在美團的文章中已經對這個結果做了詳細的解釋,接下來我就用我的圖解方式再分析一波這個過程

jdk6 執行流程

第一步:執行 String s = new String("1"),要清楚這行程式碼的執行過程,我們還是得從位元組碼入手,這行程式碼對應的位元組碼如下:

  public static void main(java.lang.String[]);
    Code:
       0: new           #2                  // class java/lang/String
       3: dup
       4: ldc           #3                  // String 1
       6: invokespecial #4                  // Method java/lang/String."<init>":(Ljava/lang/String;)V
       9: astore_1
      10: return

new :建立了一個類的例項(還沒有呼叫構造器函式),並將其引用壓入運算元棧頂

dup:複製棧頂數值並將複製值壓入棧頂,這是因為invokespecialastore_1各需要消耗一個引用

ldc:解析常量池符號引用,將實際的直接引用壓入運算元棧頂

invokespecial:彈出此時棧頂的常量引用及物件引用,執行invokespecial指令,呼叫建構函式

astore_1:將此時運算元棧頂的元素彈出,賦值給區域性變數表中1號元素(0號元素存的是main函式的引數)

我們可以將上面整個過程分為兩個階段

  1. 解析常量
  2. 呼叫建構函式建立物件並返回引用

在解析常量的過程中,因為該字串常量是第一次解析,所以會先在永久代中建立一個字串例項物件,並將其引用新增到字串常量池中。此時記憶體狀態如下:

image-20200617015119885

當真正通過new方式建立物件完成後,對應的記憶體狀態如下,因為在分析class檔案中的常量池的時候已經對棧區做了詳細的分析,所以這裡就省略一些細節了,在執行完這行程式碼後,棧區存在一個引用,指向 了堆區的一個字串例項記憶體狀態對應如下:

image-20200617015331040

第二步:緊接著,我們呼叫了s的intern方法,對應程式碼就是 s.intern()

當intern方法執行時,因為此時字串常量池中已經存在了一個字面量資訊跟s相同的字串的引用,所以此時記憶體狀態不會發生任何改變。

第三步:執行String s2 = "1",此時因為常量池中已經存在了字面量1的對應字串例項的引用,所以,這裡就直接返回了這個引用並且賦值給了區域性變數s2。對應的記憶體狀態如下:

image-20200617015738520

到這裡就很清晰了,s跟s2指向兩個不同的物件,所以s==s2肯定是false嘛~

如果看過美團那篇文章的同學可能會有些疑惑,我在圖中對常量池的描述跟美團文章圖中略有差異,在美團那篇文章中,直接將具體的字串例項放到了字串常量池中,而在我上面的圖中,字串常量池存的永遠時引用,它的圖是這樣畫的

image-20200617010744250

就我查閱的資料而言,我個人不贊同這種說法,常量池中應該儲存的僅僅是引用。關於這個問題,我已經向美團的團隊進行了留言,也請大佬出來糾錯!

接著我們分析s3跟s4,對應的就是這幾行程式碼:

String s3 = new String("1") + new String("1");
s3.intern();
String s4 = "11";
System.out.println(s3 == s4);

我們一行行分析,看看執行完後,記憶體的狀態是什麼樣的

第一步:String s3 = new String("1") + new String("1"),執行完成後,堆區多了兩個匿名物件,這個我們不用多關注,另外堆區還多了一個字面量為11的字串例項,並且棧中存在一個引用指向這個例項

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片儲存下來直接上傳(img-NVeeWKoO-1592334452491)(upload\image-20200617020742618.png)]

實際上上圖中還少了一個匿名的StringBuilder的物件,這是因為當我們在進行字串拼接時,編譯器預設會建立一個StringBuilder物件並呼叫其append方法來進行拼接,最後再呼叫其toString方法來轉換成一個字串,StringBuildertoString方法其實就是new一個字串

public String toString() {
    // Create a copy, don't share the array
    return new String(value, 0, count);
}

這也是為什麼在圖中會說在堆上多了一個字面量為11的字串例項的原因,因為實際上就是new出來的嘛!

第二步:s3.intern()

呼叫intern方法後,因為字串常量池中目前沒有11這個字面量對應的字串例項的應用,所以JVM會先從堆區複製一個字串例項到永久代中,再將其引用新增到字串常量池中,最終的記憶體狀態就如下所示

image-20200617021429393

第三步:String s4 = "11"

這應該沒啥好說的了吧,常量池中有了,直接指向對應的字串例項

image-20200617022337592

到這裡可以發現,s3跟s4指向的根本就是兩個不同的物件,所以也返回false

jdk7 執行流程

在jdk1.7中,s跟s2的執行結果還是一樣的,這是因為 String s = new String("1")這行程式碼本身就建立了兩個字串物件,一個屬於被常量池引用的駐留字串,而另外一個只是堆上的一個普通字串物件。跟1.6的區別在於,1.7中的駐留字串位於堆上,而1.6中的位於方法區中,但是本質上它們還是兩個不同的物件,在下面程式碼執行完後

    String s = new String("1");
    s.intern();
    String s2 = "1";
    System.out.println(s == s2);

記憶體狀態為:

image-20200617023304860

但是對於s3跟s4確不同了,因為在jdk1.7中不會再去複製字串例項了,在intern方法執行時在發現堆上有對應的物件之後,直接將這個對應的引用新增到字串常量池中,所以程式碼執行完,記憶體狀態對應如下:

image-20200617023832783

看到了吧,s3跟s4指向的同一個物件,這是因為intern方法執行時,直接s3這個引用複製到了常量池,之後執行String s4= "11"的時候,直接再將常量池中的引用複製給了s4,所以s3==s4肯定為true啦。

在理解了它們之間的差異之後,我們再來思考一個問題,假設我現在將程式碼改成這個樣子,那麼執行結果是什麼樣的呢?

public static void main(String[] args) {
    String s = new String("1");
    String sintern = s.intern();
    String s2 = "1";
    System.out.println(sintern == s2);

    String s3 = new String("1") + new String("1");
    String s3intern = s3.intern();
    String s4 = "11";
    System.out.println(s3intern == s4);
}

上面這段程式碼執行起來結果會有差異嗎?大家可以自行思考~

在我們對字串常量池有了一定理解之後會發現,其實通過String name = "dmz"這行程式碼申明一個字串,實際的執行邏輯就像下面這段虛擬碼所示

/**
  * 這段程式碼邏輯類比於
  * <code>String s = "字面量"</code>;這種方式申明一個字串
  * 其中字面量就是在""中的值
  *
  */
public String declareString(字面量) {
    String s;
    // 這是一個偽方法,標明會根據字面量的值到字串值中查詢是否存在對應String例項的引用
    s = findInStringTable(字面量);
    // 說明字串池中已經存在了這個引用,那麼直接返回
    if (s != null) {
        return s;
    }
    // 不存在這個引用,需要新建一個字串例項,然後呼叫其intern方法將其拘留到字串池中,
    // 最後返回這個新建字串的引用
    s = new String(字面量);
    // 呼叫intern方法,將建立好的字串放入到StringTable中,
    // 類似就是呼叫StringTable.add(s)這也的一個偽方法
    s.intern();
    return s;
}

按照這個邏輯,我們將我們將上面思考題中的所有字面量進行替換,會發現不管在哪個版本中結果都應該返回true。

執行時常量池

位置在哪?

位於方法區中,1.6在永久代,1.7在元空間中,永久代跟元空間都是對方法區的實現

用來幹什麼?

jvm在執行某個類的時候,必須經過載入、連線、初始化,而連線又包括驗證# 位置在哪?

位於方法區中,1.6在永久代,1.7在元空間中,永久代跟元空間都是對方法區的實現

用來幹什麼?

jvm在執行某個類的時候,必須經過載入、連線、初始化,而連線又包括驗證、準備、解析三個階段。而當類載入到記憶體中後,jvm就會將class常量池中的內容存放到執行時常量池中,由此可知,執行時常量池也是每個類都有一個。在上面我也說了,class常量池中存的是字面量和符號引用,也就是說他們存的並不是物件的例項,而是物件的符號引用值。而經過解析(resolve)之後,也就是把符號引用替換為直接引用,解析的過程會去查詢全域性字串池,也就是我們上面所說的StringTable,以保證執行時常量池所引用的字串與全域性字串池中所引用的是一致的

所以簡單來說,執行時常量池就是用來存放class常量池中的內容的。

總結

我們將三者進行一個比較

image-20200617025721586

以一道測試題結束

// 環境1.7及以上
public class Clazz {
    public static void main(String[] args) {
        String s1 = new StringBuilder().append("ja").append("va1").toString();
        String s2 = s1.intern();
        System.out.println(s1==s2);
        
        String s5 = "dmz";
        String s3 = new StringBuilder().append("d").append("mz").toString();
        String s4 = s3.intern();
        System.out.println(s3 == s4);

        String s7 = new StringBuilder().append("s").append("pring").toString();
        String s8 = s7.intern();
        String s6 = "spring";
        System.out.println(s7 == s8);
    }
}

答案是true,false,true。大家可以仔細思考為什麼,如有疑惑可以給我留言,或者進群交流!

如果本文對你有幫助的話,記得點個贊吧!也歡迎關注我的公眾號,微信搜尋:程式設計師DMZ,或者掃描下方二維碼,跟著我一起認認真真學Java,踏踏實實做一個coder。

公眾號

我叫DMZ,一個在學習路上匍匐前行的小菜鳥!

參考文章:

R大博文:請別再拿“String s = new String("xyz");建立了多少個String例項”來面試了吧

R大知乎回答:JVM 常量池中儲存的是物件還是引用呢?

Java中幾種常量池的區分

方法區,永久代和元空間

美團:深入解析String#intern

參考書籍:

《深入理解Java虛擬機器》第二版

《深入理解Java虛擬機器》第三版

《Java虛擬機器規範》

相關文章