T-CREATOR

Java の OutOfMemoryError を根治する:ヒープ/メタスペース/スレッドの診断術

Java の OutOfMemoryError を根治する:ヒープ/メタスペース/スレッドの診断術

Java アプリケーションの本番環境で突然発生する java.lang.OutOfMemoryError は、開発者にとって最も頭を悩ませるエラーの一つですね。アプリケーションがクラッシュし、ユーザーへのサービス提供が止まってしまうこの問題は、迅速かつ正確な診断が求められます。

このエラーの厄介な点は、単に「メモリが足りない」という表面的な問題だけでなく、ヒープ領域の枯渇、メタスペースの不足、ネイティブスレッドの生成失敗など、複数の原因が存在することでしょう。さらに、本番環境で突然発生することが多く、再現が難しいケースも少なくありません。

本記事では、OutOfMemoryError の種類を正確に見極め、根本原因を突き止めるための診断手法と、実践的な解決策を段階的にご紹介します。実際のエラーコードとともに、ヒープダンプやスレッドダンプの取得・分析方法、そして JVM オプションの調整まで、現場で即戦力となる知識を身につけていただけますよ。

背景

Java のメモリ構造と OutOfMemoryError の関係性

Java アプリケーションは JVM(Java Virtual Machine)上で動作し、JVM はメモリを複数の領域に分けて管理しています。これらの領域は、それぞれ異なる用途に使用され、どの領域が枯渇するかによって発生する OutOfMemoryError の種類が変わってくるのです。

主要なメモリ領域には、以下の 3 つがあります。

#メモリ領域用途枯渇時のエラー
1ヒープ領域(Heap)オブジェクトインスタンスの格納Java heap space
2メタスペース(Metaspace)クラスメタデータの格納Metaspace
3スレッドスタック(Thread Stack)スレッドごとのローカル変数・呼び出し履歴unable to create new native thread

以下の図は、JVM のメモリ構造と OutOfMemoryError の発生箇所を示しています。

mermaidflowchart TB
    jvm["JVM メモリ空間"]
    heap["ヒープ領域<br/>(Heap)"]
    meta["メタスペース<br/>(Metaspace)"]
    stack["スレッドスタック<br/>(Thread Stack)"]

    oom_heap["OutOfMemoryError:<br/>Java heap space"]
    oom_meta["OutOfMemoryError:<br/>Metaspace"]
    oom_thread["OutOfMemoryError:<br/>unable to create<br/>new native thread"]

    jvm --> heap
    jvm --> meta
    jvm --> stack

    heap -.->|枯渇時| oom_heap
    meta -.->|枯渇時| oom_meta
    stack -.->|枯渇時| oom_thread

図で理解できる要点

  • JVM は 3 つの主要なメモリ領域を持ち、それぞれ独立して管理されています
  • 各領域が枯渇すると、異なるメッセージの OutOfMemoryError が発生します
  • エラーメッセージを見れば、どの領域に問題があるかを特定できます

OutOfMemoryError が発生する典型的なシナリオ

実際の開発現場では、以下のようなシナリオで OutOfMemoryError が発生することが多いです。

まず、ヒープ領域の枯渇は、大量のオブジェクトを生成し続けたり、メモリリークによって不要なオブジェクトが GC(ガベージコレクション)されないケースで発生します。例えば、キャッシュの実装が不適切で、キャッシュされたオブジェクトが永遠に保持され続ける場合などが該当しますね。

次に、メタスペースの枯渇は、クラスローダーによって大量のクラスがロードされ続ける場合に発生します。特に、動的にクラスを生成するフレームワーク(Groovy、Spring の動的プロキシなど)や、頻繁にアプリケーションを再デプロイする開発環境で見られることが多いでしょう。

そして、スレッド生成の失敗は、OS のリソース制限に達した場合や、スレッドプールの設定が不適切でスレッドが無制限に生成されるケースで発生します。

これらの問題を正確に診断し、適切な対策を講じることが、安定したアプリケーション運用の鍵となります。

課題

OutOfMemoryError の診断を困難にする要因

