Java: 文字列結合の速度を JDK 11 で測る

Java で文字列結合をする場合に、String+ でやるべきか、StringBuilderappend でやるべきか、という議論が昔からある。

僕は JDK 9 と 10 を触らずに今日まで来てしまったけど、ちょうど JDK 11 がリリースされたので、最新の状況を測って比較してみることにした。

実験内容

文字列結合を繰り返してひとつの長い文字列を生成するケースを、簡単なコードで動かして所要時間を計測した。

使ったのは 2013 年購入の MacBook Pro で、OS は macOS High Sierra (10.13.6)。Oracle JDK 8 と OpenJDK 11 (Oracle によるビルド) でそれぞれコンパイル・実行して比較している。

テストコード

まず、+ 演算子を使って、固定の文字列を 10 万回結合して、ひとつの長い文字列を作るのにかかる時間を測定する。

static final int N = 100_000;

public static void main(String[] args) {
    long start = System.currentTimeMillis();
    
    String s = "";
    for (int i = 0; i < N; i++) {
        s += "a";
    }
    System.out.println(s.length());

    long end = System.currentTimeMillis();
    
    System.out.println(end - start);
}

StringBuilder でも同様なことをやる。

static final int N = 100_000;

public static void main(String[] args) {
    long start = System.currentTimeMillis();
    
    StringBuilder b = new StringBuilder();
    for (int i = 0; i < N; i++) {
        b.append("a");
    }
    String s = b.toString();
    System.out.println(s.length());

    long end = System.currentTimeMillis();
    
    System.out.println(end - start);
}

加えて、+ 演算子StringBuilder のそれぞれで、以下の 3 パターンを試した。

  • "a"を 10 万回足す
  • "あ" を 10 万回足す
  • "a" を 5 万回足した後、"あ" を 5 万回足す

計 6 ケースのそれぞれについて、数回実行して数値が安定したのを確認してから、3 回実行して平均をとった。

測定結果

長文になるので、使ったコードは後回しにして、結果から先に書く。

表中の SBStringBuilder。計測時間の単位はすべてミリ秒だ。

JDK8 + JDK8 SB JDK11 + JDK11 SB
"a"×10万回 5,275 6 997 8
"あ"×10万回 5,230 5 1,687 8
"a"×5万回+"あ"×5万回 5,192 5 1,528 12

考察

まず、このケースにおいては、JDK11 でも、+ 演算子より StringBuilder の方が 100 倍以上高速のようだ。

とはいえ、+ 演算子での結合は、JDK8 から JDK11 までの間で大幅に高速化されている。特に、"a" だけを結合した場合はかなり高速になっている。これはおそらく JDK9 での 2 つの改善点による影響が大きい。

"a" を 5 万回追加した後に "あ" を5 万回追加したパターンでは、+ 演算子でも StringBuilder でもちょっと時間がかかっている。おそらく、JEP 254: Compact Strings 導入後の StringStringBuilder は、LATIN1 範囲外の文字がはじめて出現した時点でデータを 1 文字 1 バイトから 2 バイトに配置し直す必要があるためではないかと思う。この仕組みについては下記ブログに詳しい。

d.hatena.ne.jp

StringBuilder での結果を JDK8 と JDK11 で比べると、わずかに JDK11 が遅い。これはなんだろう。JEP 254: Compact Strings に対応するためにロジックが増えたためかもしれない。

全体として数値を眺めて見ると、+ 演算子での結合がだいぶ早くなってきたので、StringBuilder を使っていないコードを見かけても目くじら立てて怒らなくてもいいかなという気はする。一方で、それでも 2 桁違うのは確かなので、StringBuilder を目の敵にする必要もなさそうだ。

結論としては、ループの中で繰り返し文字列結合するようなケースでは今まで通り StringBuilder を活用しつつ、1 つの式や文で収まるような簡単な文字列結合では今まで通り可読性のために + 演算子を使えばよい、ということになりそうだ。

おさらい

JDK8 まで

Java では不変の文字列を String で表し、プログラム中の文字列リテラルString になるようコンパイルされる。

String s = "はてなブログ";

また、文字列の結合を行うために StringBuilder が用意されており、append メソッドを使って文字列を繋げていき、最後に toString メソッドで結合結果の String を得ることができる。

StringBuilder b = new StringBuilder();
b.append("はてな");
b.append("ブログ");
String s = b.toString();

ただし、プログラムを書く上で、文字列結合は頻繁に必要になるため、明示的に StringBuilder を使って結合する以外に、+ 演算子で連結することができる。数値演算と同様に、加算代入演算子 += を使うこともできる。

