【翻譯】為什么Java中的String不可變


筆主前言

眾所周知,String是Java的JDK中最重要的基礎類之一,在筆主心中的地位已經等同於int、boolean等基礎數據類型,是超越了一般Object引用類型的高端大氣上檔次的存在。

但是稍有研究的人就會發現,String對象是不可修改的,源代碼中的String類被定義為final,即為終態,不可繼承,String也不提供任何直接修改對象內部值的方法,每次使用replace、substring、trim等方法,或是使用字符串連接符+時,都是返回一個全新的String對象,整個String對象的值只能通過構造函數,在初始化對象實例時一次性輸入(當然Java語法允許直接使用雙引號方式快捷獲取String對象實例)。

如果需要動態修改、構造字符串,則需要通過StringBuilder或StringBuffer對象進行操作,並在最終輸出時通過toString()、substring()等方法得到String對象。直接使用String對象進行連接、增刪替換字符等操作,將不可避免地產生大量臨時String對象,影響CPU效率和增加資源回收負擔。

 

今天偶然看到一個外文文章,較為完整詳細客觀科學的論述了String類被如此設計成不可變結構的原因,下面筆主結合自己的理解,盡量通過淺顯的語言意譯成中文,科普一下知識。

原文鏈接:http://www.programcreek.com/2013/04/why-string-is-immutable-in-java/


 

為什么Java中的String是不可變的?

 

要解釋String被設計成不可變結構的原因,需要從存儲空間、同步性、數據類型等方面去分析。

 

解釋1:滿足 String Pool (String intern pool) 字符串保留池的需要

Java語法設計中專門針對String類型,提供了一個特殊的存儲機制,叫字符串保留池String intern pool,簡單點說,這個池是在內存堆中專門划分一塊空間,用來保存所有String對象數據,當程序猿構造一個新字符串String對象時,Java編譯機制會優先在這個池子里查找是否已經存在能滿足需要的String對象,如果有的話就直接返回該對象的地址引用(沒有的話就正常的構造一個新對象,丟進去存起來),因此實際上構造兩三個乃至成千上萬個同一句話的String對象,得到的是同一個對象引用,這能避免很多不必要的空間開銷。

然而如果String對象本身允許被二次修改值內容的話,其中一個引用對String對象的修改將不可避免地影響其他正在引用該對象的變量,誘發出不可預測的后果。

 

* 上面所說的機制,僅適用於使用以下語法構造String對象的場景:

String string1 = "abcd";
String string2 = "abcd";  // string1 == string2

String string1 = new String("abcd");
String string2 = new String("abcd");  // string1 != string2

 

解釋2:緩存Hashcode的需要

在HashMap等需要使用hashCode作為鍵值存儲地址的數據結構中,String對象常常作為這些數據結構的key值,常見地組合成如 HashMap<String, Object> 等類型的哈希表結構使用。

當HashMap需要隨機調取某個元素的時候(例如 hashMap.get("money"); ),HashMap將調用作為key值的String對象的hashCode()方法,獲取能代表這個對象的唯一數值hashCode,定位這個鍵值對的實際存儲地址,繼而可以像數組一樣通過array[index]這樣的下標方式直接訪問到目標元素。

由於String類型具備不可變的特性,因此在String對象內的hashCode()方法實際上只需執行一次計算過程,計算后把結果緩存到一個內部私有變量 int hash 中,而后每次需要調用這個String對象的hashCode()方法時,僅僅需要把上次的計算結果hash返回去即可,在物理上強效地保證了這個結果的絕對正確性,當HashMap需要頻繁的讀取訪問任意一組鍵值對的時候,能節省非常多的CPU計算開銷。

 

解釋3:協助其他對象的使用

* 這一部分看得不是很懂,就筆主的淺顯理解不能十分認同原文中的這個解釋理由,這一塊將按原文翻譯與筆主的理解觀點同步展示說明。

 

先展示一段不太真實的代碼:

HashSet<String> set = new HashSet<String>();
set.add(new String("a"));
set.add(new String("b"));
set.add(new String("c"));
 
for(String a: set)
    a.value = "a";

