プログラマは非効率的なコードを書くべきである
「プログラマは非効率的なコードを書くべきである。」
メモリやCPUリソースを普段から意識してはいけない! こう書くと山盛りの反論が来そうです。ですが性能の良いコードを普段から書く必要あるのでしょうか?
もちろんプログラムの実装による性能改善は重要です。
パフォーマンスチューニングをミドルウェアで頑張ったり、金の弾丸でサーバーを増強したり増やしたりするよりも圧倒的な効率で性能改善が出来ることはザラです。
それと同時に「こっちの方が効率が良いと思って...」という気持ちでちょっぴり複雑なコードを作ってしまう事も良くあります。
さて、性能とは可読性より優先するものでしょうか??? Stringのプラス演算子のコストが高いから常にStringBuilderを使うべき、Lambdaは遅いからfor文を使え。リフレクションは遅い。色々あります。ええ、確かに理論上遅いはずです。
「その幻想をぶち壊す!」
「確かに効率が悪いのは確かさ。でもその効率って奴は何かを...そう可読性とかを犠牲にしてまで守らなきゃならないもんなのか!?(上条さん風)」
というわけで、計測大事だよねって観点で実際最近の言語/マシンではどのくらいなもんかをマイクロベンチして見ます。 ちなみに特性は当然言語や処理系によって変わりますが今回は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でコンパイル時のの結果だけ見てもさほど意味はない気がしますのでちゃんと測ります。 今回マイクロベンチは下記の手法で行なっています。
文字列結合を大量に繰り返すケース
これは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!