【JDK原始碼分析】String的儲存區與不可變性

大雄45發表於2020-03-08
導讀
我們有時會發生疑惑:為什麼透過字串常量例項化的String型別物件是一樣的,而透過new所建立String物件卻不一樣呢?且看下面分解。
1. 資料儲存區

String是一個比較特殊的類,除了new之外,還可以用字面常量來定義。為了弄清楚這二者間的區別,首先我們得明白JVM執行時資料儲存區,這裡有一張圖對此有清晰的描述:
【JDK原始碼分析】String的儲存區與不可變性【JDK原始碼分析】String的儲存區與不可變性

非共享資料儲存區

非共享資料儲存區是線上程啟動時被建立的,包括:

  • 程式計數器(program counter register)控制執行緒的執行;
  • 棧(JVM Stack, Native Method Stack)儲存方法呼叫與物件的引用等。
  • 共享資料儲存區

    該儲存區被所有執行緒所共享,可分為:

  • 堆(Heap)儲存所有的Java物件,當執行new物件時,會在堆裡自動進行記憶體分配。
  • 方法區(Method Area)儲存常量池(run-time constant pool)、欄位與方法的資料、方法與構造器的程式碼。
  • 2. 兩種例項化

    例項化String物件:

public class StringLiterals {
    public static void main(String[] args) {
        String one = "Test";
        String two = "Test";
        String three = "T" + "e" + "s" + "t";
        String four = new String("Test");
    }
}

javap -c StringLiterals反編譯生成位元組碼,我們選取感興趣的部分如下:

  public static void main(java.lang.String[]);
    Code:
       0: ldc           #2                  // String Test
       2: astore_1
       3: ldc           #2                  // String Test
       5: astore_2
       6: ldc           #2                  // String Test
       8: astore_3
       9: new           #3                  // class java/lang/String
      12: dup
      13: ldc           #2                  // String Test
      15: invokespecial #4                  // Method java/lang/String."": (Ljava/lang/String;)V
      18: astore        4
      20: return
}

ldc #2表示從常量池中取#2的常量入棧,astore_1表示將引用存在本地變數1中。因此,我們可以看出:物件one、two、three均指向常量池中的字面常量"Test";物件four是在堆中new的新物件;如下圖所示:
【JDK原始碼分析】String的儲存區與不可變性【JDK原始碼分析】String的儲存區與不可變性
總結如下:

  • 當用字面常量例項化時,String物件儲存在常量池;
  • 當用new例項化時,String物件儲存在堆中;
  • 運算子==比較的是物件的引用,當其指向的物件不同時,則為false。因此,開篇中的程式碼會出現透過new所建立String物件不一樣。

    3. 不可變String
    String原始碼

    JDK7的String類:

    public final class String
        implements java.io.Serializable, Comparable, CharSequence {
        /** The value is used for character storage. */
        private final char value[];
        /** Cache the hash code for the string */
        private int hash; // Default to 0
    }

    String類被宣告為final,不可以被繼承,所有的方法隱式地指定為final,因為無法被覆蓋。欄位char value[]表示String類所對應的字串,被宣告為private final;即初始化後不能被修改。常用的new例項化物件String s1 = new String("abcd");的構造器:

    public String(String original) {
        this.value = original.value;
        this.hash = original.hash;
    }

    只需將value與hash的欄位值進行傳遞即可。

    不可變性

    所謂不可變性(immutability)指類不可以透過常用的API被修改。為了更好地理解不可變性,我們先來看《Thinking in Java》中的一段程式碼:

    //: operators/Assignment.java
    // Assignment with objects is a bit tricky.
    import static net.mindview.util.Print.*;
    class Tank {
      int level;
    }   
    public class Assignment {
      public static void main(String[] args) {
        Tank t1 = new Tank();
        Tank t2 = new Tank();
        t1.level = 9;
        t2.level = 47;
        print("1: t1.level: " + t1.level +
              ", t2.level: " + t2.level);
        t1 = t2;
        print("2: t1.level: " + t1.level +
              ", t2.level: " + t2.level);
        t1.level = 27;
        print("3: t1.level: " + t1.level +
              ", t2.level: " + t2.level);
      }
    } /* Output:
    1: t1.level: 9, t2.level: 47
    2: t1.level: 47, t2.level: 47
    3: t1.level: 27, t2.level: 27
    *///:~

    上述程式碼中,在賦值操作t1 = t2;之後,t1、t2包含的是相同的引用,指向同一個物件。因此對t1物件的修改,直接影響了t2物件的欄位改變。顯然,Tank類是可變的。

    也許,有人會說s = s.concat("ef");不是修改了物件s麼?而事實上,我們去看concat的實現,會發現其返回的是新String物件(return new String(buf, true););改變的只是s1引用所指向的物件,如下圖所示:
    【JDK原始碼分析】String的儲存區與不可變性【JDK原始碼分析】String的儲存區與不可變性

    4. 反射

    String的value欄位是final的,可不可以透過過某種方式修改呢?答案是反射。在stackoverflow上有這樣一段修改value欄位的程式碼:

    String s1 = "Hello World";  
    String s2 = "Hello World";  
    String s3 = s1.substring(6);  
    System.out.println(s1); // Hello World  
    System.out.println(s2); // Hello World  
    System.out.println(s3); // World  
    Field field = String.class.getDeclaredField("value");  
    field.setAccessible(true);  
    char[] value = (char[])field.get(s1);  
    value[6] = 'J';  
    value[7] = 'a';  
    value[8] = 'v';  
    value[9] = 'a';  
    value[10] = '!';  
    System.out.println(s1); // Hello Java!  
    System.out.println(s2); // Hello Java!  
    System.out.println(s3); // World

    在上述程式碼中,為什麼物件s2的值也會被修改,而物件s3的值卻不會呢?根據前面的介紹,s1與s2指向同一個物件;所以當s1被修改後,s2也會對應地被修改。至於s3物件為什麼不會?我們來看看substring()的實現:

    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);
    }

    當beginIndex不為0時,返回的是new的String物件;當beginIndex為0時,返回的是原物件本身。如果將String s3 = s1.substring(6);改為String s3 = s1.substring(0);,那麼物件s3也會被修改了。

    如果仔細看java.lang.String.java,我們會發現:當需要改變字串內容時,String類的方法返回的是新String物件;如果沒有改變,String類的方法則返回原物件引用。這節省了儲存空間與額外的開銷。

    原文來自: 


    來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69955379/viewspace-2679060/,如需轉載,請註明出處,否則將追究法律責任。

    相關文章