Javaメモリリーク:一般的な原因と検出方法
Javaアプリケーションが起動直後は高速でも、数時間から数日経つと極端に遅くなったり、OutOfMemoryErrorをスローしたり、コンテナに強制終了されたりします。再起動しても同じサイクルが繰り返されます。これがJavaメモリリークの典型的な症状です。不要になったオブジェクトが依然として参照可能なままで、ガベージコレクタが回収できない状態です。C/C++と違い、JavaのGCはほとんどのメモリを自動管理しますが、参照が意図せず保持されるとリークが発生します。本記事では、最も一般的な原因を解説し、検出と修正のための実践的なワークフローを紹介します。
Javaメモリリークとは?
メモリリークとは、アプリケーションが使用しなくなったオブジェクトが参照されたままになり、ガベージコレクションが妨げられる状態を指します。時間の経過とともにヒープ使用量が増加し、GCの実行頻度が高まり、最終的にJVMがOutOfMemoryError: Java heap spaceをスローします。リークは小さくゆっくりしたもの(リクエストあたり数KB)から、大きく急速なもの(結果セット全体のキャッシュ)まであります。
Javaメモリリークの一般的な原因
1. 無限に増え続ける静的コレクション
静的フィールドはJVMのライフタイム中有効です。エビクションなしでキャッシュとして使われる静的Mapは典型的なリークです。例:
public class UserCache {
private static final Map<Long, User> CACHE = new HashMap<>();
public static void put(Long id, User user) {
CACHE.put(id, user); // never removed
}
}
追加されたユーザーは永遠に残ります。修正方法:サイズ制限と有効期限を持つCaffeineやGuavaなどの境界付きキャッシュを使用します。
2. クローズされていないリソース
ストリーム、コネクション、リーダーは必ずクローズする必要があります。忘れるとネイティブリソースとそのJavaラッパーがリークします。常にtry-with-resourcesを使用してください:
try (InputStream in = new FileInputStream(file)) {
// use in
} // automatically closed
これはデータベース接続、HTTPクライアント、スレッドプールにも当てはまります。
3. 削除されないリスナーとコールバック
リスナーを登録(例:addListener)しても削除しない場合、パブリッシャーがリスナーへの参照を保持し、リスナーがオブジェクトグラフ全体への参照を保持する可能性があります。これはGUI、イベントバス、長期間生存するコンテキストでよく見られます。
4. ThreadLocal変数
ThreadLocalの値はスレッドのThreadLocalMapに保存されます。スレッドプールではスレッドが再利用されるため、remove()を呼ばないと値がスレッドに紐付いたままリークします。常にクリーンアップしてください:
try {
threadLocal.set(context);
// work
} finally {
threadLocal.remove();
}
5. 外部参照を保持する内部クラス
非静的内部クラス(匿名クラスを含む)は外部インスタンスへの暗黙的な参照を保持します。内部クラスのインスタンスが外部オブジェクトより長く生存する場合(例:静的リストに保存)、外部オブジェクトは回収できません。外部インスタンスが不要な場合は内部クラスをstaticにしてください。
6. 不適切なequals()とhashCode()
HashMapのキーとしてオブジェクトを使用する場合、hashCode()をオーバーライドせずにequals()のみをオーバーライドすると(またはその逆)、ルックアップが失敗しエントリが蓄積されます。常に両方を一貫してオーバーライドしてください。
7. 文字列インターンとサブストリング
Java 7以前は、String.substring()が元のchar配列を共有していたため、小さなサブストリングが大きな配列を保持する可能性がありました。現代のJavaはコピーしますが、多くのユニークな文字列をインターン(例:String.intern())すると文字列プールが埋まる可能性があります。
Javaメモリリークの検出方法
検出は体系的なプロセスに従います。以下はステップバイステップのアプローチです:
- 時間経過に伴うヒープ使用量を監視する。JVisualVM、JConsole、またはAPMを使用します。健全なアプリはノコギリ波パターンを示します:ヒープが増加し、GCが回収し、ヒープが減少します。リークは各GC後にベースラインが上昇します。
- GCログを有効にする。
-Xlog:gc*:file=gc.log:time,uptime,level,tags(Java 9+)または-XX:+PrintGCDetails(Java 8)を追加します。GCeasyなどのツールでログを分析し、Old Genが増え続けていないか確認します。 - ヒープダンプを取得する。ヒープが高いときに
jmap -dump:live,format=b,file=heap.hprof <pid>を実行するか、jcmd <pid> GC.heap_dump heap.hprofを使用します。-XX:+HeapDumpOnOutOfMemoryErrorでOutOfMemoryError時にダンプをトリガーすることもできます。 - ヒープダンプを分析する。Eclipse MATまたはVisualVMで開きます。ドミネータツリーで最もメモリを保持しているオブジェクトを見つけます。保持サイズが大きい疑わしいコレクションを確認します。
- 2回目のダンプを取得して比較する。ダンプ間で同じオブジェクトが増加していれば、リークを発見したことになります。
- プロファイラでライブ分析を行う。YourKit、JProfiler、async-profilerなどのツールは、アプリを停止せずに割り当てサイトと参照チェーンを追跡できます。
一般的なツールの簡単な比較:
| ツール | 種類 | 最適な用途 |
|---|---|---|
| Eclipse MAT | ヒープダンプアナライザ | ドミネータとリーク容疑者の特定 |
| VisualVM | 監視+ヒープダンプ | 簡単なライブチェックと基本分析 |
| JProfiler / YourKit | プロファイラ | 割り当て追跡と参照チェーン |
| async-profiler | 低オーバーヘッドプロファイラ | フレームグラフを使った本番プロファイリング |
リークの修正と防止
リークを特定したら、意図しない参照を削除して修正します。一般的な修正方法:
- 無制限のキャッシュを境界付きのもの(Caffeine、Ehcache)に置き換える。
- すべてのCloseableリソースにtry-with-resourcesを使用する。
- finallyブロックでリスナーを登録解除するか、弱参照を使用する。
- 常に
ThreadLocal.remove()を呼び出す。 - 可能な場合は内部クラスをstaticにする。
- equals()とhashCode()を一緒にオーバーライドする。
予防は治療に勝ります。CI/CDパイプラインにメモリ監視を追加し、ヒープチェック付きの負荷テストを実行し、-Xmxを適切に設定します。SpotBugsなどの静的解析ツールで一般的なパターンを検出します。
FAQ
メモリリークとメモリスパイクの違いは何ですか?
メモリスパイクはGCが回収する一時的なヒープ使用量の増加です。リークはオブジェクトが参照可能なままで回収できないため、時間とともに着実に増加します。
ヒープが大きくてもJavaメモリリークがOutOfMemoryErrorを引き起こすことはありますか?
はい。リーク率がGCの回収能力を超えると、サイズに関係なくヒープは最終的に埋まります。ヒープを増やしても障害が遅れるだけです。
ダウンタイムなしで本番環境のメモリリークを見つけるにはどうすればよいですか?
async-profilerやJFR(Java Flight Recorder)などの低オーバーヘッドツールを使用して、割り当てとヒープ統計を記録します。jcmdでアプリ実行中にヒープダンプをトリガーすることもできますが、一時的に停止する可能性があります。
GCログやその他のテキストベースのログを分析する必要がありますか?Nginx Log Analyzerでログパターンをすばやく解析・可視化してみてください。