プログラマは非効率的なコードを書くべきである

プログラマは非効率的なコードを書くべきである。」

メモリやCPUリソースを普段から意識してはいけない! こう書くと山盛りの反論が来そうです。ですが性能の良いコードを普段から書く必要あるのでしょうか?

もちろんプログラムの実装による性能改善は重要です。

パフォーマンスチューニングをミドルウェアで頑張ったり、金の弾丸でサーバーを増強したり増やしたりするよりも圧倒的な効率で性能改善が出来ることはザラです。

それと同時に「こっちの方が効率が良いと思って...」という気持ちでちょっぴり複雑なコードを作ってしまう事も良くあります。

さて、性能とは可読性より優先するものでしょうか??? Stringのプラス演算子のコストが高いから常にStringBuilderを使うべき、Lambdaは遅いからfor文を使え。リフレクションは遅い。色々あります。ええ、確かに理論上遅いはずです。

「その幻想をぶち壊す!」 f:id:pascal256:20181015152318j:plain

「確かに効率が悪いのは確かさ。でもその効率って奴は何かを...そう可読性とかを犠牲にしてまで守らなきゃならないもんなのか!?(上条さん風)」

というわけで、計測大事だよねって観点で実際最近の言語/マシンではどのくらいなもんかをマイクロベンチして見ます。 ちなみに特性は当然言語や処理系によって変わりますが今回はJDK11を利用しています。

文字列結合編

Javaの文字列結合でStringのプラス演算子ではなくStringBuilderを使うべし! ってのは有名ですよね。 これは事実だけどStringBuilderが必須な文字列結合のケースは結構検定的です。

コンパイラ

まずは実測の前にコンパイラさんの仕事を見てみましょう

public static void checkJavacOptimizationPlus() {
    String hello = "hello(v)";
    String world = "world(v)";

    final String HELLO = "hello(c)";
    final String WOLRD = "world(c)";

    String mixedStr2 = "hello(l)" + "world(l)" + HELLO + WOLRD + hello + world;

}

public static void checkJavacOptimizationSB() {
    String hello = "hello(v)";
    String world = "world(v)";

    final String HELLO = "hello(c)";
    final String WOLRD = "world(c)";

    StringBuilder sb = new StringBuilder();
    sb.append("hello(l)");
    sb.append("world(l)");
    sb.append(HELLO);
    sb.append(WOLRD);
    sb.append(hello);
    sb.append(world);
    String sbStr = sb.toString();
}

リテラル、定数(final)、変数をJIT前のコンパイラがどう変換したかをjavapの結果を見てみます。

  public static void checkJavacOptimizationPlus();
    Code:
       0: ldc           #22                 // String hello(v)
       2: astore_0
       3: ldc           #23                 // String world(v)
       5: astore_1
       6: ldc           #24                 // String hello(c)
       8: astore_2
       9: ldc           #25                 // String world(c)
      11: astore_3
      12: aload_0
      13: aload_1
      14: invokedynamic #29,  0             // InvokeDynamic #6:makeConcatWithConstants:(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;
      19: astore        4
      21: return
  public static void checkJavacOptimizationSB();
    Code:
       0: ldc           #23                 // String hello(v)
       2: astore_0
       3: ldc           #24                 // String world(v)
       5: astore_1
       6: ldc           #25                 // String hello(c)
       8: astore_2
       9: ldc           #26                 // String world(c)
      11: astore_3
      12: new           #6                  // class java/lang/StringBuilder
      15: dup
      16: invokespecial #7                  // Method java/lang/StringBuilder."<init>":()V
      19: astore        4
      21: aload         4
      23: ldc           #30                 // String hello(l)
      25: invokevirtual #9                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      28: pop
      29: aload         4
      31: ldc           #31                 // String world(l)
      33: invokevirtual #9                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      36: pop
      37: aload         4
      39: ldc           #25                 // String hello(c)
      41: invokevirtual #9                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      44: pop
      45: aload         4
      47: ldc           #26                 // String world(c)
      49: invokevirtual #9                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      52: pop
      53: aload         4
      55: aload_0
      56: invokevirtual #9                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      59: pop
      60: aload         4
      62: aload_1
      63: invokevirtual #9                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      66: pop
      67: aload         4
      69: invokevirtual #11                 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
      72: astore        5
      74: return