原文:在這個例子中,如果String是可變的,那么當它的值發生改變時將違反Set的設計(Set只能存儲相互唯一的元素)。這個代碼案例僅為簡單的目的而設計,實際上String類不存在value變量。

 

按筆主理解解釋

在這段代碼中,三個String對象依次使用HashSet的add()方法合法地添加到了set對象中。

此時如果String是內容可變的話,那么通過后面的for循環中 a.value = "a"; 這一句偽代碼,set對象中的三個成員變量都將變成String("a"),依據解釋1所提到的String Pool的情況,這三個對象有可能會變成指向同一個字符串String對象"a",即set內部存了三個相同的對象,而這種情況違背了Set類型的元素唯一性設計定義——Set中存儲的對象必需相互獨立唯一,不能重復。

退一步講,即使這三個對象依然是三個相互獨立的String對象"a",而根據String類設計的hashCode()算法,這三個獨立的String對象依然計算出了相同的hashCode值,顯然也是違反了HashSet的設計——三個對象同時指向了相同的存儲地址。

任何一種情況都將在Set對象的外部不可控地違反了Set或HashSet本身設計的規定,誘發出不可預測的后果,而在語法檢測上卻毫無問題地通過了。

 

* 之所以說 a.value = "a"; 是偽代碼,並非因為原文所說String類不存在value變量,而是因為String類內的私有變量 private final char value[] 是不允許外部操作的,另外在數組語法上也不允許按這種方式賦值,String中的value數組確實存儲的就是初始化傳入的字符串各字符數據,同時也是String計算hashCode算法的唯一依據。

** 如果嚴格定義String是內容可變這一前提,那么解釋1中提出的String Pool將無法實現,也就是說調用三次構造函數,必然返回三個互相獨立的對象,因此此例嚴格說並不會違反Set的元素唯一性定義,但依然會出現后面的相同hashCode值問題。

*** 一般而言,不同的對象通過hashCode()方法將得到不同且唯一的hashCode值,但由於這里假想的可變String內容被更換,而導致不同的String對象產生相同hashCode值,在HashSet/HashMap中將產生異常表現,在本文最后的補充環節,筆主將使用一小段代碼進行模擬演示。

 

解釋4:安全性

String被廣泛用於網絡連接、文件IO等多種Java基礎類的參數中,如果String內容可變的話,將潛在地帶來多種嚴重安全隱患,例如鏈接地址被暗中更改等,出於同樣的原因,在Java反射機制中可變String參數也會導致潛在的安全威脅。

例如以下網絡連接代碼示例(修改自原文):

boolean connect(string url){
    // 驗證url地址是否安全,不安全的網絡訪問將被異常拋出阻止
    if (!isSecure(url)) { 
        throw new SecurityException(); 
    }
    // 上一步url已通過安全檢驗,但如果url在這里能夠被(其他線程)其他引用修改,將觸發嚴重的安全威脅
    mayCauseProblemWhileOpen(url);
}

 

解釋5:不可變對象在物理上絕對性的線程安全

由於不可變對象內容不可能被修改,因此能在多線程中被任意自由訪問而不會導致任何線程安全問題,同時也就不需要再做任何多余的同步操作開銷。

 

總的來說,String的不可變特性設計就是出於效率和安全性的考慮,這也是其他類一般情況下更傾向於被設計成不可變特性的原因。

 


 

 

補充:當HashMap遇上可變對象並產生相同hashCode值...

在這里,筆主主要想討論在解釋3的***中提到的可變String對象產生相同hashCode值在HashMap中的異常表現問題。

先說明一個前提,String類中的hashCode()方法經過改寫,與Object中的hashCode()不同,String中的hashCode()計算的唯一依據是String對象本身的字符串內容,如果存在兩個內容同為"Monkey"的String對象,這兩個對象經過hashCode()計算出的hashCode值將完全相同,被HashMap視為同一個key對象。

 

