2013年4月1日 星期一

拆穿 Java StringBuilder 的謠言

這篇文章的陳述方式及內容有許多問題,可參閱 ptt.cc Java 版後續的討論


原文網址:http://skuro.tk/2013/03/11/java-stringbuilder-myth-now-with-content/

謠言......

用 + 號來連接兩個字串是萬惡的根源。 —— 不知名的 Java 開發人員

註:這裡討論用到的程式碼都可以在 Github 上找到。

在大學的時候,我學到在 Java 中用 + 號來連接字串是一種致命的效能罪惡。 最近在 Backbase R&D 有一個內部的 review, 這個 recurring mantra 變成了謠言, 因為當你使用 + 號來連接字串時,javac 會在底層使用 StringBuilder。 我要證明這件事情,並驗證在不同環境下的真實性。

測試......

倚賴 compiler 對連接字串這件事作最佳化, 這意味著使用不同的 JDK 可能會得到完全不一樣的結果。 就我平常的工作環境,我考慮這三個 JDK 供應商:

  • Oracle JDK
  • IBM JDK
  • ECJ(僅針對開發人員)

此外,雖然我們官方支援 Java 5 跟 Java 6, 不過我們也在研究讓產品可以支援到 Java 7, adding another three-folded level of indirection on top of the three vendors.(譯註:翻譯不能 Orz) 為了懶惰簡單起見, ecj compile 出來的 bytecode 只會在 Oracle JDK 7 上頭執行。

我準備了一個 VirtualBox VM 安裝上述所有的 JDK, 然後我寫了一些 class 來代表三種不同的字串連接方式, 每一個 method 會有三到四個連接字串的動作、取決於 test case。

這些 test case 在每一回合會執行一千次、總共 100 回合。 同一個 test 的所有回合都會在同一個 VM 上頭執行, 在跑不同 test case 的時候重開 VM, 這都是為了讓 Java 在執行期可以作任何可能的最佳化動作、 而不會影響到其他 test case。 所有 JVM 啟動時都用預設的設定。

更細節的部份可以參考 benchmark runner script

程式碼

所有 test case 以及 test suite 的完整程式碼都在 Github 上頭。

下面這幾個不同的 test case 用來測量 + 號與直接使用 StringBuilder 連接字串的效能差異:

// String concat with plus
String result = "const1" + base;
result = result + "const2";

// String concat with a StringBuilder
new StringBuilder()
    .append("const1")
    .append(base)
    .append("const2")
    .append(append)
    .toString();
}

// String concat with a StringBuilder
new StringBuilder("const1")
    .append(base)
    .append("const2")
    .append(append)
    .toString();
}

大體上的想法是在常數字串前後都連接一個變數。 最後兩個 case 都是用 StringBuilder, 差異是後頭使用了傳入一個參數的 constructor, 在 builder 初始化的時候就初始化結果的一部分。

結果......

前提講的差不多了,下面這些產生出來的圖表, 每一個點對應到單一個測試回合(對同一個測試執行 1000 次)。

後頭會討這些結果以及一些有趣的細節。

plus StringBuffer() StringBuffer(String)

討論......

Oracle JDK 5 輸的很徹底,跟其他相比就是個 B 咖。 但那不是這次要討論的範圍,所以暫時不理會吧。

在上頭的圖表當中我觀察到兩件有趣的事情。 首先,使用 + 號跟明確指定用 StringBuilder 的確存在普遍的落差, 特別是在使用 Oracle Java 5 的時候比其他人差了三倍。

