筆主前言:
眾所周知,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中發揮出的重要作用。