はじめに (対象読者・この記事でわかること)

この記事は、Javaアプリケーションの稼働時に出力されるGCログを読み解きたいエンジニアや、サービス運用中に「Full GC」が頻発して困っているSRE・インフラエンジニアを対象にしています。
記事を読むことで、GCログに記載される「Full GC」と「Full GC(System)」の呼び分けがなぜ存在するのか、それぞれがどのようなトリガーで発生し、どこまでチューニングで抑制できるのかが一目でわかるようになります。
私が初めて本番ログを見たとき、「(System)」が付く付かないで検索しても明確な説明が見つからず、JVMソースまで読むハメになった経験があるため、同じ無駄をしたくない方の参考になればと思い執筆しました。

前提知識

この記事を読み進める上で、以下の知識があるとスムーズです。 - Java のヒープ領域(Young / Old / Metaspace)の基礎 - GC ログの出力オプション(-Xlog:gc* または -XX:+PrintGCDetails)の有効化方法 - プロセスのシグナル(SIGTERM や jmap/jcmd)を送った経験

Full GC と Full GC (System) がなぜ区別されるのか

Java の世界では「Full GC」と聞くと Old 領域の掃除を連想しますが、ログを見ると同じ Full でも末尾に "(System)" と付くものが混在していることに気づきます。
これは単なる表記の違いではなく、「誰がトリガーしたのか」 を明示するための区別です。
HotSpot VM 内部では「System.gc()」「jmap -histo」「JVMTI の ForceGarbageCollection」など、明示的な GC 要請が飞来した場合に only なコードパスが存在し、ここでだけ特別なフラグ(_gc_cause == GCCause::_java_lang_system_gc など)を立てて実行されます。
結果的に同じ Serial / Parallel / G1 のアルゴリズムでも、ログ出力時に gclog.cpp の以下分岐により括弧付きで表示されるのです。

Cpp
// 簡略化した hotspot/src/share/vm/gc/shared/gclog.cpp if (cause == GCCause::_java_lang_system_gc || cause == GCCause::_jvmti_force_gc) { st->print("Full GC (System)"); } else { st->print("Full GC"); }

ログで見分ける方法と抑制テクニック

Step1: ログの読み方と見分けポイント

まずは実際のログを見てみましょう。JDK 17 + G1GC の例です。

[2025-06-25T12:34:56.789+0900] GC(150) Pause Full (System) 2048M->1024M(4096M) 1.234s
[2025-06-25T12:35:10.123+0900] GC(151) Pause Full 2048M->1024M(4096M) 1.123s

一行目は (System) が付いているため、アプリケーションコードまたは運用ツールが System.gc() を呼び出したことを示します。
二行目は括弧がないため、JVM が Old 領域の空き不足や MetaSpace の拡張失敗などを理由に自律的に Full GC を実行したことを示します。

Step2: それぞれの発生トリガーを抑える JVM オプション

明示的な要請を無視したい場合は以下のフラグが有効です。

  1. -XX:+DisableExplicitGC
    System.gc() および Runtime.gc() の呼び出しを無視します。
    ただし NIO の DirectByteBuffer 解放に依存するコードがあると、Off-Heap OOM を引き起こす可能性があるため注意が必要です。

  2. -XX:+ExplicitGCInvokesConcurrent
    G1 や ParallelGC では「System.gc() が来たら並行 GC(Young GC + Concurrent Cycle)に昇格させる」オプションです。
    ログ上は Pause Full (System) ではなく Pause Young (Concurrent Start by System GC) のように変化します。

  3. メタスペース関連
    Full GC (Metadata GC Threshold) というログは JVM が自律的にメタスペースを拡張できずに発生します。
    初期値を大きくしてやる(-XX:MetaspaceSize=256m など)と頻度を減らせます。

ハマった点:DisableExplicitGC 適用後に DirectBuffer OOM が増えた

私は過去、Tomcat で -XX:+DisableExplicitGC を付けたら数日後に java.lang.OutOfMemoryError: Direct buffer memory が多発し、サービスが落ちる事象に見舞われました。
当時の私は「System.gc() が来ると止まるだけ」と理解していましたが、内部で sun.misc.VM.saveAndRemoveDirectBuffer()System.gc() の呼び出しが行われており、GC が抑制されると DirectBuffer のクリーンアップが永遠に起きなくなっていたのです。

解決策

以下のように段階的に対応し、無事に運用を安定させました。

  1. -XX:+DisableExplicitGC を外す
  2. 代わりに -XX:+ExplicitGCInvokesConcurrent を付けて、System.gc を並行 GC に誘導
  3. ソースコード側で System.gc() を呼ばない設計にリファクタリング(try-with-resources で即座にクリーンアップ)

まとめ

本記事では、Java の GC ログに記録される「Full GC」と「Full GC (System)」の違いを HotSpot VM のソースとログ例を交えて解説しました。

  • 「Full GC (System)」は明示的な GC 要請(System.gc() など)由来である
  • 「Full GC」は JVM が自律的に Old 領域やメタスペースの逼迫を検知して実行する
  • DisableExplicitGC で抑制できるが、DirectBuffer OOM のリスクがある
  • ExplicitGCInvokesConcurrent を使うと、System.gc を並行 GC に昇格させることができる

この記事を通して、ログを見ただけで「誰が GC を起動したのか」が判別できるようになり、チューニングの選択肢が広がったはずです。
次回は「G1 の Mixed GC がなぜフル GC に fallback するのか」について深掘りする予定です。

参考資料