不再怕面試被考字串---詳解Java中的字串

炭燒生蠔發表於2019-05-25

字串常量池詳解

在深入學習字串類之前, 我們先搞懂JVM是怎樣處理新生字串的.
當你知道字串的初始化細節後, 再去寫String s = "hello"String s = new String("hello")等程式碼時, 就能做到心中有數.

 

  • 首先得搞懂字串常量池的概念.
  • 常量池是Java的一項技術, 八種基礎資料型別除了float和double都實現了常量池技術. 這項技術從字面上是很好理解的: 把經常用到的資料存放在某塊記憶體中, 避免頻繁的資料建立與銷燬, 實現資料共享, 提高系統效能.
  • 字串常量池是Java常量池技術的一種實現, 在近代的JDK版本中(1.7後), 字串常量池被實現在Java堆記憶體中.
  • 下面通過三行程式碼讓大家對字串常量池建立初步認識:
public static void main(String[] args) {
    String s1 = "hello";
    String s2 = new String("hello");
    System.out.println(s1 == s2);   //false
}
  • 我們先來看看第一行程式碼String s1 = "hello";幹了什麼.

字串常量池記憶體圖

  • 對於這種直接通過雙引號""宣告字串的方式, 虛擬機器首先會到字串常量池中查詢該字串是否已經存在. 如果存在會直接返回該引用, 如果不存在則會在堆記憶體中建立該字串物件, 然後到字串常量池中註冊該字串.
  • 在本案例中虛擬機器首先會到字串常量池中查詢是否有存在"hello"字串對應的引用. 發現沒有後會在堆記憶體建立"hello"字串物件(記憶體地址0x0001), 然後到字串常量池中註冊地址為0x0001的"hello"物件, 也就是新增指向0x0001的引用. 最後把字串物件返回給s1.
  • 溫馨提示: 圖中的字串常量池中的資料是虛構的, 由於字串常量池底層是用HashTable實現的, 儲存的是鍵值對, 為了方便大家理解, 示意圖簡化了字串常量池對照表, 並採用了一些虛擬的數值.

 

  • 下面看String s2 = new String("hello");的示意圖

字串常量池記憶體圖

  • 當我們使用new關鍵字建立字串物件的時候, JVM將不會查詢字串常量池, 它將會直接在堆記憶體中建立一個字串物件, 並返回給所屬變數.
  • 所以s1和s2指向的是兩個完全不同的物件, 判斷s1 == s2的時候會返回false.

 

如果上面的知識理解起來沒有問題的話, 下面看些難點的.

public static void main(String[] args) {
    String s1 = new String("hello ") + new String("world");
    s1.intern();
    String s2 = "hello world";
    System.out.println(s1 == s2);   //true
}
  • 第一行程式碼String s1 = new String("hello ") + new String("world");的執行過程是這樣子的:
  1. 依次在堆記憶體中建立"hello "和"world"兩個字串物件
  2. 然後把它們拼接起來 (底層使用StringBuilder實現, 後面會帶大家讀反編譯程式碼)
  3. 在拼接完成後會產生新的"hello world"物件, 這時變數s1指向新物件"hello world".
  • 執行完第一行程式碼後, 記憶體是這樣子的:

字串常量池記憶體圖

 

  • 第二行程式碼s1.intern();
  • String類的原始碼中有對intern()方法的詳細介紹, 翻譯過來的意思是: 當呼叫intern()方法時, 首先會去常量池中查詢是否有該字串對應的引用, 如果有就直接返回該字串; 如果沒有, 就會在常量池中註冊該字串的引用, 然後返回該字串.
  • 由於第一行程式碼採用的是new的方式建立字串, 所以在字串常量池中沒有儲存"hello world"對應的引用, 虛擬機器會在常量池中進行註冊, 註冊完後的記憶體示意圖如下:

字串常量池記憶體圖

 

  • 第三行程式碼String s2 = "hello world";
  • 這種直接通過雙引號""宣告字串背後的執行機制我們在第一個案例提到過, 這裡正好複習一下.
  • 首先虛擬機器會去檢查字串常量池, 發現有指向"hello world"的引用. 然後把該引用所指向的字串直接返回給所屬變數.
  • 執行完第三行程式碼後, 記憶體示意圖如下:

不再怕面試被考字串---詳解Java中的字串

  • 如圖所示, s1和s2指向的是相同的物件, 所以當判斷s1 == s2時返回true.

 

  • 最後我們對字串常量池進行總結: 當用new關鍵字建立字串物件時, 不會查詢字串常量池; 當用雙引號直接宣告字串物件時, 虛擬機器將會查詢字串常量池. 說白了就是: 字串常量池提供了字串的複用功能, 除非我們要顯式建立新的字串物件, 否則對同一個字串虛擬機器只會維護一份拷貝.

 

