Memory Profiler を使用して Java ヒープとメモリ割り当てを表示する

Memory Profiler は Android Profiler のコンポーネントであり、ぎこちない動き、フリーズ、さらにはアプリのクラッシュを引き起こす可能性があるメモリリークやメモリチャーンを特定するのに役立ちます。このコンポーネントでは、アプリのメモリ使用量のリアルタイム グラフが表示され、ヒープダンプの取得、ガベージ コレクションの強制実行、メモリ割り当ての追跡が可能です。

Memory Profiler は次の手順で開きます。

  1. [View] > [Tool Windows] > [Profiler] をクリックします(またはツールバーの [Profile] をクリックします)。
  2. Android Profiler のツールバーで、プロファイリングするデバイスとアプリのプロセスを選択します。USB 経由でデバイスに接続しているにもかかわらずデバイスがリストに表示されない場合は、USB デバッグが有効になっていることを確認します。
  3. [MEMORY] タイムラインの任意の場所をクリックして Memory Profiler を開きます。

または、コマンドラインで dumpsys を使ってアプリのメモリを調べたり、logcat の GC イベントを表示したりできます。

アプリのメモリをプロファイリングする理由

Android は管理メモリ環境を備えています。アプリが一部のオブジェクトを使用していないことが検出されると、ガベージ コレクターは未使用のメモリをリリースしてヒープに戻します。Android が未使用のメモリを検出する仕組みは継続的に改善されていますが、すべての Android バージョンにおいて、ある時点でコードの実行を短時間一時停止する必要があります。ほとんどの場合、そういった一時停止は検出できませんが、システムがメモリを収集できる以上の速度でアプリがメモリを割り当てると、コレクターが十分なメモリを解放して割り当てを満たしていても、アプリで遅延が発生する場合があります。この遅延により、アプリがフレームをスキップしたり、明らかに速度が低下する場合があります。

アプリで速度低下が発生していない場合でも、メモリリークが発生していれば、アプリはバックグラウンドにあってもメモリを保持します。これにより、不必要なガベージ コレクション イベントが強制的に実行され、システム上の残りのメモリ パフォーマンスが低下する可能性があります。最終的には、システムによりアプリ プロセスが強制終了され、メモリが回収されます。その後、ユーザーがアプリに戻ったとき、アプリを完全に再起動する必要があります。

こうした問題を防ぐには、Memory Profiler を使用して次の措置を講じる必要があります。

  • パフォーマンス上の問題を引き起こす可能性がある、望ましくないメモリ割り当てパターンをタイムラインで探す。
  • Java ヒープをダンプして、任意の時点でメモリを使い切っているオブジェクトを特定する。メモリリークの特定には、長期間にわたる複数のヒープダンプが役立ちます。
  • ユーザーによる通常の操作および極端な操作の際のメモリ割り当てを記録して、コードが短時間にオブジェクトを割り当てすぎている領域、またはメモリリークが発生するオブジェクトを割り当てている領域を正確に特定する。

アプリのメモリ使用量を削減できるプログラミング上のおすすめの方法については、アプリのメモリの管理をご覧ください。

Memory Profiler の概要

Memory Profiler を最初に開くと、アプリのメモリ使用量の詳細なタイムラインと、ガベージ コレクションの強制実行、ヒープダンプの取得、メモリ割り当ての記録を行えるアクセスツールが表示されます。

図 1. Memory Profiler

図 1 に示すように、Memory Profiler のデフォルト ビューには次の項目が含まれます。

  1. ガベージ コレクション イベントを強制実行するボタン。
  2. ヒープダンプを取得するボタン。

    注: メモリ割り当てを記録するボタンは、Android 7.1(API レベル 25)以下を実行しているデバイスに接続されている場合にのみ、ヒープダンプ ボタンの右側に表示されます。

  3. プロファイラがメモリ割り当てをキャプチャする頻度を指定するためのプルダウン メニュー。適切なオプションを選択すれば、プロファイリング中にアプリのパフォーマンスを向上させることができます。
  4. タイムラインを拡大または縮小するボタン。
  5. ライブメモリ データにジャンプするボタン。
  6. アクティビティの状態、ユーザー入力イベント、画面の回転イベントを示すイベント タイムライン。
  7. 次の要素が含まれるメモリ使用量のタイムライン。
    • 各メモリカテゴリで使用されているメモリ使用量の積み上げ棒グラフ。左の Y 軸はメモリ使用量を、上部の色キーはメモリカテゴリを示しています。
    • 割り当てられたオブジェクトの数を表す破線。右の Y 軸はオブジェクトの数を示しています。
    • 各ガベージ コレクション イベントのアイコン。

