Java之String重點解析
String s = new String("abc")
這段程式碼建立了幾個物件呢?s=="abc"
這個判斷的結果是什麼?s.substring(0,2).intern()=="ab"
這個的結果是什麼呢?- s.charAt(index) 真的能表示出所有對應的字元嗎?
"abc"+"gbn"+s
直接的字串拼接是否真的比使用StringBuilder的效能低?
前言
很高興遇見你~
Java中的String物件特性,與c/c++語言是很不同的,重點在於其不可變性。那麼為了服務字串不可變性的設計,則衍生出非常多的相關問題:為什麼要保持其不可變?底層如何儲存字串?如何進行字串操作才擁有更好的效能?等等。此外,字元編碼
文章的內容圍繞著不可變 這個重點展開:
- 分析String物件的不可變性;
- 常量池的儲存原理以及intern方法的原理
- 字串拼接的原理以及優化
- 程式碼單元與程式碼點的區別
- 總結
那麼,我們開始吧~
不可變性
理解String的不可變性,我們可以簡單看幾行程式碼:
String string = "abcd"; String string1 = string.replace("a","b"); System.out.println(string); System.out.println(string1); 輸出: abcd bbcd
string.replace("a","b")
這個方法把"abcd"
中的a
換成了b
。通過輸出可以發現,原字串string
並沒有發生任何改變,replace
方法構造了一個新的字串"bbcd"
並賦值給了string1
變數。這就是String的不可變性。
再舉個栗子:把"abcd"
的最後一個字元d
改成a
,在c/c++語言中,直接修改最後一個字元即可;而在java中,需要重新建立一個String物件:abca
,因為"abcd"
本身是不可變的,不能被修改。
String物件值是不可變的,一切操作都不會改變String的值,而是通過構造新的字串來實現字串操作。
很多時候很難理解,為什麼Java要如此設計,這樣不是會導致效能的下降嗎?回顧一下我們日常使用String的場景,更多的時候並沒有直接去修改一個string,而是使用一次,則被拋棄。但下次,很可能,又再一次使用到相同的String物件。例如日誌列印:
Log.d("MainActivity",string);
前面的"MainActivity"
我們並不需要去更改他,但是卻會頻繁使用到這個字串。Java把String設計為不可變,正是為了保持資料的一致性,使得相同字面量的String引用同個物件。例如:
String s1 = "hello";
String s2 = "hello";
s1
與s2
引用的是同個String物件。如果String可變,那麼就無法實現這個設計了。因此,我們可以重複利用我們建立過的String物件,而無需重新建立他。
基於重複使用String的情況比更改String的場景更多的前提下,Java把String設計為不可變,保持資料一致性,使得同個字面量的字串可以引用同個String物件,重複利用已存在的String物件。
在《Java程式設計思想》一書中還提到另一個觀點。我們先看下面的程式碼:
public String allCase(String s){
return string.toUpperCase();
}
allCase
方法把傳入的String物件全部變成大寫並返回修改後的字串。而此時,呼叫者的期望是傳入的String物件僅僅作為提供資訊的作用,而不希望被修改,那麼String不可變的特性則非常符合這一點。
使用String物件作為引數時,我們希望不要改變String物件本身,而String的不可變性符合了這一點。
儲存原理
由於String物件的不可變特性,在儲存上也與普通的物件不一樣。我們都知道物件建立在 堆 上,而String物件其實也一樣,不一樣的是,同時也儲存在 常量池 中。處於堆區中的String物件,在GC時有極大可能被回收;而常量池中的String物件則不會輕易被回收,那麼則可以重複利用常量池中的String物件。也就是說, 常量池是String物件得以重複利用的根本原因 。
常量池不輕易垃圾回收的特性,使得常量池中的String物件可以一直存在,重複被利用。
往常量池中建立String物件的方式有兩種: 顯式使用雙引號構造字串物件、使用String物件的intern()
方法 。這兩個方法不一定會在常量池中建立物件,如果常量池中已存在相同的物件,則會直接返回該物件的引用,重複利用String物件。其他建立String物件的方法都是在堆區中建立String物件。舉個栗子吧。
當我們通過new String()
的方法或者呼叫String物件的例項方法,如string.substring()
方法,會在堆區中建立一個String物件。而當我們使用雙引號建立一個字串物件,如String s = "abc"
,或呼叫String物件的intern()
方法時,會在常量池中建立一個物件,如下圖所示:
還記得我們文章開頭的問題嗎?
String s = new String("abc")
,這句程式碼建立了幾個物件?"abc"
在常量池中構造了一個物件,new String()
方法在堆區中又建立了一個物件,所以一共是兩個。s=="abc"
的結果是false。兩個不同的物件,一個位於堆中,一個位於常量池中。s.substring(0,2).intern()=="ab"
intern方法在常量池中構建了一個值為“ab"的String物件,"ab"語句不會再去構建一個新的String物件,而是返回已經存在的String物件。所以結果是true。
只有顯式使用雙引號構造字串物件、使用String物件的
intern()
方法 這兩種方法會在常量池中建立String物件,其他方法都是在堆區建立物件。每次在常量池建立String物件前都會檢查是否存在相同的String物件,如果是則會直接返回該物件的引用,而不會重新建立一個物件。
關於intern方法還有一個問題需要講一下,在不同jdk版本所執行的具體邏輯是不同的。在jdk6以前,方法區是存放在永生代記憶體區域中,與堆區是分割開的,那麼當往常量池中建立物件時,就需要進行深拷貝,也就是把一個物件完整地複製一遍並建立新的物件,如下圖:
永生代有一個很嚴重的缺點:容易發生OOM 。永生代是有記憶體上限的,且很小,當程式大量呼叫intern方法時很容易就發生OOM。在JDK7時將常量池遷移出了永生代,改在堆區中實現,jdk8以後使用了本地空間實現。jdk7以後常量池的實現使得在常量池中建立物件可以進行淺拷貝,也就是不需要把整個物件複製過去,而只需要複製物件的引用即可,避免重複建立物件,如下圖:
觀察這個程式碼:
String s = new String(new char[]{'a'});
s.intern();
System.out.println(s=="a");
在jdk6以前建立的是兩個不同的物件,輸出為false;而jdk7以後常量池中並不會建立新的物件,引用的是同個物件,所以輸出是true。
jdk6之前使用intern建立物件使用的深拷貝,而在jdk7之後使用的是淺拷貝,得以重複利用堆區中的String物件。
通過上面的分析,String真正重複利用字串是在使用雙引號直接建立字串時。使用intern方法雖然可以返回常量池中的字串引用,但是本身已經需要堆區中的一個String物件。因而我們可以得出結論:
儘量使用雙引號顯式構建字串;如果一個字串需要頻繁被重複利用,可以呼叫intern方法將他存放到常量池中。
字串拼接
字串操作最多的莫過於字串拼接了,由於String物件的不可變性,如果每次拼接都需要建立新的字串物件就太影響效能了。因此,官方推出了兩個類: StringBuffer、StringBuilder 。這兩個類可以在不建立新的String物件的前提下拼裝字串、修改字串。如下程式碼:
StringBuilder stringBuilder = new StringBuilder("abc");
stringBuilder.append("p")
.append(new char[]{'q'})
.deleteCharAt(2)
.insert(2,"abc");
String s = stringBuilder.toString();
拼接、插入、刪除都可以很快速地完成。因此,使用StringBuilder進行修改、拼接等操作來初始化字串是更加高效率的做法。StringBuffer和StringBuilder的介面一致,但StringBuffer對操作方法都加上了synchronize關鍵字,保證執行緒安全的同時,也付出了對應的效能代價。單執行緒環境下更加建議使用StringBuilder。
拼接、修改等操作來初始化字串時使用StringBuilder和StringBuffer可以提高效能;單執行緒環境下使用StringBuilder更加合適。
一般情況下,我們會使用+
來連線字串。+
在java經過了運算子過載,可以用來拼接字串。編譯器也對+
進行了一系列的優化。觀察下面的程式碼:
String s1 = "ab"+"cd"+"fg";
String s2 = "hello"+s1;
Object object = new Object();
String s3 = s2 + object;
-
對於s1字串而言,編譯器會把
"ab"+"cd"+"fg"
直接優化成"abcdefg"
,與String s1 = "abcdefg";
是等價的。這種優化也就減少了拼接時產生的消耗。甚至比使用StringBuilder更加高效。 -
s2的拼接編譯器會自動建立一個StringBuilder來構建字串。也就相當於以下程式碼:
StringBuilder sb = new StringBuilder(); sb.append("hello"); sb.append(s1); String s2 = sb.toString();
那麼這是不是意味著我們可以不需要顯式使用StringBuilder了,反正編譯器都會幫助我們優化?當然不是,觀察下邊的程式碼:
String s = "a"; for(int i=0;i<=100;i++){ s+=i; }
這裡有100次迴圈,則會建立100個StringBuilder物件,這顯然是一個非常錯誤的做法。這時候就需要我們來顯示建立StringBuilder物件了:
StringBuilder sb = new StringBuilder("a"); for(int i=0;i<=100;i++){ sb.append(i); } String s = sb.toString();
只需要構建一個StringBuilder物件,效能就極大地提高了。
-
String s3 = s2 + object;
字串拼接也是支援直接拼接一個普通的物件,這個時候會呼叫該物件的toString
方法返回一個字串來進行拼接。toString
方法是Object類的方法,若子類沒有重寫,則會呼叫Object類的toString方法,該方法預設輸出類名+引用地址。這看起來沒有什麼問題,但是有一個大坑:切記不要在toString方法中直接使用+
拼接自身 。如下程式碼@Override public String toString() { return this+"abc"; }
這裡直接拼接this會呼叫this的toString方法,從而造成了無限遞迴。
Java對+拼接字串進行了優化:
- 可以直接拼接普通物件
- 字面量直接拼接會合成一個字面量
- 普通拼接會使用StringBuilder來進行優化
但同時也有注意這些優化是有限度的,我們需要在合適的場景選擇合適的拼接方式來提高效能。
編碼問題
在Java中,一般情況下,一個char物件可以儲存一個字元,一個char的大小是16位。但隨著計算機的發展,字符集也在不斷地發展,16位的儲存大小已經不夠用了,因此拓展了使用兩個char,也就是32位來儲存一些特殊的字元,如emoij。一個16位稱為一個 程式碼單元 ,一個字元稱為 程式碼點 ,一個程式碼點可能佔用一個程式碼單元,也可能是兩個。
在一個字串中,當我們呼叫String.length()
方法時,返回的是程式碼單元的數目, String.charAt()
返回也是對應下標的程式碼單元。這在正常情況下並沒有什麼問題。而如果允許輸入特殊字元時,這就有大問題了。要獲得真正的程式碼點數目,可以呼叫 String .codePointCount
方法;要獲得對應的程式碼點,可呼叫 String.codePointAt
方法。以此來相容拓展的字符集。
一個字元為一個程式碼點,一個char稱為一個程式碼單元。一個程式碼點可能佔據一個或兩個程式碼單元。若允許輸入特殊字元,則必須使用程式碼點為單位來操作字串。
總結
到此,關於String的一些重點問題就分析完畢了,文章開頭的問題讀者應該也都知道答案了。這些是面試常考題,也是String的重點。除此之外,關於正則表示式、輸入與輸出、常用api等等也是String相關很重要的內容,有興趣的讀者可自行學習。
希望文章對你有幫助。
參考資料
- 《Java程式設計思想》 java工程師皆知的神書,詳細講解了如何更好運用java來程式設計,感受程式設計思想。
- 《Java核心技術卷一》 入門書籍,主要講解如何使用String的api以及一些注意的點。
- 《深入理解JVM》對於理解方法區以及常量池有非常大的幫助。
- 深入解析String#intern美團技術團隊的一篇分析String.intern方法的文章。
- 感謝網路其他部落格的貢獻。
全文到此,原創不易,覺得有幫助可以點贊收藏評論轉發。
筆者才疏學淺,有任何想法歡迎評論區交流指正。
如需轉載請評論區或私信交流。另外歡迎光臨筆者的個人部落格:傳送門