なんと驚きプラス演算子のがコードがガツっと短いのです。Java8だと短いながらももうちょっとStringBuilderと近い感じだったのですが。 察するに用途が明確な分、StringBuilderよりもコンパイル時の最適化がしやすいのかもですね。 ちなもにこの例では判りづらいですがリテラルと定数の結合はjavac時点で対応されるのでゼロコストです。

実行時編

JITの効くJavaコンパイル時のの結果だけ見てもさほど意味はない気がしますのでちゃんと測ります。 今回マイクロベンチは下記の手法で行なっています。

qiita.com

文字列結合を大量に繰り返すケース

これはSQLを組立実行とか同じ結合処理をループの中で何度も繰り返すケースですね。 文字列結合をするパターンとしては一番多いのでは無いでしょうか?

public static String concatStringByPlus(int count) {
    String x = "hello";
    String y = " ";
    String z = "world";
    String s = "";
    for (int i = 0; i < count; i++) {
        s = x + y + z;
    }
    return s;
}
public static String concatStringBySB(int count) {
    String x = "hello";
    String y = " ";
    String z = "world";
    String s = "";
    for (int i = 0; i < count; i++) {
        StringBuilder sb = new StringBuilder();
        sb.append(x);
        sb.append(y);
        sb.append(z);
        s = sb.toString();
    }
    return s;
}

実行結果は以下の通り。

## 1回
testcase:Concat string by 'plus'            loop:0  arguments:[1000000] response(ms):190    gc_count:0  gc_total(ms):0  gc_max(ms):0    gc_avg(ms):0.000000
testcase:Concat string by 'StringBuilder'   loop:0  arguments:[1000000] response(ms):89 gc_count:0  gc_total(ms):0  gc_max(ms):0    gc_avg(ms):0.000000

testcase:Concat string by 'plus'            loop:0  arguments:[1000000000]  response(ms):31890  gc_count:327    gc_total(ms):49 gc_max(ms):6    gc_avg(ms):0.149847
testcase:Concat string by 'StringBuilder'   loop:0  arguments:[1000000000]  response(ms):41450  gc_count:510    gc_total(ms):14 gc_max(ms):2    gc_avg(ms):0.027451

testcase:Concat string by 'plus'            loop:0  arguments:[2000000000]  response(ms):21135  gc_count:559    gc_total(ms):5  gc_max(ms):1    gc_avg(ms):0.008945
testcase:Concat string by 'StringBuilder'   loop:0  arguments:[2000000000]  response(ms):29857  gc_count:825    gc_total(ms):16 gc_max(ms):1    gc_avg(ms):0.019394

## 2回
testcase:Concat string by 'plus'            loop:1  arguments:[1000000] response(ms):11 gc_count:0  gc_total(ms):0  gc_max(ms):0    gc_avg(ms):0.000000
testcase:Concat string by 'StringBuilder'   loop:1  arguments:[1000000] response(ms):25 gc_count:0  gc_total(ms):0  gc_max(ms):0    gc_avg(ms):0.000000

testcase:Concat string by 'plus'            loop:1  arguments:[1000000000]  response(ms):10667  gc_count:261    gc_total(ms):16 gc_max(ms):11   gc_avg(ms):0.061303
testcase:Concat string by 'StringBuilder'   loop:1  arguments:[1000000000]  response(ms):15008  gc_count:411    gc_total(ms):8  gc_max(ms):1    gc_avg(ms):0.019465

