閱讀448 返回首頁    go 阿裏雲 go 技術社區[雲棲]


《Java特種兵》1.1 String的例子,見證下我們的功底

1.1 String的例子見證下我們的功底

哇塞第1節就開始講代碼例子受不了啦胖哥你壞死了所有的書第1節都是寫這個領域有什麼東西的。

哈哈小胖哥天生就是個逆天之人哦希望你能先實踐有了感性認識後再進行理論了解內在。

下麵的代碼改編於網絡牛人的一段程序先看代碼清單1-1。

代碼清單1-1 一段String的比較程序

private static void test1() {
	String a = "a" + "b" + 1;
	String b = "ab1";
	System.out.println(a == b);
}

胖哥你是不是考我智商呀我們平時對比兩個對象不是用equals()嗎老師告訴我兩個字符串用等號是匹配不了的結果應該是false吧。那麼結果到底是多少呢

運行結果

true

什麼竟然是true為什麼是true這是要逆天嗎這小段程序徹底顛覆了我的經驗和老師教我的真理“我和我的小夥伴們驚呆了……”

胖哥告訴你這不能怪老師老師帶進門修行靠個人

也許有朋友做出了true或猜出了true那麼可否知道原因呢如果你知道那麼本節跳過無須再看如果還不知道就聽聽胖哥給你說說他所理解的原因。

要理解這個問題你需要了解些什麼

◎ 關於“==”是做什麼的

◎ equals呢

◎ a和b在內存中是什麼樣的

◎ 編譯時優化方案。

下麵的內容會很多現在我們可以站起來簡單運動一下端一杯咖啡慢慢解讀下麵的內容。

1.1.1 關於“==”

首先要知道“==”用於匹配內存單元上的內容其實就是一個數字計算機內部也隻有數字而在Java語言中當“==”匹配的時候其實就是對比兩個內存單元的內容是否一樣。

如果是原始類型byte、boolean、short、char、int、long、float、double就是直接比較它們的值。這個大家應該都清楚這裏不再詳談。

如果是引用Reference比較的就是引用的值“引用的值”可以被認為是對象的邏輯地址。如果兩個引用發生“==”操作就是比較相應的兩個對象的地址值是否一樣。換一句話說如果兩個引用所保存的對象是同一個對象則返回true否則返回false如果引用指向的是null其實這也是一個JVM賦予給它的某個指定的值。

理解寓意大家各自拿到了一個公司的offer現在我們看看哪些人拿到的offer是同一個公司的。

1.1.2 關於“equals()”

equals()方法首先是在Object類中被定義的它的定義中就是使用“==”方式來匹配的這一點大家可以參看Object類的源碼。也就是說如果不去重寫equals()方法並且對應的類其父類列表中都沒有重寫過equals()方法那麼默認的equals()操作就是對比對象的地址。

equals()方法之所以存在是希望子類去重寫這個方法實現對比值的功能類似的String就自己實現了equals()方法。為什麼要自己去實現呢因為兩個對象隻要根據具體業務的關鍵屬性值來對比確定它們是否是“一致的或相似的”返回true|false即可。

迷惑equals()不就是對比值的嗎為何說相似

答曰一日偶遇怪俠“蝸牛大師”一枚賜予神劍於數人獵人將其用於打獵農夫將它用於劈柴將軍用它保家衛國俠客用它行俠仗義、懲奸除惡等等。

例如在對比一些工程的圖紙尺寸的時候由於尺寸都會存在細節誤差可以認為寬度和高度比較接近就可以返回true而不一定非要精確匹配。另外圖紙紙張的屬性除了長度、寬度外還有如名稱、存放位置、卷號等屬性但是我們可能隻需要對比它的長度與寬度在這個範圍內其餘的屬性不會考慮。也就是說兩個對象的值是否相同是自己的業務決定的而不是Java語言來決定的。

感悟變通讓標準變為價值給你一種思想和標準你可以有不同的使用不能死扣定理我們要解決問題

迷惑equals()重寫後一般會重寫hashCode()方法嗎

要說明這個問題我們先要補充一些概念。

Java中的hashCode是什麼hashCode()方法提供了對象的hashCode值它與equals()一樣在Object類中提供不過它是一個native本地方法它的返回值默認與System.identityHashCode(object)一致。在通常情況下這個值是對象頭部的一部分二進製位組成的數字這個數字具有一定的標識對象的意義存在但絕不等價於地址。

