Javaの文字列結合はStringBuilderでいいのか

7 min読了の目安(約6700字TECH技術記事

はるか昔人々はJavaに想いをはせていた。そんな中クラウディオス・プトレマイオスは「Javaの文字列結合はStringBuilderで行うべき」という大きな発見をした。人々は彼の偉大なる発見に驚き、そして信じた。それからしばらくしてプトレマイオスの説に異を唱える青年があられて・・・!?(電○文庫が送る新感覚Boy meets Java小説!)

というのは冗談ですが、文字列結合はStringBuilderを使うべきというのは定説です。しかし何故そういわれているのでしょうか?というか本当にStringBuilderじゃないと駄目なんでしょうか?
この記事では以下の内容をお話しています。

何故文字列結合はStringBuilderを使うことが推奨されているのか

さて、Javaには文字列結合に関わる機能は複数存在します。

  • +演算子を用いた結合
  • String.concat()を用いた結合
  • StringBufferを用いた結合
  • StringBuilderを用いた結合

基本的に

  • 複数ステートメントで
  • 結合される側の文字列を複数スレッドで共有しない場合は
    メモリアロケーションの回数が少ないStringBuilderが一番高速で文字列結合できると言われています。 結合される側の文字列を複数スレッドで共有する場合はStringBufferを使えばOKですが、そんなケースは結構稀だと思います。

ここまではJavaを使う多くの方がご存知なはずです。「そういう実装になっているか速いんです」で終わってしまっては悲しいですよね。そこで、次の章からは各文字列結合の手段の特徴を紹介しようかと思います。

Java9以降における+演算子を用いた結合

JavaのStringはイミュータブル(不変)なクラスです。つまり一度作成されたインスタンスは基本的に変更不可です。しかしながらJavaのコードでは

return "a" + "b"

といった書き方ができます。これはなぜかと言うとJavaのコンパイラが上記のコードをいい感じに別の形に変換してくれるからです。具体的には上記のコードは

return "ab"

に変換されます。(これはJava8以前でも同様)賢い! さすが30億のデバイスで動いているJava。つまり文字列リテラル同士の結合はJavaコンパイラが文字列結合を用いない形に変換してくれます。文字リテラルとfinal修飾子が付与された文字列を持つ変数の結合も同様です。

JDK9以降を利用していて、結合対象の文字列どちらかが変数であるという下記のようなコードの場合は

hoge += "appendedStr";

はJavaコンパイラでinvokedynamic(以下indy)を使ったJavaバイトコードに変換されます。

// デコンパイラによってclassファイルをでコンパイルしたもの
str = invokedynamic(makeConcatWithConstants:(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;, str, MyBenchmark.APPENDED_STR);

indyをご存知無い方が大半だと思うので頑張って説明します。indyは関連付けられたブートストラップメソッド(ここではmakeConcatWithConstants)を元に、実際に呼び出す処理を実行時に生成する仕組みです。ブートストラップメソッドを用いて処理を生成するのは、初回実行時のみで2回目以降は初回実行時に生成した処理を直接呼び出します。図に表すと以下のような感じになります。

ちなみにmakeConcatWithConstantsによって生成される処理は

  1. 結合する全ての文字列を格納できる配列を作る
  2. 配列に文字列を詰める
  3. new String()で配列を文字列化

といったものになります。
(参考:https://www.slideshare.net/YujiSoftware/jep280-java-9)

この方式では1回の文字列結合中に1回のJavaインスタンス生成と配列の確保が行われます。

Java8以前における+演算子を用いた結合

実は前の章で説明した処理はパフォーマンスを改善したJava9以降の新しい処理です。Java8以前での+演算子を用いた文字列結合は下記のようにStringBuilderを用いた処理にJavaコンパイラによって変換されます。

// デコンパイラによってclassファイルをデコンパイルしたもの
str = (new StringBuilder(String.valueOf(str))).append(APPENDED_STR).toString();

この結合処理だと文字列結合するたびに

  1. StringBuilderインスタンスを生成(内部に結合する文字列の片方を含むbyte配列を持つ)
  2. StringBuilder.append()でもう片方の文字列をStringBuilder内部のbyte配列に格納(Byte配列の長さが足りない場合は新たに配列を作る)
  3. toString()によってStringBuilder内部のbyte配列を文字列化

という処理を行っています。

つまり2回のインスタンス生成と1 or 2回配列の確保が行われます。
Java9以降の+演算子を用いた結合方式と比べると明らかにメモリアロケーションの回数が多いことがわかります。

String.concat()を用いた結合

String.concatの処理はざっくり以下のような感じです。

  1. 結合先文字列のbyte配列を取得
  2. 結合される文字列のbyte配列を取得
  3. 結合先文字列長+結合される文字列長の長さを持つbyte配列を確保し、1で取得したbyte配列をコピー
  4. 3で生成したバイト配列に2で取得したbyte配列をコピー
  5. new String()で配列を文字列化

という感じです。(元ソース載せていいかわからないので手順での記載)

つまり配列の確保を3回行いインスタンスの生成を1回行っています。

StringBuilderを用いた結合

こちらの処理内容は「Java8以前における+演算子を用いた結合」で説明しましたが改めて書いておきます。
文字列の生成から結合(+結合した文字列の取得)までに必要な処理は以下の通りです。

  1. StringBuilderインスタンスを生成(内部に結合する文字列の片方を含むbyte配列を持つ)
  2. StringBuilder.append()でもう片方の文字列をStringBuilder内部のbyte配列に格納(Byte配列の長さが足りない場合は新たに配列を作る)
  3. toString()によってStringBuilder内部のbyte配列を文字列化

ポイントは toString()を実行しなければStringインスタンスは生成されない ことで、繰り返し処理の中で文字列を結合するような場合は toString()をする処理を繰り返し処理の外に書いてあげるべき です。

そうしないと一回の文字列結合のたびにStringインスタンスが生成されますので、スループットが低下します。そしてそれをやってるのがJava8以前の文字列結合なわけです。

StringBufferを用いた結合

StringBufferはスレッドセーフな文字列結合を行いたい場合に使います。
StringBufferのメソッドには大体synchronized修飾子が付与されており、それらはメソッドが同時実行されないことが保証されています。
結合処理自体はStringBuilderと大差はありません。しかしメソッドを実行するたびにStringBufferオブジェクトのロックが行われるので、その分少しだけStringBuilderよりスループットが低いです。

StringBuilderじゃなくてもいいケースがある?

StringBuilderを使わなくてもいいケースも実は存在します。それは

  • 1ステートメントの文字列結合
  • 文字列リテラル同士の文字列結合

です。

1ステートメントでの文字列結合の場合は、

  • StringBuilderの生成
  • 文字列の結合
  • Stringインスタンスの生成

これらを一度に行うので、+演算子を使った文字列結合と比べてかなり高いスループットが出せるわけではありません。

また冒頭でも説明しましたが、静的変数同士または文字列リテラル同士の文字列結合の場合は、コンパイラが文字列結合を行わない形に変換してくれるのでStringBufferを使うのは無駄です。

で実際のパフォーマンスは?

というわけで

  • 複数ステートメントにわたる文字列結合はStringBuilderが一番速いのか
  • 1ステートメントの文字列結合はStringBuilderはたいして速くないのか

を実際にJava microbenchmark harness(以下JMH)でベンチマークを取ってみました。

複数ステートメントにわたる文字列結合ベンチマーク

コードはこんな感じです。ITERATE_COUNTには10,000を指定していて、Java11で実行しました。

@Benchmark
public void testStringBuilder() {
   var sb = new StringBuilder(INITIAL_STR);
   for(var i = 0; i < ITERATE_COUNT; i++) {
       sb.append(APPENDED_STR);
   };
}

@Benchmark
public void testConcat() {
   var str = INITIAL_STR;
   for(var i = 0; i < ITERATE_COUNT; i++) {
       str.concat(APPENDED_STR);
   };
}

@Benchmark
public void testPlusOperator() {
   var str = INITIAL_STR;
   for(var i = 0; i < ITERATE_COUNT; i++) {
       str += APPENDED_STR;
   };
}

結果を見ると予想通りStringBuilderが爆速です。

Benchmark                       Mode  Cnt     Score     Error  Units
MyBenchmark.testConcat         thrpt   25  4421.067 ± 215.696  ops/s
MyBenchmark.testStringBuffer   thrpt   25  7193.853 ± 365.745  ops/s
MyBenchmark.testStringBuilder  thrpt   25  9305.013 ± 421.303  ops/s
MyBenchmark.testPlusOperator   thrpt   25     9.485 ±   0.192  ops/s

1ステートメントの文字列結合ベンチマーク

コードは以下の通りです。

@Benchmark
public void testStringBuilder() {
   var sb = new StringBuilder(INITIAL_STR);
   sb.append(APPENDED_STR)
   .append(APPENDED_STR)
   .append(APPENDED_STR)
   .append(APPENDED_STR)
   .append(APPENDED_STR)
   .append(APPENDED_STR)
   .append(APPENDED_STR)
   .append(APPENDED_STR)
   .append(APPENDED_STR)
   .append(APPENDED_STR).toString();
}

@Benchmark
public void testConcat() {
   var str = INITIAL_STR;
   str.concat(APPENDED_STR)
   .concat(APPENDED_STR)
   .concat(APPENDED_STR)
   .concat(APPENDED_STR)
   .concat(APPENDED_STR)
   .concat(APPENDED_STR)
   .concat(APPENDED_STR)
   .concat(APPENDED_STR)
   .concat(APPENDED_STR)
   .concat(APPENDED_STR);
}

@Benchmark
public void testPlusOperator() {
       var str = INITIAL_STR
           + APPENDED_STR
           + APPENDED_STR
           + APPENDED_STR
           + APPENDED_STR
           + APPENDED_STR
           + APPENDED_STR
           + APPENDED_STR
           + APPENDED_STR
           + APPENDED_STR
           + APPENDED_STR;
}

結果を見るとconcatを使った実装だけパフォーマンスが悪く、他の結合処理には大差がありません。
concatは実行する度にStringインスタンスが生成されるためにこのような結果になっていると推測できます。

Benchmark                       Mode  Cnt         Score        Error  Units
MyBenchmark.testConcat         thrpt   25   3888100.335 ± 183176.878  ops/s
MyBenchmark.testPlusOperator   thrpt   25  14174767.615 ± 538418.410  ops/s
MyBenchmark.testStringBuffer   thrpt   25  14121375.836 ± 683936.557  ops/s
MyBenchmark.testStringBuilder  thrpt   25  13717991.226 ± 496286.302  ops/s

まとめ

実装を確認することで、改めてJavaに実装された文字列結合処理それぞれの特徴を知ることができました。
やはり

  • 複数ステートメントで
  • 結合される側の文字列を複数スレッドで共有しない場合は

メモリアロケーションの回数が少ないStringBuilderが一番高速で文字列結合できる
というのは間違いないと確信が持てました。