目次


リッチ・クライアント・アプリケーションのパフォーマンス

第 2 回 メモリー・リークの修復

Comments

コンテンツシリーズ

このコンテンツは全#シリーズのパート#です: リッチ・クライアント・アプリケーションのパフォーマンス

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

このコンテンツはシリーズの一部分です:リッチ・クライアント・アプリケーションのパフォーマンス

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

この連載の第 1 回では、Eclipse ベースのリッチ・クライアント・アプリケーションのパフォーマンスに関わる要素として、CPU、I/O、スレッド化を取り上げましたが、メモリー・リークもまた、パフォーマンス問題を引き起こす要因の 1 つとなります。今回の記事では、アプリケーションのメモリー使用量のモニタリングを行う方法を説明し、リッチ・クライアント・アプリケーションを開発する際に起こり得るリークとそれを解決する手法について解説します。

メモリー使用量について

RCP (Rich Client Platform) アプリケーションのメモリー使用量の合計を把握するのはなかなか一筋縄ではいかないものです。オペレーティング・システム (OS) はアプリケーションによるメモリー使用量を示し、Java プラットフォームはヒープの使用量を示しますが、その常として、OS がレポートするメモリー使用量は使用可能なヒープ・サイズよりも大きくなります。あいにく、OS がヒープ・サイズを遥かに上回るメモリー使用量をレポートすることも珍しくありません。ヒープ分析の難題の 1 つは、この「闇のスペース」に何があるのかを判断することです。

一般的には、プロセスのメモリー使用量 = Java ヒープ + コンパイル済みネイティブ・コード + バイトコード + その他 / ネイティブとなります。

しかしながら、JVM が示すそれぞれのヒープは、リリースとベンダーによって異なるのです。私が実行しているある Java アプリケーションを例にあげると、Sun の 1.6 JDK がレポートするヒープは 32.7MB で、OS がレポートする占有バイト数は 48.6MB なので、使途不明な分が 16MB あることになります。ただし総体的に見れば、この差分はそれほど問題ではありません。この場合、16MB の中にはコンパイル済みコードとバイトコードも含まれるからです。一方で、同じアプリケーションを IBM 1.5 JDK で実行した場合は、ヒープ、クラス・ローダー、コンパイル済みコードの合計が 39MB となり、OS は 45.8MB とレポートします。

通常は、Java ヒープだけに着目することで、この問題を単純にすることができます。Java ヒープに対象を絞るこのアプローチは、大抵の Java アプリケーションにとって功を奏するものであると同時に、最大の改善をもたらすことができるものでもあります。もしそうでない場合には、OS のツールを使って Java ヒープがカバーしていないネイティブ・メモリーを調べる必要があるでしょう。

差分分析

メモリー使用量に取り組む方法としてとりわけ効果的なのは、オブジェクト数に注目することです。例えば、E メール・アプリケーションで 50 件のメール・メッセージを表示しているとします。この場合、MailMessage クラスのインスタンス数はいくつになるでしょうか? 正解は 50 です。それではメール詳細やその他のメール・ドメイン・オブジェクトについてはどうでしょう。ここでフォルダーを切り替えて、新しい 50 件のメール・メッセージを表示したとしたら、インスタンスの数は 50 と 100 のどちらになると思いますか?

このような分析を始めてみると、予想以上に多くのインスタンスがあることに何度も驚かされるはずです。しかしここで注意しておく必要があるのは、必ずガーベッジ・コレクションの後にヒープ・ダンプを収集することです。そうでないと、すでに破棄されたオブジェクトまでカウントすることになってしまいます。私の場合、通常は System.gc() を実行してからヒープ・ダンプを取るようにしています。

一般的なヒープ分析は十分話題にされているので (「参考文献」を参照) ここで解説することはしませんが、代わりに私がアプリケーションのリークを見つけるために使った差分分析の手法について説明します。

基本的な考え方は以下のように単純なものです。

  1. ヒープ・ダンプを取得します。
  2. アプリケーションで何らかの操作を数回 (例えば、10 回) 実行します。
  3. もう一度ヒープ・ダンプを取得します。
  4. それぞれのヒープ・ダンプのアプリケーション・オブジェクトの数を比較します。

それから追跡したいと思うアプリケーション・オブジェクトのをまとめます。リークを見つけて修復しながら、リークしていたクラスのリストをスクリプトに追加していきます。このようにして、常にチェック対象とするアプリケーション・オブジェクトのセットを時間をかけてまとめていくわけです。

ユニット・テスト