hashCode的作用它為了產生一個可以標識對象的數字不論如何複雜的一個對象都可以用一個數字來標識。為什麼需要用一個數字來標識對象呢因為想將對象用在算法中如果不這樣許多算法還得自己去組裝數字因為算法的基礎是建立在數字基礎之上的。那麼對象如何用在算法中呢

例如在HashMap、HashSet等類似的集合類中如果用某個對象本身作為Key也就是要基於這個對象實現Hash的寫入和查找那麼對象本身如何能實現這個呢就是基於這樣一個數字來完成的隻有數字才能真正完成計算和對比操作。

hashCode隻能說是標識對象因此在Hash算法中可以將對象相對離散開這樣就可以在查找數據的時候根據這個key快速地縮小數據的範圍。但不能說hashCode值一定是唯一的所以在Hash算法中定位到具體的鏈表後需要進一步循環鏈表然後通過equals()來對比Key的值是否是一樣的。這時hashCode()與equals()似乎就成為“天生一對”。換句話說一個是為了算法快速定位數據而存在的一個是為了對比真實值而存在的。

與equasls()類似hashCode()方法也可以重寫重寫後的方法將會決定它在Hash相關數據結構中的分布情況所以這個返回值最好是能夠將對象相對離散的數據。如果發生一個極端的情況即hashCode()始終返回一個值那麼它們將存在於HashMap的同一個鏈表中將會比鏈表查詢本身還要慢。

在JDK 1.7中Hash相關的集合類對使用String作為Key的情況不再使用hashCode方式而是有了一個hash32屬性其餘的類型保持不變。

換個思路hashCode()與equals()並不是必須強製在一起如果不需要用到這樣的算法也未必要重寫對應的方法完全由你自己決定沒有語法上強製的規約。

寓意此好比寶劍是否需要有劍鞘寶貝是否需要有寶箱雄性是否必須需要雌性地球是否需要有月亮

而並非是樹是否需要土壤生命是否需要食物魚兒是否必須需要水世界是否需要陽光

感悟一切在於場景與需求十分需要但也可以在某些情況下放棄。

有人說對比兩個對象是否一致可以先對比hashCode值再對比equals()。

這似乎聽上去挺有道理的但是胖哥本人可並不這麼認為為什麼呢胖哥認為hashCode值根本不是為了對比兩個對象是否一致而存在的可以說它與兩個對象是否一致“一點關係都沒有”。

假如你希望對比兩個對象是否是同一個對象則完全可以直接用“==”來對比而不需要用hashCode()因為直接對比地址值說明兩個對象是否為同一個對象才是最靠譜的。另外默認的hashCode()方法還會發起native調用並且兩個對象都會分別發起native調用native調用的開銷也是不小的。

假如不是對比地址而是對比值自然就需要對象中的某些屬性來對比。拿String類型的對象來講如果調用某個String對象的hashCode()方法它至少會在第1次調用這個方法時遍曆所有char[]數組相關元素來計算hashCode值這裏之所以說至少是因為這個String如果並發調用hashCode()方法則可能被遍曆多次遍曆過程中還需要外加一些運算。如果對比的兩個對象分別獲取hashCode自然兩個String對象都會分別遍曆一次char[]數組。

即使hashCode一樣了也同樣證明不了這兩個String是一樣的這個hashCode是根據char[]數組的值算出來的不同的String完全可以算出一樣的值還得再次循環兩個String中所有的字符來對比一次才能證明兩個對象是一樣的。其實遍曆了兩個char[]數組已經是最壞的情況了equals()還未必會這樣做在後文的圖1-2中會詳細說明。

換一個角度來講如果是兩個其他的自定義類型的對象不是String類型的對象之間判定出來hashCode不一樣也不能說它們的值不一樣有可能equals()匹配的是一個綜合值與hashCode一點關係都沒有還是要進行equals()這樣繞來繞去往往是把簡單問題複雜化了。

equals()內部要怎麼做就去怎麼做嘛想要優化完全可以像JDK自帶的許多類那樣先對比一些簡單的屬性值再對比複雜的屬性值或者先對比業務上最快能區分對象的值再對比其他的值或者先對比地址、長度等處理方式將那些不匹配的情況盡快排出。

有人說重寫後的hashCode()內部操作確實比equals()簡單許多倍其實這個動作判定也是可以放在equals()方法的第1步中來完成的無須外部程序關注。