String s = "はて";
s = s + "なブ";
s += "ログ";

ここで問題になるのが、+ 演算子を使って書かれた文字列連結は、実際にはどのように実行されているかだ。String もオブジェクトなので、操作するにはメソッドを呼んだり新しいインスタンスを作る必要がある。String は不変の文字列なので、元の String オブジェクト自体に文字を付け足すことはできず、結合結果の新しい String を別に作るしかない。

JDK 8 までは、+ 演算子による結合を、コンパイラStringBuilder による結合に変換していた。例えば

s += "ちゃん";

と書いた場合、

s = new StringBuilder(s).append("ちゃん").toString();

と書いたのと同じ結果になるようにコンパイラが変換していた。これによって、Java 実行環境に String 結合の仕組みを作り込まなくてもよく、プログラマーは性能を気にせず気軽に + 演算子を活用できるようになった。ただし、この変換は文単位で行われるため、相変わらず StringBuilder を使うべき場面も残っていた。

典型的な例でいうと、ループ内で文字列結合を行う場合には相変わらず StringBuilder が使われてきた。

StringBuilder b = new StringBuilder();
for (String item : list) {
  b.append('[')
   .append(item)
   .append(']');
}
String s = b.toString();

これを + 演算子+= を使って次のように書いたとする。

String s = "";
for (String item : list) {
  s+= '[' + item + ']';
}

このうち + 演算子+= 演算子を使う部分がコンパイラによって変換されるわけだが、次のコードと同じになるように変換されるため、ループの繰り返しごとに StringBuilderStringインスタンスが大量に生成・破棄されることになってしまう。

String s = "";
for (String item : list) {
  s = new StringBuilder(s)
    .append('[')
    .append(item)
    .append(']')
    .toString();
}

細かいことを言えば、+ 演算子で書いていても、文字列のリテラルまたは定数だけを結合している限りにおいては、コンパイル時点で結合するというルールもある。

String s = "はてな" + "ブログ";

と書いた場合、

String s = "はてなブログ";

と同じにコンパイルされる。

そういうわけで、全世界の Java プログラマーたちは、+ 演算子を使って短く読みやすいソースコードを書きつつも、常に舞台裏の仕組みを意識し、性能に悪影響を与えそうな箇所では明示的に StringBuilder を使うという二重生活を強いられている。

なお、さらに古い時代には StringBuilder ではなく StringBuffer が使われていた。

JDK 9

JDK 9 では文字列の扱いについて改善が行われた。

ひとつは、StringStringBuffer の内部で文字列データを char[] ではなく byte[] で保持するように変更された JEP 254: Compact Strings だ。LATIN1 範囲内の文字だけを格納するのであれば 1 文字 1 バイトで持てるため、メモリ消費が削減できる上、それに伴って性能も改善された。ただし、LATIN1 範囲外の文字列が入ってきたら 1 文字 2 バイトに作り直すステップが必要になったため、日本語の全角文字や半角カタカナ、絵文字なんかが途中から含まれるような文字列だと、LATIN1 のみの場合に比べて少しだけ性能が落ちる。

このあたりの仕組みについては、上でも挙げたが下記ブログ記事が参考になる。

d.hatena.ne.jp

もうひとつの改善点は JEP 280: Indify String Concatenation だ。+ 演算子を使った文字列結合を、StringBuilder による具体的な処理に変換するのではなく、実際には存在しないメソッド名を動的呼び出し (InvokeDynamic) としてコンパイルする仕組みだ。実行時にこの動的呼び出しを最適な文字列結合ロジックにインライン展開することにより、StringBuilder を使うよりも高速な文字列結合が可能になる。

加えて、インライン展開の内容は実行時に決まるので、JVM 側が今後改善されれば、古い JDKコンパイルしたプログラムでもその恩恵を受けられることになる。ただし、この仕組みは JDK9 以降の JVM にしか存在しないため、JDK8 以前の環境でも動かしたい場合は、コンパイル時のオプションで従来通り StringBuilder を使うようにコンパイルする必要がある。

こちらについては、下記のスライドが参考になる。

www.slideshare.net

以上のように、JDK9 以降で + 演算子での文字列結合が大きく改善されたことを受けて、プログラムを書く際にも可読性を優先して StringBuilder を使わずにすべて + 演算子で書けばいいのではという意見もある。中でも過激派は、+ 演算子による世界征服を目指し、StringBuilder を使っている人を夜な夜な暗殺して回っているという。恐ろしいことである。