配合反編譯程式碼驗證字串初始化操作.

  • 相信看到這裡, 再見到有關的面試題, 你已經無所畏懼了, 因為你已經懂得了背後原理.
  • 在結束之前我們不妨再做一道壓軸題
public class Main {
    public static void main(String[] args) {
        String s1 = "hello ";
        String s2 = "world";
        String s3 = s1 + s2;
        String s4 = "hello world";
        System.out.println(s3 == s4);
    }
}

這道壓軸題是經過精心設計的, 它不但照應上面所講的字串常量池知識, 也引出了後面的話題.

  • 如果看這篇文章是你第一次往底層探索字串的經歷, 那我估計你不能立即給出答案. 因為我第一次見這幾行程式碼時也卡殼了.
  • 首先第一行和第二行是常規的字串物件宣告, 我們已經很熟悉了, 它們分別會在堆記憶體建立字串物件, 並會在字串常量池中進行註冊.
  • 影響我們做出判斷的是第三行程式碼String s3 = s1 + s2;, 我們不知道s1 + s2在建立完新字串"hello world"後是否會在字串常量池進行註冊. 說白了就是我們不知道這行程式碼是以雙引號""形式宣告字串, 還是用new關鍵字建立字串.
  • 這時, 我們應該去讀一讀這段程式碼的反編譯程式碼. 如果你沒有讀過反編譯程式碼, 不妨藉此機會入門.
  • 在命令列中輸入javap -c 對應.class檔案的絕對路徑, 按回車後即可看到反編譯檔案的程式碼段.
C:\Users\liuyj>javap -c C:\Users\liuyj\IdeaProjects\Test\target\classes\forTest\Main.class
Compiled from "Main.java"
public class forTest.Main {
  public forTest.Main();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: ldc           #2                  // String hello
       2: astore_1
       3: ldc           #3                  // String world
       5: astore_2
       6: new           #4                  // class java/lang/StringBuilder
       9: dup
      10: invokespecial #5                  // Method java/lang/StringBuilder."<init>":()V
      13: aload_1
      14: invokevirtual #6                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      17: aload_2
      18: invokevirtual #6                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      21: invokevirtual #7                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
      24: astore_3
      25: ldc           #8                  // String hello world
      27: astore        4
      29: getstatic     #9                  // Field java/lang/System.out:Ljava/io/PrintStream;
      32: aload_3
      33: aload         4
      35: if_acmpne     42
      38: iconst_1
      39: goto          43
      42: iconst_0
      43: invokevirtual #10                 // Method java/io/PrintStream.println:(Z)V
      46: return
}
  • 首先呼叫構造器完成Main類的初始化
  • 0: ldc #2 // String hello
  • 從常量池中獲取"hello "字串並推送至棧頂, 此時拿到了"hello "的引用
  • 2: astore_1
  • 將棧頂的字串引用存入第二個本地變數s1, 也就是s1已經指向了"hello "
  • 3: ldc #3 // String world
  • 5: astore_2
  • 重複開始的步驟, 此時變數s2指向"word"
  • 6: new #4 // class java/lang/StringBuilder
  • 刺激的東西來了: 這時建立了一個StringBuilder, 並把其引用值壓到棧頂
  • 9: dup
  • 複製棧頂的值, 並繼續壓入棧定, 也就意味著棧從上到下有兩份StringBuilder的引用, 將來要操作兩次StringBuilder.
  • 10: invokespecial #5 // Method java/lang/StringBuilder."<init>":()V
  • 呼叫StringBuilder的一些初始化方法, 靜態方法或父類方法, 完成初始化.
  • 13: aload_1
  • 把第二個本地變數也就是s1壓入棧頂, 現在棧頂從上往下數兩個資料依次是:s1變數和StringBuilder的引用
  • 14: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
  • 呼叫StringBuilder的append方法, 棧頂的兩個資料在這裡呼叫方法時就用上了.
  • 接下來又呼叫了一次append方法(之前StringBuilder的引用拷貝兩份就用途在此)
  • 完成後, StringBuilder中已經拼接好了"hello world", 看到這裡相信大家已經明白虛擬機器是如何拼接字串的了. 接下來就是關鍵環節

 

  • 21: invokevirtual #7 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
  • 24: astore_3
  • 拼接完字串後, 虛擬機器呼叫StringBuilder的toString()方法獲得字串hello world, 並存放至s3.
  • 激動人心的時刻來了, 我們之所以不知道這道題的答案是因為不知道字串拼接後是以new的形式還是以雙引號""的形式建立字串物件.
  • 下面是我們追蹤StringBuilder的toString()方法原始碼:
    @Override
    public String toString() {
        // Create a copy, don't share the array
        return new String(value, 0, count);
    }
  • ok, 這道題解了, s3是通過new關鍵字獲得字串物件的.
  • 回到題目, 也就是說字串常量表中沒有儲存"hello world"的引用, 當s4以引號的形式宣告字串時, 由於在字串常量池中查不到相應的引用, 所以會在堆記憶體中新建立一個字串物件. 所以s3和s4指向的不是同一個字串物件, 結果為false.

 

