高效的Java字符串 -- 一些實驗和一點經驗

近期寫了比較多的和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的源代碼是這樣寫的:

char value[ ]就是用於存儲具體的字節流的,可以看見,它被final修飾了,所以一旦初始化就不能再修改。那些String類提供的看起來能夠修改字符串的函數其實都是創建了一個新的String,然後將新的String的引用傳遞回來。(而一個取子串的操作substring不會拷貝整個字符串,相反,它只是對原有的charArray產生新的指針。)

 

特別的,在操縱大型字符串的時候,常常會需要“+”操作(例一):

如果不考慮編譯器的優化(這個稍後再說),在創建s3的過程中,首先會產生一個臨時的String變量,存儲s1 + “”,然後再產生一個臨時變量,將前一個臨時變量和s2連接起來。所以,即便這麼簡單的連接操作,也產生了兩個臨時變量,每個臨時變量都有自己獨自的char[]。

再來個例子(例二):

for循環裏面這條語句,按照我們之前的介紹需要兩個臨時變量。所以整個例子會創建10000 * 2個臨時變量.同樣的,這些臨時變量也都會有自己獨立的char[],雖然只使用一次就廢棄了。而且更加恐怖的是,隨着循環的進行,臨時變量中的char[]會隨着str的增大而增大。

所以,如果需要頻繁的使用”+”操作符進行字符串的連接,不要使用String,而是改用StringBuilder (例三):

使用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的使用,就不多說了,網上一搜一大堆資料,還有挺多實驗對比數據。

 

編譯器優化:
現在的JDK一般都會對String的”+”操作進行自動優化,比如:
String str = “hello” + “ ” + “world”;
這行代碼在編譯期間就會被優化成:
String str = “hello world”;

而像例一中的代碼:
String s3 = s1 + " " + s2;
編譯器也會用StringBuilder進行優化;
但是對於類似例二這樣的循環,貌似就沒法優化了,所以每循環一次,還是會產生一個臨時變量。

 

介紹完了StringBuilder,該切入正題了:怎樣將一個Object轉換成字符串。先看一段代碼:

 

這是一般的將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>這本書中,提供了一個改進的方法:

 

上述代碼中,每個類都新建了一個void appendTo(StringBuilder sb)的方法。在這個方法中,每個類都將自己需要轉換的字符串append到給定的StringBuilder中。外層的類(比如C class)的appendTo方法中,會調用嵌套類的appendTo方法。而原先的toString()函數,只需要初始化一個StringBuilder,然後將這個StringBuilder實例傳遞個給自己的appendTo函數,最後調用StringBuilder的toString,返回一個String變量。整個過程中不需要創建任何臨時的String變量,只有最後一步產生一個String作爲結果返回。

我採用這個方法將我的代碼重寫。最後,我將這個代碼的效果優化到由原先的每個查詢結果4600KB降到了大概300KB (其實還可以再優化的,不過這個結果對我來說已經足夠了)。

 

結語

 

雖然Java相比於C++的不同就在於它犧牲了一定的性能和對細節的某些控制來達到編程的簡單,但是這並不意味着就不能寫出高效的Java代碼來。當然,這不簡單,需要一定的技巧。
另外,雖然俺這篇blog說了很多StringBuilder的好處。但是,在<Java Performance Tuning>中,它將StringBuilder定義爲“double-edged sword”—雙刃劍。說明StringBuilder也不是放在任何地方都是合適的。至於具體的細節就直接看書吧。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章