アロケーションプロファイルの始め方

他の言語: English Español 한국어 Português 中文

私たちはしばしば、コードが正しく動かない状況に出くわすことがあり、調査をどこから始めれば良いのかさえ分からないこともあります。

ただコードを見つめていれば、いつか解決策が浮かんでくるでしょうか? もちろん、それはプロジェクトの深い知識と多大な精神的労力がなければ成り立たないでしょう。 より賢明な方法は、手元にあるツールを使いこなすことです。それらがあなたに正しい方向を示してくれるでしょう。

この記事では、実行時の問題を解決するために、どのようにメモリ割り当てのプロファイリングを行うことができるのかを見ていきます。

問題

まず、次のリポジトリをクローンしましょう:https://github.com/flounder4130/party-parrot

プロジェクトに含まれているParrot実行設定を使用して、アプリケーションを起動します。 アプリは上手く動いているようです:アニメーションの色と速度を調整することができます。 しかし、長くは経たないうちに問題が発生します。

オウムのアニメーションがフリーズしました

アプリをしばらく動かすと、アニメーションがフリーズし、その原因が何なのか全く示されません。 プログラムは時々 OutOfMemoryError をスローすることがありますが、そのスタックトレースからは問題が発生した原因は何もわかりません。

問題がどのように現れるかを確実に言う方法はありません。 このアニメーションのフリーズが興味深いのは、それが起こった後も、他のUIを操作し続けることができることです。

Info icon

このアプリの実行にはAmazon Corretto 11を使用しました。 結果は他のJVMや、同じJVMでも設定が異なる場合には異なる可能性があります。

デバッガ

デバッグを使いましょう! バグが発生しているようです。 デバッグモードでアプリケーションを起動し、アニメーションがフリーズするまで待ちます。 そして、プログラムの一時停止をクリックします。

デバッガのスレッドビューには、バグとは無関係のように見えるスタックが表示されます。 デバッガのスレッドビューには、バグとは無関係のように見えるスタックが表示されます。

残念ながら、この方法ではほとんど教えてくれません。 オウムのパーティを管理している全てのスレッドが待機状態にあるからです。 彼らのスタックを検査しても、フリーズが起こった原因については何の手がかりも得られません。 もう一つのアプローチを試してみることにしましょう。

リソースの使用状況を監視する

OutOfMemoryError が発生しているので、 分析を開始するのに良い出発点は、CPU とメモリのライブチャート (CPU and Memory Live Charts)かもしれません。 これらを使えば、実行中のプロセスのリアルタイムなリソースの使用状況を視覚化できます。 オウムアプリのチャートを開いて、アニメーションがフリーズしたときに何か見つけられるか試してみましょう。

メモリ使用率チャートは、使用されているメモリ量が増加した後に平坦になることを示しています。 メモリ使用率チャートは、使用されているメモリ量が増加した後に平坦になることを示しています。

確かに、メモリの使用量は継続的に上昇し、ある程度まで到達すると停滞します。 アニメーションが停止するまさにその時点です。 そして、その後はずっと停滞したままのようです。

これで手掛かりが得られました。 通常、メモリ使用量の曲線はのこぎりのような形をしています。 新しいオブジェクトが割り当てられるとチャートが上がり、使われなくなったオブジェクトのメモリがガベージコレクションで回収されると周期的に下がります。 通常の動作をしているプログラムの例を以下の画像に見ることができます:

使用済みメモリが常に増加し、その後定期的に減少するメモリ使用率グラフのスクリーンショット 使用済みメモリが常に増加し、その後定期的に減少するメモリ使用率グラフのスクリーンショット

鋸の歯が頻繁になると、ガベージコレクタがメモリを解放するために懸命に働いていることを意味します。 台形は、それが何も解放できないことを意味します。

JVMがガベージコレクションを実行できるかどうかをテストするために、明示的にリクエストしてみることもできます:

'CPUとメモリライブチャート'のツールバーにある'Perform GC'ボタン 'CPUとメモリライブチャート'のツールバーにある'Perform GC'ボタン

アプリが停滞点に達した後も、私たちが手動でメモリを多少解放するよう促しても、メモリの使用量は下がりません。 これは、ガベージコレクションの対象となるオブジェクトがないという仮説を裏付けています。

直感的な解決方法は、単にメモリを追加することかもしれません。 これには、実行設定に -Xmx500m のVMオプションを追加します。

'Run/Debug Configurations' ダイアログに -Xmx500m VM オプションを追加 'Run/Debug Configurations' ダイアログに -Xmx500m VM オプションを追加
Tip icon

現在選択されている実行設定の設定にすばやくアクセスするためには、 ‘Shift’を押しながらメインツールバー上の実行設定名をクリックします。

