1. メモリ取り扱いでよくあるミス
自動メモリ管理という魔法の裏側を見てみましょう。C のように各バイトを自分で管理しなくても、Java でもやり方次第ではアプリがメモリを食い潰し、動きが重くなることがあります。ここでは代表的なミスと、その回避方法を整理します。
取り外し忘れのリスナー (listeners)
Java では「リスナー」パターンがよく使われます。これは、あるオブジェクトが別のオブジェクトのイベントを購読する仕組みです。たとえば、ボタンを作ってクリックハンドラーを追加したとします:
button.addActionListener(new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
// クリック処理
}
});
問題点: ボタンやウィンドウが不要になったときに、このリスナーを削除するのを忘れる(removeActionListener)と、リスナーはメモリ上にぶら下がり続けます。たとえウィンドウを閉じて参照をすべて外しても、リスナーオブジェクトがウィンドウ(またはその逆)を参照し続け、GC がメモリを解放できません。
たとえ話: 引っ越したのにピザ屋のメルマガを解約し忘れて、古い住所宛にチラシが届き続けるようなものです。
解放されない静的コレクション
静的フィールドはクラスと同じ寿命(場合によってはアプリの終了まで)を持ちます。たとえば静的コレクションがあるとします:
public class Cache {
public static final List<String> globalList = new ArrayList<>();
}
そこにオブジェクトを追加しても削除しなければ、それらはずっとメモリに残ります。たとえ他に参照がなくても、静的コレクションからの参照のせいで GC は回収できません。
実例: デスクトップアプリの写真キャッシュを一度も掃除しない。数時間で OutOfMemoryError。
リソース(ファイル、ストリーム、接続)の未解放
Java はメモリは解放してくれますが、ファイルディスクリプタやネットワーク接続などの外部リソースは自動で閉じてくれません。ファイルやストリームを閉じ忘れると、リソースが枯渇し、やがてシステムが「もうファイルは開けません!」と言い出します(IOException: Too many open files)。
コツ: 常に try-with-resources を使いましょう:
try (FileInputStream in = new FileInputStream("data.txt")) {
// ファイルを読み込む
} // in.close() は自動的に呼び出されます!
メモリに長く居座る大きなオブジェクト
大きな配列やコレクションを作って使い終わったのに、「解放し忘れる」ことがあります。例えば:
List<byte[]> bigList = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
bigList.add(new byte[1024 * 1024]); // 各 1 MB
}
// ... bigList をクリアし忘れた
このコレクションが静的フィールドや、長寿命のオブジェクトにぶら下がっていると、そのメモリは占有されたままになります。
匿名クラス/内部クラス: 外部参照のキャプチャ
Java の匿名クラス(および内部クラス)は、外側のオブジェクトへの暗黙の参照を保持します:
public class Outer {
void doSomething() {
Runnable r = new Runnable() {
@Override
public void run() {
System.out.println("Hello from inner!");
}
};
// r はどこかに保存される
}
}
オブジェクト r が静的コレクションやキャッシュに保存されると、Outer のインスタンスが不要になっても参照を「握ったまま」になります。結果としてメモリリークが発生します。ラムダ式はややマシですが、外部クラスのフィールドをキャプチャすると、やはり参照は保持されます。
2. ガベージコレクター運用のミス
強制的な System.gc() の呼び出し
初心者はよくこう考えます。「メモリが足りない — System.gc() を呼べば全部解決!」。しかし実際には、これは JVM への依頼に過ぎず、即時の回収は保証されません。多用すると性能が大きく悪化し、長い停止やフリーズを招きます。実アプリでは JVM に任せるほうが賢明です — いつ回収するかは JVM が最適に判断します。ちなみに、明示的な GC 呼び出しを無視できる JVM もあります(例: オプション -XX:+DisableExplicitGC)。
GC ログを無視する
GC ログには、いつ回収が起き、どれほど時間がかかり、どれだけメモリが解放されたかが出ます。これを見ないと、長い停止や頻繁な Full GC、リークの兆候を見落とします。
GC ログの有効化方法:
java -Xlog:gc* -jar MyApp.jar
または旧式の JVM では:
java -XX:+PrintGCDetails -XX:+PrintGCDateStamps -jar MyApp.jar
ワークロードに不適切な GC の選択
選ぶ GC によって待ち時間や安定性は変わります。低遅延が重要(取引所、オンラインゲームなど)な場合に、ストップ・ザ・ワールドな並列型の Parallel GC は不向きです。すべてのスレッドを停止させる時間が発生します。G1 GC、ZGC、Shenandoah の検討をおすすめします。
3. コレクションまわりのミス
WeakHashMap ではなく HashMap でキャッシュを作る。
参照がなくなったら自動的に捨ててよいオブジェクトをキャッシュする場合は、WeakHashMap を使いましょう:
Map<Key, Value> cache = new WeakHashMap<>();
通常の HashMap だと、手動で掃除しない限りオブジェクトは生き続け、リークにつながります。
要素の remove() を忘れる。
コレクション(たとえばリスナーのリスト)に追加したオブジェクトを、不要になっても削除しないと、そのオブジェクトは永遠に残ります。特に寿命の長いコレクション(静的フィールドなど)では要注意です。
4. ベストプラクティス: 問題を避けるには
リスナーは必ず削除する。
オブジェクトがイベントを購読したら、不要になった時点で必ず解除します。dispose() やウィンドウ/画面のクローズ時にまとめて行うのが便利です。
button.removeActionListener(myListener);
キャッシュには弱い参照を使う。
キャッシュでオブジェクトの保持を保証する必要がないなら、WeakReference やそれに基づくコレクション(WeakHashMap)を使い、メモリが必要になったときに GC が解放できるようにしましょう。
本番環境でメモリを監視する。
jvisualvm、jconsole、あるいは APM を活用しましょう。ユーザーからの苦情になる前にリークを検知できます。
リークの疑いがあるときは heap dump を解析する
アプリがいつもよりメモリを多く消費し始めたら、heap dump(jmap や jvisualvm などで取得)を取り、どのオブジェクトがスペースを占有しているかを確認します。犯人は数分で見つかることが多いです。
JVM パラメータを調整する。
- -Xmx — ヒープの最大サイズ
- -Xms — ヒープの初期サイズ
適切な上限を設けることで、OutOfMemoryError を避け、原因切り分けも容易になります。
5. 実践: メモリリークのコード例と修正
例 1: 静的コレクション経由のリーク
public class MemoryLeakDemo {
// 静的コレクション — 永遠に生き続ける
private static final List<byte[]> leakyList = new ArrayList<>();
public static void main(String[] args) {
for (int i = 0; i < 1000; i++) {
leakyList.add(new byte[1024 * 1024]); // 毎回 1 MB
System.out.println("追加済み " + (i + 1) + " MB");
}
// OutOfMemoryError!
}
}
修正: ローカル変数を使うか、不要になったらコレクションを明示的にクリアします。
public class MemoryLeakFixed {
public static void main(String[] args) {
List<byte[]> tempList = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
tempList.add(new byte[1024 * 1024]);
System.out.println("追加済み " + (i + 1) + " MB");
}
// tempList = null; // 明示的に null を代入してもよい
// メソッドから抜けた後にオブジェクトは GC の対象になる
}
}
例 2: リスナー経由のリーク
public class Window {
private final List<EventListener> listeners = new ArrayList<>();
public void addListener(EventListener l) {
listeners.add(l);
}
// removeListener メソッドがない!
}
修正: リスナー削除用のメソッドを追加し、ウィンドウを閉じるときに呼び出します。
public void removeListener(EventListener l) {
listeners.remove(l);
}
例 3: WeakHashMap ではなく HashMap を使ったキャッシュ
Map<Object, Object> cache = new HashMap<>();
// ... いろいろ追加
修正: WeakHashMap に切り替えます:
Map<Object, Object> cache = new WeakHashMap<>();
メモリ監視のための JVM 設定のヒント
- GC ログを有効化: -Xlog:gc* または -XX:+PrintGCDetails
- ヒープの上限を設定: -Xmx512m
- キャッシュを使う場合はサイズを監視し、許されるなら弱い参照を活用する
- GC を試す: -XX:+UseG1GC、-XX:+UseZGC、-XX:+UseShenandoahGC
7. メモリ取り扱いでよくあるミス
エラー №1: リスナーや購読の外し忘れ。 オブジェクトにリスナーを追加して削除し忘れると、リスナー(とそれが参照するもの全て)がメモリに残ります。GUI やイベント駆動システムの定番の落とし穴です。removeListener/removeActionListener を使いましょう。
エラー №2: 静的コレクションを掃除しない。 静的フィールドは最も長寿命です。そこにオブジェクトを入れてコレクションを掃除しなければ、そのオブジェクトは永遠に残ります。無制限なキャッシュでは特に危険です。
エラー №3: 外部リソースを解放しない。 ストリーム、ファイル、接続を開きっぱなしにすると、メモリを食うだけでなく OS の制限に当たります。try-with-resources を使って確実にクローズしましょう。
エラー №4: System.gc() の強制呼び出し。 万能薬ではなく、JVM への依頼に過ぎません。しばしば停止を招き、性能を劣化させます。
エラー №5: キャッシュに通常のコレクションを使う。 キャッシュ内のオブジェクトが自動的に消えてよいなら、弱い/ソフト参照(WeakHashMap、SoftReference)を使いましょう。そうしないとリークになります。
エラー №6: 外部参照をキャプチャする内部クラスや匿名クラス。 内部クラスやラムダは外部オブジェクトへの参照を暗黙に保持する場合があります。長寿命のコレクションに保存するとリークになります。
エラー №7: GC ログを無視する。 GC ログを見ないと、長い停止や頻繁な Full GC に気づけません — ユーザーは「遅い/固まる」と感じます。-Xlog:gc* や -XX:+PrintGCDetails を有効にしましょう。
GO TO FULL VERSION