String對象不可變剖析

何爲不可變

        如何理解String類型值的不可變,首先需要理解何爲不可變。
         對於Java而言,除了primitive type值(即int, long, double等),其餘的都是對象。提煉一下,更普遍的問題是:如何理解不可變對象?
        對於何爲不可變對象,JLS並沒有給出一個明確定義。《java concurrency in practice》一書給出了一個粗略的定義:對象一旦創建後,其狀態不可修改,則該對象爲不可變對象。一般一個對象滿足以下三點,則可以稱爲是不可變對象:

  1. 其狀態不能在創建後再修改;
  2. 所有域都是final類型;
  3. 其構造函數構造對象期間,this引用沒有泄露。

        這裏重點說明一下第2點,一個對象其所有域都是final類型,該對象也可能是可變對象。因爲final關鍵字只是限制對象的域的引用不可變,但無法限制通過該引用去修改其對應域的內部狀態。因此,嚴格意義上的不可變對象,其final關鍵字修飾的域應該也是不可變對象和primitive type值。

PS:從技術上講,不可變對象內部域並不一定全都聲明爲final類型,String類型即是如此。在String對象的內部我們可以看到有一個名爲hash的域並不是final類型,這是因爲String類型惰性計算hashcode並存儲在hash域中(這是通過其他final類型域來保證每次的hashcode計算結果必定是相同的)。
除此之外,String對象的不可變是由於對String類型的所有改變內部存儲結構的操作都會new出一個新的String對象。

1. 我們圖解一下上文的解釋 什麼是不可變? 

String不可變很簡單,如下圖,給一個已有字符串"abcd"第二次賦值成"abcedl",不是在原內存地址上修改數據,而是重新指向一個新對象,新地址。 

 2. String爲什麼不可變? 

        翻開JDK源碼,java.lang.String類起手前三行,是這樣寫的: 

public final class String implements java.io.Serializable, Comparable<String>, CharSequence { 
    /** String本質是個char數組. 而且用final關鍵字修飾.*/ 
    private final char value[]; 
... 
... 
} 

        首先String類是用final關鍵字修飾,這說明String不可繼承。再看下面,String類的主力成員字段value是個char[ ]數組,而且是用final修飾的。final修飾的字段創建以後就不可改變。 

        有的人以爲故事就這樣完了,其實沒有。因爲雖然value是不可變,也只是value這個引用地址不可變。擋不住Array數組是可變的事實。Array的數據結構看下圖, 

       也就是說Array變量只是stack上的一個引用,數組的本體結構在heap堆。String類裏的value用final修飾,只是說stack裏的這個叫value的引用地址不可變。沒有說堆裏array本身數據不可變。看下面這個例子, 

final int[] value={1,2,3} 
int[] another={4,5,6}; 
value=another;    //編譯器報錯,final不可變 

       value用final修飾,編譯器不允許我把value指向堆區另一個地址。但如果我直接對數組元素動手,分分鐘搞定。 

       所以String是不可變,關鍵是因爲SUN公司的工程師,在後面所有String的方法裏很小心的沒有去動Array裏的元素,沒有暴露內部成員字段。private final char value[]這一句裏,private的私有訪問權限的作用都比final大。而且設計師還很小心地把整個String設成final禁止繼承,避免被其他人繼承後破壞。所以String是不可變的關鍵都在底層的實現,而不是一個final。考驗的是工程師構造數據類型,封裝數據的功力。 

3. 不可變有什麼好處? 

       這個最簡單地原因,就是爲了安全。看下面這個場景(有評論反應例子不夠清楚,現在完整地寫出來),一個函數appendStr( )在不可變的String參數後面加上一段“bbb”後返回。appendSb( )負責在可變的StringBuilder後面加“bbb”。 

class Test{ 
    //不可變的String 
    public static String appendStr(String s){ 
        s+="bbb"; 
        return s; 
    } 
 
    //可變的StringBuilder 
    public static StringBuilder appendSb(StringBuilder sb){ 
        return sb.append("bbb"); 
    } 
 