可變String模擬類:

 1 package test;
 2 
 3 /**
 4  * 可變字符串模擬類
 5  * 
 6  * @since 2014-6-21 下午6:14:16
 7  * @author Wavky.Wand
 8  */
 9 public class ModifiableString {
10     private String mContent;
11 
12     public ModifiableString(String content) {
13         mContent = content;
14     }
15 
16     /**
17      * 更變字符串內容
18      * 
19      * @param mContent
20      *            the content to set
21      */
22     public void setContent(String mContent) {
23         this.mContent = mContent;
24     }
25 
26     @Override
27     public int hashCode() {
28         return mContent.hashCode();
29     }
30 
31     /**
32      * 依據Java類設計規則與HashMap需求,同時改寫equals()方法,當兩個對象hashCode相同時,equals()方法判斷兩個對象為相同
33      */
34     @Override
35     public boolean equals(Object obj) {
36         return obj.hashCode() == mContent.hashCode();
37     }
38 }

 

測試類:

  1 package test;
  2 
  3 import java.util.HashMap;
  4 
  5 /**
  6  * 
  7  * @since 2014-2-2 下午4:16:12
  8  * @author Wavky.Wand
  9  */
 10 public class Test {
 11 
 12     public static void main(String[] args) {
 13         ModifiableString m1 = new ModifiableString("123");
 14         ModifiableString m2 = new ModifiableString("456");
 15         ModifiableString m3 = new ModifiableString("789");
 16 
 17         // 輸出三個對象的hashCode
 18         out("m1 hashCode:" + m1.hashCode());    // 48690
 19         out("m2 hashCode:" + m2.hashCode());    // 51669
 20         out("m3 hashCode:" + m3.hashCode());    // 54648
 21         
 22         // 初始化HashMap
 23         HashMap<ModifiableString, String> map = new HashMap<ModifiableString, String>();
 24         map.put(m1, "A");
 25         map.put(m2, "B");
 26         map.put(m3, "C");
 27 
 28         // 輸出初始化完畢后的HashMap
 29         out("初始化完畢的HashMap");
 30         out("map size:" + map.size());         // 3
 31         out("map.get(m1):" + map.get(m1));     // A
 32         out("map.get(m2):" + map.get(m2));     // B
 33         out("map.get(m3):" + map.get(m3));     // C
 34 
 35         out("迭代輸出HashMap的所有key值");
 36         for (ModifiableString m : map.keySet()) {
 37             out(m);                // @c9d5 @be32 @d578
 38         }
 39         
 40         out("迭代輸出HashMap的所有key對應的value值");
 41         for (ModifiableString m : map.keySet()) {
 42             out(map.get(m));       // A B C 三個value值依次正常輸出
 43         }
 44 
 45         out("迭代輸出HashMap的所有value值");
 46         for (String s : map.values()) {
 47             out(s);                // A B C 三個value值依次正常輸出
 48         }
 49         
 50         // 更改m3的內容,與m1內容相同
 51         out("m3.setContent(123)");
 52         m3.setContent("123");
 53 
 54         // 輸出更改m3內容后的信息
 55         out("m1 hashCode:" + m1.hashCode());    // 48690
 56         out("m2 hashCode:" + m2.hashCode());    // 51669
 57         out("m3 hashCode:" + m3.hashCode());    // 48690
 58         out("m1==m3:" + (m1 == m3));            // false
 59         out("m1.equals(m3):" + m1.equals(m3));  // true
 60         out("重設m3內容后的HashMap");
 61         out("map size:" + map.size());        // 3
 62         out("map.get(m1):" + map.get(m1));    // A
 63         out("map.get(m2):" + map.get(m2));    // B
 64         // 因為m3內容與m1一致,hashCode與equal方法判斷m3與m1相等,因此HashMap返回m1的內容,
 65         // 而m3對應的value值C無法再通過key獲取,類似於內存泄露狀態
 66         out("map.get(m3):" + map.get(m3));    // A
 67 
 68         out("迭代輸出HashMap的所有key值");
 69         for (ModifiableString m : map.keySet()) {
 70             out(m);                // @be32 @c9d5 @be32 實際為16進制無符號hashCode值,第一個與第三個相同
 71         }
 72         
 73         out("迭代輸出HashMap的所有key對應的value值");
 74         for (ModifiableString m : map.keySet()) {
 75             out(map.get(m));       // A B A 無法通過任何一個key獲取到第三個value值C
 76         }
 77 
 78         out("迭代輸出HashMap的所有value值");
 79         for (String s : map.values()) {
 80             out(s);                // A B C 三個value值依次正常輸出
 81         }
 82 
 83         // 移除HashMap中的m3鍵值對
 84         out("map.remove(m3)");
 85         map.remove(m3);
 86 
 87         // 輸出更改m3內容后的信息
 88         out("刪除m3內容后的HashMap");
 89         out("map size:" + map.size());        // 2 HashMap內剩余兩條鍵值對
 90         out("map.get(m1):" + map.get(m1));    // null 通過m1獲取value,無返回
 91         out("map.get(m2):" + map.get(m2));    // B
 92         out("map.get(m3):" + map.get(m3));    // null 通過m3獲取value,無返回
 93 
 94         out("迭代輸出HashMap的所有key值");
 95         for (ModifiableString m : map.keySet()) {
 96             out(m);                // @c9d5 @be32 m1或m3其中一個key被移除
 97         }
 98         
 99         out("迭代輸出HashMap的所有key對應的value值");
100         for (ModifiableString m : map.keySet()) {
101             out(map.get(m));       // B null 無法通過任何一個key獲取到原第三個value值C
102         }
103 
104         out("迭代輸出HashMap的所有value值");
105         for (String s : map.values()) {
106             out(s);                // B C 顯示實際上第一個鍵值對被刪除,而最后一個未被刪除,但無法獲取到
107         }
108     }
109 
110     static void out(Object o) {
111         System.out.println(o);
112     }
113 
114 }

 