私が使ったことがある別の手法は、ユニット・テストを作成し、その中でヒープ・ダンプの解析や、予想されるドメイン・オブジェクトのインスタンス数についてのアサーションを行うというものです。例えば、アプリケーションを起動してシナリオを実行し、ヒープ・ダンプを取得して、アサーションを行うといった流れになります。ここでは、メール・アプリケーションにメモリー・リークが見つかった例をあげます。リークを修復したら、後々コードを変更する際に同じ問題が発生しないようにするために、この問題の発生を防げるようなユニット・テストを作成しました。それが、リスト 1 のリソース使用量のユニット・テストです。

リスト 1. ヒープ・ダンプの解析を行う JUnit テスト・ケース
public void testOpenTenMessages() throws Exception {
Heap heap = Heap.from("openMessages.phd");
assertEquals(10, heap.instancesOf("cbg/mail/ui/message/MessageController"));
assertEquals(10, heap.instancesOf("cbg/mail/ui/message/viewer/AttachmentModel"));

Heap heapAfter = Heap.from("openMessagesClosed.phd");
assertEquals(0, heapAfter.instancesOf("cbg/mail/ui/message/MessageController"));
assertEquals(0, heapAfter.instancesOf("cbg/mail/ui/message/viewer/AttachmentModel"));
}

上記のユニット・テストの仕組みを説明すると、まず 10 件のメール・メッセージが開かれて、openMessages.phd という名前のヒープ・ダンプが生成されます。これらのメッセージが閉じられると、2 番目のヒープ・ダンプ、openMessagesClosed.phd が取得されます。

この 2 つのヒープ・ダンプを使用して、メモリー内に生成されているはずの各種ドメイン・オブジェクトに関するアサーションを作成します。私が想定するメール・メッセージ (MessageController) の数は、最初のヒープ・ダンプに 10 件、2 番目のヒープ・ダンプに 0 件となります。

このように自動化したヒープ分析は、ビルドごとの変更を追跡する上で有力な方法となります。標準ユニット・テストと同様、最初はリークを発見して修復したときにだけユニット・テストを作成するようにすることで、負担を減らしてかまいません。そうすることで、確実にリグレッションを避けることができます。アプリケーションのリソース使用量も、追跡対象とすべきもうひとつの測定基準として考えると有用です。実行の終了時にアプリケーションが割り当てたオブジェクト数がわかるだけでも、ビルドを重ねる上で役立ちます。

残念ながら、ヒープ分析は JVM による (そして同じ JVM でも異なるバージョンによる) 違いが顕著な領域の 1 つです。IBM の JVM では、ヒープ分析フォーマットが何度か変更されていますし、Sun の JVM ではまた別のフォーマットが使用されていて、しかもリリースごとに変更されています。

グラフィカル・デバイス・インターフェース・リソースのリーク

Windows OS では、色、フォント、グラフィック・コンテキスト (GC)、画像、カーソル、あるいは領域のそれぞれが、単一のグラフィカル・デバイス・インターフェース (GDI) リソースに対応します。GDI は Windows の用語ですが、すべての OS にこれに相当するものがあります。覚えておかなければならない重要な点は、OS 全体としての GDI リソースの数は限られているということです。アプリケーションが多数のリソースをリークしたり、あるいは使用したりしてしまうと、そのシステムで実行しているすべてのアプリケーションに影響することになります。それだけ GDI リークとは好ましくないことなのです。

GDI リソースがリークしているかどうかを判断するのは簡単で、Windows OS ではタスク マネージャまたは Process Explorer を利用できます。まず GDI 列を追加して、時間が経つにつれて増加するかどうかを調べてください (図 1 を参照)。例えば、メール・メッセージを開くと javaw プロセスに関連付けられた GDI リソースが 50 増加するのに、メール・メッセージを閉じたときには GDI リソースが 46 しか減らないのであれば、メール・メッセージを読むたびに 4 つの GDI リソースがリークしていることになります。

図 1. GDI オブジェクトを表示する Windows タスク マネージャ
GDI オブジェクトを表示する Windows タスク マネージャ
GDI オブジェクトを表示する Windows タスク マネージャ

タスク マネージャは、リークが発生していることを知らせられても、どこでリークが発生しているかを見つける手掛かりは教えてくれません。そこで、リークの場所を見つける方法として最も有力なのは、SWT 開発ツールの 1 つ、Sleak を使うことです (「参考文献」を参照)。SWT では、デバッグ・フラグを有効にすれば、各 GDI リソースが生成された場所を Sleak を使って追跡することができ、GDI リソースとそれらがどこから割り当てられたのかを確認することができます。

