你有沒有想過: 為什麼Java中String是不可變的?

AlanKeene發表於2019-02-23

解答

有三點:

1)String 在底層是用一個 private final 修飾的字元陣列 value 來儲存字串的。final 修飾符保證了 value 這個引用變數是不可變的,private 修飾符則保證了 value 是類私有的,不能通過物件例項去訪問和更改 value 陣列裡存放的字元。

注:有很多地方說 String 不可變是 final 起的作用,其實不嚴謹。因為即使我不用 final 修改 value ,但初始化完成後我能保證以後都不更改 value 這個引用變數和 value[] 陣列裡存放的值,它也是從沒變化過的。final 只是保證了 value 這個引用變數是不能更改的,但不能保證 value[] 陣列裡存放的字元是不能更改的。如果把 private 改為 public 修飾,String類的物件是可以通過訪問 value 去更改 value[] 陣列裡存放的字元的,這時 String 就不再是不可變的了。所以不如說 private 起的作用更大一些。後面我們會通過 程式碼1處 去驗證。

2)String 類並沒有對外暴露可以修改 value[] 陣列內容的方法,並且 String 類內部對字串的操作和改變都是通過新建一個 String 物件去完成的,操作完返回的是新的 String 物件,並沒有改變原來物件的 value[] 陣列。

注:String 類如果對外暴露可以更改 value[] 陣列的方法,如 setter 方法,也是不能保證 String 是不可變的。後面我們會通過 程式碼2處 去驗證。

3)String 類是用 final 修飾的,保證了 String 類是不能通過子類繼承去破壞或更改它的不可變性的。

注:如果 String 類不是用 final 修飾的,也就是 String 類是可以被子類繼承的,那子類就可以改變父類原有的方法或屬性。後面我們會通過 程式碼3處 去驗證。

以上三個條件同時滿足,才讓 String 類成了不可變類,才讓 String 類具有了一旦例項化就不能改變它的內容的屬性。

public final class String implements Serializable, Comparable<String>, CharSequence {
    private final char[] value; // 用 private final 修飾的字元陣列儲存字串
    private int hash;
    private static final long serialVersionUID = -6849794470754667710L;
	
	public String() {
        this.value = "".value; 
    }

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

    public String(char[] var1) {
        this.value = Arrays.copyOf(var1, var1.length);
    }
    ......
}
複製程式碼

面試問題:String 類是用什麼資料結構來儲存字串的?

由上面 String 的原始碼可見,String 類是用陣列的資料結構來儲存字串的

程式碼1處:

我們來看看如果把 private 修飾符換成 public,看看會發生什麼?

// 先來模擬一個String類,初始化的時候將 String 轉成 value 陣列儲存
public final class WhyStringImutable {
   public final char[] value;  // 修飾符改成了 public 
   
   public WhyStringImutable() {
       this.value = "".toCharArray();
   }
   
   public WhyStringImutable(String str){
       this.value = str.toCharArray(); // 初始化時轉為字元陣列
   }
   
   public char[] getValue(){
       return this.value;
   }
}
複製程式碼
public class WhyStringImutableTest {
    public static void main(String[] args) {
        WhyStringImutable str = new WhyStringImutable("abcd");
        System.out.println("原str中value陣列的內容為:");
        System.out.println(str.getValue()); // 列印str物件中存放的字元陣列
        System.out.println("----------");
        str.value[1] = 'e'; // 通過物件例項訪問value陣列並修改其內容
        System.out.println("修改後str中value陣列的內容為:");
        System.out.println(str.getValue()); // 列印str物件中存放的字元陣列
   }
}
複製程式碼

輸出結果:

原str中value陣列的內容為:
abcd
----------
修改後str中value陣列的內容為:
aecd
複製程式碼

由此可見,private 修改為 public 後,String 是可以通過物件例項訪問並修改所儲存的value 陣列的,並不能保證 String 的不可變性。

程式碼2處:

我們如果對外暴露可以更改 value[] 陣列的方法,如 setter 方法,看看又會發生什麼?

public final class WhyStringImutable {
    private final char[] value;

    public WhyStringImutable() {
        this.value = "".toCharArray();
    }

    public WhyStringImutable(String str){
        this.value = str.toCharArray();
    }
	
	// 對外暴露可以修改 value 陣列的方法
    public void setValue(int i, char ch){
        this.value[i] = ch;
    }
    
    public char[] getValue(){
        return this.value;
    }

}
複製程式碼
public class WhyStringImutableTest {
    public static void main(String[] args) {
        WhyStringImutable str = new WhyStringImutable("abcd");
        System.out.println("原str中value陣列的內容為:");
        System.out.println(str.getValue()); // 列印str物件中存放的字元陣列
        System.out.println("----------");
        str.setValue(1,'e'); // 通過set方法改變指定位置的value陣列元素
        System.out.println("修改後str中value陣列的內容為:");
        System.out.println(str.getValue()); // 列印str物件中存放的字元陣列
   }
}
複製程式碼

輸出結果:

原str中value陣列的內容為:
abcd
----------
修改後str中value陣列的內容為:
aecd
複製程式碼

由此可見,如果對外暴露了可以更改 value[] 陣列內容的方法,也是不能保證 String 的不可變性的。

程式碼3處:

如果 WhyStringImutable 類去掉 final 修飾,其他的保持不變,又會怎樣呢?

public class WhyStringImutable {
    private final char[] value;
    
    public WhyStringImutable() {
        this.value = "".toCharArray();
    }
    
    public WhyStringImutable(String str){
        this.value = str.toCharArray(); // 初始化時轉為字元陣列
    }
    
    public char[] getValue(){
        return this.value;
    }
}
複製程式碼

寫一個子類繼承自WhyStringImutable 並修改原來父類的屬性,實現子類自己的邏輯:

public class WhyStringImutableChild extends WhyStringImutable {

    public char[] value; // 修改字元陣列為 public 修飾,不要 final 

    public WhyStringImutableChild(String str){
        this.value = str.toCharArray();
    }

    public WhyStringImutableChild() {
        this.value = "".toCharArray();
    }

    @Override
    public char[] getValue() {
        return this.value;
    }
}
複製程式碼
public class WhyStringImutableTest {
    public static void main(String[] args) {
        WhyStringImutableChild str = new WhyStringImutableChild("abcd");
        System.out.println("原str中value陣列的內容為:");
        System.out.println(str.getValue());
        System.out.println("----------");
        str.value[1] = 's';
        System.out.println("修改後str中value陣列的內容為:");
        System.out.println(str.getValue());
    }
}
複製程式碼

執行結果:

原str中value陣列的內容為:
abcd
----------
修改後str中value陣列的內容為:
ascd
複製程式碼

由此可見,如果 String 類不是用 final 修飾的,是可以通過子類繼承來修改它原來的屬性的,所以也是不能保證它的不可變性的。

總結

綜上所分析,String 不可變的原因是 JDK 設計者巧妙的設計瞭如上三點,保證了String 類是個不可變類,讓 String 具有了不可變的屬性。考驗的是工程師構造資料型別,封裝資料的功力,而不是簡單的用 final 來修飾,背後的設計思想值得我們理解和學習。

擴充

從上面的分析,我們知道,String 確實是個不可變的類,但我們就真的沒辦法改變 String 物件的值了嗎?不是的,通過反射可以改變 String 物件的值

但是請謹慎那麼做,因為一旦通過反射改變對應的 String 物件的值,後面再建立相同內容的 String 物件時都會是反射改變後的值,這時候在後面的程式碼邏輯執行時就會出現讓你 “摸不著頭腦” 的現象,具有迷惑性,出了奇葩的問題你也很難排除到原因。後面在 程式碼4處 我們會驗證這個問題。

先來看看如何通過反射改變 String 物件的內容:

public class WhyStringImutableTest {
    public static void main(String[] args) {
		String str = new String("123");
        System.out.println("反射前 str:"+str);
        try {
            Field field = String.class.getDeclaredField("value");
            field.setAccessible(true);
            char[] aa = (char[]) field.get(str);
            aa[1] = '1';
        } catch (NoSuchFieldException | IllegalAccessException e) {
            e.printStackTrace();
        }
        System.out.println("反射後 str:"+str);
}
複製程式碼

列印結果:

反射前 str:123
反射後 str:113 // 可見,反射後,str 的值確實改變了
複製程式碼

程式碼4處:

下面我們來驗證因為一旦通過反射改變對應的 String 物件的值,後面再建立相同內容的 String 物件時都會是反射改變後的值的問題:

public class WhyStringImutableTest {
    public static void main(String[] args) {
		String str = new String("123");
        System.out.println("反射前 str:"+str);
        try {
            Field field = String.class.getDeclaredField("value");
            field.setAccessible(true);
            char[] aa = (char[]) field.get(str);
            aa[1] = '1';
        } catch (NoSuchFieldException | IllegalAccessException e) {
            e.printStackTrace();
        }
        System.out.println("反射後 str:"+str);
        
        String str2 = new String("123");
      	System.out.println("str2:"+str2); // 我們來看 str2 會輸出什麼,會輸出 113?
        System.out.println("判斷是否是同一物件:"+str == str2); // 判斷 str 和 str2 的記憶體地址值是否相等
        System.out.println("判斷內容是否相同:"+str.equals(str2)); // 判斷 str 和 str2 的內容是否相等
}
複製程式碼

執行結果如下:

反射前 str:123
反射後 str:113
str2:113 // 竟然不是123??而是輸出113,說明 str2 也是反射修改後的值。
判斷是否是同一物件:false // 輸出 false,說明在記憶體中確實建立了兩個不同的物件
判斷內容是否相同:true   // 輸出true,說明依然判斷為兩個物件內容是相等的
複製程式碼

由上面的輸出結果,我們可知,反射後再新建相同內容的字串物件時會是反射修改後的值,這就造成了很大迷惑性,在實際開發中要謹慎這麼做。

相關文章