詳解字串操作類

  • 明白了字串常量池, 我相信關於字串的建立你已經有十足的把握了. 但是這還不夠, 作為一名合格的Java工程師, 我們還必須對字串的操作做到了如指掌. 注意! 不是說你不用查api能熟練操作字串就瞭如指掌了, 而是說對String, StringBuilder, StringBuffer三大字串操作類背後的實現瞭然於胸, 這樣才能在開發的過程中做出正確, 高效的選擇.

 

String, StringBuilder, StringBuffer的底層實現

  • 點進String的原始碼, 我們可以看見String類是通過char型別陣列實現的.
public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {
    /** The value is used for character storage. */
    private final char value[];
    ...
}    

 

  • 接著檢視StringBuilder和StringBuffer的原始碼, 我們發現這兩者都繼承自AbstractStringBuilder類, 通過檢視該類的原始碼, 得知StringBuilder和StringBuffer兩個類也是通過char型別陣列實現的
abstract class AbstractStringBuilder implements Appendable, CharSequence {
    /**
     * The value is used for character storage.
     */
    char[] value;
    ...
}
  • 而且通過StringBuilder和StringBuffer繼承自同一個父類這點, 我們可以推斷出它倆的方法都是差不多的. 通過檢視原始碼也發現確實如此, 只不過StringBuffer在方法上新增了synchronized關鍵字, 證明它的方法絕大多數方法都是執行緒同步方法. 也就是說在多執行緒的環境下我們應該使用StringBuffer以保證執行緒安全, 在單執行緒環境下我們應使用StringBuilder以獲得更高的效率.

  • 既然如此, 我們的比較也就落到了StringBuilder和String身上了.

 

關於StringBuilder和String之間的討論

  • 通過檢視StringBuilder和String的原始碼我們會發現兩者之間一個關鍵的區別: 對於String, 凡是涉及到返回引數型別為String型別的方法, 在返回的時候都會通過new關鍵字建立一個新的字串物件; 而對於StringBuilder, 大多數方法都會返回StringBuilder物件自身.
/**
 * 下面擷取幾個String類的方法
 */
public String substring(int beginIndex) {
    if (beginIndex < 0) {
        throw new StringIndexOutOfBoundsException(beginIndex);
    }
    int subLen = value.length - beginIndex;
    if (subLen < 0) {
        throw new StringIndexOutOfBoundsException(subLen);
    }
    return (beginIndex == 0) ? this : new String(value, beginIndex, subLen);
}

public String concat(String str) {
    int otherLen = str.length();
    if (otherLen == 0) {
        return this;
    }
    int len = value.length;
    char buf[] = Arrays.copyOf(value, len + otherLen);
    str.getChars(buf, len);
    return new String(buf, true);
}

/**
 * 下面擷取幾個StringBuilder類的方法
 */
@Override
public StringBuilder append(String str) {
    super.append(str);
    return this;
}

@Override
public StringBuilder replace(int start, int end, String str) {
    super.replace(start, end, str);
    return this;
}

 

  • 就因為這點區別, 使得兩者在操作字串時在不同的場景下會體現出不同的效率.
  • 下面還是以拼接字串為例比較一下兩者的效能
public class Main {
    public static int time = 50000;

    public static void main(String[] args) {
        long start = System.currentTimeMillis();
        String s = "";
        for(int i = 0; i < time; i++){
            s += "test";
        }
        long end = System.currentTimeMillis();
        System.out.println("String類使用時間: " + (end - start) + "毫秒");

    }
}
//String類使用時間: 4781毫秒
public class Main {
    public static int time = 50000;