    public static void main(String[] args){ 
        //String做參數 
        String s=new String("aaa"); 
        String ns=Test.appendStr(s); 
        System.out.println("String aaa >>> "+s.toString()); 
 
        //StringBuilder做參數 
        StringBuilder sb=new StringBuilder("aaa"); 
        StringBuilder nsb=Test.appendSb(sb); 
        System.out.println("StringBuilder aaa >>> "+sb.toString()); 
    } 
} 
 
//Output:  
//String aaa >>> aaa 
//StringBuilder aaa >>> aaabbb  

       如果程序員不小心像上面例子裏,直接在傳進來的參數上加"bbb",因爲Java對象參數傳的是引用,所以可變的的StringBuffer參數就被改變了。可以看到變量sb在Test.appendSb(sb)操作之後,就變成了"aaabbb"。有的時候這可能不是程序員的本意。所以String不可變的安全性就體現在這裏。 

       再看下面這個HashSet用StringBuilder做元素的場景,問題就更嚴重了,而且更隱蔽。 

class Test{ 
    public static void main(String[] args){ 
        HashSet<StringBuilder> hs=new HashSet<StringBuilder>(); 
        StringBuilder sb1=new StringBuilder("aaa"); 
        StringBuilder sb2=new StringBuilder("aaabbb"); 
        hs.add(sb1); 
        hs.add(sb2);    //這時候HashSet裏是{"aaa","aaabbb"} 
 
        StringBuilder sb3=sb1; 
        sb3.append("bbb");  //這時候HashSet裏是{"aaabbb","aaabbb"} 
        System.out.println(hs); 
    } 
} 
//Output: 
//[aaabbb, aaabbb] 

       StringBuilder型變量sb1和sb2分別指向了堆內的字面量"aaa"和"aaabbb"。把他們都插入一個HashSet。到這一步沒問題。但如果後面我把變量sb3也指向sb1的地址,再改變sb3的值,因爲StringBuilder沒有不可變性的保護,sb3直接在原先"aaa"的地址上改。導致sb1的值也變了。這時候,HashSet上就出現了兩個相等的鍵值"aaabbb"。破壞了HashSet鍵值的唯一性。所以千萬不要用可變類型做HashMap和HashSet鍵值。 

       還有一個大家都知道,就是在併發場景下,多個線程同時讀一個資源,是不會引發竟態條件的。只有對資源做寫操作纔有危險。不可變對象不能被寫,所以線程安全。 

      最後別忘了String另外一個字符串常量池的屬性。像下面這樣字符串one和two都用字面量"something"賦值。它們其實都指向同一個內存地址。 

String one = "someString"; 
String two = "someString"; 

 

       這樣在大量使用字符串的情況下,可以節省內存空間,提高效率。但之所以能實現這個特性,String的不可變性是最基本的一個必要條件。要是內存裏字符串內容能改來改去,這麼做就完全沒有意義了。 

       前兩天有個同學看書看到System.arraycopy()函數是淺拷貝(只拷貝對象的引用,而非對象本身的複製),就試了下,下面代碼把字符串數組s1的3個元素拷貝到s2, 

String[] s1 = new String[]{"Hi","Hi","Hi"}; 
String[] s2 = new String[s1.length]; 
System.arraycopy(s1,0,s2,0,s1.length);  

       然後他修改了s1中某個元素的值,希望看到s2中對應元素的值也改變,但結果卻沒有。 

s1[2] = "Hier"; 
System.out.println(Arrays.toString(s1)); // output: [Hi,Hi,Hier] 
 
// 他期待的輸出:[Hi,Hi,Hier] 
// 實際輸出: [Hi,Hi,Hi] 
System.out.println(Arrays.toString(s1));  

       問題就出在String的不可變性上。s1[2]="Hier"爲元素賦值, 實際上不是將s1[2]元素的值改成Hier,而是重新指向了一個新的字符串對象。  

       同樣的問題還會出在自動裝箱類,用int型爲Integer對象賦值時,實際是創建了一個新對象(這點自動裝箱和String有點不同,只有[-128,127]區間的數字,虛擬機纔會緩存)。  

Integer i = 1000; 
Integer j = 1000; 
System.out.println(i == j); // false 
 
Integer i = 10; 
Integer j = 10; 
System.out.println(i == j); // true 

 

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