ガベージコレクション(Garbage Collection、GC)は、プログラムが使わなくなったメモリを実行環境が自動で見つけて解放する仕組みです。C言語の malloc/free のように開発者が手でメモリを返す必要がなくなり、解放漏れや二重解放のバグを構造的に減らせます。表記は「ガーベジコレクション」「ガーベージコレクション」「ガーベッジコレクション」とも書かれますが、指すものは同じです。なお、SSDなどのフラッシュメモリにも同じ名前の処理(削除済みデータの領域を再利用できる空き領域へ戻す処理。Kingstonの技術資料)がありますが、本記事はプログラミング言語のGCを扱います。
本記事の実測は2026年10月28日、macOS 26.6.2(x86_64)で行いました。
まとめ:ガベージコレクションの仕組みと言語別の違い
- GCとは:ルート(スタック上の変数・グローバル変数など)からたどれなくなったオブジェクトを実行環境が自動で回収する仕組み。目的は解放漏れと二重解放を防ぐこと。
- GCが走るタイミング:割り当て量がしきい値を超えたとき、メモリが逼迫したとき、明示的に呼ばれたとき。しきい値は言語ごとに違う(Pythonは割り当て数、Goはヒープの増加率)。
- 参照カウントだけの言語は循環参照を回収しない:実測ではSwift(ARC)とRust(
Rc)が循環を残し、Python・PHP・Java・.NET・Go・Node.jsは回収した。 - GCログは停止時間を読む道具:Javaは
-Xlog:gc、GoはGODEBUG=gctrace=1、Node.jsは--trace-gc。ZGCの要約行に出る時間はサイクル全体の時間で、停止時間ではない。 - 手動GCは原則呼ばない:
System.gc()は-XX:+DisableExplicitGCの指定で何もしなくなる。調整はログで原因を確かめてから行う。
ガベージコレクションとは:自動メモリ管理の定義と手動管理との違い
ガベージコレクションとは、プログラムから参照されなくなったヒープ上のオブジェクトを実行環境(ランタイム)が検出し、そのメモリを再利用できるようにする自動メモリ管理です。「garbage(ごみ)」を「collection(集める)」という名前のとおり、回収を担うプログラムをガベージコレクタと呼びます。ジョン・マッカーシーが1960年4月にCommunications of the ACMで発表したLISPの論文には、空き領域のリストが尽きたら回収サイクル(reclamation cycle)を始める、という記述がすでにあります(McCarthy「Recursive Functions of Symbolic Expressions」)。
手動メモリ管理で起きるリークと二重解放をGCが防ぐ仕組み
手動管理で起きる典型的なバグは二つあります。解放を忘れて使われないメモリが積み上がるメモリリークと、解放済みの領域をもう一度解放したり参照したりする二重解放・ダングリングポインタです。後者はクラッシュだけでなく脆弱性の原因にもなります。GCは、生存に必要なオブジェクトを保持し、不要と判定したオブジェクトを自動回収の対象にするため、この二種類のバグが言語の仕組みとして起きにくくなります。
ただし、GCがあってもリークは起きます。GCが回収するのは到達できないオブジェクトだけなので、静的なキャッシュに入れっぱなしの値や、登録を解除し忘れたイベントリスナーは使っていなくても残ります。原因の特定方法はメモリリークとは?原因の特定方法と直し方を4言語の再現コードで解説で扱っています。
GCが解放しない資源:ファイル・接続のwith・using・try-with-resourcesによる解放
GCが管理するのはメモリだけです。ファイルハンドル、ソケット、データベース接続のようなOSの資源は、それを包むオブジェクトが回収されるまで閉じられる保証がなく、回収がいつ起きるかも決まっていません。Microsoft Learnのガベージ コレクションの基礎も、アンマネージリソースには明示的なクリーンアップが必要で、Dispose メソッドで解放するよう勧めています。言語ごとの書き方は、Pythonが with、C#が using、Javaが try-with-resources、Goが defer です。ファイナライザ(回収時に呼ばれる後始末)に頼って閉じる設計は、接続数の上限に先に当たるので避けてください。
GCを持たない言語のメモリ管理:RAII・所有権・参照カウント
C++はRAII(オブジェクトの寿命とリソースの確保・解放をスコープに結び付ける設計)と std::unique_ptr・std::shared_ptr で解放を自動化します。Rustはコンパイル時に所有者を1つに決め、所有者がスコープを抜けた時点で解放します(Rustのスタックとヒープ|値がヒープへ脱出する条件とスタックオーバーフローの防ぎ方)。SwiftはARC(自動参照カウント)で、コンパイラが参照カウントの増減を挿入します。これらの方式はヒープ走査型GCによる全体停止を必要としませんが、デストラクタや連鎖的な解放処理には実行時間がかかり、参照カウント方式は後述の循環参照を回収できません。
ルートからの到達可能性で「ごみ」を決めるGCの基本的な仕組み
GCの中心にある考え方は到達可能性(reachability)です。実行環境は、確実に生きている参照の起点であるルート集合から参照をたどり、たどり着けたオブジェクトを「生存」、たどり着けないものを「ごみ」とみなします。.NETの公式ドキュメントは、ルートとして静的フィールド、スレッドのスタック上のローカル変数、CPUレジスタ、GCハンドル、ファイナライズキューを挙げています。
大事なのは、GCが判定しているのは「もう使わないか」ではなく「もうたどれないか」だという点です。プログラマーが二度と使わないつもりでも、どこかの変数やコレクションから参照が残っていれば回収されません。逆に、互いを参照し合っているだけのオブジェクトの組は、ルートからたどれなければまとめてごみです。この参照をたどる方式を総称してトレーシングGCと呼び、各オブジェクトの被参照数を数える参照カウントとは別系統として扱います。
GCが走るタイミング:割り当てのしきい値・メモリ逼迫・明示的な呼び出し
GCはプログラムの決まった行で動くのではなく、実行環境が条件を見て起動します。.NETの公式ドキュメントが挙げる条件は、(1)物理メモリが少ないとOSやホストから通知されたとき、(2)マネージドヒープの割り当て量がしきい値を超えたとき(しきい値は実行中に調整される)、(3) GC.Collect が呼ばれたとき、の三つです。他の言語も骨格は同じで、違いはしきい値の決め方にあります。
| 実行環境 | 主な起動条件 | 既定値・確認方法 |
|---|---|---|
| Python(CPython) | 割り当て数−解放数がしきい値超え | 3.14.6・3.15.0で (2000, 10, 10) |
| Go | 生存ヒープとGCルートから算出した目標量 | GOGC=100(既定) |
| Java(G1) | 新世代(Eden)が満杯 | -Xlog:gc で原因を確認 |
| .NET | 世代0の割り当てがしきい値超え | しきい値は自動調整 |
| Node.js(V8) | 新世代の領域が満杯 | ログに allocation failure |
Pythonの値は今回の実測です。gcモジュールのドキュメントは「割り当て数から解放数を引いた値が threshold0 を超えると回収を始める」と説明しています。Node.jsのログに出る allocation failure は異常ではなく、「新しい割り当てを置く場所が足りないので回収した」という通常の起動理由です。
マーク・アンド・スイープから世代別まで、代表的なGCアルゴリズム5種の特徴
実際の実行環境は以下を組み合わせて動いています。
マーク・アンド・スイープ:基本のトレーシングGCと断片化
Goの標準ランタイムが採用するマーク・アンド・スイープは、ルートから到達できるオブジェクトに印を付け(マーク)、印の無いものの領域を再利用できるようにする方式です(スイープ)。実装が単純で循環参照も回収できますが、解放後に空き領域が飛び飛びに残る断片化が起き、大きなオブジェクトを確保しにくくなります。
参照カウント:即時回収と循環参照の限界
各オブジェクトが「いくつの参照から指されているか」を数え、0になった瞬間に解放します。不要になった直後に回収されるため停止が目立ちにくい一方、互いを指し合う循環参照ではカウントが0にならず残ります。CPythonはこの弱点を補うために循環参照専用のGCを併用しています。
コピーGC:生存オブジェクトの移動と断片化の抑制
代表的なセミスペース方式では対象領域を2つに分け、生存オブジェクトをもう一方へコピーして元の領域を空けます。断片化が起きず確保も速い反面、片側を常に空けておく必要があります。生存率が低い領域ほど効率がよいため、V8のScavengerは若い世代の回収にこの方式を使っています。
コンパクション:詰め直しによる連続した空き領域の確保
生存オブジェクトをメモリの片側へ隙間なく詰め直し、連続した空き領域を作ります。オブジェクトの移動と参照の書き換えが必要なので重く、.NETでは、既定のしきい値で85,000バイト以上のオブジェクトを置く大きなオブジェクトヒープ(LOH)は、通常の回収では圧縮せず、必要に応じて明示的に圧縮を要求できます。
世代別GC:短命なオブジェクトの若い世代での集中回収
「作られたオブジェクトの大半はすぐ不要になる」という世代仮説に基づき、ヒープを若い世代と古い世代に分けます。若い世代だけを頻繁に軽く回収し(マイナーGC)、古い世代を含む重い回収(フルGC)はまれにします。Java・.NET・V8・CPythonの循環GCが世代別です。
| 方式 | 循環参照 | 断片化 | 主な使われ方 |
|---|---|---|---|
| マーク・アンド・スイープ | 回収する | 起きる | Goの本体・古い世代 |
| 参照カウント | 回収しない | 起きうる | Swift・CPython・PHP |
| コピー | 回収する | 起きない | 若い世代 |
| コンパクション | 生存判定方式次第 | 解消する | 古い世代の整理 |
| 世代別 | 生存判定方式次第 | 実装次第 | Java・.NET・V8 |
循環参照を8つの実行環境で実測:回収される言語と残る言語
「参照カウントは循環参照を回収できない」は教科書どおりの説明ですが、実際の言語がどう振る舞うかは実装で決まります。そこで、2つのオブジェクトに互いを参照させ、外からの参照をすべて切ったあとで回収されたかを、弱参照・デストラクタ・ファイナライザで観測しました。どの言語も同じ形の小さなプログラムで確かめています。
| 実行環境(版) | 方式 | 循環参照 | 観測方法 |
|---|---|---|---|
| Swift 6.3.3 | ARC | 残る(deinit呼ばれず) | deinit |
| Rust 1.99.1 | Rc | 残る(drop呼ばれず) | Drop |
| Python 3.15.0/3.14.6 | 参照カウント+循環GC | gc.collect()で回収 | weakref |
| PHP 8.5.8 | 参照カウント+循環GC | gc_collect_cycles()で回収 | __destruct |
| Java 25.0.4.1(G1) | トレーシング | System.gc()で回収 | WeakReference |
| .NET 10.0.12 | トレーシング | GC.Collect()で回収 | WeakReference |
| Go 1.27.2 | トレーシング | runtime.GC()で回収 | runtime.AddCleanup |
| Node.js v26.5.0 | トレーシング | gc()で回収 | WeakRef |
はっきり分かれたのは、SwiftのARCと、今回Rustで使用したRcです。Swiftでは2つのオブジェクトが強参照で指し合ったまま関数を抜けても deinit が一度も呼ばれず、片方を weak var にした組だけが解放されました。The Swift Programming Languageの「Automatic Reference Counting」も、強参照の循環は関係の一部を weak か unowned にして解消すると説明しています。
final class Node {
var peer: Node? // 強参照
weak var wpeer: Node? // 弱参照(カウントを増やさない)
let name: String
init(_ n: String) { name = n }
deinit { print("deinit", name) }
}
func strong() { let a = Node("strong-a"), b = Node("strong-b"); a.peer = b; b.peer = a }
func weakv() { let a = Node("weak-a"), b = Node("weak-b"); a.peer = b; b.wpeer = a }
strong(); print("strong cycle: scope exited")
weakv(); print("weak cycle: scope exited")
strong cycle: scope exited
deinit weak-a
deinit weak-b
weak cycle: scope exited
Rustも同じで、Rc 同士で指し合うと Rc::strong_count は外部参照を切る前に2で、スコープ終了後も相互参照が1本ずつ残り、片方を Weak にした組だけが drop されました。標準ライブラリのstd::rcのドキュメントも「A cycle between Rc pointers will never be deallocated.」と明記し、循環を切るには Weak を使うよう案内しています。
PythonとPHPは参照カウントが主役の言語ですが、循環参照の検出器を別に持っています。Pythonでは gc.disable() で循環GCを止めると変数を消しても組が残り、gc.collect() を呼ぶと消えました。PHPも gc_disable() の状態で unset した組が残り、gc_collect_cycles() が2を返して両方のデストラクタが走りました。
import gc, weakref
class Node:
def __init__(self):
self.peer = None
gc.disable() # 循環GCを止め、参照カウントだけにする
a, b = Node(), Node()
a.peer, b.peer = b, a # 互いを指す循環参照
probe = weakref.ref(a)
del a, b
print(probe() is not None) # True = 参照カウントだけでは残る
gc.collect()
print(probe() is not None) # False = 循環GCで回収された
gc.enable()
トレーシングGCの4つは、ルートから切り離した時点で組ごと回収対象になり、明示的に回収を起こすと消えました。ただし、これは今回の実行で観測できたという意味です。ECMAScriptの仕様は WeakRef や FinalizationRegistry について「any object or symbol will be garbage collected」ことを保証しないと明記しており、弱参照やファイナライザで回収を確かめる方法はテスト用の観測手段であって、本番の解放処理には使えません。ここから言える実務上の判断は一つです。SwiftやRustで親子・相互参照を持つデータ構造(木の親ポインタ、デリゲート、クロージャが自分を捕まえる構造)を書くときは、どちらか片方を最初から weak/Weak で設計してください。GCのある言語の感覚で書くと、エラーも警告も出ないままメモリが増え続けます。
Java・C#・Python・Go・JavaScriptのGC実装と2026年10月時点の版
同じ「自動メモリ管理」でも、方式・手動トリガー・調整の手段は言語ごとに違います。下表は2026年10月時点の公式ドキュメントとリリースノートで確認した内容です。
| 言語/環境 | 主なGC方式 | 手動トリガー | 主な調整手段 |
|---|---|---|---|
| Java(HotSpot) | 世代別(主にG1) | System.gc() | -Xmx・GC種別の指定 |
| C#(.NET) | 世代別(世代0〜2) | GC.Collect() | サーバーGC・DATAS |
| Python(CPython) | 参照カウント+循環GC | gc.collect() | gc.set_threshold() |
| Go | コンカレントなマーク・アンド・スイープ | runtime.GC() | GOGC・GOMEMLIMIT |
| JavaScript(V8) | 世代別(Orinoco) | –expose-gc時のみ | –max-old-space-size |
Python:参照カウントと循環GC、3.14.5での差し戻し
CPythonは参照カウントを常時使い、参照カウントでは回収できない循環参照だけを世代別の循環GC(gc モジュール)が回収します。参照カウントは止められませんが、循環GCは gc.disable() で止められます。3.14.0〜3.14.4は停止時間を短くするインクリメンタルGCを搭載していましたが、3.14.5で3.13と同じ世代別GCへ戻されました。gcモジュールのドキュメントにも「Changed in version 3.14.5: Generation 1 is reintroduced to maintain GC behavior from 3.13.」と記載があります。3.15のWhat’s Newも「This is the GC now used in Python 3.14.5 and later and Python 3.15.」としており、3.15では gc.get_stats() に回収時間(duration)が加わりました。3.14系でGCの挙動を調べるときは、パッチ番号まで確認してください。
Go:低遅延のコンカレントGCとGreen Tea GC
GoはGCとアプリケーションを並行に動かすマーク・アンド・スイープで、停止を短く保つことを優先しています。Go 1.25で実験機能として入ったGreen Tea GCは、Go 1.26で既定になりました。小さなオブジェクトの走査の局所性を高める設計で、リリースノートはGCを多用する実際のプログラムで、GCのオーバーヘッドが10〜40%減ることを見込むとしています。並行処理の土台であるゴルーチン(Goroutine)とは?Go言語の並行処理を支える軽量スレッドの仕組みと使い方のスタックもGCのルートになります。Go 1.27では、GCの到達可能性の判定を使って「待ち先のチャネルやロックがどこからも到達できない=二度と再開しないゴルーチン」を報告する goroutineleak プロファイルが実験扱いを外れ、/debug/pprof/goroutineleak で取れるようになりました。
.NET:世代別GCとDATASによるヒープの自動調整
.NETは世代0〜2の世代別GCを基本に、サーバー向けの「サーバーGC」とデスクトップ向けの「ワークステーションGC」を選べます。サーバーGCでは、負荷に合わせてヒープの大きさを変えるDATAS(Dynamic Adaptation To Application Sizes)が.NET 9から既定で有効です(Microsoft Learn「DATAS」)。今回の実測環境(.NET 10.0.12のコンソールアプリ)はワークステーションGCで動いていました。
Java:HotSpotの既定G1と低遅延のZGC
HotSpot JVMでは、ほとんどのハードウェアとOSの構成でG1 GCが既定で選ばれ(Java 25 GCチューニングガイド)、低遅延が必要な用途ではZGCが候補になります。CPUやメモリの少ない環境では別のGCが選ばれることがあるため、実際に使われているGCは -Xlog:gc の「Using」行で確かめてください。ZGCはJDK 21で世代別モードが加わり(JEP 439)、JDK 23で世代別が既定になり(JEP 474)、JDK 24で非世代別モードが削除されました(JEP 490)。もう一つの低遅延GCであるShenandoahも、JDK 25で世代別モードが正式機能になり、Shenandoahを搭載したJDKで -XX:+UseShenandoahGC -XX:ShenandoahGCMode=generational を指定して有効にできます(JEP 521)。ZGCの仕組みと有効化はZGCとは?Javaの低遅延GCの仕組みとG1GCとの違い・有効化手順をJDK 25・27の実測で解説、G1の世代構成やFull GCの原因の読み方はJavaのGC(ガベージコレクション)とは?Full GCの発生条件とGCログの見方をJDK 25/27で実測で詳しく扱っています。
JavaScript(V8):Node.jsとChromeのGC
ChromeとNode.jsが使うV8のGC(Orinoco)は、若い世代をコピー方式のスカベンジで、古い世代をマーク・アンド・コンパクトで回収する世代別GCです(V8公式ブログ「Trash talk」)。JavaScriptのコードからGCを起動する標準の手段はありません。Node.jsで gc() を呼べるのは --expose-gc を付けて起動したときだけで、付けずに呼ぶと「gc is not defined」の ReferenceError になりました。
GCログとは:Java・Go・Node.js・Pythonの実ログで読む停止時間と回収量
GCログは、GCが走るたびに「いつ・どの種類の回収が・何ミリ秒止めて・どれだけ解放したか」を記録したものです。応答が遅い原因がGCかどうかは、勘ではなくこの数値で判断します。以下は各実行環境のGCログ例です。Java・Go・Node.jsでは1,024バイトの配列を20万回確保し、Javaには最大ヒープ64MiBを指定しました。Pythonは自己参照するオブジェクトを5,000個作る別の処理で、各環境の性能を同一条件で比較した結果ではありません。時刻・回収量・所要時間は実行のたびに変わります。Javaで使ったコードは次のとおりで、Go・JavaScriptも同じ処理(1,024バイトの確保を20万回繰り返し、20回に1回だけリストに残して3,000個たまったら捨てる)を各言語で書いています。
import java.util.*;
public class Alloc {
public static void main(String[] a) {
List<byte[]> keep = new ArrayList<>();
for (int i = 0; i < 200_000; i++) {
byte[] b = new byte[1024];
if (i % 20 == 0) keep.add(b); // 20回に1回だけ残す
if (keep.size() > 3000) keep.clear(); // 3,000個たまったら捨てる
}
}
}
Java:-Xlog:gc の1行の読み方とZGCの注意点
$ java -Xmx64m -Xlog:gc Alloc.java
[0.030s][info][gc] Using G1
[1.424s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 34M->3M(64M) 25.705ms
1行目は使われているGCの種類です。2行目は左から、JVM起動からの経過時間、GCの通し番号、回収の種類(Pause Young=若い世代の停止を伴う回収)、起動理由、回収前→回収後のヒープ使用量(括弧内はヒープ全体)、停止時間です。この例では34MBが3MBまで減り、25.705ミリ秒止まりました。
ZGCでは同じ見方が通用しません。-XX:+UseZGC で動かすと要約行は「Major Collection (Warmup) 8M(12%)->8M(12%) 0.040s」と出ますが、この0.040秒はGCサイクル全体の時間です。-Xlog:gc,gc+phases で同じGC(0)の内訳を出すと、Pause で始まる行は若い世代と古い世代を合わせて5行(Pause Mark Start・Pause Mark End・Pause Relocate Start)で、最も長いものでも0.025ミリ秒でした。残りの「Concurrent Mark 9.050ms」「Concurrent Relocate 21.628ms」などはアプリケーションと並行に動いた処理です。ZGCの停止時間は要約行ではなく Pause で始まる行で読んでください。
Go:GODEBUG=gctrace=1 の3つの時間
$ GODEBUG=gctrace=1 go run alloc.go
gc 76 @0.149s 4%: 0.058+0.21+0.030 ms clock, 0.93+0.035/0.23/0.034+0.48 ms cpu, 3->3->1 MB, 4 MB goal, 0 MB stacks, 0 MB globals, 16 P
gc 76 は通し番号、@0.149s は開始からの経過時間、4% は開始以来GCが使った時間の割合です。続く「0.058+0.21+0.030 ms clock」が要点で、runtimeパッケージのドキュメントによると、3つの値はSTWのスイープ終了・並行のマークとスキャン・STWのマーク終了の時間です。つまり止まったのは0.058ミリ秒と0.030ミリ秒で、中央の0.21ミリ秒はアプリケーションと並行に動いた時間です。「3->3->1 MB」はGC開始時・終了時のヒープと生存ヒープ、「4 MB goal」は、このGCサイクルで設定されていた目標ヒープ量で、次のGCの開始しきい値ではありません。
回収頻度はGOGCとGOMEMLIMITの2つで決まります。GOGCの既定値は100で、目標ヒープ量は概ね「生存ヒープ+(生存ヒープ+GCルート)×GOGC÷100」で決まります。並行GCは、その目標に達する前に開始されます。値を上げるとGC回数とCPU負荷が減り、メモリ使用量が増えます。GOMEMLIMITはメモリ使用量の上限の目安で、コンテナではGoランタイムの管理外メモリなどの余裕を残して設定し、GOGCによる目標量がこの制限を超える場合にGC頻度を高めます(A Guide to the Go Garbage Collector)。
GOGC=200 GOMEMLIMIT=1GiB ./app # GC回数を減らし、メモリは1GiBを目安に抑える
Node.js:–trace-gc の Scavenge と Mark-Compact
$ node --trace-gc alloc.js
[50644:0x7fdc2780a000] 90 ms: Scavenge 4.6 (5.6) -> 3.8 (5.9) MB, pooled: 0.0 MB, 0.75 / 0.00 ms (average mu = 1.000, current mu = 1.000) allocation failure;
Scavenge は若い世代の回収、Mark-Compact は若い世代と古い世代を含むヒープ全体の回収です。「4.6 (5.6) -> 3.8 (5.9) MB」は回収前後の使用量(括弧内は確保済みの領域)、「0.75 / 0.00 ms」は回収にかかった時間です。古い世代の回収が頻繁に並び、回収後の使用量が下がらなくなっていればリークを疑います。
Python:gc.set_debug(gc.DEBUG_STATS) による世代別の回収数と所要時間
import gc
gc.set_debug(gc.DEBUG_STATS)
class N:
def __init__(s): s.p = s # 自分自身を参照する(循環参照)
for i in range(5000): N()
gc: collecting generation 0...
gc: objects in each generation: 1994 6363 0
gc: objects in permanent generation: 0
gc: done, 793 unreachable, 0 uncollectable, 0.0008s elapsed
Python 3.15.0で自分自身を参照するオブジェクトを5,000個作ったときの出力です。世代0の回収が走り、793個の到達不能オブジェクトを見つけて0.8ミリ秒で終えています。uncollectable が増え続ける場合は、回収できないオブジェクトが残っている合図です。プロセス全体の割り当ての偏りまで追うなら、本番で常時取るContinuous Profilingとは|継続的プロファイリングの仕組み・フレームグラフ・導入判断を実装目線で解説のような仕組みを併用します。
System.gc()・GC.Collect()・gc.collect()の手動呼び出しを避ける理由と例外
結論から言うと、原則として呼ばず実行環境に任せます。例外は、大量のオブジェクトが一度に不要になる区切りがはっきりしていて、手動で回収した効果を計測で確かめられた場合だけです。
Javaの System.gc() は、APIドキュメント(Java SE 25「Class System」)の表現どおり、GCを走らせるよう「提案」するだけです。しかも起動オプションで無効化できます。今回の実測では、通常起動では System.gc() の直後に循環参照の組が回収されましたが、-XX:+DisableExplicitGC を付けると同じコードで回収されず、弱参照の先は生きたままでした。
$ java Cyc.java
after System.gc enqueued: true alive: false java 25.0.4.1 gc G1 Young Generation
$ java -XX:+DisableExplicitGC Cyc.java
after System.gc enqueued: false alive: true java 25.0.4.1 gc G1 Young Generation
運用側でこのオプションを指定できるため、「System.gc() を呼んだから解放されているはず」という前提のコードは環境によって壊れます。呼ぶと重い回収を誘発しやすく、JVMが状況を見て決める回収のタイミングも乱します。C#の GC.Collect() も、Microsoft Learnは主に特別な状況やテストで使うものとしています。そのうえで誘導コレクションの解説は、コード内の特定の時点で使用メモリが大幅に減る場合などには、強制的な回収が適切なことがあるとしています。
Pythonの gc.collect() も、参照カウントで消えない循環参照を、大量のデータ処理の区切りでまとめて回収したい場面では意味があります。戻り値は回収したオブジェクト数と回収できなかったオブジェクト数の合計です。それでも毎ループで呼ぶような常用は避け、DEBUG_STATSの出力やメモリ使用量で必要性を確かめてから使ってください。
GCの停止が問題になる条件と、GC言語を採用する・見送る場面の判断
業務システムやWebアプリケーションの大半は、GCのある言語を選び、既定のGCのまま運用して問題ありません。応答時間の要件に対してGC停止の影響が十分小さいかは、本番相当の負荷で停止時間と応答時間の分布を測って判断します。GCの調整や別の選択肢を検討するのは、次の条件のどれかに当てはまるときです。
- 停止の上限が厳しい:広告入札や約定処理のように、p99で数ミリ秒を守る必要がある。まずJavaならZGC、GoならGOGC・GOMEMLIMITで要件を満たせるかを測り、満たせない部分だけをGCを持たないRustやC++で書く。
- メモリに余裕がない:組み込み機器やメモリ制限の小さいコンテナで、GCが回収を待つ分の余裕を確保できない。
- ヒープが巨大で長時間動く:数十GB級のヒープで古い世代の回収が重くなる。G1のままでよいかをGCログで確かめ、必要ならZGCを試す。Javaのヒープの構造とサイズ設定はJavaヒープとは?既定サイズ・-Xmx設定と「Java heap space」エラーの対処をJDK 25で実測を参照してください。
見送りを決める前に、GCログで「本当にGCが原因か」を確かめてください。GC停止の時刻が応答遅延と一致しない場合は、SQLや外部APIの所要時間も調べます。稼働中のシステムでメモリ使用量が増え続ける、夜間に応答が止まるといった症状の調査と改善を任せたい場合は、保守運用・内製化支援でご相談を受け付けています。
よくある質問:ガベージコレクションのメリット・発生原因・Full GC・GCログ
ガベージコレクションのメリットは何ですか?
解放漏れと二重解放・ダングリングポインタを言語の仕組みとして防げることです。代償は三つあり、回収にかかるCPU時間、回収中にスレッドを止めるStop-the-World(STW)、回収を待つ分の余分なメモリです。最近の実行環境は回収の大半をアプリケーションと並行に動かして停止を短くしており、本文のZGCの実測では1回の停止が0.03ミリ秒未満でした。
ガベージコレクションが発生する原因は何ですか?
主な原因は、新しいオブジェクトの割り当て量がしきい値を超えたことです。ほかにOSからのメモリ不足の通知や、System.gc()・GC.Collect() などの明示的な呼び出しでも起動します。GCが頻発するなら、割り当ての多い処理か、生存オブジェクトの増加(リーク)を疑います。
Full GC(フルGC)とは何ですか?
若い世代だけでなく古い世代を含むヒープ全体を対象にする回収です。停止が長くなりやすく、頻発していればヒープ不足かリークのサインです。Javaでの原因表記の読み方は関連記事で扱っています。
GCログとは何ですか?どう有効化しますか?
GCの発生時刻・種類・停止時間・回収前後のヒープ量を記録したログです。Javaは起動オプション -Xlog:gc、Goは環境変数 GODEBUG=gctrace=1、Node.jsは --trace-gc、Pythonは gc.set_debug(gc.DEBUG_STATS) で出せます。
Pythonの gc.collect() はいつ使いますか?
循環参照を含む大量のオブジェクトを作る処理の区切りで、まとめて回収したいときです。通常の参照カウントですでに解放されるオブジェクトだけなら追加回収は不要ですが、完全回収では一部の組み込み型のフリーリストも整理されるため、メモリ使用量やDEBUG_STATSの出力で循環参照が残っていると確かめてから使います。