OutOfMemoryError のトラブルシューティングには、いくつかの困難な要因が存在します。これらを理解することで、より効果的な診断アプローチを取ることができるでしょう。

エラーメッセージだけでは根本原因を特定できない

OutOfMemoryError が発生すると、以下のようなエラーメッセージが出力されます。

java// ヒープ領域の枯渇
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
java// メタスペースの枯渇
Exception in thread "main" java.lang.OutOfMemoryError: Metaspace
java// スレッド生成の失敗
Exception in thread "main" java.lang.OutOfMemoryError: unable to create new native thread

これらのエラーメッセージは、どの領域でメモリが不足したかは教えてくれますが、なぜ不足したのかは教えてくれません。例えば、Java heap space というエラーが出ても、メモリリークなのか、単純にヒープサイズが小さすぎるのか、GC が追いついていないのかは判断できないのです。

本番環境での再現が困難

多くの場合、OutOfMemoryError は本番環境の特定の負荷状況下でのみ発生します。開発環境やテスト環境では、以下の理由から再現が難しいことがあります。

#再現困難な理由具体例
1本番環境とデータ量が異なる本番では数百万レコード、開発では数千レコード
2同時接続数が異なる本番では数千同時接続、開発では数十接続
3運用時間が異なる本番は 24 時間稼働、開発は数時間程度
4外部システムの応答特性が異なる本番 API のレイテンシやタイムアウト

このため、問題が発生した時点での状態を正確に捉えることが重要になってきます。

診断に必要な情報が取得されていない

OutOfMemoryError の根本原因を特定するには、エラー発生時のメモリの状態を詳細に分析する必要があります。しかし、多くの場合、以下の情報が取得されていないことが問題を複雑にしているのです。

以下の図は、OutOfMemoryError の診断に必要な情報とその取得タイミングを示しています。

mermaidsequenceDiagram
    participant App as アプリケーション
    participant JVM as JVM
    participant Monitor as 監視システム
    participant Admin as 管理者

    App->>JVM: 正常動作
    Note over JVM: メモリ使用量が増加

    App->>JVM: メモリ割り当て要求
    JVM->>JVM: GC 実行

    alt メモリ不足
        JVM->>JVM: ヒープダンプ生成<br/>(設定時のみ)
        JVM->>App: OutOfMemoryError<br/>スロー
        App->>Monitor: エラーログ出力
        Monitor->>Admin: アラート通知

        Note over Admin: この時点で情報が<br/>ない場合が多い
    end

図で理解できる要点

  • OutOfMemoryError は予期せぬタイミングで発生します
  • エラー発生時の情報(ヒープダンプ)は事前設定がないと取得されません
  • 事後の調査では、ログとメトリクスからしか情報を得られません

上記の課題を解決するには、事前の準備と、発生時の迅速な情報収集、そして体系的な分析アプローチが不可欠です。次のセクションでは、これらの課題に対する具体的な解決策をご紹介します。

解決策

OutOfMemoryError 診断の 3 ステップアプローチ

OutOfMemoryError を根本から解決するには、体系的なアプローチが必要です。ここでは、診断から解決までの具体的な手順をご紹介しますね。

mermaidflowchart LR
    start["OutOfMemoryError<br/>発生"] --> step1["ステップ1:<br/>エラー種別の特定"]
    step1 --> step2["ステップ2:<br/>診断情報の収集"]
    step2 --> step3["ステップ3:<br/>根本原因の分析"]
    step3 --> solution["解決策の適用"]

    step1 -.->|ヒープ| heap_diag["ヒープダンプ分析"]
    step1 -.->|メタスペース| meta_diag["クラスロード分析"]
    step1 -.->|スレッド| thread_diag["スレッドダンプ分析"]

ステップ 1:エラー種別の特定

最初に行うべきことは、OutOfMemoryError の正確な種別を特定することです。エラーログから、以下のパターンを確認しましょう。

ヒープ領域の OutOfMemoryError を特定する

以下のエラーメッセージが出力されている場合、ヒープ領域の問題です。

javajava.lang.OutOfMemoryError: Java heap space
    at java.util.Arrays.copyOf(Arrays.java:3332)
    at java.util.ArrayList.grow(ArrayList.java:275)

