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

字符串常量池詳解

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

 

  • 首先得搞懂字符串常量池的概念.
  • 常量池是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"的引用. 而後把該引用所指向的字符串直接返回給所屬變量.
  • 執行完第三行代碼後, 內存示意圖以下:

  • 如圖所示, 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身上了.api

 

關於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拼接字符串獲得的反編譯代碼.bash

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體系講解的字符串知識只是字符串知識的冰山一角. 字符串操做的背後是數據結構和算法的應用, 如何可以以儘量低的時間複雜度去操做字符串, 又是一門大學問.
相關文章
相關標籤/搜索