利用可能なメモリの量に関係なく、オウムはそれをどのように使い果たしているのでしょうか? 再び、同じ状況を見ます。 追加のメモリの唯一の効果は、「パーティ」の終わりを遅らせただけでした。

メモリ使用量のグラフは、現在500Mの利用可能メモリがあるが、それにもかかわらずアプリがすべて使用することを示しています メモリ使用量のグラフは、現在500Mの利用可能メモリがあるが、それにもかかわらずアプリがすべて使用することを示しています

アロケーションプロファイリング

アプリケーションが常にメモリを足りない状態であることがわかっているので、メモリリークを疑い、そのメモリ使用状況を分析することは合理的です。 これには、 -XX:+HeapDumpOnOutOfMemoryError のVMオプションを使用してメモリダンプを収集することができます。 ヒープを検査するための完全に適切なアプローチですが、それはこれらのオブジェクトを作成するためのコードを指摘することはありません。

この情報はプロファイラのスナップショットから得ることができます: それはオブジェクトの種類に関する統計情報だけでなく、それらが作成されたときに対応するスタックトレースも明らかにします。 これはアロケーションプロファイリングの一般的なユースケースとは少し異なりますが、 問題を特定するためにそれを使うことを防ぐものはありません。

IntelliJ Profilerがアタッチされた状態でアプリケーションを実行しましょう。 実行中、プロファイラは定期的にスレッドの状態を記録し、メモリ割り当てのイベントに関するデータを収集します。 このデータは、わかりやすい形式に集約され、アプリケーションがこれらのオブジェクトを割り当てていたときに何をしていたかについての概念を私たちに与えます。

プロファイラをしばらく実行した後、レポートを開き、メモリの割り当て (Memory Allocations)を選択しましょう。

'プロファイラー' ツールウィンドウの右上隅にある 'Show' メニュー内の 'Memory Allocations' 項目 'プロファイラー' ツールウィンドウの右上隅にある 'Show' メニュー内の 'Memory Allocations' 項目

収集したデータを表示するためのいくつかのビューが利用可能です。 このチュートリアルでは、私たちはフレームグラフを使用します。 それは、収集したスタックを単一のスタック様の構造に集約し、要素の幅を収集したサンプルの数に応じて調整します。 最も広い要素は、プロファイリング期間中に最も大量に割り当てられた型を示します。

ここで注意すべき重要なことは、大量の割り当てが必ずしも問題を示すわけではないということです。 メモリリークは、割り当てられたオブジェクトがガベージコレクションされない場合にのみ発生します。 アロケーションプロファイリングがガベージコレクションについて私たちに何も教えてくれない一方で、 それでもさらなる調査のためのヒントを提供することはできます。

割り当てグラフの中で最も大きい2つのフレームは、int[] と byte[] です。 割り当てグラフの中で最も大きい2つのフレームは、int[] と byte[] です。

最も大規模な2つの要素、 byte[] int[] がどこから来ているのか見てみましょう。 スタックの上部は、これらの配列が java.awt.image パッケージからのコードによる画像処理中に作成されていることを教えてくれます。 スタックの下部は、このすべてがexecutorサービスにより管理される別のスレッドで行われていることを教えてくれます。 私たちはライブラリのコードにバグを探しているわけではありませんので、その間にあるプロジェクトのコードを見てみましょう。

上から下へ見ていくと、最初に見つかるアプリケーションのメソッドは recolor() で、 次にそれが updateParrot() に呼び出されます。 名前から判断すると、このメソッドがまさに私たちのオウムを動かしているのです。 これがどのように実装されており、なぜそれが多くの配列を必要とするのか見てみましょう。

フレームグラフにおいてupdateParrot()メソッドを指しています フレームグラフにおいてupdateParrot()メソッドを指しています

フレームをクリックすると、対応するメソッドのソースコードに移動します。

public void updateParrot() {
    currentParrotIndex = (currentParrotIndex + 1) % parrots.size();
    BufferedImage baseImage = parrots.get(currentParrotIndex);
    State state = new State(baseImage, getHue());
    BufferedImage coloredImage = cache.computeIfAbsent(state, (s) -> Recolor.recolor(baseImage, hue));
    parrot.setIcon(new ImageIcon(coloredImage));
}

どうやら updateParrot() は、基本的な画像を取り、それを再色付けするようです。 余分な作業を避けるため、実装はまず何らかのキャッシュから画像を取得しようとします。 取得のキーは State オブジェクトで、そのコンストラクタは基本画像と色調を取ります:

public State(BufferedImage baseImage, int hue) {
    this.baseImage = baseImage;
    this.hue = hue;
}

データフローを分析する

組み込みの静的解析器を使用して、 State のコンストラクタ呼び出しのための入力値の範囲をトレースできます。 baseImage コンストラクタ引数を右クリックし、メニューから解析 (Analyze) | ここまでのデータフロー (Data Flow to Here)を選択します。