ただし、Android 7.1 以前を実行しているデバイスを使用している場合は、すべてのプロファイリング データがデフォルトで表示されるわけではありません。「Advanced profiling is unavailable for the selected process」というメッセージが表示された場合は、実行構成で詳細なプロファイリングを有効にする必要があります。

  • イベント タイムライン
  • 割り当てられたオブジェクトの数
  • ガベージ コレクション イベント

Android 8.0 以降では、デバッグ可能なアプリに対して詳細なプロファイリングが常に有効になります。

メモリをカウントする方法

Memory Profiler の上部に表示される数値(図 2)は、Android システムに応じて、アプリがコミットしたすべてのプライベート メモリ ページに基づいています。このカウントには、システムまたは他のアプリで共有されるページは含まれません。

図 2. Memory Profiler の上部にあるメモリカウントの凡例

メモリカウントのカテゴリは次のとおりです。

  • Java: Java または Kotlin コードから割り当てられたオブジェクトのメモリ。
  • Native: C または C++ コードから割り当てられたオブジェクトのメモリ。

    Android フレームワークでは、コードを Java または Kotlin で記述していても、画像アセットやその他のグラフィックを処理するときなどはネイティブ メモリを使用してユーザーのさまざまなタスクが処理されるため、アプリで C++ を使用していない場合でも、使用された一部のネイティブ メモリがここに表示される場合があります。

  • Graphics: GL サーフェスや GL テクスチャなど、画面にピクセルを表示するためにグラフィック バッファ キューに使用されたメモリ(このメモリは専用の GPU メモリではなく、CPU と共有されるメモリであることに注意してください)。

  • Stack: アプリのネイティブ スタックと Java スタックの両方によって使用されたメモリ。通常、アプリが実行しているスレッドの数に関係します。

  • Code: dex バイトコード、最適化またはコンパイルされた dex コード、.so ライブラリ、フォントなど、コードやリソースのためにアプリが使用したメモリ。

  • Others: アプリによって使用された、カテゴリが不明なメモリ。

  • Allocated: アプリによって割り当てられた Java および Kotlin オブジェクトの数。C または C++ で割り当てられたオブジェクトはカウントされません。

    Android 7.1 以前を実行しているデバイスを接続した場合、この割り当てカウントは、Memory Profiler が実行中のアプリに接続された時点で初めて開始されます。したがって、プロファイリングを開始する前に割り当てられたオブジェクトはカウントされません。ただし、Android 8.0 はすべての割り当てを追跡するデバイス プロファイリング ツールを備えているため、この数値は、Android 8.0 以降で実行されているアプリの応答しない Java オブジェクトの合計数を常に表します。

以前の Android Monitor ツールのメモリカウントとちがい、新しい Memory Profiler はメモリを別の方法で記録するため、メモリ使用量が多くなったように見える場合があります。Memory Profiler は、いくつかの追加カテゴリを監視するため合計数が増えますが、Java ヒープメモリのみ確認するのであれば、「Java」の数値は以前のツールの値と同じようになるはずです。また、Java の数値は Android Monitor の値と正確に一致しないことになりますが、アプリの Java ヒープは Zygote からフォークされたため、新しい数値ではヒープに割り当てられたすべての物理メモリページがカウントされます。したがって、この数値はアプリが実際に使用する物理メモリの量を正確に表していることになります。

メモリ割り当てを表示する

メモリ割り当ては、メモリの各オブジェクトがどのように割り当てられたかを示します。特に、Memory Profiler はオブジェクト割り当てに関して次の内容を表示できます。

  • 割り当てられたオブジェクトの種類とオブジェクトのメモリ使用量。
  • 各割り当てのスタック トレースと含まれているスレッド。
  • オブジェクトが割り当て解除されたタイミング(Android 8.0 以降を実行しているデバイスを使用している場合のみ)

デバイスで Android 8.0 以降を実行している場合は、次のようにしていつでもオブジェクト割り当てを表示できます。タイムラインをドラッグして、割り当てを表示する領域を選択します(動画 1 を参照)。Android 8.0 以降にはアプリの割り当てを常に追跡するデバイス プロファイリング ツールが含まれているため、記録セッションを開始する必要はありません。

動画 1. Android 8.0 以降では、既存のタイムラインで領域を選択してオブジェクト割り当てを表示

デバイスが Android 7.1 以前を実行している場合は、Memory Profiler のツールバーで [Record memory allocations] をクリックします。Memory Profiler は記録中にアプリで発生するすべての割り当てを追跡します。完了したら、[Stop recording] をクリックします(動画 2 を確認するとわかるように同じボタンです)。

