String s = new String("abc")這段代碼創建了幾個物件呢?s=="abc"這個判斷的結果是什么?s.substring(0,2).intern()=="ab"這個的結果是什么呢?- s.charAt(index) 真的能表示出所有對應的字符嗎?
"abc"+"gbn"+s直接的字串拼接是否真的比使用StringBuilder的性能低?
前言
很高興遇見你~
Java中的String物件特性,與c/c++語言是很不同的,重點在于其不可變性,那么為了服務字串不可變性的設計,則衍生出非常多的相關問題:為什么要保持其不可變?底層如何存盤字串?如何進行字串操作才擁有更好的性能?等等,此外,字符編碼的相關知識也是非常重要;畢竟,現在使用emoij是再正常不過的事情了,
文章的內容圍繞著不可變 這個重點展開:
- 分析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方法的文章,
- 感謝網路其他博客的貢獻,
全文到此,原創不易,覺得有幫助可以點贊收藏評論轉發,
筆者才疏學淺,有任何想法歡迎評論區交流指正,
如需轉載請評論區或私信交流,另外歡迎光臨筆者的個人博客:傳送門
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/237418.html
標籤:Java
上一篇:干貨:Java中的訪問修飾符詳解
