近期寫了比較多的和Java有關的blog,原因在於最近正在對自己之前做的一個Java系統做效能調優。在這個過程中,我積累了一些經驗,也學到了不少東西。本篇亦是如此。
在我的系統中,有一個查詢,它會在記憶體中的一個Index上做搜尋,然後將尋找到的所有資料項目填入一個JSONObject中,最後調用這個JSONObject的toString函數轉換成字串,通過網路發送出去。
實驗觀察到,如果大量使用這個查詢,JVM會頻繁地調用Garbage Collection (GC)。我一開始以為是我的data structure沒有寫好,導致搜尋很耗資源,後來通過進一步實驗發現,是搜尋後將結果轉換為字串這個步驟消耗了大量的記憶體。
於是,我將”Convert result to string”這個步驟的代碼抽取出來,反覆執行,查看前後的記憶體消耗。就是實驗的結果,解讀一中的最後一列:
rs 200表示的是一個查詢結果中包括了200個資料項目(其它以此類推);
4600的單位是KB,表示的是這麼一個查詢結果轉換為字串所大致消耗的記憶體,也就是圖中的藍色地區;
56的單位也是KB,表示的這個結果轉換為String類型後,這個String的size。也就是圖中的紅色地區(但願還能看得見);
所以,這麼一個查詢結果的轉換產生了大量的臨時變數,它們消耗了大概4MB的記憶體,如果一秒鐘處理250個這樣的查詢就是1GB,這樣就不難理解為什麼我的程式大概幾十秒就需要做minor GC了,雖然我有10G+的記憶體。
以上只是一個引子,雖然由於實驗不是很嚴謹,在實驗資料上會存在一定的偏差,但是多少可以說明一點,Java本身的字串操作很有問題,它有可能成為你程式的bottleneck。所以,以下對於Java字串最佳化的方法確實不是我吃飽了撐著。
Strings are immutable. A String cannot be altered once created.
Java中的String是不可變的,這是String最重要的一條性質,也是效能不好的根本。String的原始碼是這樣寫的:
public final class String<br /> implements java.io.Serializable, Comparable<String>, CharSequence<br />{<br /> /** The value is used for character storage. */<br />private final char value[];<br />//...............<br />}<br />
char value[ ]就是用於儲存具體的位元組流的,可以看見,它被final修飾了,所以一旦初始化就不能再修改。那些String類提供的看起來能夠修改字串的函數其實都是建立了一個新的String,然後將新的String的引用傳遞迴來。(而一個取子串的操作substring不會拷貝整個字串,相反,它只是對原有的charArray產生新的指標。)
特別的,在操縱大型字串的時候,常常會需要“+”操作(例一):
String s1 = "String a";<br />String s2 = "string b";<br />String s3 = s1 + " " + s2;<br />
如果不考慮編譯器的最佳化(這個稍後再說),在建立s3的過程中,首先會產生一個臨時的String變數,儲存s1 + “”,然後再產生一個臨時變數,將前一個臨時變數和s2串連起來。所以,即便這麼簡單的串連操作,也產生了兩個臨時變數,每個臨時變數都有自己獨自的char[]。
再來個例子(例二):
String str = “hello”;<br />for (int i = 0; i < 10000; ++i) {<br /> str = str + “ ” + i;<br />}<br />
for迴圈裡面這條語句,按照我們之前的介紹需要兩個臨時變數。所以整個例子會建立10000 * 2個臨時變數.同樣的,這些臨時變數也都會有自己獨立的char[],雖然只使用一次就廢棄了。而且更加恐怖的是,隨著迴圈的進行,臨時變數中的char[]會隨著str的增大而增大。
所以,如果需要頻繁的使用”+”操作符進行字串的串連,不要使用String,而是改用StringBuilder (例三):
StringBuilder sb = new StringBuilder();<br />sb.append(“hello”);<br />for(int i = 0; i < 10000; ++i) {<br /> sb.append(“ ”).append(i);<br />}<br />String str = sb.toString();<br />
使用StringBuilder的話,不會產生臨時變數,取而代之的是StringBuilder內部的
char value[];
注意,它和String不同的就在於沒有用final修飾。每次調用append的時候,會將想追加的字串copy到value中。如果初始化的char[]數組已經填滿了,那麼StringBuilder會自動的調用void expandCapacity函數,將value的size擴大一倍。所以,使用StringBuilder不會產生任何的臨時變數。
當然,值得一提的是,所謂的expandCapacity函數,其實也是建立一個新的char數組,它的size是原先數組的size的一倍,然後將舊的char數組中的值都拷貝到新的char數組中,然後,StringBuilder就使用這個新的char數組作為自己內部的char value[]。所以,為了避免這樣的數組拷貝,應該儘可能的在初始化StringBuilder的時候設定合理的內部數組大小:
StringBuilder sb = new StringBuilder(20005);<br />sb.append(“hello”);<br />for(int i = 0; i < 10000; ++i) {<br /> sb.append(“ ”).append(i);<br />}<br />String str = sb.toString();<br />
關於StringBuilder的使用,就不多說了,網上一搜一大堆資料,還有挺多實驗對比資料。
編譯器最佳化:
現在的JDK一般都會對String的”+”操作進行自動最佳化,比如:
String str = “hello” + “ ” + “world”;
這行代碼在編譯期間就會被最佳化成:
String str = “hello world”;
而像例一中的代碼:
String s3 = s1 + " " + s2;
編譯器也會用StringBuilder進行最佳化;
但是對於類似例二這樣的迴圈,貌似就沒法最佳化了,所以每迴圈一次,還是會產生一個臨時變數。
介紹完了StringBuilder,該切入正題了:怎樣將一個Object轉換成字串。先看一段代碼:
class A {<br />//private data</p><p>@Override<br />public String toString() {<br />// ...........<br />}<br />}<br />class B {<br />//private data</p><p>@Override<br />public String toString() {<br />//..........<br />}<br />}<br />public class C {<br />private A a;<br />private B b;</p><p>private String data;</p><p>@Override<br />public String toString() {<br />StringBuilder sb = new StringBuilder();<br />sb.append(data).append(" ").append(a.toString()).append(" ")<br />.append(b.toString());<br />return sb.toString();<br />}<br />}
這是一般的將Object轉換為String的寫法,就是重載它的toString()函數。
C中包含了A,B的執行個體a,b,所以,在C的toString()函數中,我們會首先調用a和b的toString,然後將它們和C內部其它的資料連線起來。在這個過程中,雖然我們也用到了StringBuilder,但是在調用a.toString()和b.toString()的時候仍然產生了兩個臨時的String變數,這兩個變數使用一次就廢棄了。如果假設A和B中又內嵌了其它的類型,那麼在A和B的toString()函數中就也需要去調用這些類型的toString(),這樣會導致更多的臨時變數。
在JSONObject這個類中,就是這麼做的。最外層的JSONObject的toString()函數會調用它內嵌的所有類型執行個體的toString(),所以一旦這個JSON嵌套的層級比較多,那麼大量的臨時變數會被建立。
在<Java Performance Tuning>這本書中,提供了一個改進的方法:
class A {<br />//private data</p><p>public void appendTo(StringBuilder sb) {<br />sb.append(......);<br />}</p><p>@Override<br />public String toString() {<br />StringBuilder sb = new StringBuilder();<br />appendTo(sb);<br />return sb.toString();<br />}<br />}<br />class B {<br />//private data<br />public void appendTo(StringBuilder sb) {<br />sb.append(......);<br />}</p><p>@Override<br />public String toString() {<br />StringBuilder sb = new StringBuilder();<br />appendTo(sb);<br />return sb.toString();<br />}<br />}<br />public class C{<br />private A a;<br />private B b;</p><p>private String data;</p><p>public void appendTo(StringBuilder sb) {<br />sb.append(data).append(" ");<br />a.appendTo(sb);<br />sb.append(" ");<br />b.appendTo(sb);<br />}</p><p>@Override<br />public String toString() {<br />StringBuilder sb = new StringBuilder();<br />appendTo(sb);<br />return sb.toString();<br />}<br />}<br />
上述代碼中,每個類都建立了一個void appendTo(StringBuilder sb)的方法。在這個方法中,每個類都將自己需要轉換的字串append到給定的StringBuilder中。外層的類(比如C class)的appendTo方法中,會調用嵌套類的appendTo方法。而原先的toString()函數,只需要初始化一個StringBuilder,然後將這個StringBuilder執行個體傳遞個給自己的appendTo函數,最後調用StringBuilder的toString,返回一個String變數。整個過程中不需要建立任何臨時的String變數,只有最後一步產生一個String作為結果返回。
我採用這個方法將My Code重寫。最後,我將這個代碼的效果最佳化到由原先的每個查詢結果4600KB降到了大概300KB (其實還可以再最佳化的,不過這個結果對我來說已經足夠了)。
結語
雖然Java相比於C++的不同就在於它犧牲了一定的效能和對細節的某些控制來達到編程的簡單,但是這並不意味著就不能寫出高效的Java代碼來。當然,這不簡單,需要一定的技巧。
另外,雖然俺這篇blog說了很多StringBuilder的好處。但是,在<Java Performance Tuning>中,它將StringBuilder定義為“double-edged sword”—雙刃劍。說明StringBuilder也不是放在任何地方都是合適的。至於具體的細節就直接看書吧。