    public static void main(String[] args) {
        long start = System.currentTimeMillis();
        StringBuilder sb = new StringBuilder();
        for(int i = 0; i < time; i++){
            sb.append("test");
        }
        long end = System.currentTimeMillis();
        System.out.println("StringBuilder類使用時間: " + (end - start) + "毫秒");

    }
}
//StringBuilder類使用時間: 5毫秒
  • 就拼接5萬次字串而言, StringBuilder的效率是String類的956倍.
  • 我們再次通過反編譯程式碼看看造成兩者效能差距的原因, 先看String類. (為了方便閱讀程式碼, 我刪除了計時部分的程式碼, 並重新編譯, 得到的main方法反編譯程式碼如下)
public static void main(java.lang.String[]);
    Code:
       0: ldc           #2                  // String, 將""空字串載入到棧頂
       2: astore_1                          //存放到s變數中
       3: iconst_0                          //把int型數0壓棧
       4: istore_2                          //存到變數i中
       5: iload_2                           //把i的值壓到棧頂(0)
       6: getstatic     #3                  // Field time:I 拿到靜態變數time的值, 壓到棧頂
       9: if_icmpge     38                  // 比較棧頂兩個int值, for迴圈中的判定, 如果i比time小就繼續執行, 否則跳轉
       
//從這裡開始, 就是for迴圈部分
      12: new           #4                  // class java/lang/StringBuilder
      15: dup
      16: invokespecial #5                  // Method java/lang/StringBuilder."<init>":()V
      19: aload_1
      20: invokevirtual #6                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      23: ldc           #7                  // String test
      25: invokevirtual #6                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      28: invokevirtual #8                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
      31: astore_1                          //每拼接完一次, 就把新的字串物件引用儲存在第二個本地變數中
//到這裡一次for迴圈結束
      32: iinc          2, 1                //變數i加1
      35: goto          5                   //繼續迴圈
      38: return
  • 從反彙編程式碼中可以看到, 當用String類拼接字串時, 每次都會生成一個StringBuilder物件, 然後呼叫兩次append()方法把字串拼接好, 最後通過StringBuilder的toString()方法new出一個新的字串物件.
  • 也就是說每次拼接都會new出兩個物件, 並進行兩次方法呼叫, 如果拼接的次數過多, 建立物件所帶來的時延會降低系統效率, 同時會造成巨大的記憶體浪費. 而且當記憶體不夠用時, 虛擬機器會進行垃圾回收, 這也是一項相當耗時的操作, 會大大降低系統效能.

 

  • 下面是使用StringBuilder拼接字串得到的反編譯程式碼.
  public static void main(java.lang.String[]);
    Code:
       0: new           #2                  // class java/lang/StringBuilder
       3: dup
       4: invokespecial #3                  // Method java/lang/StringBuilder."<init>":()V
       7: astore_1
       8: iconst_0
       9: istore_2
      10: iload_2
      11: getstatic     #4                  // Field time:I
      14: if_icmpge     30

//從這裡開始執行for迴圈內的程式碼
      17: aload_1
      18: ldc           #5                  // String test
      20: invokevirtual #6                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      23: pop
//到這裡一次for迴圈結束      
      24: iinc          2, 1
      27: goto          10
      30: return
  • 可以看到StringBuilder拼接字串就簡單多了, 直接把要拼接的字串放到棧頂進行append就完事了, 除了開始時建立了StringBuilder物件, 執行時期沒有建立過其他任何物件, 每次迴圈只呼叫一次append方法. 所以從效率上看, 拼接大量字串時, StringBuilder要比String類給力得多.

 

  • 當然String類也不是沒有優勢的, 從操作字串api的豐富度上來講, String是要多於StringBuilder的, 在日常操作中很多業務都需要用到String類的api.
  • 在拼接字串時, 如果是簡單的拼接, 比如說String s = "hello " + "world";, String類的效率會更高一點.
  • 但如果需要拼接大量字串, StringBuilder無疑是更合適的選擇.

 

  • 講到這裡, Java中的字串背後的原理就講得差不多, 相信在瞭解虛擬機器操作字串的細節後, 你在使用字串時會更加得心應手. 字串是程式設計中一個重要的話題, 本文圍繞Java體系講解的字串知識只是字串知識的冰山一角. 字串操作的背後是資料結構和演算法的應用, 如何能夠以儘可能低的時間複雜度去操作字串, 又是一門大學問.

 

  • 最後歡迎關注我的公眾號, 我會在公眾號中持續更新系統的Java後端面試題分析, 將會囊括Java基礎知識到主流框架原理, 力求深入每個知識點背後的原理. 還會分享關於程式設計的趣味漫畫.

相關文章