アロケーションプロファイルの始め方
他の言語: English Español 한국어 Português 中文
私たちはしばしば、コードが正しく動かない状況に出くわすことがあり、調査をどこから始めれば良いのかさえ分からないこともあります。
ただコードを見つめていれば、いつか解決策が浮かんでくるでしょうか? もちろん、それはプロジェクトの深い知識と多大な精神的労力がなければ成り立たないでしょう。 より賢明な方法は、手元にあるツールを使いこなすことです。それらがあなたに正しい方向を示してくれるでしょう。

この記事では、実行時の問題を解決するために、どのようにメモリ割り当てのプロファイリングを行うことができるのかを見ていきます。
問題
まず、次のリポジトリをクローンしましょう:https://github.com/flounder4130/party-parrot 。
プロジェクトに含まれているParrot実行設定を使用して、アプリケーションを起動します。 アプリは上手く動いているようです:アニメーションの色と速度を調整することができます。 しかし、長くは経たないうちに問題が発生します。

アプリをしばらく動かすと、アニメーションがフリーズし、その原因が何なのか全く示されません。
プログラムは時々 OutOfMemoryError
をスローすることがありますが、そのスタックトレースからは問題が発生した原因は何もわかりません。
問題がどのように現れるかを確実に言う方法はありません。 このアニメーションのフリーズが興味深いのは、それが起こった後も、他のUIを操作し続けることができることです。
このアプリの実行にはAmazon Corretto 11を使用しました。 結果は他のJVMや、同じJVMでも設定が異なる場合には異なる可能性があります。
デバッガ
デバッグを使いましょう! バグが発生しているようです。 デバッグモードでアプリケーションを起動し、アニメーションがフリーズするまで待ちます。 そして、プログラムの一時停止をクリックします。


残念ながら、この方法ではほとんど教えてくれません。 オウムのパーティを管理している全てのスレッドが待機状態にあるからです。 彼らのスタックを検査しても、フリーズが起こった原因については何の手がかりも得られません。 もう一つのアプローチを試してみることにしましょう。
リソースの使用状況を監視する
OutOfMemoryError
が発生しているので、
分析を開始するのに良い出発点は、CPU とメモリのライブチャート (CPU and Memory Live Charts)かもしれません。
これらを使えば、実行中のプロセスのリアルタイムなリソースの使用状況を視覚化できます。
オウムアプリのチャートを開いて、アニメーションがフリーズしたときに何か見つけられるか試してみましょう。


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


鋸の歯が頻繁になると、ガベージコレクタがメモリを解放するために懸命に働いていることを意味します。 台形は、それが何も解放できないことを意味します。
JVMがガベージコレクションを実行できるかどうかをテストするために、明示的にリクエストしてみることもできます:


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


現在選択されている実行設定の設定にすばやくアクセスするためには、 ‘Shift’を押しながらメインツールバー上の実行設定名をクリックします。
利用可能なメモリの量に関係なく、オウムはそれをどのように使い果たしているのでしょうか? 再び、同じ状況を見ます。 追加のメモリの唯一の効果は、「パーティ」の終わりを遅らせただけでした。


アロケーションプロファイリング
アプリケーションが常にメモリを足りない状態であることがわかっているので、メモリリークを疑い、そのメモリ使用状況を分析することは合理的です。
これには、 -XX:+HeapDumpOnOutOfMemoryError
のVMオプションを使用してメモリダンプを収集することができます。
ヒープを検査するための完全に適切なアプローチですが、それはこれらのオブジェクトを作成するためのコードを指摘することはありません。
この情報はプロファイラのスナップショットから得ることができます: それはオブジェクトの種類に関する統計情報だけでなく、それらが作成されたときに対応するスタックトレースも明らかにします。 これはアロケーションプロファイリングの一般的なユースケースとは少し異なりますが、 問題を特定するためにそれを使うことを防ぐものはありません。
IntelliJ Profilerがアタッチされた状態でアプリケーションを実行しましょう。 実行中、プロファイラは定期的にスレッドの状態を記録し、メモリ割り当てのイベントに関するデータを収集します。 このデータは、わかりやすい形式に集約され、アプリケーションがこれらのオブジェクトを割り当てていたときに何をしていたかについての概念を私たちに与えます。
プロファイラをしばらく実行した後、レポートを開き、メモリの割り当て (Memory Allocations)を選択しましょう。


収集したデータを表示するためのいくつかのビューが利用可能です。 このチュートリアルでは、私たちはフレームグラフを使用します。 それは、収集したスタックを単一のスタック様の構造に集約し、要素の幅を収集したサンプルの数に応じて調整します。 最も広い要素は、プロファイリング期間中に最も大量に割り当てられた型を示します。
ここで注意すべき重要なことは、大量の割り当てが必ずしも問題を示すわけではないということです。 メモリリークは、割り当てられたオブジェクトがガベージコレクションされない場合にのみ発生します。 アロケーションプロファイリングがガベージコレクションについて私たちに何も教えてくれない一方で、 それでもさらなる調査のためのヒントを提供することはできます。
![割り当てグラフの中で最も大きい2つのフレームは、int[] と byte[] です。](/img/profile-memory-allocations/flame-graph-1.png)
![割り当てグラフの中で最も大きい2つのフレームは、int[] と byte[] です。](/img/profile-memory-allocations/flame-graph-1-dark.png)
最も大規模な2つの要素、 byte[]
と int[]
がどこから来ているのか見てみましょう。
スタックの上部は、これらの配列が java.awt.image
パッケージからのコードによる画像処理中に作成されていることを教えてくれます。
スタックの下部は、このすべてがexecutorサービスにより管理される別のスレッドで行われていることを教えてくれます。
私たちはライブラリのコードにバグを探しているわけではありませんので、その間にあるプロジェクトのコードを見てみましょう。
上から下へ見ていくと、最初に見つかるアプリケーションのメソッドは recolor()
で、
次にそれが 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)を選択します。


ノードを展開し、 ImageIO.read(path.toFile())
に注目します。
これは、基本画像がファイルのセットから来ていることを示しています。
この行をダブルクリックし、近くにある PARROTS_PATH
定数を見てみると、
ファイルの場所がわかります:
public static final String PARROTS_PATH = "src/main/resources";
このディレクトリに移動してみると、次のような表示が見られます。


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


hue
変数を変更するコードを検査すると、その初期値が 50
であることがわかります。
次に、それはスライダーで設定されるか、 updateHue()
メソッドから自動的に更新されます。
どちらの場合でも、常に 1
から 100
の範囲内にあります。
つまり、私たちは100種類の色調と10枚の基本画像を持っているので、 キャッシュが1000要素以上大きくなることは決してありません。これを確認してみましょう。
条件付きブレークポイント
ここでデバッガが便利になるところです。 キャッシュのサイズを条件付きブレークポイントで確認することができます。
ホットコードに条件付きブレークポイントを設定すると、 ターゲットアプリケーションが大幅に遅くなる可能性があります。
アップデートアクションでブレークポイントを設定し、キャッシュのサイズが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とメモリのライブチャート を使用できます。


これはずっと良いです!
まとめ
この投稿では、問題の一般的な症状から始まり、理論と私たちに利用可能な様々なツールを使って、問題を引き起こしているコードの正確な行を見つけるまで、検索範囲を段階的に絞り込む方法を見てきました。 さらに重要なことには、そのオウムのパーティーが何があっても続けられることを確認しました!
いつものように、皆さんのフィードバックをお待ちしています! ハッピープロファイリングを!