SWT と JFace には、各種のキャッシュ内で GDI リソースを管理できるようにするためのクラスがいくつかありますが、キャッシングが思ったよりも厄介になることがよくあります。キャッシュをいつ、どのように使用するかが必ずしも明確ではないからです。GDI リソースを使用する設計では、以下の点に注意する必要があります。

  • GDI リークはいかなる場合でも容認すべきではありません。必ず修正する必要があります。
  • リークを修正したら、次の 2 つの問題を検討します。
    • アプリケーションに必要な GDI リソースの合計数
    • リソースの生成にかかるコスト

GDI リソースの合計数

アプリケーション内の GDI リソースの合計数、そしてその中で重複するリソースの数は把握しておかなければなりません。重複リソースの数が重要となる理由は、アプリケーションが使用する GDI リソースの合計数を減らすためには、できる限り GDI リソースを共有する必要があるからです。重複するリソースは気づかないうちに簡単に生成されてしまいます (私は重複を検出するように修正した Sleak ツールやその他の便利な Sleak の変更を Eclipse に寄与しています)。

GDI リソースの生成にかかるコスト

一般的に、フォントやイメージを生成するのは、色を生成する場合よりもコストがかかります。アプリケーションによっては、イメージを生成することがユーザー・アクションにおける膨大なコストになることもあります。そのような場合には、SWT/JFace が提供するキャッシュを検討してください。

可能であれば、プラットフォームにリソースを管理させるようにしてください。画像やアイコンの属性をプラットフォームの拡張機能 (ビュー、アクションなど) に指定すると、リソースを確実に正しく生成および破棄するのはプラットフォームの役割になります。最善のコードというのは、作成する必要も保守する必要もないコードとも言えるのです。このアドバイスに従って既存のプラットフォームにあるフォントと画像をできるだけ使用すれば、当然、共有するリソースが増えることになります。

可能な限り、リソースは共有してください。通常その最善の方法となるのは、リソースを共通バンドルにグループ化することです。これは共通リソースをリファクタリングすることだと考えてください。

それぞれの UI バンドルには ImageRegistry が関連付けられています。このレジストリーは、頻繁に使用する画像を保管するために使用することができます。ここで重要なのは、頻繁に使用するという点です。すべてのリソースをレジストリーに配置する開発者を見かけることがありますが、これは一般的には適切ではありません。このレジストリーは名前 > 画像、または名前 > 画像記述子のマッピングを保持します。画像記述子は画像の簡単な説明なので、GDI は関連付けられません。画像レジストリーには画像記述子を事前に定義しておくことができ、そうすることによって、初めて画像が要求されたときにレジストリーが画像記述子を生成するようになります。

それほど頻繁に使用しない画像については、自分で生成または破棄することも、あるいは LocalResourceManager を使用することもできます。LocalResourceManager のコンストラクターは ウィジェットの形をとることもあります。このようにして生成された LocalResourceManager は、ウィジェットが破棄されると関連付けられたリソースをクリーンアップします。

リスナー・リーク

リスナーに関連するリークは、UI コードによくある問題です (「参考文献」を参照)。リスナー・リークは多くの場合、メモリーと時間を無駄にします。リスナーをオブジェクトに追加すると、ウィジェットとリスナーとの間に直接的で強力な関係を作ることになります (図 2 を参照)。リスナーとリスナーが参照するすべてのものは、ウィジェットが存続する限りメモリーに維持されます。ウィジェット、あるいはそのウィジェットを含む親が破棄されると SWT はリスナーを削除し、それによって強力な関係が解消されるわけですが、開発者がこの点を正しく理解していないと思われるコードをよく目にします。

図 2. リスナーの例
リスナーの例
リスナーの例

JFace/SWT のリスナーは、そのリスナーが追加されているオブジェクトが適切なタイミングで破棄されている限り、削除する必要はありません。そこで重要になるのが、リスナーを追加するオブジェクトのライフサイクルを理解することです。リスナーをオブジェクトに追加する際には常に、リスナーの存続期間、そしてリスナーを追加するオブジェクトが何であるかを把握しておいてください。

例えば、ビューを生成するアプリケーションがあるとします。ビューにはボタンがあります。このビューを開発する際には、アプリケーションがボタンのクリックに応答できるようにボタンに選択リスナーを追加しますが、このボタン・リスナーを削除するための破棄リスナーをビューに追加する必要はありません。また、ボタンが破棄されたときにボタン・リスナーを削除するための破棄リスナーをボタンに追加する必要もありません。ボタンが破棄されると、SWT がボタンのリスナーを削除してくれるからです。つまり、冗長なコードを記述したり、余計な作業を行う必要はまったくありません。

