Java字符串之性能優化

基礎類型轉化成String

在程序中你可能時常會需要將別的類型轉化成String,有時候可能是一些基礎類型的值。在拼接字符串的時候,如果你有兩個或者多個基礎類型的值需要放到前面,你需要顯式的將第一個值轉化成String(不然的話像System.out.println(1+'a')會輸出98,而不是"1a")。當然了,有一組String.valueOf方法可以完成這個(或者是基礎類型對應的包裝類的方法),不過如果有更好的方法能少敲點代碼的話,誰還會願意這麼寫呢?

在基礎類型前面拼接上一個空串(""+1)是最簡單的方法了。這個表達式的結果就是一個String,在這之後你就可以隨意的進行字符串拼接操作了——編譯器會自動將那些基礎類型全轉化成String的。

不幸的是,這是最糟糕的實現方法了。要想知道爲什麼,我們得先介紹下這個字符串拼接在Java裏是如何處理的。如果一個字符串(不管是字面常量也好,或者是變量,方法調用的結果也好)後面跟着一個+號,再後面是任何的類型表達式:

string_exp + any_exp

Java編譯器會把它變成:

new StringBuilder().append( string_exp ).append( any_exp ).toString()

如果表達式裏有多個+號的話,後面相應也會多多幾個StringBuilder.append的調用,最後纔是toString方法。

StringBuilder(String)這個構造方法會分配一塊16個字符的內存緩衝區。因此,如果後面拼接的字符不超過16的話,StringBuilder不需要再重新分配內存,不過如果超過16個字符的話StringBuilder會擴充自己的緩衝區。最後調用toString方法的時候,會拷貝StringBuilder裏面的緩衝區,新生成一個String對象返回。

這意味着基礎類型轉化成String的時候,最糟糕的情況就是你得創建:一個StringBuilder對象,一個char[16]數組,一個String對象,一個能把輸入值存進去的char[]數組。使用String.valueOf的話,至少StringBuilder對象省掉了。

有的時候或許你根本就不需要轉化基礎類型。比如,你正在解析一個字符串,它是用單引號分隔開的。最初你可能是這麼寫的:

final int nextComma = str.indexOf("'");

或者是這樣:

final int nextComma = str.indexOf('\'');

程序開發完了,需求變更了,需要支持任意的分隔符。當然了,你的第一反應是,得將這個分隔符存到一個String對象中,然後使用String.indexOf方法來進行拆分。我們假設有個預先配置好的分隔符就放到m_separator字段裏(譯註:能用這個變量名的,應該不是Java開發出身的吧。。)。那麼,你解析的代碼應該會是這樣的:

private static List<String> split( final String str )
{
    final List<String> res = new ArrayList<String>( 10 );
    int pos, prev = 0;
    while ( ( pos = str.indexOf( m_separator, prev ) ) != -1 )
    {
        res.add( str.substring( prev, pos ) );
        prev = pos + m_separator.length(); // start from next char after separator
    }
    res.add( str.substring( prev ) );
    return res;
}

不過後面你發現這個分隔符就只有一個字符。在初始化的時候,你把String mseparator改成了char mseparator,然後把setter方法也一起改了。但你希望解析的方法不要改動太大(代碼現在是好使的,我爲什麼要費勁去改它呢?):

private static List<String> split2( final String str )
{
    final List<String> res = new ArrayList<String>( 10 );
    int pos, prev = 0;
    while ( ( pos = str.indexOf("" + m_separatorChar, prev ) ) != -1 )
    {
        res.add( str.substring( prev, pos ) );
        prev = pos + 1; // start from next char after separator
    }
    res.add( str.substring( prev ) );
    return res;
}

正如你所看到的,indexOf方法的調用被改動了,不過它還是新建出了一個字符串然後傳遞進去。當然,這麼做是錯的,因爲還有一個indexOf方法是接收char類型而不是String類型的。我們用它來改寫一下:

private static List<String> split3( final String str )
{
    final List<String> res = new ArrayList<String>( 10 );
    int pos, prev = 0;
    while ( ( pos = str.indexOf(m_separatorChar, prev ) ) != -1 )
    {
        res.add( str.substring( prev, pos ) );
        prev = pos + 1; // start from next char after separator
    }
    res.add( str.substring( prev ) );
    return res;
}

我們來用上面的三種實現來進行測試,將"abc,def,ghi,jkl,mno,pqr,stu,vwx,yz"這個串解析1000萬次。下面是Java 641和715的運行時間。Java7由於它的String.substring方法線性複雜度的所以運行時間反而增加了。關於這個你可以參考下這裏的資料。

