リアルタイム Java での開発

第 2 回 サービス品質を改善する

リアルタイム Java を使って Java アプリケーションによるサービス品質のばらつきを抑える

Comments

コンテンツシリーズ

このコンテンツは全#シリーズのパート#です: リアルタイム Java での開発

このシリーズの続きに乞うご期待。

このコンテンツはシリーズの一部分です:リアルタイム Java での開発

このシリーズの続きに乞うご期待。

Java アプリケーションによるサービス品質の不安定さは一般に、予期せぬタイミングで発生する中断、つまり遅延が原因ですが、この不安定さはソフトウェア・スタック全体で発生する可能性があります。遅延をもたらす原因としては、以下のものが考えられます。

  • ハードウェア (キャッシングなどの処理中)
  • ファームウェア (CPU 温度データの処理など、システム管理プロセスによる割り込み処理)
  • オペレーティング・システム (割り込みに対する応答や、定期的にスケジュールされたデーモン・アクティビティーの実行)
  • 同じシステム上で実行中の他のプログラム
  • JVM (ガーベッジ・コレクション、JIT (Just-in-Time) コンパイル、クラス・ロード)
  • Java アプリケーション自体

下位レベルで引き起こされた遅延をそれよりも上位のレベルで補償できることはまれです。したがって、サービス品質の不安定さをアプリケーション・レベルだけで解決しようとしても、真の問題を解決することにはならず、JVM または OS での遅延を別の場所に移すだけとなるかもしれません。幸い、下位レベルでの待ち時間は上位レベルでの待ち時間よりも比較的短いので、JVM または OS よりも下位のレベルに注意を向けなければならないのは、サービス品質の不安定さ軽減の要求が極めて高い場合に限られます。けれども要求がそれほど厳しくないのであれば、JVM レベルとアプリケーションでの取り組みにフォーカスできるはずです。

リアルタイム Java では、JVM でのパフォーマンスが不安定になる原因、そしてアプリケーション・レベルでのパフォーマンスが不安定になる原因に対処する上で必要なツールを提供しており、それによってユーザーが必要とするサービス品質を実現しようとします。この記事では、JVM とアプリケーション・レベルでのパフォーマンスが不安定な原因について詳しく説明した後、この不安定さの影響を軽減するために使用できるツールおよび手法を説明します。そして最後に、これらのツールと手法の概念を実証する単純な Java サーバー・アプリケーションを紹介します。

不安定なパフォーマンスの原因に対する対策

JVM でのパフォーマンスの不安定さは、主に Java 言語の動的な性質が原因となっています。

  • メモリーがアプリケーションによって明示的に解放されることは決してなく、ガーベッジ・コレクターによって定期的に回収されます。
  • クラスはアプリケーションが初めて使用するときに解決されます。
  • ネイティブ・コードはアプリケーションの実行中、頻繁に呼び出されるクラスとメソッドに基づいて JIT (Just-In-Time) コンパイラーによってコンパイルされます (再コンパイルも可能)。

Java アプリケーション・レベルでは、スレッド化の管理がパフォーマンスの不安定さに関係する重要な要素となります。

ガーベッジ・コレクションによる中断

ガーベッジ・コレクターが動作してプログラムによって使われなくなったメモリーを回収するときには、すべてのアプリケーション・スレッドを停止することもできれば (このタイプのコレクターは、STW (Stop-The-World) コレクターとして知られています)、一部の処理をアプリケーションと並行して行うこともできます。いずれの場合にしても、ガーベッジ・コレクターに必要なリソースをアプリケーションが使用することはできません。そのためガーベッジ・コレクション (GC) が Java アプリケーションの中断とパフォーマンスの不安定さの原因となることは、よく知られています。GC には多くのモデルがあり、それぞれに長所と欠点があるものの、アプリケーションの目標が GC による中断時間を短くすることである場合、主な選択肢は世代別コレクターを選ぶか、リアルタイム・コレクターを選ぶかのどちらかです。

世代別コレクターは、ヒープを少なくとも 2 つのセクションに編成します。一方のセクションは新世代領域、もう一方は旧世代 (または終身世代) 領域と呼ばれます。新しいオブジェクトは常に新世代領域に割り当てられます。新世代領域が空きメモリーを使い果たすと、ガーベッジはその領域でのみ収集されるため、比較的小さな新世代領域を使うことで通常の GC サイクルに要する時間をかなり短くすることができます。何回かの新世代領域の収集を経て残ったオブジェクトは、旧世代領域にプロモートされます。旧世代領域で収集が行われる頻度は一般に新世代領域よりも少ないものの、旧世代領域は新世代領域よりも遙かに広い領域であるため、GC サイクルにはかなりの時間がかかります。世代別ガーベッジ・コレクターを利用すると GC による平均中断時間は比較的短くなりますが、旧世代領域の収集コストにより中断時間の標準偏差がかなり大きくなる場合があります。したがって、世代別コレクターがとりわけ効果的なのは、時間が経ってもライブ・データのセットにそれほど変更がない一方、大量のガーベッジが生成されるアプリケーションです。このシナリオでは旧世代領域の収集が行われることは極めてまれであり、GC による中断時間は短時間の新世代領域の収集に起因することになります。

世代別コレクターとは対照的に、リアルタイム・ガーベッジ・コレクターはその振る舞いを制御することによって、GC サイクルの長さを大幅に短縮するか (アプリケーションがアイドル状態のときを利用して GC サイクルを実行)、または GC サイクルがアプリケーションのパフォーマンスに与える影響を小さくします (アプリケーションとの「契約」に従った小さな単位ごとに処理を実行)。このいずれかのコレクターを使用することで、特定のタスクを完了する場合の最悪のケースを予測することが可能になります。例えば IBM® WebSphere® Real-Time JVM のガーベッジ・コレクターは、GC サイクルを少しずつ実行することで完了できるように小さな作業単位 (GC クォンタ) に分割します。小さなクォンタをスケジューリングするのであればアプリケーションのパフォーマンスに与える影響は極めて小さくなり、遅延時間は数百マイクロ秒ほど (通常の方法による遅延時間は 1 ミリ秒未満) に抑えられます。ここまで短い遅延時間を実現するには、ガーベッジ・コレクターが「アプリケーション使用率契約」の概念を取り入れた上で、その処理を計画することができなければなりません。この契約によって、GC がアプリケーションに割り込んで処理を実行する頻度が制御されるからです。例えば、デフォルトの使用率契約が 70 パーセントだとすると、GC は 10 ミリ秒のうち最大 3 ミリ秒しか使用することができません。このデフォルト使用率契約に従って GC をリアルタイム・オペレーティング・システムで実行すると、標準中断時間は約 500 マイクロ秒となります (IBM WebSphere Real Time ガーベッジ・コレクターの動作についての詳細は、「リアルタイム Java、第 4 回: リアルタイム・ガーベッジ・コレクション」を参照してください)。

