命短し、パフォーマンスTips

Javaは今年で10歳になった。
J2SEだけでもメジャーバージョンはJDK1.0からJDK1.5まで6もある。
1.0以前のαにβ、数々のマイナーバージョンも入れたらいくつになるだろうか。


それだけバージョンの多いJavaだが、今でもほとんどのプログラムが変更なしで動く互換性の高さは素晴らしいことだと思う。
だが、バージョンを経るに従い変更点に影響を大きく受けたものがある。パフォーマンスTipsだ。Javaが遅いというのは既に迷信であるが、未だに妙なTipsを律儀に守っている人が多い。静的解析のルールになってしまっているととても始末が悪い。


これらの古く、役に立たなくなってしまったルールはJVMの改良による点が大きい。
あなたは以下の質問に全て答えられるだろうか?

  1. JITJVMに装備されたのはどのバージョンか?
  2. HotSpotがJVMにデフォルトで装備されたのはどのバージョンか?
  3. Mark-Sweep-CompactionがJVMに装備されたのはどのバージョンか?
  4. 世代別GCが装備されたのはどのバージョンか?

これらの質問に答えられるだけの知識を持てば、あるTipsがどのバージョンまで有効なのかがわかるようになる。パフォーマンスTipsの寿命はとても短いのだ。

Developer WorksにおけるBrian Goetz氏の連載、Javaの理論と実践にはそのようなパフォーマンスTipsの神話とJVMに関する記事が豊富だ。
記事の一覧*1

例えばIBM - Japanという記事ではメソッドをfinalにすることが無駄なことがあげられている。昔、たぶんJDK1.0の頃だと思ったが、javac -Oでコンパイルするとインライン展開されるというのがDDJ等で記事になっていたと思う。しかし現在のHotSpotJITではfinalであろうと、なかろうと動的に多用されるメソッドはインライン展開される。それに現在javacの-Oオプションはまったく働かない。全てはJITに託されているのだ。

nullを明示的に代入する必要はない

IBM - Japanを読むとわかるのだが、GCを助けるために使用しなくなった変数にnullを明示的に代入することは現在意味がほとんどない。むしろ遅くなると書いてある。ほとんどというのが微妙だが、これは記事に解説してあるとおり、配列などに使用しないオブジェクトの参照を残している場合には、明示的な消去がいるためだ。*2しかし、そのような場合意外には特に代入の必要はないようだ。これは現在のGCは使用されていないオブジェクトを回収するのではなく、使用中のオブジェクトを移動する作業がメインだからである。使用中のオブジェクトの検索は個々の変数ではなく、RootSetと呼ばれるものが使用される。詳しくは氏の記事を参照してほしい。


現在でもGoogleJava、nullの代入で検索すると、これを進める記事が数多く発見できた。こういうものは実際に検証する人がでないので、ほとんどなくなっていかないものだ。

Stringの'+'による連結は問題だろうか?

Stringの連結に'+'を使うことを嫌う人が多い。曰く、パフォーマンスのためにStringBufferを使うべきだと。本当だろうか?


簡単なプログラムを書いてJDK1.4とJDK1.5とでコンパイルし、逆コンパイルしてどのようなコードになるのかを調べてみた。
JDK1.4ではStringBufferを、JDK1.5ではStringBuilderを使って連結していた。なんてことはない、明示的に書こうと大した違いはないのである。恐らく長いループの途中の処理でもない限り有意な差は出ないであろう。
ただし、最終的に使用されるCHARの配列*3の量を考えるとStringBufferもStringBuilderも2倍拡張の法則が適用される。文字列同士の合計サイズ以上にバッファが用意されるこれらは視点によっては無駄に感じられるであろう。だから、途中に生成されるCHAR配列のサイズも心配な神経質なあなたはString#concatを使用したほうが良い。
文字列の長さの合計が大きく、足す数が3以上ならStringBuffer等でコンストラクタでバッファサイズを明示的に指定して記述したほうが良いだろう。しかしそれはとても限定的な状況であると言える。
まじめに計算していないのだが、オブジェクトのインスタンシエーションの数がこれらの方法で異なると思われる。色々なタイミングでCharの配列を捨てて作り直すからだ。しかし現在のJavaのヒープの取り扱いではアロケーションにかかるコストはとても少ない。これはヒープ領域がフラグメンテーションを起こさないからである。詳しくはBrian氏の記事を参照してほしい。

さらにGCを考えてみる。JDK1.4以上では世代別GCが用意されており、短時間で使用されなくなるオブジェクトは処理の非常に速いCopyGCというGCが使用される。よって途中で作成されたオブジェクトは非常に速いタイミングで回収される。明示的であれ、なかれ、StringBufferやStringBuilderを使用してもそれはすぐに回収される。CopyGCはフラグメントを起こさないのでメモリ領域が汚れることを心配する必要も一切ない。よって余計なオブジェクトを作成することを気にすることはほとんどない。


これらを考慮すると、大きなループや、よく使用されるメソッドでもない限りStringの足し算を禁止する理由はないと思われる。
一律にStringBufferを使用させるルールを今でも数多くみるのだが、いい加減にやめたほうが良いのではないだろうか。
足し算のとても良いところとして、他のどの方法よりも読みやすい点が挙げられる。

最後に

業務用アプリを担当しているとパフォーマンスばかりが考慮されて、色々と迷惑なルールを押し付けられることが多い。しかしBrian氏の記事にもあるとおりにそのほとんどは賞味切れだったりする。またパフォーマンスの問題には80:20の法則があるように、2割のコードが8割の処理時間を消費しているものである。やたらと一律なルールで縛るよりも、適時パフォーマンスをプロファイラで測定し、ボトルネックを知るほうが良い。最近はNetBeansのプロファイラのような、無料で、とても高機能なプロファイラが利用できるようになってきている。

パフォーマンスばかりを気にして、保守性の低い、読みがたいコードばかりを書かないようにしたいものである。
締めとして次の言葉を引用する。
Premature optimization is the root of all evil

*1:リンクは原文一覧だが、日本のデベロッパワークスにも翻訳があるのでググってみて下さい

*2:ただし、配列全体の廃棄は有効である。中をnullして回る必要は全くない。

*3:StringはCharの配列とオフセット値、長さにより構成されるオブジェクトである。