testcase:Concat string by 'plus'            loop:1  arguments:[2000000000]  response(ms):21203  gc_count:526    gc_total(ms):11 gc_max(ms):2    gc_avg(ms):0.020913
testcase:Concat string by 'StringBuilder'   loop:1  arguments:[2000000000]  response(ms):29767  gc_count:857    gc_total(ms):19 gc_max(ms):1    gc_avg(ms):0.022170

## 3回
testcase:Concat string by 'plus'            loop:2  arguments:[1000000] response(ms):13 gc_count:0  gc_total(ms):0  gc_max(ms):0    gc_avg(ms):0.000000
testcase:Concat string by 'StringBuilder'   loop:2  arguments:[1000000] response(ms):16 gc_count:0  gc_total(ms):0  gc_max(ms):0    gc_avg(ms):0.000000

testcase:Concat string by 'plus'            loop:2  arguments:[1000000000]  response(ms):10555  gc_count:264    gc_total(ms):2  gc_max(ms):1    gc_avg(ms):0.007576
testcase:Concat string by 'StringBuilder'   loop:2  arguments:[1000000000]  response(ms):14941  gc_count:412    gc_total(ms):10 gc_max(ms):2    gc_avg(ms):0.024272

testcase:Concat string by 'plus'            loop:2  arguments:[2000000000]  response(ms):21221  gc_count:525    gc_total(ms):11 gc_max(ms):1    gc_avg(ms):0.020952
testcase:Concat string by 'StringBuilder'   loop:2  arguments:[2000000000]  response(ms):29832  gc_count:855    gc_total(ms):24 gc_max(ms):1    gc_avg(ms):0.028070

1回目はwarm upなので無視するとしてですが2回目いこうに注目しても実は+演算子の方が高速です。 StringBuilderはclearとか無いので毎回初期化するでしょうしそのコストもあるでしょうね。 何れにしても100万回のループで3ms、20億回のループで8秒程度なのでよほど変なバッチを組んだ時以外は気にする事はないでしょう。 リアルタイムなら秒レベルの差は気になりますがで億回ループ処理で数秒なら誤差です。

大量の文字列を一つに連結するケース

先ほどの例はStringBuildernの使い勝手としてはフェアじゃないので違う例も試します。

大量の文字列を1つの文字列に結合するパターンです。

public static String concatAllStringByPlus(int count) {
    String x = "hello";
    String y = " ";
    String z = "world";
    String s = "";
    for (int i = 0; i < count; i++) {
        s += x + y + z;
    }
    return s;
}

public static String concatAllStringBySB(int count) {
    String x = "hello";
    String y = " ";
    String z = "world";
    StringBuilder sb = new StringBuilder();
    for (int i = 0; i < count; i++) {
        sb.append(x);
        sb.append(y);
        sb.append(z);
    }
    return sb.toString();
}

実行結果

## 1回目
testcase:Concat all string by 'plus'            loop:0  arguments:[10000]   response(ms):716    gc_count:0  gc_total(ms):0  gc_max(ms):0    gc_avg(ms):0.000000
testcase:Concat all string by 'StringBuilder'   loop:0  arguments:[10000]   response(ms):1  gc_count:0  gc_total(ms):0  gc_max(ms):0    gc_avg(ms):0.000000

testcase:Concat all string by 'plus'            loop:0  arguments:[100000]  response(ms):44470  gc_count:1164   gc_total(ms):205    gc_max(ms):44   gc_avg(ms):0.176117
testcase:Concat all string by 'StringBuilder'   loop:0  arguments:[100000]  response(ms):9  gc_count:0  gc_total(ms):0  gc_max(ms):0    gc_avg(ms):0.000000

testcase:Concat all string by 'plus'            loop:0  arguments:[200000]  response(ms):60044  gc_count:1263   gc_total(ms):119    gc_max(ms):10   gc_avg(ms):0.094220
testcase:Concat all string by 'StringBuilder'   loop:0  arguments:[200000]  response(ms):8  gc_count:0  gc_total(ms):0  gc_max(ms):0    gc_avg(ms):0.000000

