Kengo's blog

Technical articles about original projects, JVM, Static Analysis and TypeScript.

同期化のパフォーマンス計測

シルバーウィークのまとまった時間を利用して、同期化のパフォーマンスについて調査。どのように遅いのか・なぜ遅いのかを追いましたが、詳細な内部実装はJVM仮想マシン仕様書にも記載されておらず「なぜ」まではわかりませんでした。
中途半端な内容になりましたが、実施した計測や調べた内容をまとめておきます。

結論

  • synchronizedブロックは大型クラスのコンストラクタと同等の負荷(キャッシュしたりFlyweightパターンを適用したりしたくなる負荷)と受け取るべき。
  • volatile修飾子はsynchronizedブロックの約20%の負荷で、比較的軽量であることが確かめられた。
  • より細かな内部実装を把握するには、JVMソースコードを読む必要がありそう。

パフォーマンス計測

同期化は遅いと言われるが、ではどの程度遅いのか?を計測。手元の環境*1でシングルスレッドにおけるパフォーマンスを計測した結果は以下の通りです。ウェブ上の情報ではJavaスレッドメモ(Hishidama's Java thread Memo)が様々な視点から検証されており勉強になりました。

計測内容 平均[ms/100M回]
synchronized(private static Object){private int++}
4,283.7
synchronized(private Object){private int++}
3,664.2
synchronized(local Object){private int++}
4,087.4
synchronized(private static Object){local int++}
3,948.6
synchronized(private Object){local int++}
4,064.9
synchronized(local Object){local int++}
3,802.8
private volatile static int++
820.4
private volatile int++
802.2
private static int++
10.7
private int++
11.9
local int++(参考値)
0.5
volatile修飾子をつけると約80倍遅い
アクセスする順序を保証するだけで約80倍もの速度劣化が見受けられました。
この遅さがキャッシュを使えなかったことに起因するのかアクセス順序の保証に起因するのかは、この測定からは分かりません。またこれを明らかにする検証方法も思いつかないため、ソースを読むしかないと思っています。
synchronizedブロックへの利用はフィールド変数に対するインクリメントの400倍必要
400倍が大きいか小さいかは様々な見方が可能だと思いますが、個人的には大きいと見ています。Dateのコンストラクタより遅くSimpleDateFormatのコンストラクタより速いことがHishidamaさんによって示されていることからも、大型クラスのコンストラクタと同等の負荷=キャッシュしたりFlyweightパターンを適用したりしたくなる負荷 と受け取るべきでしょう。
なぜかsynchronized(private Object){private int++}が速い
モニタとインクリメントするフィールドの双方がprivateの場合に高速化が確認できました。classファイルを読む限りでは原因は分かりません。JVMが実行する時点で何らかの最適化が走っているのかもしれません。
ローカル変数へのアクセスはフィールドへのアクセスよりも格段に速い
目的からはそれますがこの傾向が顕著に見られました。フィールドならばstaticかそうでないかはさほど影響しないようです。これはローカル変数がスレッドのオペランドスタックに積まれることに起因すると思われます。
補足:commons-lang StopWatchによる時間測定

速度の測定にはSystem#currentTimeMillis()を利用するのが一般的かと思いますが、今回はcommons-langにあるStopWatchを利用しました。動機はただ単に使ってみたかっただけで深い意味はありません。
ラップタイムを取得するようなメソッドがあればより便利なのですが、現時点ではスプリットタイムのみ用意されているようです。

import org.apache.commons.lang.time.StopWatch;
// ...

StopWatch stopWatch = new StopWatch();
// 10回測定する
for (int i = 0; i < 10; ++i) {
	stopWatch.start();
	for (int j = 0; j < 100000000; ++j) {
		// 負荷を測りたい処理を100,000,000回実行
	}
	long time = stopWatch.getTime();
	System.out.println(time);
	stopWatch.reset();
}

synchronizedとvolatileは何をやっているのか

仮想マシン仕様にこうした修飾子やブロックの意味(JVMに対する要求)は載っていますが、インタフェースであって内部実装は規定されていません。こうした速度検証における情報源としては若干もの足りません。
ヒントとしてはsynchronizedがmonitorenter・monitorexitというオペコードによって実現されることが言及されています。

追跡調査に向けて

monitorenter・monitorexitの内部実装を読む
JVM仕様書には詳細な規程はなく、JVM実装に依存すると思われます。しかしこれ以上同期化を実現する基底技術を知りたければ、これを追う必要があるでしょう。
スレッドが持つ「キャッシュ」を調べる
ドキュメントに目を通していると、スレッドが「キャッシュ」によってメモリアクセスを高速化しているとの記述が散見されます。これはCPUのL2キャッシュなどハードウェアレベルのキャッシュであると思われます。
オペランドスタックの実装調査
なぜローカル変数へのアクセスが速いのか?を知る上で必要になると推測しています。CPUのレジスタを使えるためか、それともヒープへのアクセスにいくつかの制限や関門があり遅いのかのどちらかでしょう。

*1:Ubuntu9.04, JDK1.6.0_14, CPU 2.80GHz, Heap 512MB