読者です 読者をやめる 読者になる 読者になる

かとじゅんの技術日誌

技術の話をするところ

Stringの連結はそう簡単なものではない

追記:
指摘の通りで、現実的な連結回数での計測でもないですし、統計手法を用いた分析をしていないので、このエントリの計測値は当てにしないでください。なので以下のブログを参考にしてください。

currentTimeMillis()で計測しておいて
plusTime:14780, concatTime:7053, sbuilderTime:7, sbufferTime:13
とか、その7とか13の有効数字はいくつだっての。

激しく今更感があるタイトルですが(;・∀・)
昔に取り上げたのですが、
文字列の結合をやるからといって、すぐにStringの+をつかってはいけない - じゅんいち☆かとうの技術日誌
Stringの+演算子は間違った使い方するとパフォーマンスが低下しますよっていう話題。若干ネタ成分ありますが、ご容赦ください。
これ系の話題は自分的にはオワコンなんですが、最近、また話題を見つけたし、大事なことなので掘り下げてみようと思います。

この辺、StringとStringBuilderの特性を知らないで計測しているので、Stringの+演算子がとんでもなく遅いことになっていますけど、全くそんなことないです。問題は2つあって一つがStringの書き方で最適化が効かないような書き方をしている点とStringが不得意な巨大なStringを作り出している点。
(中略)
String:163, Builder:78, Buffer:114

ここで注意が必要なのは、100万回を実行してもStringとStringBuilderにはあまり差がないという結論。

StringBuilderは速くないのか?

確かにそれは正しいのですが、このサンプルでは、100万回繰り返しているがループ内でStringBuilderのインスタンスを生成して2回しかappendメソッドを呼んでいない。これではStringの+演算子とはそれほど差がでないと思います。

for(int i = 0; i < loop; i++) {
     StringBuilder sb = new StringBuilder("abcde");
     sb.append("1234567890");
     sb.append("うほうほ");
     sb.toString();
}

本当に評価するなら、以下の処理時間を計測しないと文字列連結コストの比較できないと思いますよ。

StringBuilder sb = new StringBuilder("abcde");
for(int i = 0; i < loop; i++) {
     sb.append("1234567890");
}
sb.toString();

ということで、以下のようなコードを書いてみました。100万回は長いので10万回にしました。
それと、文字列の連結にはconcatメソッドもあるのでそれも含めています。

package test;

public class StringTest {
	public static void main(String[] args) {
		long start = 0;
		long loop = 100000;

		start = System.currentTimeMillis();
		String plusResult = "";
		for (int i = 0; i < loop; i++) {
			plusResult += "a";
		}
		long plusTime = System.currentTimeMillis() - start;
		System.out.println("cancatResult = " + plusResult);

		start = System.currentTimeMillis();
		String concatResult = "";
		for (int i = 0; i < loop; i++) {
			concatResult = concatResult.concat("a");
		}
		long concatTime = System.currentTimeMillis() - start;
		System.out.println("cancatResult = " + concatResult);

		start = System.currentTimeMillis();
		StringBuilder sbuilder = new StringBuilder();
		for (int i = 0; i < loop; i++) {
			sbuilder.append("a");
		}
		String sbuilderResult = sbuilder.toString();
		long sbuilderTime = System.currentTimeMillis() - start;
		System.out.println("sbuilderResult = " + sbuilderResult);

		start = System.currentTimeMillis();
		StringBuffer sbuffer = new StringBuffer();
		for (int i = 0; i < loop; i++) {
			sbuffer.append("a");
		}
		String sbufferResult = sbuffer.toString();
		long sbufferTime = System.currentTimeMillis() - start;
		System.out.println("sbufferResult = " + sbufferResult);
		System.out
				.printf("plusTime:%d, concatTime:%d, sbuilderTime:%d, sbufferTime:%d\n",
						plusTime, concatTime, sbuilderTime, sbufferTime);
	}

}

結果は以下。単位はmsec。

plusTime:14780, concatTime:7053, sbuilderTime:7, sbufferTime:13

この場合は、Stringの+演算子は一番遅いです。その次はconcatメソッド、StringBufferで、一番速いのはStringBuilderです。
下の追記参照。

Stringの+演算子が遅い理由

Stringに対する+演算子は、「文字列連結演算子」と呼ばれます。これはStringクラスのメソッドではありません。

s += "abc";
s = s + "abc";

メソッドではないのに、どういう処理が行われるのか。

答えは「Java言語仕様3版」の「15.18.1.2 文字列連結の最適化」にあります。
以下P435-436の引用。今回の場合は太字のところの部分です。

一時的なStringオブジェクトの生成と破棄を回避するため,変換と連結を単一のステップで実行することが実装に対して許されている。また,連続して文字列連結を行う際におけるパフォーマンスの向上を目的として,クラスStringBufferや同種の手法を採用し,式の評価中に生成される一時的なStringオブジェクト数を削減することがJavaコンパイラに対して許されている。
プリミティブ型の場合,プリミティブ型を文字列に直接変換することによって,ラッパ・オブジェクトの生成を回避するような霞適化も実装に対して許されている。