このエラーは、オブジェクトのインスタンスを格納するヒープ領域が不足していることを示しています。

メタスペースの OutOfMemoryError を特定する

以下のメッセージの場合、メタスペースの問題ですね。

javajava.lang.OutOfMemoryError: Metaspace
    at java.lang.ClassLoader.defineClass1(Native Method)
    at java.lang.ClassLoader.defineClass(ClassLoader.java:756)

これは、クラスメタデータを格納するメタスペース領域が不足していることを意味します。

スレッド関連の OutOfMemoryError を特定する

以下のエラーは、スレッド生成の失敗を示しています。

javajava.lang.OutOfMemoryError: unable to create new native thread
    at java.lang.Thread.start0(Native Method)
    at java.lang.Thread.start(Thread.java:717)

このエラーは、OS のリソース制限や JVM のスレッド数上限に達したことを示しているのです。

ステップ 2:診断情報の収集設定

OutOfMemoryError の根本原因を分析するには、エラー発生時の詳細な情報が必要です。以下の JVM オプションを設定することで、自動的に診断情報を収集できます。

ヒープダンプの自動取得設定

OutOfMemoryError 発生時に自動的にヒープダンプを取得する設定です。

bash# ヒープダンプを自動取得する JVM オプション
-XX:+HeapDumpOnOutOfMemoryError
bash# ヒープダンプの出力先を指定
-XX:HeapDumpPath=/var/log/java/heapdump.hprof

これらのオプションを設定しておくと、OutOfMemoryError 発生時に自動的にヒープの状態がファイルに保存されます。

GC ログの有効化

GC(ガベージコレクション)の動作を詳細に記録することで、メモリの使用パターンを分析できます。

bash# GC ログを有効化(Java 9 以降)
-Xlog:gc*:file=/var/log/java/gc.log:time,uptime,level,tags
bash# GC ログをローテーション設定
-Xlog:gc*:file=/var/log/java/gc.log:time,uptime:filecount=5,filesize=100M

この設定により、GC の実行頻度、停止時間、メモリの解放状況などが記録されます。

メモリ使用状況の監視設定

JVM のメモリ使用状況をリアルタイムで監視するために、JMX(Java Management Extensions)を有効化しましょう。

bash# JMX を有効化する設定
-Dcom.sun.management.jmxremote
-Dcom.sun.management.jmxremote.port=9010
bash# 認証とSSLの設定(本番環境では必須)
-Dcom.sun.management.jmxremote.authenticate=true
-Dcom.sun.management.jmxremote.ssl=true

JMX を有効にすることで、VisualVM や JConsole などのツールからリアルタイムでメモリ使用状況を確認できるようになります。

ステップ 3:種別ごとの解決策

エラーの種別が特定できたら、それぞれに適した解決策を適用します。

ヒープ領域不足の解決策

ヒープサイズの調整は、最も基本的な対策です。

bash# ヒープサイズの初期値と最大値を設定
-Xms2g -Xmx4g

上記の設定では、初期ヒープサイズを 2GB、最大ヒープサイズを 4GB に設定しています。本番環境では、両方を同じ値に設定することで、ヒープサイズの動的な変更によるパフォーマンス低下を防げますよ。

メタスペース不足の解決策

メタスペースのサイズを調整します。

bash# メタスペースの初期サイズと最大サイズを設定
-XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m

動的にクラスを生成するアプリケーション(Groovy、Spring Boot など)では、デフォルトのサイズでは不足することがあるため、適切に調整する必要があります。

スレッド生成失敗の解決策

スレッドスタックサイズを調整することで、より多くのスレッドを生成できるようになります。

bash# スレッドスタックサイズを削減(デフォルトは1MB程度)
-Xss512k

ただし、スタックサイズを小さくしすぎると StackOverflowError が発生する可能性があるため、アプリケーションの再帰呼び出しの深さを考慮して設定してください。

また、OS レベルでのスレッド数制限を確認することも重要です。

bash# Linux でのスレッド数上限を確認
ulimit -u
bash# スレッド数上限を増やす(一時的)
ulimit -u 65535