動画 2. Android 7.1 以前では、メモリ割り当てを明示的に記録

タイムラインで領域を選択すると(または、Android 7.1 以前を実行しているデバイスで記録セッションを完了すると)、クラス名でグループ化され、ヒープカウントの順に割り当て済みのオブジェクトがタイムラインの下に表示されます。

割り当て記録を検査するには、次の手順を実行します。

  1. リストに目をとおして、ヒープカウントが著しく大きく、リークが発生している可能性があるオブジェクトを見つけます。既知のクラスを見つけやすくするには、[Class Name] 列の見出しをクリックして、アルファベット順に並べ替えてからクラス名をクリックします。 図 3 に示すように、右側に [Instance View] ペインが表示され、そのクラスの各インスタンスが表示されます。
    • または、[Filter] をクリックするかまたは Ctrl+F(Mac の場合は Command+F)を押しながら検索フィールドにクラス名またはパッケージ名を入力して、オブジェクトをすばやく検索することができます。プルダウン メニューで [Arrange by callstack] を選択して、メソッド名で検索することもできます。正規表現を使用する場合は、[Regex] のチェックボックスをオンにします。検索クエリで大文字と小文字を区別する場合は、[Match case] のチェックボックスをオンにします。
  2. [Instance View] ペインでインスタンスをクリックします。ペインの下に [Call Stack] タブが表示され、そのインスタンスが割り当てられた場所と含まれるスレッドが示されます。
  3. [Call Stack] タブで任意の行を右クリックし、[Jump to Source] を選択してコードをエディタで開きます。

図 3. 右側の [Instance View] ペインに割り当てられた各オブジェクトの詳細が表示される

割り当てられたオブジェクト一覧の上にある 2 つのメニューで、調査するヒープと、データの整理方法を選択できます。

左側のメニューで、調査するヒープを選択します。

  • default heap: システムによってヒープが指定されていない場合。
  • image heap: 起動時にプリロードされたクラスを含むシステム ブートイメージ。この割り当てが変化することはありません。
  • zygote heap: Android システムでアプリプロセスがフォークされたコピー オン ライト ヒープ。
  • app heap: アプリがメモリを割り当てるプライマリ ヒープ。
  • JNI heap: Java Native Interface(JNI)参照の割り当てと開放が行われる場所を示すヒープ。

右側のメニューで、割り当ての配置方法を選択します。

  • Arrange by class: クラス名に基づいてすべての割り当てをグループ化(デフォルト)。
  • Arrange by package: パッケージ名に基づいてすべての割り当てをグループ化。
  • Arrange by callstack: すべての割り当てを対応するコールスタックにグループ化。

プロファイリング中にアプリのパフォーマンスを改善する

プロファイリング中にアプリのパフォーマンスを改善するため、Memory Profiler はデフォルトでメモリ割り当てをサンプリングします。API レベル 26 以上を実行しているデバイスでテストする際は、[Allocation Tracking] プルダウンを使用してこの動作を変更できます。使用できるオプションは次のとおりです。

  • Full: メモリ内のすべてのオブジェクト割り当てをキャプチャする。これは Android Studio 3.2 以前のデフォルトの動作です。多数のオブジェクトを割り当てるアプリの場合は、プロファイリング中にアプリが目に見えて遅くなることがあります。
  • Sampled: メモリ内のオブジェクト割り当てを定期的にサンプリングする。これはデフォルトのオプションで、プロファイリング時のアプリのパフォーマンスへの影響は小さくなります。短時間に多数のオブジェクトを割り当てるアプリでは、依然として速度が目に見えて低下する可能性があります。
  • Off: アプリのメモリ割り当てのトラッキングを停止する。

グローバル JNI 参照の表示

Java Native Interface (JNI)は、Java コードとネイティブ コードが互いに呼び出せるようにするフレームワークです。

JNI 参照はネイティブ コードによって手動で管理されるため、ネイティブ コードによって使用される Java オブジェクトが長期間存在し続ける可能性があります。JNI 参照が最初に明示的に削除されずに破棄されると、Java ヒープ上の一部のオブジェクトに到達不能となる可能性があります。また、グローバル JNI 参照制限を使い切ることも可能です。

このような問題のトラブルシューティングには、Memory Profiler の [JNI heap] ビューを使用してすべてのグローバル JNI 参照を表示し、それらを Java タイプとネイティブ コール スタックでフィルタリングします。この情報から、グローバル JNI 参照がいつどこで作成、削除されるかを見つけることができます。

