レベル: 上級 Brian Goetz (brian@quiotix.com), Software consultant, Quiotix
2001年 7月 01日 他の多くのプログラミング言語と異なり、Java言語仕様はスレッド化および並行性のサポートを明示的に定めています。並行性に関する言語サポートによって、スレッド間の共用データと操作タイミングに関して、指定および制約管理がより簡単になりました。しかし、並行プログラミングの難解さには変わりがありません。この3回シリーズは、Java言語のマルチスレッド・プログラミングの裏に潜む主要な問題について、特にスレッド・セーフティーがJavaプログラムのパフォーマンスに及ぼす影響を、プログラマーの方に理解していただく事を目的としています。
ほとんどのプログラミング言語では、言語仕様において、スレッド化および並行性に関して"沈黙"を守っています。そのため、これらのトピックは長い間、プラットフォームまたはオペレーティング・システムに組み込みを一任してきました。これに対して、Java言語仕様 (JLS) はスレッド化モデルを明示的に定義しており、プログラムをスレッド・セーフにするための複数の言語要素を開発者に提供します。
スレッド化の明示的なサポートには長所と短所があります。スレッド化の威力と便利さを利用するプログラムの記述はより容易になりますが、どんなクラスもずっと多くマルチスレッド環境で使用されるようになるため、記述するクラスのスレッド・セーフティーに対し注意しなければなりません。
多くのユーザーは、まずスレッド化を理解しなければならないことに気が付きます。それは、ユーザーがスレッドの作成と管理を行うプログラムを記述するためではなく、マルチスレッド化されたツールやフレームワークを使用するためです。Swing GUIフレームワークを使用している開発者や、サーブレットまたはJSPのページを記述している開発者は、(気付いているか否かにかかわらず) スレッド化の複雑さに直面しています。
Java設計者は、マルチプロセッサー・システムを含めた現代のハードウェア上で、快適に動作する言語を開発しようとしてきました。この実現のために、スレッド間の調整管理作業の大半は、開発者が背負わされる結果になっていました。たとえば、プログラマーは、スレッド間で共用されるデータを、どこに配置するかを指定しなければなりません。Javaプログラムにおいてスレッド間の調整管理を行うための基本的な道具立ては、synchronized キーワードです。同期化を指定しない場合は、JVMは、異なるスレッドでの操作の実行のタイミングおよび順序をきわめて自由に決定することができます。ほとんどの場合、それはパフォーマンスがより高くなるので望ましいことですが、この最適化によってどのような場合に、プログラムの処理の正確さが損なわれるかについて理解しておくという負担が、プログラマーに嫁せられます。
synchronized が真に意味するところ
ほとんどのJavaプログラマーは、mutex (相互排除セマフォー) の実行またはクリティカル・セクション (原子的に実行されるコードのブロック) の定義の観点からしか、同期化ブロックまたは同期化メソッドについて考えません。synchronized の意味には、相互の排他性と原子性を含んでいますが、モニターの開始前およびモニターの終了後に実際に起こることは、非常に複雑です。
synchronized が意味するところは、保護セクションへのアクセスは、一時点には、1つのスレッドにしか許されないことを保証するということですが、しかし同期化しているスレッドのメイン・メモリーに関する相互作用のルールについても言及しています。Java Memory Model (JMM) について考える際には、次のように考えればよいでしょう。各スレッドは、別々のプロセッサーで稼働しており、すべてのプロセッサーは共通の主記憶空間にアクセスします。そして、各プロセッサーは、独自のキャッシュを持っていますが、その内容はメイン・メモリーとに常に同期しているわけではありません。同期化されていない場合は、2つのスレッドが同一の記憶域を、異なる値がストアされていると解釈することが許されます(JMMにより)。モニター (ロック) で同期化している場合、JMMは、以下のことを実現します。すなわち、あるスレッドがキャッシュにデータを入れ参照している主記憶に対し、他のスレッドがロックを掛け、リリースする前にメモリの内容を変更した場合、当スレッドのキャッシュの内容を即座に無効とします。同期化がプログラム・パフォーマンスに重大な影響を与える理由は簡単です。キャッシュの頻繁なフラッシュには、コストがかかるのです。
細心の注意を払う
適切な同期化に失敗した結果は深刻なものです。データ破損と競合状態は、プログラムの破損や不正確な結果の算出、あるいは予測不可能な動作の原因となる可能性があります。さらに悪いことに、このような状態は多くの場合、まれにまた散発的にしか起こらないため、問題の検出と再現が困難になります。構成あるいは負荷のいずれかにおいて、テスト環境が本番環境と実質的に異なる場合、このような問題がテスト環境でまったく起こらない可能性もあり、実際は単にまだ障害が起こっていないだけなのに、プログラムに問題がないという誤った結論を導きます。
 |
競合状態の定義
競合状態は、2つ以上のスレッドまたはプロセスがいくつかの共用データを読み込み、または書き込み、その最終結果が、スレッドがスケジュールされたタイミングに依存している状態です。競合状態は、予測不可能な結果と困難なプログラム・バグの原因となる可能性があります。
|
|
これに対して、同期化を不適切または過度に使用すると、パフォーマンスの低下やデッドロックなど、他の問題を生じる可能性があります。確かにパフォーマンスの低下はデータ破損ほど深刻な問題ではありませんが、依然として重大な問題となりえます。優れたマルチスレッド・プログラムの作成には、破損からのデータ保護に十分な同期化を行うことが必要ですが、デッドロックの危険性やプログラム・パフォーマンスの不必要な低下を生じるほどに同期化をすることがないように、細心の注意を払う必要があります。
同期化にかかるコスト
キャッシュのフラッシュおよび無効化に関するルールのために、Java言語の同期化ブロックは一般に、原子的な「テスト・アンド・セット・ビット」機械語命令によって実装されている多くのプラットフォームのクリティカル・セクション機能よりも、コストがかかります。プログラムが、単一プロセッサー上で動作している単一スレッドのみを持つ場合でも、同期化メソッド・コールの速度は依然として非同期化メソッド・コールの速度より遅くなります。同期化により、ロックに対する競合が生じる場合は、いくつかのスレッド・スイッチとシステム・コールが必要とされるため、パフォーマンス・ペナルティーは実質的に増大します。
幸いにも、JVMの継続的な改良によって、Javaプログラムのパフォーマンス全体が向上し、また各リリースでの同期化の相対的なコストが低減してきており、将来の改良も期待されます。さらに、同期化のパフォーマンス・コストは多くの場合誇張されたものです。有名なある文献によると、同期化メソッド・コールの速度は非同期化メソッド・コールの50分の1であるとのことです。これが言っていることは正しいかもしれませんが、大きな誤解を招くものでもあり、多くの開発者が、同期化が必要な場合でさえそれを回避してしまう原因となっています。
競合のない同期化は、ブロックまたはメソッドに対して固定のパフォーマンス・ペナルティーを課すため、同期化のパフォーマンス・ペナルティーを厳密に割合として表すことにはほとんど意味がありません。この固定の遅延によって示されるパフォーマンス・ペナルティーの割合は、同期化ブロック内で行われている作業量によって決まります。空のメソッドヘの同期化コールの速度は、空のメソッドの非同期化コールの20分の1かもしれませんが、一体どのくらいの割合で空のメソッドがコールされるのでしょうか。より典型的な小さいメソッドの同期化ペナルティーを測定する場合、その割合の数値はずっと許容可能なものになるでしょう。
表1は、そのような数値のいくつかを比較検討しています。それは、同期化メソッド・コールのコストとそれと同等の非同期化メソッド・コールをいくつかの異なるケースおよびいくつかの異なるプラットフォームとJVMに分けて比較しています。各ケースでは、シンプルなプログラムを動かし、メソッドを10,000,000回コールして同期化および非同期化バージョンの両方をコールするループの実行時間を評価し、その結果を比較しました。表のデータは、非同期化バージョンに対する同期化バージョンの実行時間の比率であり、同期化のパフォーマンス・ペナルティーを示しています。各実行では、リスト1に示されているシンプルなメソッドの1つをコールします。
表1は、同期化メソッド・コールと非同期化メソッド・コールの相対的なパフォーマンスのみが示されています。絶対的なパフォーマンス・ペナルティーを評価するには、このデータに示されていないJVMの速度の向上も計算に含めなければなりません。テストの大半では、全体的なJVMパフォーマンスが各JVMバージョンに従って実質的に向上しており、1.4Java仮想マシンのパフォーマンスも、そのリリースの際におそらくさらに向上するでしょう。
表1. 競合のない同期化のパフォーマンス・ペナルティー
| JDK | staticEmpty | empty | fetch | hashmapGet | singleton | create |
|---|
| Linux / JDK 1.1 | 9.2 | 2.4 | 2.5 | なし | 2.0 | 1.42 | | Linux / IBM Java SDK 1.1 | 33.9 | 18.4 | 14.1 | なし | 6.9 | 1.2 | | Linux / JDK 1.2 | 2.5 | 2.2 | 2.2 | 1.64 | 2.2 | 1.4 | | Linux / JDK 1.3 (JITなし) | 2.52 | 2.58 | 2.02 | 1.44 | 1.4 | 1.1 | | Linux / JDK 1.3 - サーバー | 28.9 | 21.0 | 39.0 | 1.87 | 9.0 | 2.3 | | Linux / JDK 1.3 - クライアント | 21.2 | 4.2 | 4.3 | 1.7 | 5.2 | 2.1 | | Linux / IBM Java SDK 1.3 | 8.2 | 33.4 | 33.4 | 1.7 | 20.7 | 35.3 | | Linux / gcj 3.0 | 2.1 | 3.6 | 3.3 | 1.2 | 2.4 | 2.1 | | Solaris / JDK 1.1 | 38.6 | 20.1 | 12.8 | なし | 11.8 | 2.1 | | Solaris / JDK 1.2 | 39.2 | 8.6 | 5.0 | 1.4 | 3.1 | 3.1 | | Solaris / JDK 1.3 (JITなし) | 2.0 | 1.8 | 1.8 | 1.0 | 1.2 | 1.1 | | Solaris / JDK 1.3 - クライアント | 19.8 | 1.5 | 1.1 | 1.3 | 2.1 | 1.7 | | Solaris / JDK 1.3 - サーバー | 1.8 | 2.3 | 53.0 | 1.3 | 4.2 | 3.2 |
リスト1. ベンチマークで使用されるシンプルなメソッド
public static void staticEmpty() { }
public void empty() { }
public Object fetch() { return field; }
public Object singleton() {
if (singletonField == null)
singletonField = new Object();
return singletonField;
}
public Object hashmapGet() {
return hashMap.get("this");
}
public Object create() { return new Object();
}
|
またこれらの小さなベンチマークは、動的コンパイラーがある場合のパフォーマンス結果の解釈に関する課題を示しています。JITの有無によるJDK 1.3の割合の大きな差については多少の説明が必要です。きわめてシンプルなメソッド (empty とfetch) に関しては、ベンチマーク・テストの性質 (ほとんど何の作業も行わないタイトなループを実行するだけ) によって、JITがループ全体をダイナミックにコンパイルできるようになり、実行時間がほとんど無となります。JITが実際のプログラムにおいてそれを行えるか否かは、多くの要因によって決定されるため、公正な比較を行うには、おそらく非JITの計測値のほうがより役に立つでしょう。より実際的なメソッド (create とhashmapGet) に関しては、JITは、前述のよりシンプルなメソッドのような、非同期化のケースと比較して大きな向上を示すことはできませんでした。またJVMがテストの大部分を最適化することができたか否かについてはわかりません。同様に、同等のIBM JDKとSun JDKを比べると、同期化バージョンがよりコストのかかるものであるということではなく、IBM Java SDKがより積極的に非同期化ループを最適化したという事実がわかります。これはタイミングの数値において明白でした (ここでは示されていない)。
このような数値から引き出される結論は、競合のない同期化には依然としてパフォーマンス・ペナルティーが存在しますが、それは多くの実際的なメソッドに対して「適度な」レベルであるということです。つまりペナルティーはほとんどの場合、(比較的小さい数の)10から200パーセント間の値です。結果として、すべてのメソッドを同期化させることは依然として適切ではありませんが (また、デッドロックの可能性を増大させてしまいますが)、同期化をそれほど恐れる必要はありません。ここで使用されている簡単なテストによって、競合のない同期化がオブジェクト作成またはHashMap 検索よりコストがかからないことがわかります。
当初、書籍や記事に競合のない同期化には膨大なコストがかかることが書かれており、多くのプログラマーは同期化を回避するために大変苦労しました。このような恐れから、double-checked locking(DCL)イディオムなどの問題のあるテクニックが数多く生まれました。DCLは、Javaプログラミングに関する多くの書籍や記事で広く推薦されており、不要な同期化を回避するためのきわめて優れた方法のように思われますが、実際にはそれは正しく機能するものではなく、使用すべきではありません。それが機能しない理由はかなり複雑であり、この記事の範囲を超えています (リンクに関しては、参考文献を参照)。
不抗争の答弁
同期化が適切に使用されていることを前提にすると、スレッドが実際にロックの競合を行う際に、同期化のパフォーマンスの本当の影響が生じます。競合のない同期化と競合のある同期化ではそのコストは大きく違います。簡単なテスト・プログラムによって、競合のある同期の速度が競合のない同期化の50分の1であることが示されました。この事実と上記の結果とを組み合わせると、競合のある同期化がコスト面で少なくとも50のオブジェクト作成に匹敵することがわかります。
アプリケーションの同期化の使用を調整する際は、単に同期化の使用を完全に回避するのではなく、実際の競合の量を低減すべきです。このシリーズの第2回では、ロックの粒度の低減、同期化ブロックのサイズの縮小、スレッド間で共用されるデータ量の低減など、競合を低減するためのテクニックを取り上げます。
同期化が必要な場合
プログラムをスレッド・セーフにするには、まずスレッド間で共用されるデータの洗い出しを行う必要があります。後で他のスレッドによって読み込まれる可能性のあるデータの書き込みを行う場合、あるいは、他のスレッドによって書き込まれた可能性のあるデータの読み込みを行う場合、そのデータは共用データであり、アクセスの際に同期化させなければなりません。共用リファレンスが非ヌルであるか否かを確認する際にもこれらのルールが当てはまることを知って驚くプログラマーもいます。
多くの人々は、これらの定義が驚くほど厳しいものであると感じます。特にJLSによって32ビット読取りが、最小限の単位であることが定義されて以来、単にオブジェクトのフィールドを読むためだけにロックを獲得する必要はないことが、広く信じられています。しかし残念ながら、この考えは誤っています。問題のフィールドがvolatile であることを宣言していない場合、JMMは、プラットフォームに対して、キャッシュの一貫性、すなわちプロセッサーをまたがっての連続的な整合性を要求しないので、いくつかのプラットフォームでは同期化がない場合に古いデータを読めてしまいます。詳細に関しては、参考文献を参照してください。
共用データを確認したら、次はそのデータを保護する方法を決定しなければなりません。簡単な場合は、単にデータ・フィールドがvolatile であることを宣言することによって、データ領域を保護することができます。また共用データの読み込みまたは書き込みの前にロックを獲得する方法もあります。どんなロックが使用されているかを明示的に決定して特定のフィールドまたはオブジェクトを保護し、コードと一緒に記述を残しておくのがよい方法です。
また単にアクセス機能メソッドを同期化させること (あるいはその基礎となるフィールドがvolatile であることを宣言すること) だけでは共用フィールドの保護に十分でないことも重要です。そのような例について考えましょう。
...
private int foo;
public synchronized int getFoo() { return foo; } public synchronized void setFoo(int f) { foo = f; }
|
呼び出し側がfoo プロパティーの加算を要求する場合、それを実行するための以下のコードはスレッド・セーフではありません。
...
setFoo(getFoo() + 1); |
2つのスレッドが同時にfoo の加算を実行する場合、結果としてfoo の値はタイミングによって1あるいは2増加します。呼び出し側は、ロックで同期化してこの競合状態を回避することが必要でしょう。どんなロックで同期化すべきかをクラスのJavaDocに記述しておくことはよい習慣であり、そうすればクラスの呼び出し側が推測する必要がなくなります。
上記のような状況は、複数の粒度レベルでのデータ完全性にどのように注意しなければならないかを示すよい例です。アクセス機能の同期化によって、呼び出し側はプロパティー値の一貫した最新バージョンへアクセスすることができます。しかし将来のプロパティー値と現在値の一貫性、あるいは複数のプロパティー間の一貫性を望む場合は、おそらく粒度の粗いロックで複合的な操作を同期化させなければなりません。
疑わしいときは同期化ラッパーについて考える
クラスを記述する際に、クラスが共用で使用されるのかどうか分からないときがあります。クラスはスレッド・セーフであることが望まれますが、単一スレッド環境なのに、常に同期化のオーバーヘッドを伴うようにクラスに負担を負わせることは好ましくはありません。またクラスが使用される際にどのロックの粒度が適切であるかわからないかもしれません。幸いにも、同期化ラッパーを提供することによって、どちらの方法も採用できます。Collectionsクラスはこのテクニックのよい例です。このクラスは非同期的ですが、フレームワークで定義されている各インターフェースについて、各メソッドを同期化バージョンによってラップする同期化ラッパー (たとえば、Collections.synchronizedMap()) があります。
結論
JLSはプログラムをスレッド・セーフにすることができるツールを提供しますが、スレッド・セーフがただで手に入る訳ではありません。同期化の使用はパフォーマンス・ペナルティーを伴い、それを正しく使用しないと、データ破損、一貫性のない結果、あるいはデッドロックの危険に直面する可能性があります。幸いにも、過去数年間JVMはかなり改良されており、同期化の適切な使用に伴うパフォーマンス・ペナルティーも低減しています。データがスレッド間でどのように共用されるのかを注意深く分析して共用データの操作を適切に同期化させることによって、過度のパフォーマンス・オーバーヘッドをこうむることなく、プログラムをスレッド・セーフにすることができます。
参考文献
著者について  | |  | Brian Goetz氏は、過去15年間ソフトウェア開発者としての経験を持つソフトウェア・コンサルタントです。彼は、カリフォルニア州ロスアルトスにあるソフトウェア開発コンサルティング会社
Quiotix
のPrincipal Consultantです。連絡先はbrian@quiotix.com です。 |
記事の評価
|