恒久的に変更する場合は、​/​etc​/​security​/​limits.conf ファイルを編集します。

これらの解決策を適用した後は、必ずアプリケーションの動作を監視し、問題が解決されたことを確認してくださいね。

具体例

実際のトラブルシューティング事例

ここでは、実際に発生した OutOfMemoryError のケースを 3 つご紹介し、それぞれの診断手順と解決方法を具体的に解説します。

事例 1:キャッシュによるヒープメモリリーク

発生状況

本番環境の Spring Boot アプリケーションで、起動から 48 時間後に以下のエラーが発生しました。

java// エラーログに出力された内容
2025-03-15 14:23:45.123 ERROR [http-nio-8080-exec-42]
java.lang.OutOfMemoryError: Java heap space
    at java.util.HashMap.resize(HashMap.java:704)
    at java.util.HashMap.putVal(HashMap.java:663)
    at com.example.service.UserCacheService.cacheUser(UserCacheService.java:45)

診断プロセス

まず、事前に設定していた -XX:+HeapDumpOnOutOfMemoryError オプションにより、ヒープダンプファイルが自動生成されました。

bash# ヒープダンプファイルの確認
ls -lh /var/log/java/
# 出力例:java_pid12345.hprof (3.8 GB)

次に、Eclipse MAT(Memory Analyzer Tool)でヒープダンプを分析しました。

bash# MAT で最もメモリを消費しているオブジェクトを確認
# Leak Suspects Report を実行

分析の結果、以下のことが判明しました。

#分析項目結果
1最大メモリ消費オブジェクトHashMap インスタンス(2.1 GB)
2HashMap のエントリ数約 420 万エントリ
3保持されているクラスUserCacheService$CachedUser
4GC Root からの参照UserCacheService の static フィールド

問題のコード

問題の原因となっていたコードは以下の通りです。

java// UserCacheService.java - 問題のあるキャッシュ実装
@Service
public class UserCacheService {
    // static な HashMap でユーザー情報をキャッシュ
    private static final Map<Long, CachedUser> userCache = new HashMap<>();
java    // キャッシュにユーザーを追加(削除ロジックがない)
    public void cacheUser(User user) {
        CachedUser cached = new CachedUser(
            user.getId(),
            user.getName(),
            user.getEmail(),
            LocalDateTime.now()
        );
        userCache.put(user.getId(), cached);
    }

このコードの問題点は、キャッシュに追加されたエントリが削除されることなく蓄積し続けることです。

解決方法

Caffeine ライブラリを使用した、有効期限と最大サイズ制限のあるキャッシュに変更しました。

java// 依存関係の追加(build.gradle)
dependencies {
    implementation 'com.github.ben-manes.caffeine:caffeine:3.1.8'
}
java// 改善後の UserCacheService.java
@Service
public class UserCacheService {
    // Caffeine で有効期限と最大サイズを設定
    private final Cache<Long, CachedUser> userCache = Caffeine.newBuilder()
        .maximumSize(10_000)  // 最大 1万エントリ
        .expireAfterWrite(1, TimeUnit.HOURS)  // 1時間で期限切れ
        .build();
java    // キャッシュの取得(存在しない場合は読み込み)
    public CachedUser getUser(Long userId) {
        return userCache.get(userId, id -> {
            User user = userRepository.findById(id)
                .orElseThrow(() -> new UserNotFoundException(id));
            return new CachedUser(
                user.getId(),
                user.getName(),
                user.getEmail(),
                LocalDateTime.now()
            );
        });
    }

効果

この変更により、以下の改善が見られました。

#項目改善前改善後
1最大ヒープ使用量3.8 GB1.2 GB
2OutOfMemoryError 発生48 時間後発生なし(30 日間監視)
3GC 停止時間平均 850ms平均 120ms

事例 2:動的クラス生成によるメタスペース枯渇

発生状況

開発環境で Groovy スクリプトを動的に実行する機能を実装後、以下のエラーが頻発しました。

java// エラーログ
2025-03-18 10:15:33.456 ERROR [scheduler-thread-5]
java.lang.OutOfMemoryError: Metaspace
    at java.lang.ClassLoader.defineClass1(Native Method)
    at groovy.lang.GroovyClassLoader.defineClass(GroovyClassLoader.java:492)

診断プロセス

JConsole を使用して、メタスペースの使用状況をリアルタイムで監視しました。

bash# JConsole の起動(JMX 接続)
jconsole localhost:9010

監視の結果、以下の傾向が観察されました。