可以看到的是,簡單的一個重構,明顯的縮短了分割字符串所需要的時間(split/split2->split3)。

  split split2 split3
Java 6 4.65 sec 10.34 sec 3.8 sec
Java 7 6.72 sec 8.29 sec 4.37 sec

字符串拼接

本文當然也不能完全不提字符串拼接另外兩種方法。第一種是String.concat,這個很少會用到。它內部其實是分配了一個char[],長度就是拼接後的字符串的長度,它將字符串的數據拷貝到裏面,最後使用了私有的構造方法來生成了一個新的字符串,這個構造方法不會再對char[]進行拷貝,因此這個方法調用只創建了兩個對象,一個是String本身,還有一個就是它內部的char[]。不幸的是,除非你只拼接兩個字符串,這個方法纔會比較高效一些。

還有一種方法就是使用StringBuilder類,以及它的一系列的append方法。如果你有很多要拼接的值的話,這個方法當然是最快的了。它在Java5中被首度引入,用來替代StringBuffer。它們的主要區別就是StringBuffer是線程安全的,而StringBuilder不是。不過你會經常併發的拼接字符串麼難道?

在測試中,我們把0到100000之間的數全部進行了拼接,分別使用了String.concat, +操作符,還有StringBuilder,代碼如下:

String res = ""; 
for ( int i = 0; i < ITERS; ++i )
{
    final String s = Integer.toString( i );
    res = res.concat( s ); //second option: res += s;
}        
//third option:        
StringBuilder res = new StringBuilder(); 
for ( int i = 0; i < ITERS; ++i )
{
    final String s = Integer.toString( i );
    res.append( s );
}
String.concat + StringBuilder.append
10.145 sec 42.677 sec 0.012 sec

結果非常明顯——O(n)的時間複雜度明顯要比O(n2) 要強得多。不過在實際工作中會用到大量的+操作符——因爲它們實在是非常方便。爲了解決這個問題,從Java6 update 20開始,引入了一個-XX:+OtimizeStringConcat開關。在Java 702和Java 715之間的版本,它是默認打開着的(在Java 6_41中還是默認關閉着的),因此可能你得手動將它打開。跟其它-XX的選項一樣,它的文檔也相當的差:

Optimize String concatenation operations where possible. (Introduced in Java 6 Update 20)

我們假設Oracle的工程師實現這個選項的時候是盡了最大努力的吧。坊間傳聞,它是把一些StringBuilder拼接的邏輯替換成了類似String.concat那樣的實現——它先生成一個合適大小的char[]然後再把東西拷貝進去。最後生成一個String。那些嵌套的拼接操作它可能也支持(str1 +(str2+str3) +str4)。打開這個選項後進行測試,結果表明,+號的性能跟String.concat的十分接近:

String.concat + StringBuilder.append
10.19 sec 10.722 sec 0.013 sec

我們做另外一個測試。正如前面提到的,默認的StringBuilder構造器分配的是16個字符的緩衝區。當需要添加第17個字符時,這個緩衝區會被擴充。我們把100到100000間的數字分別追加到"12345678901234”的後面。結果串的長度應該是在17到20之間,因此默認的+操作符的實現會需要StringBuilder重新調整大小。作爲對比,我們再做另一個測試,在這裏我們直接創建一個StringBuilder(21)來保證它的緩衝區足夠大,而不會重新調整:

final String s = BASE + i;
final String s = new StringBuilder( 21 ).append( BASE ).append( i ).toString();

沒有打開這個選項的話,+號的實現會比顯式的StringBuilder的實現的時間要多出一半。打開了這個選項後,兩邊的結果是一樣的。不過有趣的是,即使是StringBuilder的實現本身,打開了開關後速度居然也變快了!

+, 開關關閉 +, 開關打開 new StringBuilder(21),開關關閉 new StringBuilder(21),開關打開
0.958 sec 0.494 sec 0.663 sec 0.494 sec

總結

  • 當轉化成字符串的時候,應當避免使用""串進行轉化。使用合適的String.valueOf方法或者包裝類的toString(value)方法。
  • 儘量使用StringBuilder進行字符串拼接。檢查下老舊碼,把那些能替換掉的StringBuffer也替換成它。
  • 使用Java 6 update 20引入的-XX:+OptimizeStringConcat選項來提高字符串拼接的性能。在最近的Java7的版本中已經默認打開了,不過在Java 6_41還是關閉的。

原創文章轉載請註明出處:Java字符串之性能優化

英文原文鏈接

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