分析

在這個略顯冗長的測試類中,分別執行了三個主要步驟:

1、使用三個獨立的ModifiableString對象(分別為m1:123->A, m2:456->B, m3:789->C)初始化一個HashMap表對象,第一輪的HashMap信息輸出顯示,三個對象均被正常添加到map表中,並能分別通過三個key(m1/m2/m3)讀取對應的value值(A/B/C)。

2、更改第三個key對象m3的內容為123(與第一個key對象m1相同),輸出信息顯示三個key依然為相互獨立的對象,但m3的hashCode值變成與m1的一樣,第二輪HashMap信息輸出顯示,HashMap依然持有三個鍵值對,通過m3作為key獲取到的value值為m1對應的value值A,而m3本來對應的value值C卻無法再通過keySet()方法返回的任何一個key獲取得到。

3、刪除第三個key對象m3,第三輪HashMap信息輸出顯示,HashMap持有的鍵值對剩下兩個,分別是m2的key和m1或m3的key(由於hashCode一樣,無法區別),其中只有m2對應的value B能正常獲取到,而通過迭代value顯示出HashMap內被刪除的是第一個key對象m1對應的鍵值對,而非m3對應的,但m3對應的value值C依舊無法通過keySet()方法返回的任何一個key獲取得到。

 

 

通過三個步驟的HashMap內部結構解析圖可以看到,HashMap中的每個鍵值對依然持有各自獨立的key對象,但是在后面的兩步驟中,第三個鍵值對一直處於異常狀態,無法正常的通過key對象獲取。

在深入HashMap的源代碼中,逐步跟蹤put(K key, V value)->addEntry(int hash, K key, V value, int bucketIndex)->createEntry(int hash, K key, V value, int bucketIndex)方法可以發現,在數據初始化過程中,三個對象通過HashMap的put()方法,最終被存放在一個內部鍵值數組Entry<K,V>[] table中,存放的位置正好是這個對象的hashCode值代表的下標位置,同樣跟蹤get(Object key)->getEntry(Object key)方法可以發現,使用key對象通過get()方法,最終獲取到的是HashMap中的table數組中,這個key的hashCode值代表的下標位置存儲的value值。

而上面的第二步驟通過人為方式強制改變了第三個key對象m3的hashCode值,自然就丟失了獲取m3對應的value的索引了,因為整個數據更改過程並沒有通知到HashMap更新原本m3對應的value在table數組中的存儲位置,所以實際上從第二步驟開始,整個HashMap的內部數據就已經處於一種非同步的異常狀態,無法繼續正常工作了。

 

結論

從這個實驗中可以看出,HashMap並不支持key對象的hashCode發生動態變化,不可變對象是作為HashMap的key的最優選擇。

另外也從側面反映出了String的不可變特性在解釋2解釋3中發揮出的重要作用。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM