レベル: 中級 Andrew Citron (citron@us.ibm.com), Senior Programmer, IBM Chris Seekamp (seekamp@us.ibm.com), Programming Advisor, IBM Martin Presler-Marshall (mpresler@us.ibm.com), Software Performance Analyst, IBM
2007年 10月 02日 複数のスレッドが可変のコレクションへのアクセスを共有できるようにする一般的な方法、つまりコレクションへアクセスする際の同期は、パフォーマンスのボトルネックになり得ます。この記事を読んで、頻繁に読み取られる一方、更新頻度の少ないデータ構造に対してこのボトルネックを最小限に抑える手法を学んでください。この手法は Java™ 5.0 以降で使用することができます。
複数の Java スレッド間で共有されるデータを使う上での欠点は、コンテンツのビューの一貫性を維持するためにデータへのアクセスを同期させなければならないことです。ビューに一貫性がないと、アプリケーションの障害の原因となります。例えば、Hashtable クラスの put() および get() メソッドは同期させられますが、同期が要求されているおかげで、この両メソッドは実行時にそれぞれが単独で同じデータに同時にアクセスすることができます。さもないと、アプリケーションのデータ構造が壊れる可能性があります。
アプリケーションのスレッドがメソッドへの頻繁なアクセスによってブロック状態に至った場合、これらのメソッドに関連する同期点がボトルネックとなる可能性があります。つまり、コンテンツにアクセスできるのは一度に 1 つのスレッドだけで、それ以外のスレッドは順番が来るまで待たなければなりません。有益な働きをするはずのスレッドがキューに入れられてしまえば、当然パフォーマンスとスループットに影響が出ます。パフォーマンスの分析によって、同期メソッドが実はキューイング・ポイントの原因となっていることがわかった場合には、コードを最適化してみるだけの価値はあります。
頻繁に変更されないデータの場合、世代データ構造と呼ばれる手法でオーバーヘッドが小さい volatile を使い、安全に可変データ構造を公開することができます。こうすることで、頻繁にアクセスされるにも関わらず変更されることが少ないデータ構造であれば、パフォーマンスが向上します。例えば、Hashtable のような同期データ構造を使う代わりに、HashMap などの非同期データ構造を使うこともできます。この手法の秘訣は、以下のとおりです。
- データ構造の更新時に新しいコピーが作成されます。
- このコピーに完全にデータが設定されます。
- volatile 参照によって更新がすべてのコンシューマーに安全に公開されます。
この手法では、データ構造の同じインスタンスで同時に get および put オペレーションが実行されることは決してありません。したがって、2 つのスレッドが同時にデータ構造の更新を試みることはなく、読み取りスレッドは常に一貫性のある最新バージョンのデータを参照することになります (この手法は、データが頻繁に更新されるとしても機能しますが、その場合、改善された並行性によるパフォーマンスの向上は望めません。データ構造に頻繁にデータを再設定すると、同期したアクセサー・メソッドを使わないことによってもたらされる利点が相殺されてしまうからです)。
クラスのペアに対する適用性 |
クラスのペアに対する適用性
Hashtable は、複数のスレッドで共有されるデータへのアクセスを提供する数多くの Java クラスのうちの 1 つです。HashMap は機能の点では Hashtable に似ていますが、スレッド・セーフではありません。この記事で紹介する手法は、互いに似通ったクラスのペアに適用することができます。ただし、どちらか一方のクラスだけが同期したアクセサー・メソッドを持っているペアの場合は例外です。例えば、Vector には同期したアクセサー・メソッドがありますが、ArrayList にはありません。そのため、どちらも同様の機能を提供しますが、この記事の手法は適用することはできません。 |
|
この手法は、Java 言語が持つ以下の 3 つの特性を利用します。
-
自動ガーベッジ・コレクション。Java ランタイムは、オブジェクトへの最後の参照が無くなったときに自動的にオブジェクトを解放できます。アプリケーションに必要なアクションは、オブジェクトを使用し終わった時点でオブジェクト参照が残っていないことを確認すること以外にありません。それまでに生成されたオブジェクトは、最後のクライアントが使用し終わると自動的に解放されます。
-
オブジェクト参照のアトミック性。オブジェクトにアクセスする単純な割り当てステートメントに対する割り込みはできません。これはつまり、オブジェクトを使用する側のスレッドが古い (ただし完全な) オブジェクトのコピーで正しい結果をもたらすことができる限り、単一オブジェクトの割り当てステートメントを基準に同期する必要はないということです。ただし注意すべき重要な点として、割り当てが行われる前に新規オブジェクトの生成が確実に完了するための措置を、オブジェクトを生成する側のスレッドで取る必要があることには変わりありません。この記事の「 考察」セクションで説明するように、オブジェクトの生成が完了してから割り当てが行われることを保証するためには、生成側のスレッドでの同期が必要です。しかし使用側のスレッドでは同期を使用する必要がないことから、コストの高いキューイング・ポイントは取り除かれます。
-
Java メモリー・モデル。Java メモリー・モデルは
synchronized と volatile のセマンティクスを指定しています。これらのルールによって、共有オブジェクトとそれぞれのコンテンツが現在実行中のスレッド以外のスレッドに対して可視になる条件が定義されます。
データ構造の 2 つの別個のインスタンスを維持すると、データ構造に含まれるデータに変更が生じてくるという場合には、上記の Java 言語の特性を利用できます。いったんデータが設定されたデータ構造が再び変更されることはありません。つまり、データ構造は事実上、不変だということです。get および put オペレーションが同じデータ構造で同時に実行可能であるとしたら危険ですが、ここで説明する手法では、すべての put オペレーションが完了してからでないと get オペレーションは実行可能になりません。
手法の説明
リスト 1 は、この手法を説明するサンプル・コードです。
リスト 1. キューイング・ポイントを防ぐ生成側/使用側のコード
static volatile Map currentMap = new HashMap(); // this must be volatile to ensure
// consumers will see updated values
static Object lockbox = new Object();
public static void buildNewMap() { // This is called by the producer
// when the data needs to be updated.
synchronized (lockbox) { // This must be synchronized because
// of the Java memory model.
Map newMap = new HashMap(currentMap); // For cases where new data is based on
// the existing values, you can use the
// currentMap as a starting point.
// add or remove any new or changed items to the newMap
newMap.put(....);
newMap.put(....);
currentMap = newMap;
}
/* After the above synchronization block, everything that is in the HashMap is
visible outside this thread. The updated set of values is available to
the consumer threads.
As long as assignment operation can complete without being interrupted
and is guaranteed to be written to shared memory and the consumer can
live with the out of date information temporarily, this should work fine. */
}
public static Object getFromCurrentMap(Object key) { // Called by consumer threads.
Map m = currentMap; // No locking around this is required.
Object result = m.get(key); // get on a HashMap is not synchronized.
// Do any additional processing needed using the result.
return(result);
}
|
リスト 1 の内容を以下に説明します。
考察
newMap がいったん currentMap に割り当てられると、currentMap の内容は決して変更されません。HashMap は事実上、不変だということです。そのため、複数の get オペレーションを並行で実行してパフォーマンスを大幅に向上させることもできます。『Java 並行処理プログラミング』(「参考文献」を参照) のセクション 3.5.4 で Brian Goetz が述べているように、「安全に公開された事実上不変のオブジェクトは、その後は同期を取らずに使用することができます」。この安全な公開とは、volatile 参照による結果です。
データの読み取り中に唯一、変更される可能性があるのは currentMap 変数へのオブジェクト参照です。使用側のスレッドが値にアクセスすると同時に生成側のスレッドが現行の値を新しい値で上書きする可能性は考えられますが、Java 言語では、オブジェクト参照は 1 つの単位としての操作なので、使用側のスレッドがオブジェクトにアクセスする際に同期を取る必要はありません。起こり得る最悪の事態は、使用側のスレッドが currentMap への参照を取得した後に、生成側のスレッドがその参照をそれよりも新しい内容で上書きすることです。この場合、使用側のスレッドは、多少古くなってはいるものの内部では依然として一貫性のあるデータを使用することになります。生成側のスレッドが実行可能な状態になる前に使用側のスレッドが再度実行された場合にも、これと同じ結果になります。しかし一般には、このような事態が問題になることはありません。重要なのは、currentMap の内容は公開される時点では常に、自己矛盾が一切なく、不変であるということです。
このような上書きが行われると、使用側のスレッドが「古い」バージョンのデータを参照する場合があります。「新しい」オブジェクト参照で古いオブジェクト参照を上書きしたにも関わらず、一部の使用側のスレッドがまだ古いオブジェクトを参照するということです。使用側の最後のスレッドが古いオブジェクトを参照し終わると、そのオブジェクトはスコープから外れ、ガーベッジ・コレクションの対象となります。この経過は Java ランタイムによって追跡されます。アプリケーションが古いオブジェクトを明示的に解放する必要はありません。古いオブジェクトは自動的に解放されるからです。
アプリケーションのニーズに応じて、新しいバージョンの currentMap が定期的に作成される場合もあります。上記で説明したステップに従えば、このような更新が安全に繰り返し行われることを確実にできます。
リスト 1 の synchronized ブロックが必要な理由は、2 つの生成側のスレッドが同時に currentMap を更新しようとして競合しないことを保証するためです。そうでないとデータの損失につながり、使用側のスレッドが不確定な結果を見ることになりかねません。synchronized は最適化プログラムがこのような決定を行わないようにし、原則的にマップ作成全体がアトミックな操作として扱われるようにします。currentMap 変数の値が変更された後に使用側のスレッドが古い値を見続けないことを保証するのは、volatile キーワードです。さらに重要な点として、このキーワードによって、クライアントがオブジェクト参照を逆参照して到達する値が少なくともその参照よりも古いものでないことを保証します。通常の参照では、このような順序付けは保証されません。
生成側のスレッドが 1 つのスレッドになるよう設計されていて、アプリケーションの設計によってその 1 つのスレッドだけが currentMap を更新すると保証される場合、synchronized ブロックの後に currentmap = newMap の割り当てを移動するのは有効です。ただし、このように変更しても大幅なパフォーマンスの向上は見込めません。変更によってコードの普遍性が失われる上、エラーを引き起こす可能性もあるため、このような追加変更を行うことは、たとえ生成側のスレッドが 1 つだけだとしても問題視されます。
synchronized ブロックと volatile キーワードの使用による最終的な効果は、使用側のスレッドが確実に一貫したビューを見られるということです。生成側のスレッドにとっては、公開後にデータ構造が変更されないという事実が助けになります。この場合 (本質的に不変のオブジェクト・グラフの公開) に必要となるのは、ルート・オブジェクト参照を安全に公開することだけです。使用側のスレッドのルート参照へのアクセスを同期させることもできますが、それはこの手法で回避しようとしているキューイング・ポイントになる可能性があります。Brian Goetz はこの方法を「安価な読み書きロック」手法と呼んでいます (「参考文献」を参照)。
まとめ
この記事で説明した手法は、共有データが頻繁に変更されることなく、複数の実行スレッドによって同時にアクセスされる場合に適用できます。ただしこれを適用するのは、データが絶対的に最新の状態であることがアプリケーションの要件ではない場合に限ります。
この手法が最終的に実現するのは、いずれは変更する可能性のある共有データへの同時アクセスです。高い並行性が求められる環境では、この手法によってアプリケーション内に不要なキューイング・ポイントを作らないようにすることができます。
注意しておかなければならないのは、Java メモリー・モデルの複雑さにより、ここで説明した手法は Java 5.0 以降でないと機能しないという点です。それより前の Java のバージョンでは、完全にデータ設定がされていない HashMap や、あるいは壊れているか有効性または一貫性に欠けた HashMap の内部データ構造のビューがクライアント・アプリケーションに表示されるおそれがあります。
謝辞
この記事を完全かつ正確なものにするためにテクニカル・レビューおよび提案で協力してくれた Brian Goetz 氏に感謝します。
参考文献 学ぶために
議論するために
著者について  | 
|  | Andy Citron は、ノースカロライナ州 Research Triangle Park にある WebSphere Portal パフォーマンス・グループのメンバーです。Mwave Multimedia Card とその電話応答および通話識別サブシステム、ワード・プロセッサー、オペレーティング・システム、そして無線インターネット・アクセスなどの製品作成に従事した期間を含め、IBM での経歴は 30 年に及びます。 APPC (または LU6.2) として知られる SNA 通信プロトコルの主任アーキテクトに就任したのは 1980年後半です。SNA 設計グループでの彼の研究が、分散二相コミット処理の分野で数多くの特許をもたらしました。 |
 | 
|  | Chris Seekamp は、IBM Software Group のWorkplace, Portal, and Collaboration Software 部門でプログラミング・コンサルタントを務めています。Lotus Sametime、Lotus Connections、WebSphere Portal、WebSphere Transcoding Publisher をはじめ、さまざまな製品に取り組んできました。彼はこれまで 15 年以上、オブジェクト指向の設計開発手法を適用しています。最初に適用したのは C++ で、次が Java 言語です。また、Linux とオープン・ソース・ソフトウェアにも高い関心を持っています。 |
 | 
|  | Martin Presler-Marshall は、ノースカロライナ州 Research Triangle Park 所在の IBM でシニア・プログラマーを務めています。1995年に開発者として IBM 初の HTTP サーバー製品の開発に取り組んで以来、Web 関連のソフトウェアに携わってきました。現在は、IBM Lotus 部門の WPLC パフォーマンス・チームでパフォーマンスのエキスパートとして活躍しています。このチームで彼が専門としているのは、IBM WebSphere Portal および IBM Lotus Quickr のパフォーマンス向上です。彼は P3P 仕様など、いくつかの W3C 勧告および技術レポートの作成にも参加しました。IBM に入社したのは 1991年です。余暇はキャンプやサイクリング、そして木材加工、テコンドーを楽しんでいます。 |
記事の評価
|