はじめに - Java 6におけるスレッドの最適化
Sun、 IBM、BEAやその他のJVMベンダーが、それぞれのJava 6仮想マシンが提供するロック管理と同期の最適化に多くの注意を払ってきました。バイアスドロック、ロックの粗粒度化、エスケープ解析によるロックの削除、適応型スピンロックといった機能は、すべてアプリケーションのスレッド間でより効果的なオブジェクト共有を可能にし、並列性をより高めるために設計されたものです。こうした個々の機能は洗練されており、興味深いものですが、疑問があります;本当にこうした約束を果たしてくれているのでしょうか?2つのパートからなるこの記事では、私はこうした機能を詳しく調査します。シングルスレッドベンチマークの助けを借りて、パフォーマンスに関する疑問に答える試みをしようと思います。
ロックは悲観的である
Java でサポートされているロックの(ほとんどのスレッドライブラリと同様の)モデルは、非常に悲観的なものです。お互いに干渉しあう二つ以上のスレッドが同じデータを利用するような危険性がある時、これが問題を起こすことを避けるために、ロックを用いた厳格なソリューションを用いなくてはなりません。しかし研究によると、ロックの競合が起きることはほとんど稀だということを示しています。言い換えると、スレッドがロックを取得しようとするときに、待ち状態にさせられることはほとんどないということです。しかしロックの獲得は、複数の順序づけられたアクションを引き起こします。その結果としてオーバーヘッドがはっきりと蓄積しますし、それを避けることもできません。
私たちにはいくつかの選択肢があります。スレッドセーフなStringBufferを使うことを例にあげて考えてみて下さい。そしてあなた自身に質問してみて下さい。単一のスレッドからしかアクセスされることが無いと知っているのに、あなたがまだStringBufferを使っているのですか?なぜ StringBuilderを代わりに使わないのですか?
ほとんどのロックが競合せず、まれに競合する事があると言う事を知っていても、あまり役には立ちません。なぜなら、二つのスレッドが同じデータにアクセスする可能性がわずかにでも存在するならば、同期によるロックを使用してアクセス保護を行う事はほぼ必須だからです。結局、「本当にロックが必要だったのか?」と言う疑問に答える事が出来るのは、実行環境のコンテキスト内でロックを見つけたときだけです。この疑問に答えるため、JVM開発者は HotSpotとJITにおいて実験を行ってきました。この作業により、私たちは今や適応スピンロックやバイアスド・ロック、そしてロックの疎粒度化とロック除去と言う二つの形式のロック省略を利用できます。ベンチマークを始める前に、こうした機能について調べるする時間を少し取り、それらがどのように動作するのかを全て理解しましょう。
エスケープ解析 - ロック除去に関する説明
エスケープ解析とは、実行中のアプリケーション内で全参照のスコープを調べ上げると言うものです。この解析はHotSpotプロファイラの通常作業の一部として行われます。もしオブジェクトへの参照がローカルスコープに限定されていて、より広いスコープに"抜け出す(escape)"事がないと(エスケープ解析を通じて)HotSpotが理解できたなら、JITにたくさんの実行時最適化を適用する事が出来ます。そうした最適化の一つが、ロックの除去として知られているものです。ロックへの参照がいくらかのローカルスコープに限定されている場合、それが示唆するところは、ロックにアクセスするのはそのロックを作成したスレッドのみである、と言う事です。そうした条件の元では、同期ブロック内の値は絶対に競合する事がない、と言う事を表しています。これはつまり、私たちはそのロックを本当に必要としている訳ではないと言う事であり、安全に除去する事が出来ます。以下のメソッドを考えてみてください。
publicString concatBuffer(String s1, String s2, String s3) {,
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}
図1. ローカルのStringBufferを用いてStringの連結を行う
もし私たちが変数sbをよく調べれば、concatBufferメソッドの境界内でのみ生存すると言う事をすぐに見つけられます。さらにsbに対する参照は、それが宣言されたスコープから"抜け出す"事は絶対にありません。従って、他のスレッドがsbへのアクセスを行う方法は存在しないのです。これを踏まえると、私たちはsbに対するロック保護を除去できると言う事がわかります。
一見するとロックの除去により、ロックが本当に必要とされない場所においては、あらゆる同期のペナルティ無しにスレッドセーフなコードを書く事が出来るように見えます。「それが本当に働いているのかどうか」と言う疑問については、ベンチマークで詳しく調べることにしましょう。
バイアスド・ロックの説明
バイアスド・ロックは、生存中に一つ以上のスレッドにアクセスされるロックはほとんどない、と言う観察結果に基づくものです。複数のスレッドがデータを共有する際、アクセスが競合する事はまれです。こうした観点からバイアスド・ロックの利点を理解するには、ロック(モニタ)がどのように獲得されるかを最初に調べる必要があります。
ロックの獲得は、2つのステップからなるダンスです。まずあなたは、ロックの貸し出しを受ける必要があります。一度あなたが貸し出しを受けると、あなたはロックを手に入れるのは自由です。その貸し出しを受けるため、スレッドは高価なアトミック命令を実行する必要があります。ロックの解放とは、伝統的には貸し出しを放棄する事です。この観点からすると、コードの同期ブロックをスレッドがループ処理するような場合に、アクセスを最適化する事が出来るはずだと考えるのではないでしょうか。一つの例としては、ループ全体を含むようにロックの範囲を広げるなどです。そうすれば、スレッドはループの回数分ロックにアクセスする必要がなく、一度のアクセスで済むようになります。しかし、これは良い解決策ではありません。なぜなら(そのロックの期間中ずっと)他のスレッドを閉め出す事になり、他のスレッドに対しても公平にアクセスを与える事が出来なくなるからです。もっと理にかなったソリューションは、ループ中のスレッドに対してロックの優先的な利用を実現する(偏らせる=バイアス)ことです。
スレッドに対するロックを偏らせるという事はつまり、スレッドがロックに対する貸し出しを解放する必要がなくなると言う事です。従って、その後のロック獲得はそれほど高価なものではなくなります。スレッドがロック貸し出しを解放するのは、他のスレッドがロックを獲得しようと試みたときだけです。Java 6のHotSpot/JIT実装は、バイアスド・ロックの最適化をデフォルトで行います。
ロックの疎粒度化についての説明
さらにもう一つのスレッド最適化はロックの疎粒度化、もしくはマージです。ロックの疎粒度化は、隣接した同期ブロックを一つの同期ブロックにマージできそうだ、と言うときに起こります。このテーマに関するバリエーションとしては、複数の同期ブロックを一つに結合すると言うものです。この最適化は、同じロックオブジェクトが全てのメソッドによって使われている場合に適用されます。図2に示すような例を考えてみてください。
public static String concatToBuffer(StringBuffer sb, String s1, String s2, String s3) {
sb.append(s1);
sb.append(s2);
sb.append(s3);
return
}
図2. ローカルでないStringBufferを用いてStringの連結を行う
この場合、StringBufferは非ローカルなスコープを持ち、いくつかのスレッドからアクセスされ得ます。従ってエスケープ解析は、このロックを安全に省略する事は出来ないと決定します。もしこのロックが一つのスレッドからのみアクセスされるとしたら、バイアスド・ロックが適用されます。興味深い事に、ロック疎粒度化の決定は、そのロックの競合状態にあるスレッドの数とは無関係に行われます。今回の例では、インスタンスに対するロックは4回獲得されます: 3回のappendメソッドと、一度のtoStringメソッドです。最初に行われるアクションは、メソッドのインライン化です。そして私たちは、ロックを獲得する4回の呼び出しを、メソッド本体全てを囲む一つの呼び出しへと置き換える事が出来ます。
最終的には、長い一つのクリティカルセクションが出来上がると言うことになるわけです。その結果(そのクリティカルセクション中はずっと)他のスレッドが停止させられるため、スループットが減ってしまいます。そのためループ内のロックは、ループの構築子を含むようには疎粒度化されません。
スレッドの一時停止 対 スピン
あるスレッドが、他のスレッドによるロックの解放を待っているとき、通常はオペレーティングシステムによって一時停止されます。スレッドを一時停止すると言う事は、CPU割当時間を消費しきる前にCPU外にスワップするよう、オペレーティングシステムに対して要求すると言う事です。ロックを保持するスレッドがクリティカルセクションを抜けたときに、一時停止されたスレッドを起こしてもらおうと言う訳です。そのスレッドは再スケジューリングされ、コンテキストスイッチによってCPUに復帰する必要があります。こうした一連の流れは全て、JVM、OS、ハードウェアに対して余分な負荷を強いる事になります。
この問題解決に役立つであろう観察結果があります;ロックは通常非常に短い時間しか保持されないと言う事です。これは、ロックを獲得するためのウェイト時間が非常に短いのなら、スレッドを一時停止する必要はないだろう、と言う事を表しています。ウェイトするためには、スレッドをビジーなループ(スピン)させておけば良いと言う事です。このテクニックは、スピンロックとして知られています。
スピンロックは、ロック期間が非常に短い場合にうまく働きます。一方そのロックが長時間保持される場合は、スピンしているスレッドが何もせずにCPUを消費するので、スピンは無駄となります。JDK 1.4.2でスピンロックが導入された際には、二つのフェーズに分割されていました。スレッドが一時停止される前に10回(デフォルト値)スピンしてみる、と言うものだったのです。
適応型スピン
JDK 1.6では適応型スピンが導入されました。適応型スピンでは、スピンする期間は全く決まっていません。同一のロック/ロック所有者の状態に対して、前回試行したスピンの結果に基づくポリシーによって決定されます。あるロックオブジェクトに対するスピンが最近成功したと言う履歴があり、実行中のスレッドが同じロックを保持しているなら、スピンは再度成功する可能性が高いと言う訳です。そうすると、相対的に長い時間 - 例えば100回とか - スピン行われます。一方スピンがなかなか成功しないようなら、CPUサイクルを一切無駄にすることのないよう、そのままにしておかれます。
StringBuffer対StringBuilderのベンチマーク
こうした全ての気の利いた最適化の効果をいかにして測定すればよいでしょう?それを正確に決定するのは単純なことではありません。何よりもまず、どんなベンチマークがよいのかと言うのが問題です。この質問に答えるために私が決めたのは、人々がいつもコード内で使用する一般的なイディオムをいくつか見ていこう、と言う事です。その中でもひときわ目立ったのは、「Stringの代わりにStringBufferを使用すると、どれほどコストの節約になるのか?」と言う古い質問でした。
良く親しまれているアドバイスは; 変更が必要なら、Stringの代わりにStringBufferを使おう、と言うものです。その理由はきわめてはっきりしています。Stringは変更不可であり、変更を必要としているなら、StringBufferの方がよりコストが低いと言うものです。このアドバイスが、StringBuilder - StringBufferの新しい(1.5から)非同期な「いとこ」 - について認識できていないのはとても興味深いことです。StringBuilderとStringBufferの間の違いは、単に同期の有無だけです。この二つをベンチマークで測定し、パフォーマンスに違いが生じるなら、それは同期のコストが表面化しているのだ、と考えられます。さあ、探検の始まりです。「競合しないロックのコストはどれほどなのだろうか」と言うのが最初の質問です。
(リスト1に示した)ベンチマークの本質は、2?3個の文字列を連結すると言うものです。バッファの初期容量は、結合される三つの文字列を保持するのに十分な大きさです。これにより、クリティカルセクション内で行う作業量を最小化する事ができ、同期のコストに対する計測値がゆがめられないようにする事が出来ます。
ベンチマーク結果
以下に示すのが、EliminateLocks(ロック省略)、UseBiasedLocking(バイアスド・ロックの使用)、DoEscapeAnalysis(エスケープ解析)の三つの選択肢の意味のある組み合わせの結果です。
図3. ベンチマーク結果
結果についての考察
非同期のStringBuilderを使用する目的は、パフォーマンスの基準となる点を提供するためでした。また、最適化がStringBuilderのパフォーマンスに何らかの影響を与えるかどうかも気になった点です。結果からわかる通り、StringBuilderのパフォーマンスはベンチマークを通じて一定でした。これらのフラグはロックの使用を最適化する事を直接狙ったものだったため、この結果は期待通りです。パフォーマンス的には対極なものとして、あらゆる最適化無しに同期されたStringBufferを使用すると、だいたい3倍遅くなると言うのが見て取れます。
図3に表されている結果を左から右に見ていくと、ロック省略がきちんとパフォーマンスを向上させているのがわかります。しかしそのパフォーマンス向上は、バイアスド・ロックによってもたらされているものと比べると見劣りします。実際には、列Cを除けば、バイアスド・ロック付きの全ての実行結果は大体同程度のパフォーマンス向上をもたらしています。では、列Cは何でしょう?
生のベンチマーク結果を扱っている過程で、6回に1回くらい明らかに長い時間がかかるということが注意を引きました。その違いは十分に大きく、ベンチマークが二つの完全に異なる最適化を報告しているように見えました。いろいろ考えた結果、私は高い値と低い値を分けて報告する事に決めました(BとCです)。深く調査していない段階で、私に出来る事は仮説を立てる事だけです。その仮説とは、適用可能な最適化が一つ以上(二つが最有力)存在しており、ほとんどの場合バイアスド・ロックが勝利するが、常にそうなる訳ではない、と言うような競合条件が存在しているのではないか、と言うものです。他の最適化が勝つ事により、バイアスド・ロックは阻止されるか、適用が遅れるのです。
エスケープ解析の結果も気になります。このベンチマークが本質的にシングルスレッドであると言う事から、私はエスケープ解析がロックを完全に省略し、 StringBufferのパフォーマンスがStringBuilderと同等になる事を期待していました。それが起こらなかった事は非常にはっきりしています。もう一つの問題は; 実行するごとに、私のマシン上での実行時間が変化したことです。何人かの同僚が、彼らのシステム上でテストしてみると、さらに良くわからないことが起きました。いくつかのケースでは、最適化によるスピードの向上があまり起こらなかったのです。
とりあえずのまとめ
図 3に示された結果は、私が期待したほどのものではありませんでしたが、ロックのオーバーヘッドのほとんどが最適化によって除去されると言う事を示しています。しかし、私の同僚たちによって行われたテストでは結果が異なり、結果の正確性が怪しまれる結果となってしまいました。このベンチマークは、ロックのオーバーヘッドを測定したものと言えるのでしょうか?まとめるにはまだ時期尚早で、まだ何か必要とされているのでしょうか?この記事のパート2(参考記事)では、このベンチマークをより深く見ていき、これらの疑問に答えたいと思います。その過程で気づいた事は、「結果を得る」と言うのは容易であると言う事、そしてその結果は私たちの疑問に対する答えを持っているのかも知れませんが、それを見つけ出すのがより困難なタスクだと言う事です。
public class LockTest {
private static final int MAX = 20000000; // 20 million
public static void main(String[] args) throws InterruptedException {
// warm up the method cache
for (int i = 0; i < MAX; i++) {
concatBuffer("Josh", "James", "Duke");
concatBuilder("Josh", "James", "Duke");
}
System.gc();
Thread.sleep(1000);
System.out.println("Starting test");
long start = System.currentTimeMillis();
for (int i = 0; i < MAX; i++) {
concatBuffer("Josh", "James", "Duke");
}
long bufferCost = System.currentTimeMillis() - start;
System.out.println("StringBuffer: " + bufferCost + " ms.");
System.gc();
Thread.sleep(1000);
start = System.currentTimeMillis();
for (int i = 0; i < MAX; i++) {
concatBuilder("Josh", "James", "Duke");
}
long builderCost = System.currentTimeMillis() - start;
System.out.println("StringBuilder: " + builderCost + " ms.");
System.out.println("Thread safety overhead of StringBuffer: "
+ ((bufferCost * 10000 / (builderCost * 100)) - 100) + "%\n");
}
public static String concatBuffer(String s1, String s2, String s3) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}
public static String concatBuilder(String s1, String s2, String s3) {
StringBuilder sb = new StringBuilder();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}
}
ベンチマークを実行するには
私はこのベンチマークを、Intel Core 2 Duo搭載の32ビットWindows Vistaラップトップ上で、Java 1.6.0_04を用いて実行しました。全ての最適化がサーバーVMに実装されている事に注意してください。それは、私のプラットフォームではデフォルトのVMではありません。また、JREでは利用できず、JDKでしか利用できません。サーバーVMを使うため、私は-serverオプションをコマンドライン上で指定しました。他のオプションは:
- -XX:+DoEscapeAnalysis, デフォルトではオフになっています
- -XX:+UseBiasedLocking, デフォルトではオンになっています
- -XX:+EliminateLocks, デフォルトではオンになっています
ベンチマークを実行するため、ソースをコンパイルしてから以下のようなコマンドラインを使用してください。
java-server -XX:+DoEscapeAnalysis LockTest
Jeroen Borgersについて
Jeroen Borgersは、ITアーキテクト集団Xebiaのシニアコンサルタントです。Xebiaは、エンタープライズJavaとアジャイル開発に特化した、国際的なITコンサルタント会社兼プロジェクト組織です。Jeroen は、エンタープライズJavaのパフォーマンス問題で顧客を助け、Javaパフォーマンスチューニングコースの講師を務めています。彼は1996年から開発者として、アーキテクトとして、チームリーダーとして、品質責任者として、助言者として、監査役として、パフォーマンステスターとチューニング担当者として、いくつもの業種で様々なJavaプロジェクトに従事してきました。彼は2005年から、もっぱらパフォーマンスの仕事に従事しています。
謝辞
この記事は、いろんな他の人の助けを得て完成する事が出来ました。スペシャルサンクス:
Cliff Click博士, SunのサーバーVMにおけるかつてのリードアーキテクトであり、現在Azul Systemsで働いています; 分析の手伝いと、貴重な情報源を教えてくれました。
Kirk Pepperdine, Javaパフォーマンスの権威です; 貢献を行ったり、広範囲の編集作業を行ってくれたことに加え、その過程においても手助けしてくれました。
David Dagastine, SunのJVMバフォーマンスチームリーダーです。説明を行ってくれただけではなく、私を正しい方向に導いてくれました。
Xebiaの同僚たちは、ベンチマークの実行を手伝ってくれました。
リソース
実践的なJava並行処理については、Brian Goetzなどの記事があります。
Java theory and practice: Synchronization optimizations in Mustang (リンク)
Did escape analysis escape from Java 6 (リンク)
Dave Dice's Weblog (リンク)
Java SE 6 Performance White Paper (リンク)
原文はこちらです:http://www.infoq.com/articles/java-threading-optimizations-p1
(このArticleは2008年6月18日に原文が掲載されました)