アプリを実行している状態で検査対象のタイムライン部分を選択し、クラスリストの上にあるプルダウン メニューで [JNI heap] を選択します。これで、通常のようにヒープ内のオブジェクトを検査できます。[Allocation Call Stack] タブでオブジェクトをダブルクリックすると、コード内のどこで JNI 参照の割り当てと解除が行われているかを確認できます(図 4 参照)。

図 4. グローバル JNI 参照の表示

アプリの JNI コードに対するメモリ割り当てを調べるには、Android 8.0 以降を搭載するデバイスにアプリをデプロイする必要があります。

JNI について詳しくは、JNI のヒントをご覧ください。

ヒープダンプの取得

ヒープダンプは、ヒープダンプの取得時にアプリのどのオブジェクトがメモリを使用しているかを示します。特に、長いユーザー セッションの後は、メモリに存在しないと思われるオブジェクトが引き続きメモリにあることが示されるため、ヒープダンプによりメモリリークの特定が容易になります。

ヒープダンプを取得したら、次の内容を確認できます。

  • アプリが割り当てたオブジェクトのタイプと各タイプのオブジェクト数。
  • 各オブジェクトが使用しているメモリ量。
  • コードで各オブジェクトへの参照が保持される場所。
  • オブジェクトが割り当てられた場所のコールスタック(現在、割り当ての記録中にヒープダンプを取得した場合、Android 7.1 以前を使用して取得されたヒープダンプのみでコールスタックを利用できます)。

ヒープダンプをキャプチャするには、Memory Profiler ツールバーの [Dump Java heap] をクリックします。 ヒープをダンプする際に Java メモリの使用量が一時的に増加する場合がありますが、問題はありません。ヒープダンプはアプリと同じプロセスで発生し、データを収集するためにいくらかのメモリが必要になるためです。

図 5 のように、ヒープダンプはメモリ タイムラインの下に表示され、ヒープのすべてのクラスタイプを示します。

図 5. ヒープダンプの表示

より厳密なタイミングでダンプを作成したい場合は、アプリコード内の重要な場所で dumpHprofData() を呼び出してヒープダンプを作成することができます。

クラスの一覧には、次の情報が表示されます。

  • Allocations: ヒープ内の割り当ての数。
  • Native Size: このオブジェクト タイプで使用されるネイティブ メモリの合計量(バイト単位)。この列は Android 7.0 以降でのみ表示されます。

    Android では、Bitmap などのフレームワーク クラスにネイティブ メモリが使用されているため、ここでは Java で割り当てられたオブジェクト用のメモリが表示されます。

  • Shallow Size: このオブジェクト タイプによって使用されている Java メモリの合計量(バイト)。

  • Retained Size: このクラスのすべてのインスタンスのために保持されているメモリの合計サイズ(バイト単位)。

割り当てられたオブジェクト一覧の上にある 2 つのメニューで、調査するヒープダンプと、データの整理方法を選択できます。

左側のメニューで、調査するヒープを選択します。

  • default heap: システムによってヒープが指定されていない場合。
  • app heap: アプリがメモリを割り当てるプライマリ ヒープ。
  • image heap: 起動時にプリロードされたクラスを含むシステム ブートイメージ。この割り当てが変化することはありません。
  • zygote heap: Android システムでアプリプロセスがフォークされたコピー オン ライト ヒープ。

右側のメニューで、割り当ての配置方法を選択します。

  • Arrange by class: クラス名に基づいてすべての割り当てをグループ化(デフォルト)。
  • Arrange by package: パッケージ名に基づいてすべての割り当てをグループ化。
  • Arrange by callstack: すべての割り当てを対応するコールスタックにグループ化。このオプションは、割り当ての記録中にヒープダンプを取得する場合にのみ機能します。ただし、記録の開始前にヒープにオブジェクトが存在する可能性があるため、これらの割り当ては最初にクラス名ごとに表示されます。

デフォルトでは、一覧は [Retained Size] 列で並べ替えられます。別の列の値で並べ替えるには、列の見出しをクリックします。

クラス名をクリックして右側の [Instance View] ウィンドウを開きます(図 6 参照)。それぞれのインスタンスには次のものが含まれます。

  • Depth: GC ルートから選択済みのインスタンスへの最小ホップ数。
  • Native Size: このインスタンスのネイティブ メモリ内でのサイズ。この列は Android 7.0 以降でのみ表示されます。
  • Shallow Size: Java メモリ内でのこのインスタンスのサイズ。
  • Retained Size: このインスタンスがドミネートするメモリのサイズ(ドミネーター ツリーに従う)。

図 6. タイムラインに表示されるヒープダンプの取得に必要な時間