Java言語仕様 第3版 (The Java Series)

Java言語仕様 第3版 (The Java Series)


分かりやすく説明すると、以下のコードは

     s += "(;´Д`)";
     s += "(´∀`)";

コンパイラによって「文字列連結の最適化」が実施されて

     s = (new StringBuilder(String.valueOf(s))).append("(;´Д`)").toString();
     s = (new StringBuilder(String.valueOf(s))).append("(´∀`)").toString();

になります。
つまり、文字列連結演算子は、StringBuilderへの構文糖衣です。文字列連結演算子を使うというのはStringBuilderを使うということになります。
文字列連結演算子がが出現する度にStringBuilder生成して利用するコードになるため処理速度面で不利になります。複数行に、文字列連結演算子を記述すると効率は悪くなります。当然、一行にまとめて書いたほうが効率がよくなるのは自明。
こっちも一緒に読むとよいです。

JDK1.5で「+演算子」が実際にどう変換されるか、jadを使って見てみた。

Stringのconcatメソッドが速くない理由

なぜかというと、Stringのconcatではnew Stringを都度newするからですね。

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

単一の文字列を連結する程度であれば、concatメソッドで十分です。逆に大量に連結する場合は使うと効率が悪くなります。

StringBuilderが速い理由

StringBuilderだと、追加する文字列の内容をchar配列に格納していき、最後のtoStringメソッド時にnew Stringします。

abstract class AbstractStringBuilder implements Appendable, CharSequence {
// ...
    public AbstractStringBuilder append(StringBuffer sb) {
     if (sb == null)
            return append("null");
     int len = sb.length();
     int newCount = count + len;
     if (newCount > value.length)
         expandCapacity(newCount);
     sb.getChars(0, len, value, count);
     count = newCount;
     return this;
    }
// ...
}
public final class StringBuilder extends AbstractStringBuilder implements java.io.Serializable, CharSequence {
// ...
    public String toString() {
        // Create a copy, don't share the array
     return new String(value, 0, count);
    }
// ...

そのため、大量の連結を行う場合は効率がよいのです。逆に単一の文字列を連結するような場合はあまり効率はよい方法とは言えません。

StringBufferが若干遅い理由

publicのメソッドには固有ロック(synchronized)が適用されているからです。ロックを取得するコストがかかるため若干遅いわけです。スレッドセーフ版のStringBuilderとしての役割があるので、同期化のコストを払っても安全に文字列の組み立てを行う目的があります。

public final class StringBuffer ...
    public synchronized StringBuffer append(String str) {
	super.append(str);
        return this;
    }
// ... 
    public synchronized String toString() {
	return new String(value, 0, count);
    }
}

連結方法の使い分け方

StringBuilderでは単一の文字列を一度だけ追加するようなケースでは大袈裟で、あまり効率がよいとはいえないと前述しましたが、文字列連結演算子は以下のようにStringBuilderに最適化されてしまいます。

     s += "(;´Д`)";
     // 最適化後 : s = (new StringBuilder(String.valueOf(s))).append("(;´Д`)").toString();

単一の文字列の連結だけであれば、concatメソッドを使うといいかもしれません。

     s = s.concat("(;´Д`)");

しかし、複数回の連結がある場合はconcatメソッドで以下のように書くより

     s = a.concat(b).concat(c);

としたほうが、可読性が高く、実行効率も多少よいはずです。

     s = a + b + c;
     // 最適化後 : String s = (new StringBuilder(String.valueOf(a))).append(b).append(c).toString();

無論、今のVMの最適化やマシンパワーをもってすれば「文字列連結演算子」なんぞ神経質になる必要はないという考え方もあるでしょう。

まとめ

まとめると、性能を求める場合や、ループ処理などで大量に連結処理をする場合は、StringBuilderを使う。(同期化が必要な場合はStringBuffer) それ以外では、単一の文字列を追加する場合はconcatメソッド、複数個の文字列を追加する場合は「文字列連結演算子」で書いたほうがよいと思います。ただ、concatメソッドと「文字列連結演算子」の使い分けは、さほど神経質にならなくてもよいかもしれません。
ということで、Stringの連結はそう簡単ではないということでした。

Effective Java 第二版「項目51 文字列結合のパフォーマンスに用心する」にも同様の話題があります。

Effective Java 第2版 (The Java Series)

Effective Java 第2版 (The Java Series)

追記:
↑は一般論という話。StringBuilderですが、確かに同一インスタンスで10万回も追加しないというのはありますね。ということで、100回とか1000回とかの少ない回数でどうか計測してみた。

100回連結 plusTime:1, concatTime:0, sbuilderTime:0, sbufferTime:0
1000回連結 plusTime:30, concatTime:2, sbuilderTime:20, sbufferTime:0

確かに決定的な差はないかも。ということは、id:y_nakanishiさんのいう通りですね。
通常の用途では、性能面で問題になることが殆どないはずなので、「文字列連結演算子」か、concatメソッドを使えばよいという結論か。
id:y_nakanishiさん、ありがとうございました!