RCP アプリケーションでよくある例は、ワークベンチ・ページのリスナーとして自らを追加するビューを作成する場合に起こります。ワークベンチ・ページは長期間存続する傾向があり、通常はアプリケーションがシャットダウンされるまで閉じられません (ワークベンチ・ページが閉じられることによって、リスナーは削除されます)。このような場合は、ワークベンチ・ページにリスナーが持っている関係の解消を任せるべきではありません。ビューが閉じられるタイミングで、そのビューをリスナーとして削除するようにしてください。

チャット・プログラムでも、オブジェクトのライフサイクルについて理解不足と思われる例を見たことがあります。チャット・ウィンドウが開かれるたびに、バディー・リストにリスナーが追加されるというものですが、チャット・ウィンドウがリスナーを削除するようになっていなかったのです。それだけでは特に問題になりませんが、バディー・リストも破棄されずに存続するとなると話は別です。これにより、バディー・リストにリスナーが次から次へと追加され、すべてのリスナーがそのまま維持されてしまうのです。ここで強調しなければならない点は、これがパフォーマンスの低下を招くと同時にメモリー・リークも起こしているということです。それぞれのチャット・ウィンドウとすべての到達可能なオブジェクトはこのリスナー・リークの結果として維持され、さらにバディー・リストが一連のリスナーに対し通知するたびに、すでに閉じられているチャット・ウィンドウにまで通知していることとなり時間を無駄にしているのです。

もう 1 つのよくある例は、設定が変更されたときに UI を更新できるように、設定ストアにリスナーを追加するというものです。ビューの生成時、またはアクションの生成時に設定ストア・リスナーが追加されるようにする開発者がいましたが、設定ストア・リスナーを削除しなければ、リスナーを累積させてしまうという問題があります。通常、設定ストアが閉じられるのはシャットダウン時のみだからです。

アクションは特殊なケースです。アクションにはライフサイクルというものがないため、アクションを生成しても、それが破棄されたり、不要になる時期についてはほとんど制御することができません。したがって、通常、アクションを生成する際に、リスナーを他のオブジェクトに追加してはいけないのです。オブジェクトに追加したリスナーを削除する明確な方法はないからです。

リスナー・リークの検出方法

リスナー・リークを検出するには、以下の 2 つの方法をお勧めします。

  • コード・レビューの実施: アプリケーション・コードの中で、想定以上に長期間存続すると思われるオブジェクトにリスナーを追加している箇所を探します。これらの箇所のリストを作成し、通常は実行時にデバッガーを使用して、そのリスナーが想定通りに動作しているかを検証します。addListener ごとに対応する removeListener があるというだけでは不十分で、開発者は、あるメソッドが呼び出されると思い込んでその中に removeListener を組み込んだものの、実際にはそのメソッドが呼び出されないといったことはよくあるのです。
  • 以下の手順に沿った、プロファイラーまたは差分分析の実行:
    1. アプリケーションを起動します。
    2. ウォームアップを行います。
    3. メモリー・スナップショットを取得します。
    4. アクション (チャット・ウィンドウを開く、メール・メッセージを読むなど) を 5 回繰り返して実行します。
    5. メモリー・スナップショットを取得します。
    6. アプリケーション・オブジェクトのインスタンス数を調べます。例えばリスナー・リークがある場合、正常な数よりも 5 つ多いリスナーが見つかるはずです。

まとめ

この記事を読んで、ビルドごとのアプリケーションのヒープ使用量を測定する方法、そして、もしリークが発生してしまった場合にこれを見つけて修正するための便利な手法をある程度おわかりいただければ幸いです。まだ実践していないという方は、まずはビルドごとにアプリケーションが使用するヒープの量を追跡することから始め、その後ヒープ分析を追加してみてください。最初のうちはヒープ・ダンプをそれほど活用できなくても、ビルドごとに収集を重ねることによって、いざというときには非常に有益なものとなるはずです。いったんヒープ・ダンプの収集を開始すれば、ドメイン・オブジェクトの差分分析を組み込むこともできます。まずは手近なところから始めて、これらの手法に慣れながら徐々に手を広げていってください。


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


関連トピック


コメント

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

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=60
Zone=Java technology, Open source
ArticleID=257934
ArticleTitle=リッチ・クライアント・アプリケーションのパフォーマンス: 第 2 回 メモリー・リークの修復
publish-date=08072007