ヒープ・サイズとアプリケーション使用率は、アプリケーションをリアルタイム・ガーベッジ・コレクターで実行する場合に検討しなければならない重要な調整オプションです。アプリケーション使用率が高くなるほど、ガーベッジ・コレクターが処理を完了させるために割り当てられる時間は少なくなります。そのため GC サイクルを少しずつ実行して確実に完了させるには、大きなヒープ・サイズが必要となります。割り当てられた時間にガーベッジ・コレクターが対応しきれなくなると、GC は同期収集に戻ります。

例えば、IBM WebSphere Real-Time JVM ではアプリケーション使用率がデフォルトで 70 パーセントに設定されるため、アプリケーションをこの JVM で実行する場合、(使用率契約のない) 世代別ガーベッジ・コレクターを使用した JVM で実行する場合よりも大きなデフォルト・ヒープが必要になります。リアルタイム・ガーベッジ・コレクターは GC による中断の長さを制御するため、ヒープ・サイズを増やして GC の頻度を減らしても、個々の中断時間が延びることはありません。一方、非リアルタイム・ガーベッジ・コレクターでは、ヒープ・サイズを増やすと通常は GC サイクルの頻度が減り、それによってガーベッジ・コレクターによる全体的な影響は少なくなります。しかし GC サイクルが発生したときには、中断時間が長くなります (検査対象のヒープが大きいため)。

IBM WebSphere Real Time JVM では、-Xmx<size> オプションを使用してヒープのサイズを調整することができます。例えば -Xmx512m と指定すると、ヒープのサイズは 512MB になります。さらに、アプリケーション使用率を調整することも可能です。例えば -Xgc:targetUtilization=80 と指定すると、アプリケーション使用率は 80 パーセントに設定されます。

Java クラス・ロードによる中断

Java 言語仕様の要件により、クラスの解決、ロード、検証、および初期化は、アプリケーションがそのクラスを初めて参照する時点で行われます。このことから、時間制約の厳しい処理が行われているときに、例えばクラス C が初めて参照されると、このクラス C の解決、検証、ロード、初期化に費やされる時間によって、処理にかかる時間が想定よりも長くなってしまいます。クラス C をロードする際にはそのクラスの検証も行われますが、この検証のためにさらに他のクラスをロードしなければならない場合もあります。そのため、Java アプリケーションが特定のクラスを初めて使用するために招く遅延全体が、想定よりも遥かに長くなることもあります。

クラスが初めて参照されるのが、アプリケーションがしばらく実行されてからになるのは、なぜなのでしょうか。新しいクラスがロードされる一般的な理由の 1 つは、まれにしか実行されないパスです。例えばリスト 1 のコードには、実行される可能性の少ない if 条件が含まれています (簡単のため、この記事のリストからは例外およびエラー処理の大部分が省略されています)。

リスト 1. 新規クラスをロードする原因となる、まれにしか実行されない条件の例
Iterator<MyClass> cursor = list.iterator();
while (cursor.hasNext()) {
    MyClass o = cursor.next();
    if (o.getID() == 17) {
        NeverBeforeLoadedClass o2 = new NeverBeforeLoadedClass(o);
        // do something with o2
    }
    else {
        // do something with o
    }
}

アプリケーションが実行されてからでないとロードされないクラスの例としては、例外クラスも挙げられます。理想的には (常にそうとは限りませんが) 例外はまれにしか発生しないからです。例外を処理するには大抵時間がかかるため、さらなるクラスをロードする際の追加オーバーヘッドによって処理の待ち時間が臨界しきい値を超えてしまうこともあります。そのため概して、時間制約の厳しい処理の実行中には、できる限り例外がスローされないようにしなければなりません。

特定のサービスが Java クラス・ライブラリーで使用される場合にも、新規クラスがロードされる可能性があります。このようなサービスに該当するのが、リフレクションです。リフレクション・クラスの基礎となる実装は、JVM 内でロードする新規クラスをロードの時点になってから生成します。そのため時間制約のあるコードで繰り返しリフレクション・クラスを使用すると、クラス・ロードのアクティビティーが継続的に行われることになり遅延を招く可能性があります。リフレクションによって生成されているクラスを検出する最善の方法は、-verbose:class オプションを使用することです。そしてプログラムの実行中にこのようなクラスの生成を回避するのに最も効果がある方法は、アプリケーションで時間制約が厳しい部分では、ストリングからクラス、フィールド、またはメソッドをマッピングするためにリフレクション・サービスを使用しないことでしょう。代わりにアプリケーションの早い段階でリフレクション・サービスを呼び出し、その結果作成されたクラスを後で使用できるように保存することによって、このようなクラスのほとんどがその時点になってから作成されることがないようにします。

時間制約が厳しいアプリケーションの部分でクラス・ロードによる遅延を回避する一般的な手法は、アプリケーションの起動時または初期化の段階でクラスをプリロードすることです。このプリロード・ステップは起動に遅れを生じさせるものの (残念ながら、あるメトリックを改善することによって別のメトリックに悪影響を及ぼすことは珍しくありません)、慎重に使用すれば、その後の望ましくないクラス・ロードを排除することができます。しかもこの起動プロセスは簡単に実装することができます (リスト 2 を参照)。