  • Groovy スクリプト実行ごとにメタスペース使用量が増加
  • Full GC が実行されてもメタスペースが解放されない
  • 約 3000 回のスクリプト実行後にメタスペースが上限に達する

問題のコード

問題のコードは、毎回新しい GroovyClassLoader を生成していました。

java// 問題のある実装
@Service
public class ScriptExecutor {
    public Object executeScript(String script) {
        // 毎回新しい ClassLoader を生成(メモリリーク)
        GroovyClassLoader classLoader = new GroovyClassLoader();
java        try {
            Class<?> scriptClass = classLoader.parseClass(script);
            GroovyObject instance = (GroovyObject) scriptClass.newInstance();
            return instance.invokeMethod("run", null);
        } catch (Exception e) {
            throw new ScriptExecutionException("Script execution failed", e);
        }
    }
}

GroovyClassLoader が GC されず、ロードされたクラスのメタデータがメタスペースに蓄積していました。

解決方法

ClassLoader を再利用し、定期的にリフレッシュする仕組みを実装しました。

java// 改善後の実装
@Service
public class ScriptExecutor {
    private GroovyClassLoader classLoader;
    private final AtomicInteger executionCount = new AtomicInteger(0);
    private static final int REFRESH_THRESHOLD = 1000;
java    @PostConstruct
    public void init() {
        this.classLoader = new GroovyClassLoader();
    }
java    public synchronized Object executeScript(String script) {
        // 一定回数実行後に ClassLoader をリフレッシュ
        if (executionCount.incrementAndGet() > REFRESH_THRESHOLD) {
            refreshClassLoader();
            executionCount.set(0);
        }
java        try {
            Class<?> scriptClass = classLoader.parseClass(script);
            GroovyObject instance = (GroovyObject) scriptClass.newInstance();
            return instance.invokeMethod("run", null);
        } catch (Exception e) {
            throw new ScriptExecutionException("Script execution failed", e);
        }
    }
java    private void refreshClassLoader() {
        // 古い ClassLoader を破棄
        if (classLoader != null) {
            try {
                classLoader.close();
            } catch (IOException e) {
                log.warn("Failed to close GroovyClassLoader", e);
            }
        }
        // 新しい ClassLoader を生成
        classLoader = new GroovyClassLoader();
        log.info("GroovyClassLoader refreshed");
    }
}

また、メタスペースのサイズを適切に設定しました。

bash# メタスペースの設定を追加
-XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m

効果

#項目改善前改善後
1メタスペース最大使用量256 MB(上限到達)180 MB(安定)
2OutOfMemoryError 発生頻度3000 回実行後発生なし
3ClassLoader 数累積増加常に 1

事例 3:スレッドプール設定ミスによるスレッド枯渇

発生状況

高負荷時に以下のエラーが発生し、アプリケーションが応答不能になりました。

java// エラーログ
2025-03-20 16:45:12.789 ERROR [http-nio-8080-exec-1024]
java.lang.OutOfMemoryError: unable to create new native thread
    at java.lang.Thread.start0(Native Method)
    at java.util.concurrent.ThreadPoolExecutor.addWorker(ThreadPoolExecutor.java:957)

診断プロセス

スレッドダンプを取得して、スレッドの状態を分析しました。

bash# スレッドダンプの取得(プロセスID が 12345 の場合)
jstack 12345 > threaddump.txt
bash# スレッド数を確認
grep "java.lang.Thread.State" threaddump.txt | wc -l
# 出力:2048

分析の結果、以下のことが判明しました。