補充String的equals()方法中默認就要先對比傳入的對象與當前的this是不是同一個對象。在HashMap等集合類的查找過程中也不是單純的equals()也會先對比兩個對象是不是同一個對象。

好累休息休息左三圈、右三圈再來看看胖哥為你做解讀

a和b的內存情況是什麼樣的

回到“代碼清單1-1”的例子中其中的等號說明a和b是指向同一塊內存空間的就像兩個人拿到同一個公司的Offer一樣他們像什麼呢見圖1-1“死冤家又在一起了”

D3[LWJEVP4{GK(5ND$P4~82

圖1-1 兩個冤家又拿到同一個公司的Offer

為什麼a、b兩個引用都引用到同一塊空間了呢請看1.1.3節的內容解釋不過在這一節中我們先感性認識下JVM的一些“東東”在第3章中會有更詳細的介紹。小夥伴們不要著急我們一步步來學習。

1.1.3 編譯時優化方案

a引用是通過“+”賦值的b引用是直接賦值的那為什麼a和b兩個引用會指向同一個內存單元這就是JVM的“編譯時優化”。如此神奇小夥伴們驚呆了吧

當編譯器在編譯代碼String a = “a” + “b” + 1;時會將其編譯為String a = “ab1″;。

為何因為都是“常量”編譯器認為這3個常量疊加會得到固定的值無須運行時再進行計算所以就會這樣優化。

疑惑編譯器為何要做此優化

寓意“小胖”說我的報銷單寫好了並蓋章了“小明”說我的也OK了那麼就合並一起郵寄報銷單吧。“小銳”說我的快寫好了不過還沒蓋章那你寫好後再說吧。

寓意為提升整體工作效率和節約資源能提前做的事情就提前做。我們自己設計一種平台或語言的時候是否會考慮這些呢

補充編譯器類似的優化還有許多在後文中會有介紹例如當程序中出現int i = 3 * 4 + 120時並不是在實際運行時再計算i的值而是在編譯時直接變成了i = 132。

容易出錯JVM隻會優化它可以幫你優化的部分它並不是對所有的內容都可以優化。例如就拿上麵疊加字符串的例子來說如果幾個字符串疊加中出現了“變量”即在編譯時還不確定具體的值是多少那麼JVM是不會去做這樣的編譯時合並的。而JVM具體會做什麼樣的優化不做什麼樣的優化需要我們不斷去學習才能把工作做得更好。

同理證明的道理String的“+”操作並不一定比StringBuilder.append()慢如果是編譯時合並就會更快因為在運行時是直接獲取的根本不需要再去運算。同理千萬不要堅定地認為什麼方式快、什麼方式慢一定要講究場景。而為什麼在很多例子中StringBuilder. append()比String的“+”操作快呢在後文中胖哥會繼續介紹原因。

1.1.4 補充一個例子

胖哥我開始有點興趣了不過感覺在String方麵還沒過癮

OK胖哥就再補充一個例子。

代碼清單1-2 String測試1

private static String getA() {return"a";}
public static void test2() {
	String a = "a";
	final String c = "a";

	String b = a + "b";
	String d = c + "b";
	String e = getA() + "b";

	String compare = "ab";
	System.out.println(b == compare);
	System.out.println(d == compare);
	System.out.println(e == compare);
	}

這段代碼是說編譯優化看看輸出是什麼
false

true

false
看到這個結果如果你沒有“抓狂”一種可能是你真的懂了還有一種可能就是你真的不懂這段代碼在說什麼。在這裏胖哥就給大家闡述一下這個輸出的基本原因。

第1個輸出false。

“b”與“compare”對比根據代碼清單1-2中的解釋compare是一個常量那麼b為什麼不是呢因為b = a + “b”a並不是一個常量雖然a作為一個局部變量它也指向一個常量但是其引用上並未“強製約束”是不可以被改變的。雖然知道它在這段代碼中是不會改變的但運行時任何事情都會發生尤其是在“字節碼增強”技術麵前當代碼發生切入後就可能發生改變。所以編譯器是不會做這樣優化的所以此時在進行“+”運算時會被編譯為下麵類似的結果

StringBuilder temp = new StringBuilder();
temp.append(a).append("b");
String b = temp.toString();

這個編譯結果以及編譯時合並的優化並非胖哥憑空捏造的在後文中探討javap命令時這些內容將得到實際的印證。

第2個輸出true。

與第1個輸出false做了一個鮮明對比區別在於對疊加的變量c有一個final修飾符。從定義上強製約束了c是不允許被改變的由於final不可變所以編譯器自然認為結果是不可變的。

final還有更多的特性用於並發編程中我們將在第5章中再次與它親密接觸。

第3個輸出false。

它的疊加內容來源於一個方法雖然方法內返回一個常量的引用但是編譯器並不會去看方法內部做了什麼因為這樣的優化會使編譯器困惑編譯器可能需要遞歸才能知道到底返回什麼而遞歸的深度是不可預測的遞歸過後它也並不確保一定返回某一個指定的常量。另外即使返回的是一個常量但是它是對常量的引用實現一份拷貝返回的這份拷貝並不是final的。

也許在JIT的優化中會實現動態inline()但是編譯器是肯定不會去做這個動作的。

疑惑我怎麼知道編譯器有沒有做這樣的優化

答曰懂得站在他人角度和場景看待事物的不同側麵加以推導結合別人的意見和建議就有機會知道真相。

寓意如果我是語言的作者我會如何設計再結合提供驗證、本質、文檔共同引導我們了解真相。

理解方式編譯器優化一定是在編譯階段能確定優化後不會影響整體功能類似於final引用這個引用隻能被賦值一次但是它無法確定賦值的內容是什麼。隻有在編譯階段能確定這個final引用賦值的內容編譯器才有可能進行編譯時優化請不要和運行時的操作扯到一起那樣你可能理解不清楚而在編譯階段能確定的內容隻能來自於常量池中例如int、long、String等常量也就是不包含new String()、new Integer()這樣的操作因為這是運行時決定的也不包含方法的返回值。因為運行時它可能返回不同的值帶著這個基本思想對於編譯時的優化理解就基本不會出錯了。

1.1.5 跟String較上勁了

看到這裏你是否覺得自己對天天使用的String還不是特別了解而胖哥之所以不斷談到String的一些細節不僅僅是要說String本身有什麼樣的特征在Java語言中還有許許多多的“類”值得我們去研究。

如果你覺得還沒過癮和String較勁上了那麼關於String我們再來舉個例子然後胖哥做個小小總結這裏的故事就結局了。

代碼清單1-3 String測試2

public static void test3() {
	String a = "a";
	String b = a + "b";
	String c = "ab";
	String d = new String(b);
	println(b == c);
	println(c == d);
	println(c == d.intern());
	println(b.intern() == d.intern());
}

這段代碼與上一個例子類似隻是增加了intern()的調用。同樣的我們可以先看看輸出是什麼。

false

false

true

true

從輸出上看規律胖哥相信有不少小夥伴們應該能猜到是intern()方法在起作用。是的沒錯就是它。

HotSpot VM 7及以前的版本中都是有Perm Gen永久代這個板塊的第3章中詳述在前麵例子中所談到的String引用所指向的對象它們存儲的空間正是這個板塊中的一個“常量池”區域它對於同一個值的字符串保證全局唯一。

如何保證全局唯一呢當調用intern()方法時JVM會在這個常量池中通過equals()方法查找是否存在等值的String如果存在則直接返回常量池中這個String對象的地址若沒有找到則會創建等值的字符串即等值的char[]數組字符串但是char[]是新開辟的一份拷貝空間然後再返回這個新創建空間的地址。隻要是同樣的字符串當調用intern()方法時都會得到常量池中對應String的引用所以兩個字符串通過intern()操作後用等號是可以匹配的。

回到代碼清單1-3的例子中字符串”ab”本身就在常量池中當發生new String(b)操作時僅僅是進行了char[]數組的拷貝創建了一個String實例。當這個新創建的實例發生intern()操作時在常量池中是能找到這個對象的其餘的內容依此類推可以得到為什麼會這樣的答案。

此時能否有所感悟沒有的話沒關係這是第1節胖哥帶你一起感悟

◎ String到底還有多少方法我沒見過

◎ intern()有何用途有什麼坑它是否會在常量池中被注銷

◎ 是否開始覺得這些東西複雜又像是回到最簡單的道理上來了

◎ 是否認為自己的功底需要細化

1.1.6 intern()/equals()

String可能有很多的方法還沒見過在研究清楚String之前還得看不少其他的代碼。String並沒有我們想的那麼簡單而且它在我們的工作中無處不在所以需要去學習它。

胖哥在本節中也隻是給大家講個大概String中的許多奧秘會在本書後麵的章節中不時出現大家留意哦。

如果仔細看了對intern()的文檔描述就應該知道intern()本身的效率並不高它雖然在常量池中但它也需要通過equals()方法去對比在常量池中找是否存在相同的字符串才能返回結果。顯然這個過程中對比的通常不止一個字符串而是多個字符串效率自然要更低一些。另外它需要“保證唯一”所以需要有鎖的介入效率自然再打折扣。因此直接使用它循環對比得出的效率通常會比循環equals()的效率低。但這並不是說對比地址比equals()要慢一些它是輸在了對比地址之前需要先找到地址上。

它的效率低是相對equals()來講的如果要細化認識這兩者的效率並應用在實踐中那麼我們要先看看String.equals()方法的實現細節請看圖1-2。

W6KLZ]~JU3VRO4OEGKJS0FV

圖1-2 String.equals()源碼

不論是JDK 1.6還是1.7String.equals()的代碼邏輯大致歸納如下。

1判定傳入的對象和當前對象是否為同一個對象如果是就直接返回true就像在代碼清單1-1中一樣兩個人拿到同一個公司的offer。

2判定傳入對象的類型是否為String若不是則返回false如果是null也不會成立。

3判定傳入的String與當前String的長度是否一致若不一致就返回false。

4循環對比兩個字符串的char[]數組逐個對比字符是否一致若存在不一致的情況則直接返回false。

5循環結束都沒有找到不匹配的所以最後返回true。

由此我們至少得出兩個最基本的結論。

1要匹配上就要遍曆兩個數組逐個下標匹配這是最壞的情況。沒想到吧匹配上了還是最壞的情況。那麼對於大字符串來講這件事情是悲劇的。

2字符串首先匹配了長度如果長度不一致就返回false。這對於我們來講也是樂觀的事情如果真的遇上大字符串匹配問題也許就有其他的技巧性方法哦。

好了我們回過頭來看看intern()比equals()還慢的問題。這個理論結果可以用循環多次equals()然後循環多次intern()最後做“==”匹配來測試得到的直接結論是使用equals()效率會更高一些。一些曾經認為intern()對比地址效率高的“技術控”同學們有點傻眼了。

而intern()也並非無用武之地它也可以用在一些類似於“枚舉”的功能中隻是它是通過字符串來表達而已其實枚舉在底層的實現也是字符串。

例如在某種設計中會涉及很多數據類型如int、double、float等此時需要管理這些數據類型因此通常是以字符串方式來存儲的。當需要檢索數據時通過字典會得到它們的類型然後根據不同的類型做不同的數據轉換。這個動作最簡單的方法可能就是用一個類型列表for循環equals()匹配傳入的類型參數匹配到對應的類型後就跳轉到對應的方法中。如果類型有上百種之多自然equals()的次數會非常多而equals()的效率很多時候其實不好說或許有些時候可以換個思路來做。

在加載數據字典的類型時就直接intern()到內存中因為這些類型是固定的在真正匹配數據類型的時候就是“常量比常量”對比地址這樣比較就快速多了因為它不會使用equals()也不是匹配的時候再去調用intern()。

舉個例子假如記錄的是數據庫的數據類型。這個中間平台在執行某些操作時需要檢測每個表的元數據的每個列的類型在很多時候這些元數據可能會被事先加載到內存中前提是元數據不是特別多並且以intern()方式直接注入到常量池中那麼在後麵逐個列的類型匹配過程中就不用equals()了而是直接用“==”因為這些內容已經在常量池中了。

在這樣的情況下不同的表和屬性如果類型是相同的那麼它們所包含的字符串對象也會是同一個同時也可以在一定程度上節約空間。

這個方法比枚舉要“土”一些但是它也是一種方案我們完全可以用枚舉來實現而枚舉內在的實現也是類似的方式隻是它是“虛擬機”級別自帶的而已。

intern()注入到常量池中的對象和普通的對象一樣占用著相應的內存空間唯一的區別是它存儲的位置是在Perm Gen永久代的常量池中。永久代會注銷嗎會的它也會被注銷在FULL GC的時候如果沒有任何引用指向它則會被注銷。關於永久代的一些故事我們還是放到第3章來討論細節吧。

本章探討的intern()僅僅針對JDK 1.6環境如果將環境更換為JDK 1.7String pool將不會存在Perm Gen當中而是存在堆當中部分代碼的測試結果將有可能更加詭異請大家悉知。在JDK 1.7環境下運行本章給出的例子結果會有少數不一致胖哥在本書光盤相應的測試類中增加了testForJDK17()方法大家可以單獨看看在JDK 1.7下可能還會有意想不到的效果。

1.1.7 StringBuilder.append()與String“+”的PK

在很多的書籍和博客中會經常看到某些小夥伴們用一個for循環幾千萬次來證明StringBuilder.append()操作要比String“+”操作的效率高出許多倍。其實胖哥並不這麼認為為何呢因為胖哥知道這是怎麼發生的下麵就跟著胖哥的思路來看看吧。

胖哥認為如果一件事物失去了價值它就沒有存在的必要所以它存在必有價值。

在前麵的例子中String通過“+”拚接的時候如果拚接的對象是常量則會在被編譯器編譯時合並優化這個合並操作是在編譯階段就完成的不需要運行時再去分配一個空間自然效率是最高的StringBuilder.append()也不可能做到。如果是運行時拚接呢在後麵內容的學習中我們會發現一個Java字符串進行“+”操作時在編譯前後大概是如下這樣的。

原始代碼為

String a = "a";

String b = "b";

String c = a + b + "f";

編譯器會將它編譯為

String a = "a";

String b = "b";

StringBuilder temp = new StringBuilder();
temp.append(a).append(b).append("f");
String c = temp.toString();

<strong>注</strong>為了說明實際問題胖哥才用Java代碼來說明字符串拚接中“+”的真正道理在實際的場景中這是class字節碼中的內容而這個temp是胖哥虛擬出來的。

如果將String的“+”操作放在循環中那麼自然在循環體內部就會創建出來無窮多的StringBuilder對象並且執行append()後再調用toString()來生成一個新的String對象。這些臨時對象將會占用大量的內存空間導致頻繁的GC。我們用圖1-3來描述循環疊加字符串編譯前後的代碼對比。

圖1-3 循環疊加字符串編譯前後的代碼對比
UWVFIPC_P@2YLO9D[_AYNKE
在循環過程中a引用所指向的字符串會越來越大這就意味著垃圾空間也會越來越大。當這個a所指向的字符串達到一定程度後必然會進入Old區域若它所占用的空間達到Old空間的1/4需要再次分配空間的時候就有可能發生OOM而最多達到1/4的時候就肯定會發生OOM。為何是1/4我們得先看看StringBuilder做了些什麼。

上麵的代碼在循環內部會先分配一個StringBuilder對象這個對象內部會分配16個長度的char[]數組當發生append()操作時如果空間夠用就繼續向後添加元素若空間不夠用則會嚐試擴展空間。擴展空間的規則是使用當前“StringBuilder的count值 + 傳入字符串的長度”作為新的char[]數組的參考值這個參考值將會與StringBuilder的char[]數組總長度的2倍來取最大值也就是最少會擴展為原來的2倍。

count值並不是char[]數組的總長度而是當前StringBuilder中有效元素的個數或者說是通過StringBuilder.length()方法得到的值。char[]數組的總長度可以通過capacity()方法獲取到。

關於StringBuilder.append()功能的說明我們來看看它的源碼實現就更加清楚了如圖1-4所示。
A@NLIXPM529CI1()1OE)_`O

圖1-4 StringBuilder.append()的內存擴展

回到前麵提到的“循環疊加字符串”的例子中每次擴容會最少擴容2倍而在每次擴容前原來的對象還需要暫時保留那麼至少在某個時間點需要3倍的內存空間自然JVM的Young空間的Survivor區域很快裝不下要讓它進入Old區域。

當a引用的對象空間達到了Old區域的1/4大小時同樣會先分配一個StringBuilder對象初始化的長度也是16個長度的char[]數組。這個StringBuilder首先會進行append(a)操作在這次append操作時發現空間不夠大需要分配一個更大的空間來存放數據但是這個字符串本身還不能被釋放掉。

此時字符串已經很大那麼肯定它不會隻有16個長度根據上麵對append()源碼的描述新分配的空間應當是a引用當前所引用的字符串的長度因為當前的count值應當是0而同時也可以發現其實StringBuilder內部已經沒有空閑區了所以Old區域最少占用了1/4 + 1/4 = 1/2的大小。

還沒有完還需要append(隨機字符串)來操作此時StringBuilder是沒有空閑區域的那麼自然空間是不夠用的又會涉及擴容的操作隻要隨機字符串有1個字符擴容最少是原來的2倍就需要1/4 *2=1/2的空間那麼剩餘的一半Old空間就又被用掉了。

分配一個2倍大小的空間需要將數據先拷貝過來原來的空間才能釋放掉。但是這個時候還沒分配出來原來的空間自然釋放不掉那麼Old * 1/4大小的String空間再一次疊加的時候就有可能將整個內存“撐死”。

假如append()的隨機字符串是空字符串” “就不會發生擴容這樣是不是就不會發生OOM了呢這還沒有完哦最後還需要toString()方式來創建一個新的String對象這個過程會開辟一個同樣大小的char[]數組將內容拷貝過去也就是說如果拚接前字符串所占用的空間已經達到了Old區域的1/3大小就肯定會發生OOM了。如果你非要想想這個時候的空間是不是就差“幾個字節”就不會發生OOM呢但是要考慮到下一輪循環是100%會發生OOM的。

不過大家不要因為後麵還有一次toString()需要分配空間就認為前一種情況在內存空間1/5的時候就可以導致OOM要知道前麵在發生擴容的時候如果擴容成功原來的數組就可以被當成垃圾釋放掉了這是在StringBuilder內部替換char[]的時候發生的。也就是說原來的數組生命周期不會到toString()這一步。

這裏的例子是假設JVM所有的內存給一個String來使用在實際的場景中肯定不是這樣的肯定會有其他的開銷因此內存溢出的概率會更高。

再回過頭來看看用StringBuilder來寫測試代碼時是怎麼寫的。

StringBuilder builder = new StringBuilder();
for(…) {
  builder.append(隨機字符串);
}

這段代碼中的Stringbuilder對象隻有一個雖然內部也隻是先分配了16個長度的char[]數組但是在擴容的時候始終是2倍擴容。更加重要的是它每次擴容後都會有一半的空間是空閑的這一半的空間正好是擴容前數組的大小。

由於多出來的一半空間通常不會在每次append()的時候發生擴容而且對象空間越大越不容易發生擴容。更不會發生的是每次操作都會申請一個大的StringBuilder對象並將它很快當成垃圾並且在操作過程中還對這個很大的StringBuilder做擴容操作。

這種寫法最壞的情況是它所占用的內存空間接近Old區域1/3的時候發生擴容會導致OOM這裏和String的拚接有點區別如果遇到類似的差幾個字節則導致OOM此時String的拚接在下一輪循環時必然會發生OOM但是StringBuilder擴容後會經曆很長的一個過程不再發生擴容。

另外在StringBuilder.append()中間過程中產生的垃圾內存大多數都是小塊的內存它所產生的垃圾就是拚接的對象以及擴容時原來的空間當發生String的“+”操作時前一次String的“+”操作的結果就成了垃圾內存自然垃圾會越來越多而且內在擴容還會產生更大塊的垃圾在這種場景下通常使用StringBuilder來拚接字符串效率會高出許多倍。

所以首先確認一點不是String的“+”操作本身慢而是大循環中大量的內存使用使得它的內存開銷變大導致了係統頻繁GC在很多時候程序慢都是因為GC多造成的而且是更多的FULL GC效率才會急劇下降。

但是回過頭來想一想在實際的工作場景中很少會遇到for循環幾百萬次去疊加字符串的情況雖然許多不同的線程疊加字符串的次數加在一起可能會有幾百萬次但是它們本身就會開辟出不同的空間使用StringBuilder也是每個線程單獨使用自己的空間如果是共享的就存在並發問題。總而言之如果是少量的小字符串疊加那麼采用append()帶來的效率提升其實並不會太明顯但是如果遇到大量的字符串疊加或大字符串疊加的時候這些字符串不是常量字符串那麼使用append()來拚接字符串效率的確會更高一些。

在JVM中提倡的重點是讓這個“線程內所使用的內存”盡快結束以便讓JVM認為它是垃圾在Young空間就盡量釋放掉盡量不要讓它進入Old區域。一個很重要的因素是代碼是否跑得夠快其次是分配的空間要足夠小這些內容將在第3章中探討。

StringBuilder的使用也是有“坑”的。上麵已經看到StringBuilder內部也可能會創建新的數組並不斷將原來的數組數據拷貝到新的數組中雖然不會像“+”那樣每次操作都會出現拷貝但是同樣會出現很多的內存碎片。

如果你要優化到極致則需要深知業務細節申請的空間盡量確保有很少的拷貝和碎片甚至於沒有例如new StringBuilder(1024)。如果你是一個高手對代碼有潔癖那麼擴展StringBuilder實現的方法也是同樣可以的。但是如果你不是很清楚具體的業務則最好直接用默認的方式其實它很多時候未必真的那麼節約內存。為什麼呢

如果拚接的字符串不足1024個或差距很大那麼肯定是浪費空間的。換個角度如果是append(a).append(b)操作a的長度是2b的長度是1023擴容是必然的隻是擴容前需要釋放掉的char[]數組長度是10242KB以上的空間而且擴容後的長度是2048分配4KB以上的空間。如果使用默認的new StringBuilder()也會發生擴容擴容前需要釋放掉的char[]數組空間長度是16拋開頭部的32字節而新分配的char[]數組長度是1025而不是2048。

這也再次說明世事無絕對一定要看場景沒有什麼寫法是絕對好的使用這種方式通常是希望在字符串拚接的過程中將中間擴容降低到最少。

在類似的問題上也有人問過胖哥多個字符串拚接時有的字符串很大有的字符串很小是先append大字符串還是小字符串呢胖哥也不能給出明確的答案隻能說有一些不同的情況。

先添加小字符串再添加大字符串在什麼時候比較好用呢小字符串不是特別多大字符串就一兩個。在這種情況下如果先append大字符串在擴容的時候可能不會進行2倍擴容而是選擇當前的count值+大字符串的長度來擴容而且擴容後是沒有剩餘空間的此時再來append小字符串也會發生2倍擴容自然浪費的空間會很多。如果先append小字符串可能就擴展到幾十個字符或幾百個字符幾次擴容就可以搞定而且擴容過程中的垃圾內存都是很小塊的。由於板塊小擴容的過程也是迅速的後麵append大字符串該有的擴容開銷依然有隻是通常不會選擇2倍擴容而已。

如果要添加許多小字符串例如上千個小字符串大字符串也不是太多此時如果先append大字符串在發生第1個小字符串append時就會進行2倍擴容那麼就會有一半的剩餘空間這個剩餘空間可以容納非常多的小字符串自然擴容開銷就要小很多。反過來如果先append許許多多的小字符串由於初始化隻有16個長度的char[]數組那麼上千個小字符串疊加起來可能會發生10次左右的擴容而擴容的空間也會越變越大。

關於String的“+”的補充說明

上麵提到String的“+”會創建StringBuilder對象然後再操作那麼粒度是多大呢並不是“+”相關的兩個String為粒度而是一行代碼為粒度。什麼叫作一行代碼為粒度呢例如

   String str = a + b + c + d + e;

這就是一行它隻會申請一個StringBuilder執行多次append()操作然後將其賦值給str引用如果換成了兩條代碼就會申請多個。

但是這個“一行”並不是指Java代碼中的一行因為我們可以把Java代碼中的很多行一層層嵌套到一條代碼中其實實際運行的代碼還是多行而是指可以連續在一起的代碼。

關於內存拷貝和碎片在本書3.5節中會有非常詳盡的解釋和說明。

本節胖哥用了十分簡單的3個代碼例子來說明有許多基礎內容是值得我們去研究的。不知你是否能體會到其中的變化都是使用許多簡單的基礎知識加以變通的結果這種變化是十分多的但“萬變不離其宗”。

本節從某種意義上印證了功底的重要性即明白基本是明白內在的一個基礎上層都是由此演變而來的。

同樣的大家可以自己去研究String的其他方法例如startsWith()、endsWith()是如何實現的如果代碼中出現了反反複複這樣的操作是否有解決的思路。比如說要對每個請求的後綴進行endsWith()處理自然就需要與許多的後綴循環進行匹配而每個匹配都可能會去對比裏麵的許多字符那麼也許可以用其他的方法來處理。

本節胖哥是否對String做了一個詮釋呢沒有肯定沒有也不會

本節所提到的內容僅僅是想讓你客觀認識一下自己是否需要補充一些基礎知識希望你以此為例學會看內在、看源碼活學活用。如果你認為自己“需要補充功底”了胖哥的目的就達到了此書分享過去的我的那些事獻給還在成長的你。

本節的故事到這裏就結束了胖哥不知道你是否滿意如果滿意則祝願你做一個美夢以更好的心情和心態學習下一節的內容。

最後更新:2017-05-23 14:33:11

  上一篇:go  Akka筆記之日誌及測試
  下一篇:go  Java IO: 字節和字符數組