古いJavaアプリケーションの『クラスローダーリーク』が本番メモリを蝕む理由──長期運用システムの隠れた負債

本番環境で月1回のメモリ再起動が「当たり前」になっていないか

長期運用されているJavaアプリケーションを引き継ぐと、ときどき運用チームから「月1回、夜中にサーバーを再起動してメモリを解放している」という話を聞きます。その理由を尋ねると、「メモリ使用量が徐々に増えていくから」という答えが返ってくることが多い。

これはクラスローダーリークの典型的な症状です。

Javaのガベージコレクション(GC)は、参照が失われたオブジェクトのメモリを解放します。しかしクラスローダー自体が保持されたまま、その中に詰め込まれたクラス定義やメタデータが永遠に解放されないという状況が発生することがあります。結果として、メモリは少しずつ蝕まれ、やがてOutOfMemoryErrorに至ります。

この問題が注目される背景には、2010年代後半以降のJavaエコシステムの変化があります。アプリケーションサーバーの動的なクラスローディング、複数バージョンのライブラリの同時ロード、コンテナ環境での再デプロイの頻度増加──こうした要因が、従来は目立たなかった問題を表面化させました。

クラスローダーリークが発生する現実的なシナリオ

理論だけでは判断しづらいので、現場で実際に起きやすい状況を整理してみます。

1. 動的なクラスローディングと参照の循環

アプリケーションサーバーがプラグイン機構やホットデプロイを備えている場合、新しいバージョンのクラスを読み込むたびに新しいクラスローダーインスタンスが生成されます。しかし、古いクラスローダーが参照を保持したまま、どこからも到達不可能になると、GCの対象にならず残り続けます。

2. 静的フィールドへの無意識な登録

ライブラリやフレームワークが、初期化時に自分自身を静的なCollectionに登録する設計になっていることがあります。アンロード時にこの登録を解除しなければ、そのクラスローダーは永遠に参照され続けます。

// 問題パターンの例
public class PluginRegistry {
    private static final List<Object> plugins = new ArrayList<>();
    
    public static void register(Object plugin) {
        plugins.add(plugin);  // アンロード時に削除されない
    }
}

3. ThreadLocalとタイムアウト処理の組み合わせ

ThreadLocal変数にオブジェクトを保持していて、スレッドプールが使い回される環境では、古いクラスローダーの参照がスレッドに残り続けることがあります。特にアプリケーションサーバーのワーカースレッドは長寿命なため、この問題は深刻です。

4. 外部ライブラリの初期化ロジック

データベースドライバ、ロギングフレームワーク、キャッシュライブラリなど、多くの外部ライブラリは初期化時にシステムリソースを登録します。これらが適切にクリーンアップされないと、クラスローダーリークの原因になります。

開発現場での判断と対策

この問題の厄介な点は、開発環境では再現しにくいということです。開発環境では頻繁にアプリケーションを再起動するため、メモリリークが顕在化する前に環境がリセットされます。本番環境で数週間から数ヶ月の連続運用が始まって初めて問題が明らかになります。

中小規模の組織が現実的に取れるアクション

段階1: 現象の把握

まず、本当にクラスローダーリークなのかを確認する必要があります。以下のポイントを確認してください。

  • ヒープメモリ使用量がアプリケーション再起動後に一度リセットされるか
  • GC後もメモリが徐々に増え続けるか
  • FullGCの実行間隔が短くなっていないか

これらが確認できれば、クラスローダーリークの可能性が高いです。

段階2: ホットデプロイの頻度を減らす

本番環境でのホットデプロイ(アプリケーション再起動なしでの更新)が常態化している場合、まずこれを見直してください。ゼロダウンタイムデプロイメントが必要でなければ、計画的な再起動を組み込む方が、リークの蓄積を防げます。

段階3: ライブラリのバージョン確認

使用しているアプリケーションサーバー、ORM、ロギングライブラリが古いバージョンでないか確認します。クラスローダーリークは既知の問題として、新しいバージョンで修正されていることが多いです。

段階4: メモリダンプの分析

定期的なメモリ再起動を「当たり前」にしている場合、一度メモリダンプを取得して分析する価値があります。Eclipse Memory Analyzer(MAT)などのツールを使えば、どのクラスローダーがメモリを占有しているかを可視化できます。

// jcmdを使ったメモリダンプ取得の例
jcmd <PID> GC.heap_dump <ファイルパス>

段階5: 明示的なリソースクリーンアップ

自社開発のコンポーネントについては、初期化と対になるクリーンアップメソッドを実装してください。アプリケーションサーバーのシャットダウンフック、またはフレームワークのライフサイクルリスナーで、これらのクリーンアップを確実に実行する仕組みを作ります。

向くケースと向かないケース

この問題が深刻なケース

  • 月1回以上の定期的なメモリ再起動が必要
  • ホットデプロイを頻繁に行う(週に複数回など)
  • 基幹系システムで、ダウンタイムが許されない環境
  • 数年運用されている古いJavaアプリケーション

優先度が低いケース

  • マイクロサービス化済みで、各サービスが独立して再起動可能
  • Kubernetes等のコンテナ環境で、定期的なポッド再起動が組み込まれている
  • 開発環境のみの小規模アプリケーション

長期運用システムへの向き合い方

クラスローダーリークは、Javaの内部メカニズムに関わる問題であり、アプリケーション層だけでは完全に防ぎきれないことがあります。しかし、月1回の再起動が「仕方ない」で済まされている状態は、運用負荷と潜在的なトラブルリスクを増やしています。

現場では、「新しく書き直す」ことばかり検討されがちですが、既存システムの動作メカニズムを丁寧に把握し、段階的に改善していく方が、実務的には現実的です。メモリダンプの分析、ライブラリのアップデート、デプロイ戦略の見直し──こうした地道な対策が、長期運用システムの負債を減らす鍵になります。