## 2回目
testcase:Concat all string by 'plus'            loop:1  arguments:[10000]   response(ms):93 gc_count:0  gc_total(ms):0  gc_max(ms):0    gc_avg(ms):0.000000
testcase:Concat all string by 'StringBuilder'   loop:1  arguments:[10000]   response(ms):0  gc_count:0  gc_total(ms):0  gc_max(ms):0    gc_avg(ms):0.000000

testcase:Concat all string by 'plus'            loop:1  arguments:[100000]  response(ms):12685  gc_count:262    gc_total(ms):31 gc_max(ms):8    gc_avg(ms):0.118321
testcase:Concat all string by 'StringBuilder'   loop:1  arguments:[100000]  response(ms):2  gc_count:0  gc_total(ms):0  gc_max(ms):0    gc_avg(ms):0.000000

testcase:Concat all string by 'plus'            loop:1  arguments:[200000]  response(ms):61873  gc_count:1249   gc_total(ms):108    gc_max(ms):11   gc_avg(ms):0.086469
testcase:Concat all string by 'StringBuilder'   loop:1  arguments:[200000]  response(ms):3  gc_count:0  gc_total(ms):0  gc_max(ms):0    gc_avg(ms):0.000000

## 3回目
testcase:Concat all string by 'plus'            loop:2  arguments:[10000]   response(ms):111    gc_count:0  gc_total(ms):0  gc_max(ms):0    gc_avg(ms):0.000000
testcase:Concat all string by 'StringBuilder'   loop:2  arguments:[10000]   response(ms):0  gc_count:0  gc_total(ms):0  gc_max(ms):0    gc_avg(ms):0.000000

testcase:Concat all string by 'plus'            loop:2  arguments:[100000]  response(ms):12227  gc_count:269    gc_total(ms):13 gc_max(ms):2    gc_avg(ms):0.048327
testcase:Concat all string by 'StringBuilder'   loop:2  arguments:[100000]  response(ms):1  gc_count:0  gc_total(ms):0  gc_max(ms):0    gc_avg(ms):0.000000

testcase:Concat all string by 'plus'            loop:2  arguments:[200000]  response(ms):56216  gc_count:1262   gc_total(ms):29 gc_max(ms):1    gc_avg(ms):0.022979
testcase:Concat all string by 'StringBuilder'   loop:2  arguments:[200000]  response(ms):2  gc_count:0  gc_total(ms):0  gc_max(ms):0    gc_avg(ms):0.000000

圧倒的速度差!

プラス演算子だと1万件でも100ms程度でそこからどんどん遅くなりますが、StringBuilderは速度が安定しています。

なので「1万個以上のStringを結合するときはStringBuilderが確実に良い」です。

でも、「そんな処理普段書きますか? 1万個の文字列結合ですよ???」

少なくとも100件とか1000件のループで気にする必要はありません。文字列結合が大量に発生するケースも先のSQLのようにトータル件数はともかく個別の変数単位の件数はしれてるものです。

通常はプラス演算子を使うのがベストプラクティスでしょう。

まとめ

早過ぎる最適化は技術的負債の一つです。

もちろん、LinedListをインデックスアクセスするとか致命的に向いてない処理もありますが、基本的にはインターフェースに対して抽象度の高い操作をすれば、ライブラリやコンパイラで弄る余地があるので最適化されます。

これはJavaに限らず多くの言語の基本的な特性なので、マイクロベンチマークに拘った過度な最適化は基本的にはせず、遅くなったらプロファイルを適切にとったうえでピンポイントに修正すればいいのです。

若手のうちはつい最適化に日ごろから気を使いすぎてしまうので「統的な最適化テクニックもJITの前には無力だ。人間が頑張るのは最後で良い」という事を伝えたくてこの記事を書いてみました。

それではHappy Hacking!