  • アプリケーションが 2000 以上のスレッドを生成していた
  • ほとんどのスレッドが WAITING または TIMED_WAITING 状態
  • 外部 API 呼び出しで長時間待機しているスレッドが多数存在

問題のコード

ExecutorService の設定が不適切でした。

java// 問題のある設定
@Configuration
public class AsyncConfig {
    @Bean
    public Executor taskExecutor() {
        // 無制限にスレッドを生成してしまう設定
        ThreadPoolExecutor executor = new ThreadPoolExecutor(
            10,                    // コアプールサイズ
            Integer.MAX_VALUE,     // 最大プールサイズ(実質無制限)
            60L, TimeUnit.SECONDS,
            new SynchronousQueue<>()  // キューなし
        );
        return executor;
    }
}

この設定では、リクエストが殺到すると無制限にスレッドが生成されてしまいます。

解決方法

適切な上限とキューを設定し、拒否ポリシーを追加しました。

java// 改善後の設定
@Configuration
public class AsyncConfig {
    @Bean
    public Executor taskExecutor() {
        ThreadPoolExecutor executor = new ThreadPoolExecutor(
            20,                          // コアプールサイズ
            50,                          // 最大プールサイズ(上限設定)
            60L, TimeUnit.SECONDS,
            new LinkedBlockingQueue<>(100),  // 待機キュー(100件)
            new ThreadPoolExecutor.CallerRunsPolicy()  // 拒否時はリクエスト元で実行
        );
java        // スレッド名のプレフィックスを設定(デバッグ用)
        executor.setThreadFactory(new ThreadFactory() {
            private final AtomicInteger threadNumber = new AtomicInteger(1);

            @Override
            public Thread newThread(Runnable r) {
                return new Thread(r, "async-task-" + threadNumber.getAndAndIncrement());
            }
        });
java        return executor;
    }
}

さらに、外部 API 呼び出しにタイムアウトを設定しました。

java// RestClient にタイムアウトを設定
@Bean
public RestTemplate restTemplate() {
    HttpComponentsClientHttpRequestFactory factory =
        new HttpComponentsClientHttpRequestFactory();

    factory.setConnectTimeout(5000);     // 接続タイムアウト 5秒
    factory.setReadTimeout(10000);       // 読み取りタイムアウト 10秒

    return new RestTemplate(factory);
}

OS レベルのスレッド上限も確認し、適切に設定しました。

bash# /etc/security/limits.conf に追加
appuser soft nproc 4096
appuser hard nproc 8192

効果

#項目改善前改善後
1最大スレッド数2048+50(上限)
2OutOfMemoryError 発生高負荷時に頻発発生なし
3平均応答時間8.5 秒1.2 秒

これらの事例から分かるように、OutOfMemoryError の根本原因は様々ですが、適切な診断と段階的なアプローチにより、確実に解決できるのです。

まとめ

Java の OutOfMemoryError は、アプリケーションの安定運用において避けて通れない重要な課題です。本記事では、ヒープ、メタスペース、スレッドという 3 つの主要な原因と、それぞれの診断・解決方法を詳しくご紹介しました。

最も重要なポイントは、事前の準備体系的な診断アプローチですね。-XX:+HeapDumpOnOutOfMemoryError や GC ログの設定を事前に行っておくことで、問題発生時に必要な情報を確実に取得できます。また、エラーの種別を正確に特定し、ヒープダンプやスレッドダンプなどの診断ツールを使いこなすことで、根本原因を突き止めることができるでしょう。

実際の事例でご紹介したように、問題の原因は多岐にわたります。キャッシュの実装ミス、動的クラス生成の管理不足、スレッドプールの設定ミスなど、それぞれに適した解決策を適用する必要があるのです。

しかし、何よりも大切なのは、問題が発生してから対処するのではなく、発生を未然に防ぐという姿勢ではないでしょうか。適切なメモリ設定、効率的なキャッシュ戦略、スレッドプールの上限設定、そして継続的な監視体制の構築が、安定したアプリケーション運用の基盤となります。

本記事でご紹介した診断手法と解決策を、ぜひ皆さんのプロジェクトに活用していただき、OutOfMemoryError のない安定したシステム運用を実現してくださいね。

関連リンク