String 既然能做性能調優,我直呼內行
碼哥,String 還能優化啥?你是不是框我?
莫慌,今天給大家見識一下不一樣的 String,從根上拿捏直達 G 點。
並且碼哥分享一個例子:通過性能調優我們能實現百兆記憶體輕鬆存儲幾十 G 數據。
String
對象是我們每天都「摸」的對象類型,但是她的性能問題我們卻總是忽略。
愛她,不能只會簡單一起玩耍,要深入了解String
的內心深處,做一個「心有猛虎,細嗅薔薇」的暖男。
通過以下幾點分析,我們一步步揭開她的衣裳,直達內心深處,提升一個 Level,讓 String
直接起飛:
- 字元串對象的特性;
- String 的不可變性;
- 大字元串構建技巧;
- String.intern 節省記憶體;
- 字元串分割技巧;
String 身體解密
想要深入了解,就先從基本組成開始……
「String 締造者」對 String
對象做了大量優化來節省記憶體,從而提升 String 的性能:
Java 6 及之前
數據存儲在 char[]
數組中,String
通過 offset
和 count
兩個屬性定位 char[]
數據獲取字元串。
這樣可以高效快速的定位並共享數組對象,並且節省記憶體,但是有可能導致記憶體泄漏。
共享 char 數組為啥可能會導致記憶體泄漏呢?
String(int offset, int count, char value[]) {
this.value = value;
this.offset = offset;
this.count = count;
}
public String substring(int beginIndex, int endIndex) {
//check boundary
return new String(offset + beginIndex, endIndex - beginIndex, value);
}
調用 substring()
的時候雖然創建了新的字元串,但字元串的值 value
仍然指向的是記憶體中的同一個數組,如下圖所示:
如果我們僅僅是用 substring
獲取一小段字元,而原始 string
字元串非常大的情況下,substring 的對象如果一直被引用。
此時 String
字元串也無法回收,從而導致記憶體泄露。
如果有大量這種通過 substring 獲取超大字元串中一小段字元串的操作,會因為記憶體泄露而導致記憶體溢出。
JDK7、8
去掉了 offset
和 count
兩個變數,減少了 String 對象佔用的記憶體。
substring 源碼:
public String(char value[], int offset, int count) {
this.value = Arrays.copyOfRange(value, offset, offset + count);
}
public String substring(int beginIndex, int endIndex) {
int subLen = endIndex - beginIndex;
return new String(value, beginIndex, subLen);
}
substring()
通過 new String()
返回了一個新的字元串對象,在創建新的對象時通過 Arrays.copyOfRange()
深度拷貝了一個新的字元數組。
如下圖所示:
String.substring 方法不再共享 char[]
數組的數據,解決了可能記憶體泄漏的問題。
Java 9
將 char[]
欄位改為 byte[]
,新增 coder
屬性。
碼哥,為什麼這麼改呢?
一個 char 字元占 2 個位元組,16 位。存儲單位元組編碼內的字元(佔一個位元組的字元)就顯得非常浪費。
為了節約記憶體空間,於是使用了 1 個位元組占 8 位的 byte 數組來存放字元串。
勤儉節約的女神,誰不愛……
新屬性 coder 的作用是:在計算字元串長度或者使用 indexOf()
方法時,我們需要根據編碼類型來計算字元串長度。
coder 的值分別表示不同編碼類型:
- 0:表示使用
Latin-1
(單位元組編碼); - 1:使用
UTF-16
。
String 的不可變性
了解了String
的基本組成之後,發現 String 還有一個比外在更性感的特性,她被 final
關鍵字修飾,char 數組也是。
我們知道類被 final 修飾代表該類不可繼承,而 char[]
被 final+private
修飾,代表了 String
對象不可被更改。
String 對象一旦創建成功,就不能再對它進行改變。
final 修飾的好處
安全性
當你在調用其他方法時,比如調用一些系統級操作指令之前,可能會有一系列校驗。
如果是可變類的話,可能在你校驗過後,它的內部的值又被改變了,這樣有可能會引起嚴重的系統崩潰問題。
高性能快取
String
不可變之後就能保證 hash
值得唯一性,使得類似 HashMap
容器才能實現相應的 key-value
快取功能。
實現字元串常量池
由於不可變,才得以實現字元串常量池。
字元串常量池指的是在創建字元串的時候,先去「常量池」查找是否創建過該「字元串」;
如果有,則不會開闢新空間創建字元串,而是直接把常量池中該字元串的引用返回給此對象。
創建字元串的兩種方式:
- String str1 = 「碼哥位元組」;
- String str2 = new String(「碼哥位元組」);
當程式碼中使用第一種方式創建字元串對象時,JVM 首先會檢查該對象是否在字元串常量池中,如果在,就返回該對象引用。
否則新的字元串將在常量池中被創建,並返回該引用。
這樣可以減少同一個值的字元串對象的重複創建,節約記憶體。
第二種方式創建,在編譯類文件時,”碼哥位元組” 字元串將會放入到常量結構中,在類載入時,「碼哥位元組” 將會在常量池中創建;
在調用 new 時,JVM 命令將會調用 String 的構造函數,在堆記憶體中創建一個 String 對象,同時該對象指向「常量池」中的「碼哥位元組」字元串,str 指向剛剛在堆上創建的 String 對象;
如下圖:
什麼是對象和對象引用呀?
str 屬於方法棧的字面量,它指向堆中的 String 對象,並不是對象本。
對象在記憶體中是一塊記憶體地址,str 則是指向這個記憶體地址的引用。
也就是說 str 並不是對象,而只是一個對象引用。
碼哥,字元串的不可變到底指的是什麼呀?
String str = "Java";
str = "Java,yyds"
第一次賦值 「Java」,第二次賦值「Java,yyds」,str 值確實改變了,為什麼我還說 String 對象不可變呢?
這是因為 str 只是 String 對象的引用,並不是對象本身。
真正的對象依然還在記憶體中,沒有被改變。
優化實戰
了解了 String 的對象實現原理和特性,是時候要深入女神內心,結合實際場景,如何更上一層樓優化 String 對象的使用。
大字元串如何構建
既然 String 對象是不可變,所以我們在頻繁拼接字元串的時候是否意味著創建多個對象呢?
String str = "癩蛤蟆撩青蛙" + "長的丑" + "玩的花";
是不是以為先生成「癩蛤蟆撩青蛙」對象,再生成「癩蛤蟆撩青蛙長的丑」對象,最後生成「癩蛤蟆撩青蛙長得丑玩的花」對象。
實際運行中,只有一個對象生成。
這是為什麼呢?
雖然程式碼寫的醜陋,但是編譯器自動優化了程式碼。
再看下面例子:
String str = "小青蛙";
for(int i=0; i<1000; i++) {
str += i;
}
上面的程式碼編譯後,你可以看到編譯器同樣對這段程式碼進行了優化。
Java 在進行字元串的拼接時,偏向使用 StringBuilder,這樣可以提高程式的效率。
String str = "小青蛙";
for(int i=0; i<1000; i++) {
str = (new StringBuilder(String.valueOf(str))).append(i).toString();
}
即使如此,還是循環內重複創建 StringBuilder
對象。
敲黑板
所以做字元串拼接的時候,我建議你還是要顯示地使用 String Builder 來提升系統性能。
如果在多執行緒編程中,String 對象的拼接涉及到執行緒安全,你可以使用 StringBuffer。
運用 intern 節省記憶體
直接看intern()
方法的定義與源碼:
intern()
是一個本地方法,它的定義中說的是,當調用 intern
方法時,如果字元串常量池中已經包含此字元串,則直接返回此字元串的引用。
否則將此字元串添加到常量池中,並返回字元串的引用。
如果不包含此字元串,先將字元串添加到常量池中,再返回此對象的引用。
什麼情況下適合使用
intern()
方法?
Twitter 工程師曾分享過一個 String.intern()
的使用示例,Twitter 每次發布消息狀態的時候,都會產生一個地址資訊,以當時 Twitter 用戶的規模預估,伺服器需要 20G 的記憶體來存儲地址資訊。
public class Location {
private String city;
private String region;
private String countryCode;
private double longitude;
private double latitude;
}
考慮到其中有很多用戶在地址資訊上是有重合的,比如,國家、省份、城市等,這時就可以將這部分資訊單獨列出一個類,以減少重複,程式碼如下:
public class SharedLocation {
private String city;
private String region;
private String countryCode;
}
public class Location {
private SharedLocation sharedLocation;
double longitude;
double latitude;
}
通過優化,數據存儲大小減到了 20G 左右。
但對於記憶體存儲這個數據來說,依然很大,怎麼辦呢?
Twitter 工程師使用 String.intern()
使重複性非常高的地址資訊存儲大小從 20G 降到幾百兆,從而優化了 String 對象的存儲。
核心程式碼如下:
SharedLocation sharedLocation = new SharedLocation();
sharedLocation.setCity(messageInfo.getCity().intern());
sharedLocation.setCountryCode(messageInfo.getRegion().intern());
sharedLocation.setRegion(messageInfo.getCountryCode().intern());
弄個簡單例子方便理解:
String a =new String("abc").intern();
String b = new String("abc").intern();
System.out.print(a==b);
輸出結果:true
。
在載入類的時候會在常量池中創建一個字元串對象,內容是「abc」。
創建局部 a 變數時,調用 new Sting() 會在堆記憶體中創建一個 String 對象,String 對象中的 char 數組將會引用常量池中字元串。
在調用 intern 方法之後,會去常量池中查找是否有等於該字元串對象的引用,有就返回引用。
創建 b 變數時,調用 new Sting() 會在堆記憶體中創建一個 String 對象,String 對象中的 char 數組將會引用常量池中字元串。
在調用 intern 方法之後,會去常量池中查找是否有等於該字元串對象的引用,有就返回引用給局部變數。
而剛在堆記憶體中的兩個對象,由於沒有引用指向它,將會被垃圾回收。
所以 a 和 b 引用的是同一個對象。
字元串分割有妙招
Split() 方法使用了正則表達式實現了其強大的分割功能,而正則表達式的性能是非常不穩定的。
使用不恰當會引起回溯問題,很可能導致 CPU 居高不下。
Java 正則表達式使用的引擎實現是 NFA(Non deterministic Finite Automaton,確定型有窮自動機)自動機,這種正則表達式引擎在進行字元匹配時會發生回溯(backtracking),而一旦發生回溯,那其消耗的時間就會變得很長,有可能是幾分鐘,也有可能是幾個小時,時間長短取決於回溯的次數和複雜度。
所以我們應該慎重使用 Split()
方法,我們可以用String.indexOf()
方法代替 Split()
方法完成字元串的分割。
總結與思考
我們從 String 進化歷程掌握了她的組成,不斷的改變成員變數節約記憶體。
她的不可變性從而實現了字元串常量池,減少同一個字元串的重複創建,節約記憶體。
但也是因為這個特性,我們在做長字元串拼接時,需要顯示使用 StringBuilder,以提高字元串的拼接性能。
最後,在優化方面,我們還可以使用 intern 方法,讓變數字元串對象重複使用常量池中相同值的對象,進而節約記憶體。
最後,出一個問題給大家,歡迎在評論區留言,點贊對多的將獲取碼哥贈送的書籍。
通過三種不同的方式創建了三個對象,再依次兩兩匹配,每組被匹配的兩個對象是否相等?程式碼如下:
String str1 = "abc";
String str2 = new String("abc");
String str3 = str2.intern();
assertSame(str1 == str2);
assertSame(str2 == str3);
assertSame(str1 == str3)
公zhong號後台回復:「String」獲取答案。