ヒープを検査するには、次の手順を実行します。

  1. リストに目をとおして、ヒープカウントが著しく大きく、リークが発生している可能性があるオブジェクトを見つけます。既知のクラスを見つけやすくするには、[Class Name] 列の見出しをクリックして、アルファベット順に並べ替えてからクラス名をクリックします。 図 6 に示すように、右側に [Instance View] ペインが表示され、そのクラスの各インスタンスが表示されます。
    • または、[Filter] をクリックするかまたは Ctrl+F(Mac の場合は Command+F)を押しながら検索フィールドにクラス名またはパッケージ名を入力して、オブジェクトをすばやく検索することができます。プルダウン メニューで [Arrange by callstack] を選択して、メソッド名で検索することもできます。正規表現を使用する場合は、[Regex] のチェックボックスをオンにします。検索クエリで大文字と小文字を区別する場合は、[Match case] のチェックボックスをオンにします。
  2. [Instance View] ペインでインスタンスをクリックします。ペインの下に [References] タブが表示され、そのオブジェクトへのすべての参照が示されます。

    または、インスタンス名の横にある矢印をクリックしてそのすべてのフィールドを表示してから、フィールド名をクリックしてすべての参照を表示します。フィールドのインスタンスの詳細を表示する場合は、フィールドを右クリックして、[Go to Instance] を選択します。

  3. [References] タブで、メモリリークが発生している可能性のある参照を特定するには、タブを右クリックして [Go to Instance] を選択します。これにより、ヒープダンプから対応するインスタンスが選択され、そのインスタンス データが表示されます。

ヒープダンプでは、次のいずれかが原因で生じたメモリリークに注目します。

  • ActivityContextViewDrawableActivityContext コンテナへの参照を保持しているその他のオブジェクトへの長期的参照。
  • Activity インスタンスを保持できる、Runnable などの静的ではない内部クラス。
  • 必要以上に長くオブジェクトを保持するキャッシュ。

ヒープダンプを HPROF ファイルとして保存する

ヒープダンプを取得した後は、Memory Profiler の実行中にのみデータが表示されます。プロファイリング セッションを終了すると、ヒープダンプは失われます。そのため、後でレビューするためにヒープダンプを保存する場合は、ヒープダンプを HPROF ファイルにエクスポートします。Android Studio 3.1 以前では、[Export capture to file] ボタンがタイムラインの下のツールバーの左側にあります。Android Studio 3.2 以降では、[Sessions] ペインの各 [Heap Dump] エントリの右側に [Export Heap Dump] ボタンがあります。[Export As] ダイアログが表示されるので、ファイル名拡張子 .hprof をつけてファイルを保存します。

jhat など別の HPROF アナライザーを使用するには、HPROF ファイルを Android 形式から Java SE HPROF 形式に変換する必要があります。android_sdk/platform-tools/ ディレクトリにある hprof-conv ツールを使って変換できます。元の HPROF ファイルと変換済みの HPROFR ファイルの書き出し先の 2 つの引数を指定して、hprof-conv コマンドを実行します。たとえば、次のように指定します。

hprof-conv heap-original.hprof heap-converted.hprof
    

ヒープダンプ ファイルのインポート

HPROF(.hprof)ファイルをインポートするには、[Start a new profiling session] をクリックします。[Sessions] ペインで [Load from file] を選択し、ファイル ブラウザからファイルを選択します。

HPROF ファイルをファイル ブラウザからをエディタ ウィンドウにドラッグしてインポートすることもできます。

メモリをプロファイリングするためのテクニック

Memory Profiler を使用する際は、アプリコードに過度な負荷をかけてメモリリークを強制的に発生させる必要があります。アプリでメモリリークを発生させるには、ヒープを調査する前にしばらくの間アプリを実行するという方法があります。リークは、ヒープ内の割り当ての先頭に徐々に出現してきます。ただし、リークが少ない場合は、検出できるまで長時間アプリを実行する必要があります。

次のいずれかの方法で、メモリリークをトリガーすることも可能です。

  • さまざまなアクティビティ状態で、デバイスを縦向きから横向きに回転させてから元に戻す動作を何度か行う。デバイスを回転させると、ActivityContext、または View オブジェクトのリークがアプリで発生することがよくあります。これは、システムにより Activity が再作成され、これらのいずれかのオブジェクトへの参照がどこか別の場所で保持されている場合、システムはこのようなオブジェクトをガベージ コレクションの対象にできないためです。
  • さまざまなアクティビティ状態で、自分のアプリと別のアプリを切り替える(ホーム画面に移動してから自分のアプリに戻るなど)。

ヒント: 上述の手順は、monkeyrunner テスト フレームワークを使用しても実行できます。