第二個觀察到的現象是,大多數的 JDK 在明確指定用 `StringBuilder 時可以提供比 + 號快兩倍的速度, 而 IBM JDK 6 看起來沒有減損任何效能, 在所有的 test case 中始終保持在 25ms 左右的時間。

仔細看一下產生出來的 bytecode 揭露了一些有趣的細節。

bytecode 表示:

註:Github 上也有 decompile 後的 class。

在所有的 JDK 上應該總是StringBuilder 來實作連接字串, 即使有 + 可以用。 此外,比較所有供應商的所有版本, 在同樣的 test case 下幾乎沒有什麼分別。 唯一比較有區隔的是 ecj,它是唯一一個對 CatPlus test case 作最佳化, 會使用傳入一個參數的 StringBuilder constructor,而不是 StringBuilder()

比較產生的 bytecode 可以看到在不同情境下可能會影響效能的部份:

  • 用 + 號連接字串時,每一次都會建立一個新的 StringBuilder instance。 這很容易導致效能下降,因為要產生一堆用完就丟 instance, 而造成 garbage collector 的壓力。

  • compiler 會依照字面上的意思, 只有在你指定用傳入一個參數的 StringBuilder constructor, compiler 才會用它。 這分別導致 CatSB 呼叫了四次 StringBuilder.append()、 而 CatSB2 呼叫三次。

結論......

分析 bytecode 提供了問題的最終答案:

需要明確指定用 StringBuilder 來增進效能嗎?
是的!

上面的圖表顯示的很清楚了,用 + 號會損失 50% 的效能; 除非你用 IBM JDK 6, 那只會筆明確指定使用 StringBuilder 稍微差一點點。

此外,看 JIT 最佳化 如何影響整體效能十分有趣。 例如:即使兩個指定使用 StringBuilder 的 test case, 它們的 bytecode 看起來不一樣, 但是長時間運作之後它們得到的結果還是幾乎一樣的。

confirmed

8 則留言:

  1. 本文大錯,僅有在 compiler 可以最佳化的有限場景才是,通常是宣告的地方,不然放到method/loop裡面就知道了,因為只有在 method/loop 才會對效能造成大影響,少數幾個 String 根本不再效能需要調教的地方。
    其實根本不用那麼麻煩,de-compiler 看一眼就知道都是改用 StringBuffer/StringBuilder 了。

    回覆刪除
    回覆
    1. 抱歉,我不太確定你的意思,尤其是「僅有在 compiler 可以最佳化的有限場景才是,通常是宣告的地方」

      另外,以原文作者有提供 bytecode 的 javap 結果
      例如 JDK 7 的版本
      https://github.com/skuro/stringbuilder/blob/master/java7/CatPlus.class.txt
      是說他的 CatPlus.java 本身就寫的很怪
      單純看加號的部份還是用 append() 沒錯

      換這個例子可能更明顯:

      public class CatPlus {
      public String foo (String a, String b) {
      return "const1" + a + "const2" + b;
      }
      }

      那麼 javap 的結果會是

      public class CatPlus extends java.lang.Object{
      public CatPlus();
      Code:
      0: aload_0
      1: invokespecial #1; //Method java/lang/Object."":()V
      4: return

      public java.lang.String foo(java.lang.String, java.lang.String);
      Code:
      0: new #2; //class java/lang/StringBuilder
      3: dup
      4: invokespecial #3; //Method java/lang/StringBuilder."":()V
      7: ldc #4; //String const1
      9: invokevirtual #5; //Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      12: aload_1
      13: invokevirtual #5; //Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      16: ldc #6; //String const2
      18: invokevirtual #5; //Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      21: aload_2
      22: invokevirtual #5; //Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      25: invokevirtual #7; //Method java/lang/StringBuilder.toString:()Ljava/lang/String;
      28: areturn

      }

      不知道這之間的差距是不是你想要說的意思?

      刪除
    2. 舉例通常會出問題的程式,比如手工報表。

      String report = "";
      for(..;..;..){
      report = report + line;
      }

      line 可以直接做,或是寫在 method 中,狀況類似,
      他只會最佳化中間的 (compliance 太低還不會最佳化 String,但是會最佳化別的 XD )
      String report = "";
      for(..;..;..){
      report = new StringBuilder(String.valueOf(report)).append(line).toString();
      }

      通常 report 很大一樣死,標準作法是一開始就寫給他,
      StringBuilder reportSb = new StringBuilder();
      for(..;..;..){
      reportSb.append(line);
      }
      String report = reportSb.toString();

      所以有好習慣用 StringBuilder 是好的,只有在小地方確定沒問題的才用 String 相加,
      因為少呼叫的東西根本沒差那一點,常呼叫的東西就是很可怕的效能因素。

      刪除
  2. 我覺的樓上的大大點搞不清楚人家要說明的東西~
    這篇文章明明就寫的很棒,贊+1
    文中道盡了使用字串相加與StringBuilder的差異性
    人家下的結論也沒錯,結論也說明了使用加號相連接時,總會建立StringBuilder的instance,
    這會給GC很大的負擔,
    所以作者建議你自己使用StringBuilder ,
    以避開每次都會產生StringBuilder Instance的問題,
    因為你對於StringBuilder有控制權了,
    你可以選擇在回圈內只使用一個Instance。

    而上樓上大大只是拿人家文中的重點再講一篇。然後還先否定別人的內容,然後又拿別人的內容來講,這不是很奇怪嗎?

    另外文中已經有說明使用字串加法會自動產生StringBuilder。
    所以這也說明了使用StringBuilder是看時機使用,
    如果你的字串連結是那種使用了StringBuilder和編譯結果會長的一樣的,
    那就表示你不用脫褲子放屁了。

    作者又沒說要把全部的字串連接全部改成StringBuilder ~"~
    而且從作者的內文可以看出作者當然懂得使用時機,何需你來提點?

    回覆刪除
  3. (用 markdown 語法寫)

    半年前的文章,害我花了一點時間重新 loading XD

    這篇翻譯文章,是真的很有問題的。只是因為我都花了幾個小時在翻譯 & 上稿,所以才沒有把他砍掉 Orz。所以在文章一開頭才補了 PTT Java 版的後續討論。

    簡單地說:

    * 在很久以前(JDK 1.5+?),字串運算的 + 就會幫你轉換成 StringBuilder。所以像 `foo = foo1 + foo2 + foo3 + foo4 + foo4 + foo5;` 這種 pattern 還是可以安心用 + 號沒有問題,因為 + 的再多也只有一個 StringBuilder 的 instance。
    * 對於迴圈中的 `foo = foo + foo;` 則無法,因為他還是會產生一堆 instance

    而這篇文章最大的問題就在於,以 byte code 來看,他的三個 test case 幾乎沒有差別,也沒有涵蓋「實際用 + 號會引發的問題」。而最後的結論也不符合真實狀況。

    以上

    回覆刪除
  4. 所以本文已經點出迴圈內使用字串加法會產生的問題了(這才是本文的重點)
    至於怎麼運用就我們自己要決定了

    回覆刪除
  5. 路過的人問個題外話:foo = foo + bar; 怎麼都沒有人想用 foo += bar; 呢!?

    另如果使用 java.lang.String.concat(String str); 效能不知是否有影響(這個其實自己去看 decompiled code 就大概猜得出來了吧但本人又懶又好奇)

    回覆刪除
  6. ㄜ... 沒意外的話,foo = foo + bar 跟 foo += bar 是等意,純粹是 syntax sugar。
    然後 String.concat() 這玩意,我想不用 decompiled code,直接看 source code 就可以了

    public String concat(String str) {
    int otherLen = str.length();
    if (otherLen == 0) {
    return this;
    }
    int len = value.length;
    char buf[] = Arrays.copyOf(value, len + otherLen);
    str.getChars(buf, len);
    return new String(buf, true);
    }

    他還是會產生新的 instance

    回覆刪除