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

解答

有三點:
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,說明依然判斷爲兩個對象內容是相等的

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

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