'Analyze dataflow to'ツールウィンドウは、値の可能な発生元をノードとして表示します 'Analyze dataflow to'ツールウィンドウは、値の可能な発生元をノードとして表示します

ノードを展開し、 ImageIO.read(path.toFile()) に注目します。 これは、基本画像がファイルのセットから来ていることを示しています。 この行をダブルクリックし、近くにある PARROTS_PATH 定数を見てみると、 ファイルの場所がわかります:

public static final String PARROTS_PATH = "src/main/resources";

このディレクトリに移動してみると、次のような表示が見られます。

プロジェクトツールウィンドウのsrc/main/javaにある10つの画像ファイル プロジェクトツールウィンドウのsrc/main/javaにある10つの画像ファイル

これは、オウムの可能な位置に対応する10つの基本イメージです。 さて、 hue コンストラクタ引数はどうでしょうか?

'Analyze dataflow to'ツールウィンドウは、値の可能な発生元をノードとして表示します 'Analyze dataflow to'ツールウィンドウは、値の可能な発生元をノードとして表示します

hue 変数を変更するコードを検査すると、その初期値が 50 であることがわかります。 次に、それはスライダーで設定されるか、 updateHue() メソッドから自動的に更新されます。 どちらの場合でも、常に 1 から 100 の範囲内にあります。

つまり、私たちは100種類の色調と10枚の基本画像を持っているので、 キャッシュが1000要素以上大きくなることは決してありません。これを確認してみましょう。

条件付きブレークポイント

ここでデバッガが便利になるところです。 キャッシュのサイズを条件付きブレークポイントで確認することができます。

Info icon

ホットコードに条件付きブレークポイントを設定すると、 ターゲットアプリケーションが大幅に遅くなる可能性があります

アップデートアクションでブレークポイントを設定し、キャッシュのサイズが1000要素を超えたときにだけアプリケーションを中断する条件を追加しましょう。

ブレークポイント設定ダイアログで条件 'cache.size() > 1000' を指定中 ブレークポイント設定ダイアログで条件 'cache.size() > 1000' を指定中

次に、デバッグモードでアプリを実行します。

強調表示されている行は、ブレークポイントが発動し、デバッガーがアプリケーションを一時停止したことを示しています 強調表示されている行は、ブレークポイントが発動し、デバッガーがアプリケーションを一時停止したことを示しています

確かに、プログラムをしばらく実行した後、このブレークポイントで停止します。 これは、問題が確かにキャッシュにあることを意味します。

コードの検査

Cmd + B cache に適用すると、その定義場所に移動します。

private static final Map<State, BufferedImage> cache = new HashMap<>();

HashMap のドキュメンテーションを確認すると、その実装は equals() および hashCode() メソッドに依存していることがわかります。そして、キーとして使用されるタイプはそれらを正しくオーバーライドしなければなりません。 それを確認しましょう。 Cmd + B State に適用すると、クラス定義に移動します。

class State {
    private final BufferedImage baseImage;
    private final int hue;

    public State(BufferedImage baseImage, int hue) {
        this.baseImage = baseImage;
        this.hue = hue;
    }

    public BufferedImage getBaseImage() { return baseImage; }

    public int getHue() { return hue; }
}

問題の箇所を見つけたようです: equals() および hashCode() の実装はただ間違っているだけでなく、完全に欠落しています!

メソッドのオーバーライド

equals() および hashCode() の実装を書くのは地味な作業です。 幸い、現代のツールではこれらを自動生成できます。

State クラス内で、Cmd + N を押し、Equals と HashCode (equals() and hashcode())を選択します。提案を承認し、カレット出現するまで次へ (Next)をクリックします。

@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || getClass() != o.getClass()) return false;
    State state = (State) o;
    return hue == state.hue && Objects.equals(baseImage, state.baseImage);
}

@Override
public int hashCode() {
    return Objects.hash(baseImage, hue);
}

修正の確認

アプリケーションを再起動し、改善されているかどうか確認しましょう。 再びCPUとメモリのライブチャート を使用できます。

'CPU and Memory Live Charts'のグラフは、もはや平坦化せず、定期的に下がります 'CPU and Memory Live Charts'のグラフは、もはや平坦化せず、定期的に下がります

これはずっと良いです!

まとめ

この投稿では、問題の一般的な症状から始まり、理論と私たちに利用可能な様々なツールを使って、問題を引き起こしているコードの正確な行を見つけるまで、検索範囲を段階的に絞り込む方法を見てきました。 さらに重要なことには、そのオウムのパーティーが何があっても続けられることを確認しました!

いつものように、皆さんのフィードバックをお待ちしています! ハッピープロファイリングを!

all posts ->