リスト 2. クラスのリストによるクラス・ロードの制御
Iterator<String> classIt = listOfClassNamesToLoad.iterator();
while (classIt.hasNext()) {
    String className = classIt.next();
    try {
        Class clazz = Class.forName(className);
        String n=clazz.getName();
    } catch (Exception e) {
    System.err.println("Could not load class: " + className);
    System.err.println(e);
}

clazz.getName() を呼び出していることに注目してください。この呼び出しによってクラスは強制的に初期化されることになります。クラスのリストを作成するには、実行中のアプリケーションから情報を収集するか、アプリケーションによってロードされることになるクラスを判断することができるユーティリティーを使用しなければなりません。一例として、-verbose:class オプションを指定して実行中のプログラムの出力を取り込む方法が考えられます。このオプションを指定してプログラムを実行したときの出力例をリスト 3 に記載します。これは、IBM WebSphere Real Time 製品を使用した場合の出力です。

リスト 3. -verbose:class を指定して実行した java による出力 (抜粋)
    ...
    class load: java/util/zip/ZipConstants
    class load: java/util/zip/ZipFile
    class load: java/util/jar/JarFile
    class load: sun/misc/JavaUtilJarAccess
    class load: java/util/jar/JavaUtilJarAccessImpl
    class load: java/util/zip/ZipEntry
    class load: java/util/jar/JarEntry
    class load: java/util/jar/JarFile$JarFileEntry
    class load: java/net/URLConnection
    class load: java/net/JarURLConnection
    class load: sun/net/www/protocol/jar/JarURLConnection
    ...

アプリケーションを 1 回実行し、その際にアプリケーションがロードしたクラスのリストを保存し、その保存したリストを、リスト 2 に記載したループ用のクラス名のリストに利用することで、これらのクラスはアプリケーションが実行を開始する前に確実にロードされるようになります。当然のことながら、アプリケーションの実行ごとに異なるパスが取得される可能性があるため、1 回の実行によるリストは完全なものではないかもしれません。それに加え、アプリケーションがまだ開発中であれば、新しく作成されたコードや変更されたコードが、このリストには含まれていない新しいクラスに依存する可能性もあります (または、リストに含まれているクラスが不要になるという場合もあります)。残念ながら、クラスのリストを管理するのが、クラスをプリロードするというこの手法を使用する上で極めて厄介な部分となります。また、この手法を使用する場合には、-verbose:class によって出力されたクラスの名前は Class.forName() に必要なフォーマットとは一致していないことを念頭に入れておいてください。詳細出力ではクラス・パッケージがスラッシュで区切られますが、Class.forName() での区切り文字はピリオドでなければなりません。

クラス・ロードが懸念事項となるアプリケーションの場合、RATCAT (Real Time Class Analysis Tool) や IBM Real Time Application Execution Optimizer for Java など、プリロードの管理に利用できるツールがいくつかあります (「参考文献」を参照)。これらのツールによって、プリロード対象の正しいクラスのリストを特定する処理と、クラス・プリロード・コードをアプリケーションに組み込む処理を自動化することができます。

JIT コンパイラーでのコードのコンパイルによる中断

JVM 内部にある、遅延の原因の 3 つ目は、JIT コンパイラーです。JIT コンパイラーはアプリケーションの実行中に、プログラムのメソッドを javac コンパイラーによって生成されたバイトコードから、アプケーションを実行する CPU のネイティブ命令に変換します。JIT コンパイラーは Java バイトコードのプラットフォーム中立性を犠牲にすることなくアプリケーションのハイ・パフォーマンスを可能にすることから、Java プラットフォームの成功には欠かせません。この 10 数年にわたり、JIT コンパイラーのエンジニアたちは Java アプリケーションのスループットと待ち時間の改善という点で驚異的な前進を遂げました。

スループットと待ち時間は改善されましたが、残念なことに、Java アプリケーションの実行の中断が伴います。JIT コンパイラーは特定のメソッドのコンパイル済みコードを生成する (あるいはコードの再コンパイルをする) ために、アプリケーション・プログラムからサイクルを「借用」するからです。コンパイルするメソッドのサイズ、そして JIT がどれだけ積極的にそのメソッドをコンパイル対象として選択するかによって、コンパイル時間は 1 ミリ秒に満たないものから 1 秒を超えるもの (JIT コンパイラーによって、アプリケーションの実行時間に大きく影響すると判断された特大メソッドの場合) にまで及びます。しかし、アプリケーション・レベルでの実行時間の予期せぬばらつきの原因となるのは、JIT コンパイラー自体のアクティビティーだけではありません。スループットと待ち時間のパフォーマンスが最も効率的になるように改善する際に、JIT コンパイラーのエンジニアたちは主にアプリケーションの平均的な実行ケースを対象としたため、JIT コンパイラーが一般に実行する最適化は、「通常は」適切な最適化であるか、あるいは「大体において」ハイ・パフォーマンスとなるような多様な最適化です。通例、これらの最適化は非常に効果があり、アプリケーションの実行中に最も一般的な状況に最適化を上手く適応させる経験則も開発されています。しかし場合によっては、このような最適化であることがパフォーマンスの不安定さを大きくする原因となります。

すべてのクラスをプリロードするように要求するだけでなく、プリロードしたクラスのメソッドをアプリケーションの初期化中にコンパイルするよう JIT コンパイラーに明示的に要求することもできます。リスト 4 では、クラスをプリロードするリスト 2 のコードを拡張し、メソッドのコンパイルを制御しています。

リスト 4. メソッドのコンパイルの制御
Iterator<String> classIt = listOfClassNamesToLoad.iterator();
while (classIt.hasNext()) {
    String className = classIt.next();
    try {
        Class clazz = Class.forName(className);
        String n = clazz.name();
        java.lang.Compiler.compileClass(clazz);
    } catch (Exception e) {
        System.err.println("Could not load class: " + className);
        System.err.println(e);
    }
}
java.lang.Compiler.disable();  // optional

このコードでは、一連のクラスがロードされ、ロードされたクラスのメソッドがすべて JIT コンパイラーによってコンパイルされます。そして最後の行で、その後のアプリケーションの実行に対して JIT コンパイラーが無効にされます。

一般にこの手法は、JIT コンパイラーにコンパイル対象のメソッドの選択をすべて任せる場合に比べ、全体的なスループットや待ち時間のパフォーマンスが低下する結果となります。JIT コンパイラーはメソッドが呼び出される前に実行されることから、コンパイル対象のメソッドを最適化する最善の方法に関する情報は遙かに乏しくなります。そのため、コンパイルされたメソッドの実行時間は比較的長くなるはずです。さらにコンパイラーは無効にされるため、プログラムの実行時間を大きく左右するメソッドがあったとしても、そのメソッドはコンパイルされません。つまり、最近のほとんどの JVM で使用されているような適応的 JIT コンパイル・フレームワークはアクティブにならないということであり、JIT コンパイラーが引き起こす大量の中断を減らす上では Compiler.disable() コマンドが絶対に必要というわけではありません。JIT コンパイラー以外の中断原因は、アプリケーションのホット・メソッド (頻繁に実行されるメソッド) で実行される一層積極的な再コンパイルです。これらの再コンパイルに必要な時間のほうが一般的に長く、アプリケーションの実行時間に大きな影響を及ぼす可能性があります。さらに特定の JVM 内の JIT コンパイラーは、disable() メソッドが呼び出されてもアンロードされません。そのため、アプリケーション・プログラムの実行フェーズの間、メモリーが使用されたまま、共有ライブラリーがロードされたまま、そして JIT コンパイラーのその他の成果物が存在したまま、となる場合もあります。

ネイティブ・コードのコンパイルがアプリケーションのパフォーマンスに影響する度合いは、もちろんアプリケーションによって異なります。コンパイルが問題になるかどうかを調べる最善の手段は、詳細出力を有効にして、コンパイル発生時にアプリケーションの実行時間がコンパイルに影響されているかどうかを調べることです。例えば IBM WebSphere Real Time JVM では、-Xjit:verbose コマンドライン・オプションを指定すると JIT 詳細ロギングが有効になります。

このプリロードと早期コンパイル手法以外には、アプリケーションの作成者が JIT コンパイラーによって引き起こされる中断を回避する手段はほとんどありません。特殊なベンダー固有の JIT コンパイラー・コマンドライン・オプションを使用することもできますが、その方法には危険が伴います。JVM ベンダーはこれらのオプションを本番シナリオではほとんどサポートしていないからです。ベンダー固有の JIT コンパイラー・コマンドライン・オプションはデフォルト構成ではないため、ベンダーによるテストは不十分であるだけでなく、リリースによってその名前も意味も変わる場合があります。

ただし一部の代替 JVM では、JIT コンパイラーによる中断がどれほどの重要性を持つかに応じて 2、3 のオプションを選択できるようになっています。ハード・リアルタイム Java システム専用に設計されたリアルタイム JVM では、通常それよりもオプション数が充実しています。例えば IBM WebSphere Real Time For Real Time Linux® JVM には、JIT コンパイラーによる中断を減少させるレベルがそれぞれに異なる以下の 5 つのコード・コンパイル・ストラテジーが用意されています。

  • デフォルト JIT コンパイルにより、JIT コンパイラー・スレッドを低い優先度で実行
  • 最初に AOT (Ahead-Of-Time) コンパイル・コードを使用する低優先度のデフォルト JIT コンパイルを実行
  • 起動時にプログラムで制御したコンパイルを使用した後、再コンパイルを有効に設定
  • 起動時にプログラムで制御したコンパイルを使用した後、再コンパイルを無効に設定
  • AOT コンパイル・コードだけを使用

上記のストラテジーは、予想されるスループットと待ち時間のパフォーマンスのレベルが高く、予想される中断時間が長い順に記載しています。つまり、JIT コンパイル・スレッドを最低の優先度 (アプリケーション・スレッドよりも低い優先度) で実行するデフォルト JIT コンパイル・オプションでは、予想されるスループット・パフォーマンスは最も高くなりますが、それと同時に JIT コンパイルによる中断時間は (5 つのストラテジーのうち) 最も長くなることが予想されます。最初の 2 つのストラテジーでは非同期コンパイルを使用します。これはつまり、(再) コンパイル対象として選択されたメソッドを呼び出そうとするアプリケーション・スレッドは、コンパイルが完了するまで待機する必要がないということです。最後のストラテジーでは、スループットと待ち時間のパフォーマンスは最も低くなることが予想されますが、このシナリオでは JIT コンパイラーが完全に無効にされるため、JIT コンパイラーによる中断時間はまったくありません。

IBM WebSphere Real Time for Real Time Linux JVM では、admincache というツールを提供しています。このツールを使用すれば、一連の JAR ファイルのクラス。ファイルが含まれる共有クラス・キャッシュを作成することができる他、オプションで、同じキャッシュにこれらのクラスの AOT コンパイル・コードを保存することもできます。java コマンドラインにオプションを設定することによって、共有クラス・キャッシュに保存されたクラスをキャッシュからロードし、クラスがロードされると AOT コードが自動的に JVM にロードされるようにするというわけです。AOT コンパイル・コードの利点を十分に活用するために必要なのは、リスト 2 に記載したようなクラスのプリロード・ループだけです。admincache のドキュメントへのリンクは、「参考文献」を参照してください。

スレッド管理

トランザクション・サーバーなどのマルチスレッド・アプリケーションでトランザクション時間のばらつきをなくすためには、スレッドの実行を制御することが不可欠です。Java プログラミング言語では、スレッドの優先度という概念を盛り込んだスレッド化モデルを定義しているものの、実際の JVM でのスレッドの振る舞いを定義するのは主に実装です。そのため、Java プログラミングが頼りにできるルールはほとんどありません。例えば、Java スレッドに 10 あるスレッド優先度のうちの 1 を割り当てることができても、これらのアプリケーション・レベルの優先度と OS の優先度の値とのマッピングは実装によって定義されます (JVM がすべての Java スレッドの優先度を OS の同じ優先度の値にマッピングしたとしても、その有効性にはまったく問題ありません)。その上、Java スレッドのスケジューリング・ポリシーも実装によって定義されますが、通常はタイム・スライスが割り当てられることになり、優先度の高いスレッドであっても優先度の低いスレッドと CPU リソースを共有するという事態になってしまいます。優先度の低いスレッドとリソースを共有するということは、他のタスクがタイム・スライスを取得できるようにスケジューリングされている場合、優先度の高いスレッドに遅延が発生する可能性があるということです。スレッドによって使用可能な CPU の時間は、優先度だけでなく、スケジューリングする必要のあるスレッドの合計数によっても左右されることに留意してください。特定の時点でアクティブなスレッドの数を厳密に制御できない限り、最高の優先度を持つスレッドでさえも、処理を実行するのにかかる時間に比較的大きな差が出てくることになります。

要するに、ワーカー・スレッドに最高の Java スレッドの優先度 (java.lang.Thread.MAX_PRIORITY) を指定したとしても、システム上の優先度の低いタスクから十分に分離したことにはなりません。残念ながら対処方法としては、決められたワーキング・スレッドのセットを使用し、(GC によって未使用スレッドを収集している間に新しいスレッドを割り当て続けたり、スレッド・プールを拡大/縮小したりしないようにして) アプリケーションの実行中にシステム上の優先度の低いアクティビティー数を最小限にすることくらいしかなさそうです。なぜなら、標準 Java スレッド化モデルでは、スレッド化の動作を制御するために必要なツールを提供していないためです。この場合、ソフト・リアルタイム JVM でさえも、標準 Java スレッド化モデルに依存するとしたら大きな助けにはなりません。

一方、RTSJ (Real Time Specification for Java) をサポートする IBM WebSphere Real Time for Real Time Linux V2.0 や Sun の RTS 2 などのハード・リアルタイム JVM では、標準 Java よりもスレッド化の動作を大幅に改善することができます。標準 Java 言語と VM 仕様に対する機能強化のなかでも、RTSJ が導入している RealtimeThread および NoHeapRealtimeThread という 2 つの新しいタイプのスレッドは、標準 Java スレッド化モデルより遙かに厳密に定義されています。このようなスレッドは、真の意味で優先度ベースのプリエンプティブ・スケジューリングを行います。このスケジューリング方式では、優先度の高いタスクを実行する必要がある場合に、それよりも優先度の低いタスクが現在プロセッサー・コアで実行されているとしても、優先度の高いタスクを実行できるように優先度の低いタスクがプリエンプトされます。

ほとんどのリアルタイム OS ではこのプリエンプションを 10 マイクロ秒程度で実行できるため、プリエンプションによって影響を受けるのは極めて微妙なタイミング要件を持つアプリケーションだけです。大抵の OS 上で動作する JVM では、お馴染みのラウンドロビン・スケジューリング・ポリシーを使用しますが、この 2 つの新しいスレッド・タイプはファーストイン・ファーストアウト (FIFO) スケジューリング・ポリシーを使用します。ラウンド・ロビン・スケジューリング・ポリシーと FIFO スケジューリング・ポリシーとの最も顕著な違いは、FIFO スケジューリング・ポリシーでは、同じ優先度のスレッドのうち、いったん実行が開始されたスレッドは、ブロックされるか自発的にプロセッサーをリリースするまで実行を継続するという点です。このモデルの利点は、たとえ同じ優先度を持つタスクが複数あったとしてもプロセッサーは共有されないため、特定タスクの実行時間を予測しやすくなることです。さらに、スレッドがブロックされないように同期および I/O アクティビティーを排除することができれば、いったん開始されたタスクが OS によって干渉を受けることはありません。しかし実際にはすべての同期を排除することは極めて難しいため、実際のタスクでここまでの理想を実現するのは困難なはずです。それでもやはり、アプリケーション設計者にとって FIFO スケジューリングは遅延を抑えるための重要な手段となります。

RTSJ はいわば、リアルタイムの動作をするアプリケーションを設計するために役立つツールが揃った大きなツール・ボックスのようなものです。そのうちいくつかのツールだけを使うこともできれば、アプリケーションを完全に作成し直して、そのパフォーマンスを極めて予測しやすいものにすることもできます。RealtimeThread を使用するようにアプリケーションを変更するのは大抵の場合、難しいことではありません。Java リフレクション・サービスを慎重に使用すれば、リアルタイム JVM にアクセスして Java コードをコンパイルしなくても変更することができます。

しかし FIFO スケジューリングによる変動の少なさのメリットを利用するには、アプリケーションに変更を加えなければなりません。FIFO スケジューリングの動作はラウンド・ロビン・スケジューリングの場合とは異なるため、その違いによって、一部の Java プログラムはハングアップする可能性があるからです。例えば、アプリケーションが Thread.yield() を使用してコア上で他のスレッドを実行できるようにしようとしても (この手法は、コア全体を使用せずに一定の条件をポーリングするためによく使用されます)、目的の効果は得られません。FIFO スケジューリングでは、Thread.yield() が現行のスレッドをブロックすることはないからです。現行のスレッドはスケジューリング可能な状態を維持し、しかも OS カーネル内のスケジューリング・キューの先頭にあるため、そのまま実行を続けます。したがって CPU リソースに公平にアクセスできるようにすることを目的としたコーディング・パターンが、実際には条件が真になるまで待機するどころか、たまたま実行の開始場所となった任意の CPU コアを 100 パーセント使用することになります。それだけなら、まだいい方です。その条件を設定する必要があるスレッドに低い優先度が割り当てられているとしたら、このスレッドがコアにアクセスして該当する条件を設定することは不可能になります。この問題は、現在のマルチコア・プロセッサーでは起こりそうにないにせよ、RealtimeThread を採用する際には使用する優先度を慎重に考慮しなければならないことを浮き彫りにしています。最も確実な方法は、すべてのスレッドに同じ優先度の値を使用させて、Thread.yield() や同様のスピン・ループを使用しないことです。こうすれば、決してブロックされないスピン・ループによって CPU が占有されることはなくなります。もちろん、RealtimeThread に使用できる優先度の値を十分に活用すれば、サービス品質の目標を達成できる可能性は最も高くなります (アプリケーションで RealtimeThread を使用する際の詳しいヒントについては、「リアルタイム Java、第 3 回: スレッド化と同期」を参照してください)。

Java サーバーの例

記事の残りでは、これまでのセクションで紹介した概念のいくつかを、Java Class Library の Executors サービスを使って作成した比較的単純な Java サーバー・アプリケーションに適用してみます。Executors サービスによって、ほんのわずかな量のアプリケーション・コードでワーカー・スレッドのプールを管理するサーバーを作成することが可能になります (リスト 5 を参照)。

リスト 5. Executors サービスを使用した Server クラスと TaskHandler クラス
import java.util.concurrent.Executors;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ThreadFactory;

class Server {
    private ExecutorService threadPool;
    Server(int numThreads) {
        ThreadFactory theFactory = new ThreadFactory();
        this.threadPool = Executors.newFixedThreadPool(numThreads, theFactory);
    }

    public void start() {
        while (true) {
            // main server handling loop, find a task to do
            // create a "TaskHandler" object to complete this operation
            TaskHandler task = new TaskHandler();
            this.threadPool.execute(task);
        }
        this.threadPool.shutdown();
    }

    public static void main(String[] args) {
        int serverThreads = Integer.parseInt(args[0]);
        Server theServer = new Server(serverThreads);
        theServer.start();
    }
}

class TaskHandler extends Runnable {
    public void run() {
        // code to handle a "task"
    }
}

このサーバーは、サーバーの作成時に指定された最大数に達するまで、必要な数だけワーカー・スレッドを作成します (この特定の例では、最大数はコマンドラインからデコードされます)。それぞれのワーカー・スレッドが、TaskHandler クラスを使用して処理の一部を実行します。ここでの目的から、作成する TaskHandler.run() メソッドは毎回一定の実行時間を費やすようにします。したがって、測定された TaskHandler.run() 実行時間のばらつきは、ベースにある JVM での中断またはパフォーマンスの不安定さ、何らかのスレッド化の問題、あるいはそれよりも下のスタック・レベルでもたらされた中断が原因ということになります。リスト 6 に、TaskHandler クラスを記載します。

リスト 6. パフォーマンスが予測可能な TaskHandler クラス
import java.lang.Runnable;
class TaskHandler implements Runnable {
    static public int N=50000;
    static public int M=100;
    static long result=0L;
    
    // constant work per transaction
    public void run() {
        long dispatchTime = System.nanoTime();
        long x=0L;
        for (int j=0;j < M;j++) {
            for (int i=0;i < N;i++) {
                x = x + i;
            }
        }
        result = x;
        long endTime = System.nanoTime();
        Server.reportTiming(dispatchTime, endTime);
    }
}

この run() メソッド内のループは、最初の N (50,000) 個の整数の合計値を M (100) 回計算します。MN の値は、このメソッドを実行するマシン上でのトランザクション時間が約 10 ミリ秒になり、1 回の処理で OS スケジューリング・クォンタ (通常は約 10 ミリ秒継続) による割り込みが行われるように選択した値です。この計算には、実行時間を極めて容易に予測できる優れたコードを JIT コンパイラーが生成できるように、ループを構成しました。そのため、ループの実行時間を計測するための System.nanoTime() が呼び出されてから、再度呼び出されるまでの間、run() メソッドが明示的にブロックされることはありません。測定対象のコードの実行時間は容易に予測できるため、このコードを使用すれば、遅延とパフォーマンスのばらつきの大きな原因は必ずしも測定対象のコードではないことを明らかにすることができます。

このアプリケーションをもう少し現実的なものにするために、TaskHandler コードの実行中にガーベッジ・コレクター・サブシステムがアクティブになるようにします。この GCStressThread クラスはリスト 7 のとおりです。

リスト 7. ガーベッジを常時生成する GCStressThread クラス
class GCStressThread extends Thread {
    HashMap<Integer,BinaryTree> map;
    volatile boolean stop = false;

    class BinaryTree {
        public BinaryTree left;
        public BinaryTree right;
        public Long value;
    }
    private void allocateSomeData(boolean useSleep) {
        try {
            for (int i=0;i < 125;i++) {
                if (useSleep)
                    Thread.sleep(100);
                BinaryTree newTree = createNewTree(15); // create full 15-level BinaryTree
                this.map.put(new Integer(i), newTree);
            }
        } catch (InterruptedException e) {
            stop = true;
        }
    }

    public void initialize() {
        this.map = new HashMap<Integer,BinaryTree>();
        allocateSomeData(false);
        System.out.println("\nFinished initializing\n");
    }

    public void run() {
        while (!stop) {
            allocateSomeData(true);
        }
    }
}

GCStressThread は、HashMap を使用して一連の BinaryTree を維持します。このスレッドは HashMap に対して同じ Integer キーのセットを繰り返し処理し、新規 BinaryTree 構造を保存します。これらの構造には、全 15 レベルの BinaryTree が取り込まれているだけです (つまり HashMap に保存された BinaryTree ごとに 215 = 32,768 のノードとなります)。HashMap は常に 125 の BinaryTree (ライブ・データ) を保持し、100 ミリ秒ごとに、そのうちの 1 つを新しい BinaryTree と交換します。このように、このデータ構造はかなり複雑なライブ・オブジェクトのセットを維持するとともに、一定の速さでガーベッジを生成します。HashMap は初めに initialize() ルーチンにより、125 の BinaryTree 一式で初期化されます。これにより、各ツリーを割り当てるたびに中断が発生することはなくなります。GCStressThread が (サーバーが起動する直前に) 開始されると、サーバーのワーカー・スレッドによる TaskHandler の処理全体をとおして動作します。

このサーバーを制御するクライアントは使用しません。ただ単に、サーバーの (Server.start() メソッドに含まれる) メイン・ループ内に直接 NUM_OPERATIONS == 10000 という処理を作成します。リスト 8 に、Server.start() メソッドを記載します。

リスト 8. サーバー内部での処理のディスパッチ
public void start() {
    for (int m=0; m < NUM_OPERATIONS;m++) {
        TaskHandler task = new TaskHandler();
        threadPool.execute(task);
    }
    try {
        while (!serverShutdown) { // boolean set to true when done
            Thread.sleep(1000);
        }
    }
    catch (InterruptedException e) {
    }
}

それぞれの TaskHandler.run() の呼び出しが完了するまでの時間統計を収集すれば、JVM およびアプリケーションの設計によってもたらされるばらつきがどの程度であるかがわかります。ここでは、8 つの物理コアを搭載した IBM xServer e5440 を Red Hat RHEL MRG リアルタイム・オペレーティング・システムと併せて使用しました (ハイパースレッド機能は無効にしてあります。ベンチマークではハイパースレッド機能によってスループプットを改善することができますが、仮想コアは完全でないことから、ハイパースレッド機能を有効にしたプロセッサーで処理を行った場合の物理コアのパフォーマンスでは、時間にかなりの差が出てくるはずです)。IBM Java6 SR3 JVM を備えた 8 コア搭載マシンで 6 つのスレッドを使用してこのサーバーを実行したところ (Server メイン・スレッドと GCStressorThread が使用できるように、それぞれ 1 つのコアを残しておきます)、以下の (代表的な) 結果となりました。

$ java -Xms700m -Xmx700m -Xgcpolicy:optthruput Server 6
10000 operations in 16582 ms
Throughput is 603 operations / second
Histogram of operation times:
9ms - 10ms      9942    99 %
10ms - 11ms     2       0 %
11ms - 12ms     32      0 %
30ms - 40ms     4       0 %
70ms - 80ms     1       0 %
200ms - 300ms   6       0 %
400ms - 500ms   6       0 %
500ms - 542ms   6       0 %

この結果からわかるように、ほとんどすべての処理は 10 ミリ秒以内で完了していますが、完了するまでの所要時間が 0.5 秒 (50倍の時間) を超えている処理がいくつかあります。これはかなりのばらつきです。そこでこれから、Java クラス・ロード、JIT ネイティブ・コード・コンパイル、GC、およびスレッド化によって引き起こされる遅延をなくすことによって、このばらつきをある程度排除する方法を検討します。

まず始めに、-verbose:class を使用して、アプリケーションが実行期間全体でロードしたクラスのリストを収集します。出力をファイルに保存した上でそのファイルを変更し、各行に適切にフォーマット設定された名前が 1 つ含まれるようにします。Server クラスには preload() メソッドを組み込んでクラスのそれぞれをロードし、ロードしたクラスに含まれるすべてのメソッドを JIT コンパイラーでコンパイルした後、JIT コンパイラーを無効にします (リスト 9 を参照)。

リスト 9. サーバーのクラスおよびメソッドのプリロード
private void preload(String classesFileName) {
    try {
        FileReader fReader = new FileReader(classesFileName);
        BufferedReader reader = new BufferedReader(fReader);
        String className = reader.readLine();
        while (className != null) {
            try {
                Class clazz = Class.forName(className);
                String n = clazz.getName();
                Compiler.compileClass(clazz);
            } catch (Exception e) {
            }
            className = reader.readLine();
        }
    } catch (Exception e) {
    }
    Compiler.disable();
}

この単純なサーバーでは、クラス・ロードは大きな問題ではありません。使用している TaskHandler.run() メソッドは至って単純で、クラスがいったんロードされると、その後の Server の実行時にはそれほど大量のクラス・ロードが発生しないためです。これは、-verbose:class を設定して実行すれば確認することができます。主なメリットは、測定対象の TaskHandler 処理を実行する前にメソッドをコンパイルすることによってもたらされます。ウォームアップ・ループを使用するという方法もありますが、JIT コンパイラーがコンパイル対象のメソッドを選択するために従う経験則は JVM 実装によって異なるため、この方法はそれぞれの JVM に固有になりがちです。Compiler.compile() サービスを使用するとコンパイル・アクティビティーは制御しやすくなるものの、記事ですでに説明したように、この方法を使用するとスループットが大幅に低下することになります。これらのオプションを使用してアプリケーションを実行した結果は、以下のとおりです。

$ java -Xms700m -Xmx700m -Xgcpolicy:optthruput Server 6
10000 operations in 20936 ms
Throughput is 477 operations / second
Histogram of operation times:
11ms - 12ms     9509    95 %
12ms - 13ms     478     4 %
13ms - 14ms     1       0 %
400ms - 500ms   6       0 %
500ms - 527ms   6       0 %

最長の遅延にはほとんど変わりがありませんが、ヒストグラムは最初に比べて大幅に短くなっていることに注目してください。時間が短縮された遅延の多くは、明らかに JIT コンパイラーによって引き起こされたものなので、コンパイルを初期段階で実行してから JIT コンパイラーを無効にすれば、一歩前進することは明らかです。もう 1 つの興味深い点は、処理時間が共通して多少長くなっていることです (約 9 ミリ秒から 10 ミリ秒だったものが、11 ミリ秒から 12 秒に増加しています)。処理に要する時間が長くなった理由は、メソッドが呼び出される前の強制 JIT コンパイルによって生成されたコードの品質は通常、十分に実行されてから生成されたコードの品質よりも低いためです。JIT コンパイラーの大きな利点の 1 つは実行中のアプリケーションの動的性質を利用して効率的に実行することなので、これは意外な結果ではありません。

この記事では引き続き、(クラスをプリロードしてメソッドを事前コンパイルする) このコードを使用します。

GCStressThread は常に変化する一連のライブ・データを生成するため、世代別 GC ポリシーを使用しても、中断時間の面で大きなメリットはないはずです。代わりの手段として、IBM WebSphere Real Time for Real Time Linux V2.0 SR1 製品でリアルタイム・ガーベッジ・コレクターを試してみました。-Xgcthreads8 オプションを追加してコレクターがデフォルトの単一スレッドではなく 8 つの GC スレッドを使用できるようにしても、初めは期待外れの結果でした (コレクターは 1 つの GC スレッドだけでは、このアプリケーションの割り当て速度に、確実に対応していくことはできません)。

$ java -Xms700m -Xmx700m -Xgcpolicy:metronome -Xgcthreads8 Server 6
10000 operations in 72024 ms
Throughput is 138 operations / second
Histogram of operation times:
11ms - 12ms     82      0 %
12ms - 13ms     250     2 %
13ms - 14ms     19      0 %
14ms - 15ms     50      0 %
15ms - 16ms     339     3 %
16ms - 17ms     889     8 %
17ms - 18ms     730     7 %
18ms - 19ms     411     4 %
19ms - 20ms     287     2 %
20ms - 30ms     1051    10 %
30ms - 40ms     504     5 %
40ms - 50ms     846     8 %
50ms - 60ms     1168    11 %
60ms - 70ms     1434    14 %
70ms - 80ms     980     9 %
80ms - 90ms     349     3 %
90ms - 100ms    28      0 %
100ms - 112ms   7       0 %

リアルタイム・コレクターを使用することで最長の処理時間はかなり短縮されましたが、その一方で処理時間のばらつきも大きくなりました。それだけではありません。スループットのレートも著しく低下する結果となってしまいました。

最後のステップは、通常の Java スレッドではなく、RealtimeThread をワーカー・スレッドとして使用することです。そこで、Executors サービスに渡せる RealtimeThreadFactory クラスを作成しました (リスト 10 を参照)。

リスト 10. RealtimeThreadFactory クラス
import java.util.concurrent.ThreadFactory;
import javax.realtime.PriorityScheduler;
import javax.realtime.RealtimeThread;
import javax.realtime.Scheduler;
import javax.realtime.PriorityParameters;

class RealtimeThreadFactory implements ThreadFactory {
    public Thread newThread(Runnable r) {
        RealtimeThread rtThread = new RealtimeThread(null, null, null, null, null, r);

        // adjust parameters as needed
        PriorityParameters pp = (PriorityParameters) rtThread.getSchedulingParameters();
        PriorityScheduler scheduler = PriorityScheduler.instance();
        pp.setPriority(scheduler.getMaxPriority());

        return rtThread;
    }
}

RealtimeThreadFactory クラスのインスタンスを Executors.newFixedThreadPool() サービス・クラスに渡すと、ワーカー・スレッドは RealtimeThread になり、最高の優先度で FIFO スケジューリングを使用します。これらのワーカー・スレッドは、ガーベッジ・コレクターがその処理を実行しなければならないときにはやはり割り込まれるものの、優先度の低いその他のタスクによって割り込まれることはありません。

$ java -Xms700m -Xmx700m -Xgcpolicy:metronome -Xgcthreads8 Server 6
Handled 10000 operations in 27975 ms
Throughput is 357 operations / second
Histogram of operation times:
11ms - 12ms     159     1 %
12ms - 13ms     61      0 %
13ms - 14ms     17      0 %
14ms - 15ms     63      0 %
15ms - 16ms     1613    16 %
16ms - 17ms     4249    42 %
17ms - 18ms     2862    28 %
18ms - 19ms     975     9 %
19ms - 20ms     1       0 %

この最後の変更によって、最長の処理時間 (わずか 19 ミリ秒まで短縮) と全体的なスループット (1 秒につき最大 357 の処理) の両方が大幅に改善されます。このように、処理時間のばらつきの大幅な改善には成功しましたが、スループット・パフォーマンスにはかなりの犠牲を払っています。通常は約 12 ミリ秒で完了する処理に、さらに 4 ミリ秒から 5 ミリ秒余計にかかっている理由は、10 ミリ秒あたり最大 3 ミリ秒しかガーベッジ・コレクターの処理に使用できないことが説明します。そのため、処理の大部分に約 16 ミリ秒から 17 ミリ秒かかる結果となっています。このスループットの低下は、おそらく予想以上のものでしょう。スループットを低下させた原因は、Metronome リアルタイム・ガーベッジ・コレクターの使用に加え、リアルタイム JVM が、優先順位の逆転を保護するロック・プリミティブも変更したからです。優先順位の逆転は、FIFO スケジューリングを使用する場合に大きな問題となります (「リアルタイム Java、第 1 回: リアルタイム・システムに Java 言語を使用する」を参照)。残念なことに、マスター・スレッドとワーカー・スレッドとの同期がさらにオーバーヘッドを追加します。これが結局はスループットに影響を与えているわけですが、このオーバーヘッドは処理時間の一部としては測定されません (そのため、ヒストグラムには現れません)。

予測可能性を改善するために行った以上の変更は、サーバーにはメリットをもたらす一方、スループットがかなり顕著に低下するのは確実です。それでもやはり、いくつかの非常に長い処理時間が、許容されないサービス品質レベルを表すのであれば、RealtimeThread をリアルタイム JVM で使用することが妥当なソリューションとなるはずです。

まとめ

Java アプリケーションの世界では、アプリケーションおよびベンチマークの設計者はレポーティングおよび最適化を行うためのメトリックとして、従来からスループットと待ち時間を選択してきました。この選択は、パフォーマンス改善を目的に作成された Java ランタイムの進化にさまざまな影響を与えています。Java ランタイムは、実行時の待ち時間もスループットもかなり劣ったインタープリターとしてスタートしましたが、最近の JVM は多くのアプリケーションで、これらのメトリックに関して他の言語と互角になっています。しかし比較的最近まで、アプリケーションで認識されるパフォーマンスに大きな影響を与える他のメトリックに関しては同じことは言えませんでした。その主たる例が、サービス品質に影響するパフォーマンスの不安定さです。

特定のアプリケーション設計者が JVM でのパフォーマンスが不安定になる原因、そしてアプリケーション・レベルでのパフォーマンスが不安定になる原因に対処し、アプリケーションの使用者と顧客が期待するサービス品質を実現するために必要なツールは、リアルタイム Java の導入によって提供されています。この記事では、JVM とスレッド・スケジューリングによって生じる中断とパフォーマンスの不安定さが減るように Java アプリケーションを変更するために適用できる、さまざまな手法を紹介しました。パフォーマンスの不安定さを減少させることで、代わりに待ち時間とスループットのパフォーマンスが低下することは珍しくありません。そのパフォーマンスの低下をどこまで許容できるかによって、特定のアプリケーションに適切なツールが決まります。


ダウンロード可能なリソース


関連トピック


コメント

コメントを登録するにはサインインあるいは登録してください。

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=60
Zone=Java technology
ArticleID=434808
ArticleTitle=リアルタイム Java での開発: 第 2 回 サービス品質を改善する
publish-date=09082009