Unityでこいう疑問、ありませんか?
- なぜGCが実行された後でも、マネージドヒープ/仮想メモリの使用量が減らないのか?
GC.Collect()を何回も実行すれば常駐メモリが減るというのは本当なのか?
1. OSメモリ構造
1-1. 物理メモリ vs 仮想メモリ
- 物理メモリ(RAM):実際のハードウェアメモリです。
- 仮想メモリ(VM):OSが各アプリケーションに付与する独立したメモリ空間です。
アプリケーションは物理メモリと直接やり取りせず、仮想メモリの上で動作します。これをサンドボックス化と呼びます。各プロセスが自分だけの隔離されたアドレス空間を持つため、他のプロセスのメモリを触ることができないという意味です。サンドボックスの中でそれぞれが自分の領域だけで遊ぶイメージですね。
この構造のおかげで、開発者は物理メモリのアドレスを直接管理する必要がなく、仮想アドレスだけを扱えばよいので、プログラミングがシンプルになります。
「仮想」メモリは本当に仮想なのか?
仮想メモリといっても、物理的な実体がまったくないわけではありません。仮想メモリの実体は、OSがRAM上で管理するページテーブルというマッピング情報です。
ページテーブル(OSがRAM上で管理)
┌──────────────┬────────────────────┬────────┐
│ 仮想アドレス │ 物理アドレス │ 状態 │
├──────────────┼────────────────────┼────────┤
│ 0x1000 │ RAM 0xA000 │ マップ済│
│ 0x2000 │ (なし) │ 未マップ│
│ 0x3000 │ ディスク offset 512 │ スワップ│
└──────────────┴────────────────────┴────────┘
1GBの仮想空間を予約しても、ページテーブルのエントリ数KB分が必要なだけで、実際に1GBの物理空間がどこかに確保されるわけではありません。「仮想」という名前がついている理由はまさにこれです。仮想メモリのアドレス空間は実際の物理ストレージと1:1で対応しない、抽象的な番号体系なのです。
仮想アドレスに対応する実際のデータは、次の3つの状態のどれかです:
- RAMにある:物理フレームにマッピングされた状態。すぐにアクセス可能。
- ディスク(スワップ)にある:メモリ圧迫によりRAMから追い出された状態。アクセス時にディスクから再びRAMにロードされます。(→ スワップの詳しい説明は1-7を参照)
- まだどこにもない:アドレスだけ予約されて一度もアクセスされていない状態。物理的にデータ自体が存在せず、ページテーブルに「未マップ」という記録だけがあります。
1-2. ページとフレーム
仮想メモリはページ単位に分割されます。
| プラットフォーム | ページサイズ |
|---|---|
| iOS / macOS / Android | 16KB |
| Windows / Linux | 4KB |
- メモリ使用量はページサイズ単位で切り上げられます。
- 総仮想メモリ割り当て = ページ数 x ページサイズ
OSはページ単位でしかメモリを割り当てません。1バイトしか必要なくても最低1ページが割り当てられ、そのページ内部をどう分割して使うかはランタイムのメモリアロケータが管理します。
仮想メモリのページは物理メモリのフレームにマッピングされます。

from: https://sites.cs.ucsb.edu/~rich/class/cs170/notes/MemoryManagement/index.html
- ページイン:仮想ページ → 物理フレームへのマッピング
- ページアウト:物理フレーム → 仮想ページへの解放
このマッピングはOSがメモリ圧迫状況に応じて自動的に管理しており、開発者やアプリケーションが直接制御することはできません。
OSはアプリケーションに必要なメモリ量をどうやって知るのか?
OSが「このゲームは1GB必要だから先に1GBあげるよ」と言ってくるわけではありません。実際には、アプリケーションの実行中にメモリが必要になるたびに、ランタイムがOSにリクエストする方式です。
- ゲームが起動するとOSが最小限の仮想アドレス空間を付与。
- ゲームコードで
new byte[1024]のような割り当てが発生。 - ランタイム(Mono/.NET)が自分の管理するヒープに空きがあるか確認。
- 空きがなければ → ランタイムがOSに「ページをもっとちょうだい」とリクエスト(
mmap、VirtualAlloc等のシステムコール)。 - OSが仮想メモリページを追加割り当て。
- 実際にアクセスした時に物理メモリにマッピング(ページフォルト)。
マリオのような軽いゲームはこのリクエストが少なく、GTAのような重いゲームはマップロード・テクスチャ・NPCなどでリクエストがはるかに多くなります。結果としてGTAがより多くのページを確保するのであって、最初から「1GB予約」されるわけではないのです。
システムコールはコストが高くないのか? システムコールはユーザーモードからカーネルモードへの切り替えが必要なため、通常の関数呼び出しに比べて数十〜数百倍遅いです。そのため、実際には毎回のnewでシステムコールが走るのではなく、ランタイムが大きなチャンク(複数ページ分)を一度にリクエストして、その中で自前で配分します。チャンクを使い切ったらそこで初めてOSに再度リクエストする方式です。
実際の流れ:
new obj1 → ランタイム内部で配分(システムコール X)
new obj2 → ランタイム内部で配分(システムコール X)
new obj3 → ランタイム内部で配分(システムコール X)
...
new obj999 → チャンク消費 → OSに新しいチャンクをリクエスト(システムコール O)
new obj1000→ 新しいチャンクから配分(システムコール X)
「OSが勝手に割り当ててくれる」という表現の正確な意味を整理すると:
- 仮想メモリの確保:ランタイムがOSに明示的にリクエストする(自動ではない)。
- 物理メモリのマッピング:OSがページフォルト時に自動的に処理する(ここが自動)。
- 物理→仮想の回収(ページアウト):OSがメモリ圧迫に応じて自動的に処理する。
1-3. 遅延割り当て(Lazy Allocation)
メモリを割り当てnewても、すぐに物理メモリに載るわけではありません。仮想メモリアドレスが確保された状態であり、実際に値を読み書きする瞬間にページフォルトが発生します。
ページフォルトとは「まだ物理メモリにマッピングされていない仮想アドレスにアクセスした」というイベントです。読み取りでも書き込みでも関係なく発生し、この時OSが物理フレームを割り当ててマッピングを完成させます。それ以降はページフォルトなしに直接アクセスできます。
C#の例:
// 1) 仮想メモリだけ予約 — 物理メモリはまだ未使用
byte[] buffer = new byte[1024 * 1024]; // 1MB配列
// 2) ここでページフォルト発生 → 物理メモリにマッピング
buffer[0] = 0xFF;
// 3) この時点から当該ページは「ダーティページ」に
ちなみにC#の配列は生成時にすべての要素をデフォルト値(0)で初期化するため、この初期化自体が書き込み動作となり、newの時点で物理マッピングが発生します。「値を入れること」と「値を変更すること」はOS視点では同じ書き込みアクセスです。ArrayとListでこのタイミングがどう異なるかについての詳細は今度別の記事で説明します。
1-4. クリーンページ vs ダーティページ
| 区分 | クリーンページ | ダーティページ |
|---|---|---|
| 定義 | 元のデータから変わっていないページ(読み取り専用等) | アプリケーションが変更または新規作成したデータを持つページ |
| 回収 | OSがいつでも解放可能(元データから再ロード可能) | すぐに削除不可能(復元元がない) |
| メモリ圧迫への寄与 | 寄与しない | 実質的なメモリ圧迫の原因 |
1-5. メモリフットプリント

from: https://www.linkedin.com/posts/retr0-jemin_%EC%9C%A0%EB%8B%88%ED%8B%B0-%EB%A9%94%EB%AA%A8%EB%A6%AC-%ED%94%84%EB%A1%9C%ED%8C%8C%EC%9D%BC%EB%9F%AC%EC%97%90-%EB%82%98%EC%98%A4%EB%8A%94-%EA%B8%B0%EA%B8%B0%EC%97%90%EC%84%9C%EC%9D%98-%EB%A9%94%EB%AA%A8%EB%A6%AC-%EC%82%AC%EC%9A%A9%EB%9F%89memory-usage-activity-7110824138201563136-bWU-?utm_source=share&utm_medium=member_desktop&rcm=ACoAAF9NlIsBpcgmlnXf6s73W-20YsXUGpUgvUc
メモリフットプリント = 全ダーティページサイズの合計
- メモリフットプリントは常駐メモリ(物理メモリ使用量)とは異なります。
- 常駐メモリにはクリーンページも含まれますが、クリーンページはいつでも破棄できるのでメモリ圧迫には寄与しません。
- したがって実質的なメモリ問題を判断する際は、ダーティページで構成されるメモリフットプリントを見るべきです。
1-6. Unix系 vs Windows
- Windows:明示的にメモリ予約(Reserve) / コミット(Commit) / デコミット(Decommit)を提供。
- Unix系(iOS, Android, macOS, Linux):こうした明示的なAPIを提供せず、OSがページ回収のタイミングを決定。
1-7. スワップ(Swap) — RAMが不足したらどうなる?
RAMがいっぱいになったからといって、すぐにシステムがダウンするわけではありません。OSはスワップまたはページファイルというメカニズムで、ディスクを一時的なRAMのように使うことができます。ただし、ディスク全体を使うのではなく、OSが予め決めたサイズ分だけ使用します。
各OS別のスワップサイズ:
| OS | スワップサイズ |
|---|---|
| Windows | 基本的にRAMの1〜3倍(例:RAM 16GB → ページファイル16〜48GB)。ユーザーが設定変更可能。 |
| Linux | インストール時に指定したスワップパーティションサイズ。通常RAMと同程度かそれ以下。0に設定も可能。 |
| macOS | ディスクの空き容量から動的に生成するが無制限ではない。 |
| iOS / Android | スワップなし。 |
メモリ圧迫がひどくなった時のOSの段階的な対応:
- クリーンページを捨てる — 元データから再ロード可能なので即座に回収。
- ダーティページをディスクに書き出す(スワップアウト) — RAMから追い出してスワップ領域に一時保存。
- スワップ領域もいっぱいになったら — アプリの強制終了またはシステムダウン。
例:RAM 16GB + スワップ 16GB
ダーティページが増え続けると:
0〜16GB → RAMで処理
16〜32GB → ディスクスワップで耐える(この時点で極度に遅い)
32GB超過 → スワップも満杯 → アプリ強制終了またはシステムダウン
ディスクはRAMより数百〜数千倍遅いので、スワップが本格的に始まるとシステムが極度に遅くなります。パソコンが突然カクカクしてハードディスクのLEDがずっと点滅している経験があれば、それがまさにスワップが発生している状況です。
モバイルではスワップがないため、メモリ圧迫がひどくなるとクリーンページを捨てた後、バックグラウンドアプリを強制終了し、それでもダメならフォアグラウンドアプリまで終了させます。モバイルゲームでメモリ管理が特に重要な理由がここにあります。
2. Unityのマネージドメモリ構造
2-1. マネージドヒープ(Managed Heap)
- すべてのC#の割り当てがここで行われます。
- Unityプロファイラーでは
GC.Allocと表記されます。
2-2. GCがメモリを即座に減らさない理由
核心:ガベージコレクタは死んだオブジェクトを「記録」するのであって、「破壊」するわけではありません。
-
GC.Collect()を呼んでも仮想メモリや物理メモリの使用量はすぐには減りません。 - 回収されたオブジェクトの空間は、OS視点ではダーティまたは物理メモリ使用中のまま残ります。
- 死んだオブジェクトが占めていた空間はフリーリストに記録され、以後の新しいオブジェクト割り当て時に再利用されます。
- 論理的には空き空間ですが、物理的には空き空間ではない状態です。
2-3. セグメントベースの管理
マネージドヒープはOSのページ単位ではなく、ブロックまたはセグメント単位で管理されます。
- セグメントは1つ以上のページで構成されます。
- 1つのセグメント内に複数のオブジェクトを割り当てることができます。
UnityのBoehm GC:1セグメント = 1ページ
UnityのBoehm GCは特異的に、セグメントサイズをOSページサイズにぴったり合わせています。 iOSではセグメント1つが16KB、Windowsでは4KBです。これはCoreCLR等の他のランタイムでセグメントを数MB単位で確保するのとは対照的です。
こうする理由は、空のセグメントをOSに返す際の効率性のためです。Boehm GCは保守的GCなのでコンパクション(オブジェクトの移動)ができません。そのためセグメント単位でまるごと空にして解放するのが唯一のメモリ回収手段であり、セグメントとページの境界が一致していればOSに「このページはもういらない」ときれいに伝えることができます。
1セグメント = 1ページの場合(Unity Boehm GC)
┌─────────┐ ┌─────────┐ ┌─────────┐
│セグメントA│ │セグメントB│ │セグメントC│
│ 全部死亡 │ │ 生存中 │ │ 全部死亡 │
│→ 解放OK │ │→ 維持 │ │→ 解放OK │
└─────────┘ └─────────┘ └─────────┘
ページ1 ページ2 ページ3
解放可能 維持 解放可能 ← きれいに回収できる
もし1セグメント = 3ページだったら?
┌────────────────────────────────────┐
│ セグメントA │
│ ページ1 ページ2 ページ3 │
│ 全部死亡 生存1個 全部死亡 │
│ 解放不可 解放不可 解放不可 │ ← 1つでも生きていれば全部解放できない
└────────────────────────────────────┘
逆にセグメントがページより小さいと、1ページの中に複数セグメントが混在し、1つのセグメントを空にしてもページ全体を解放できません。したがって1:1がBoehm GCにとって最も合理的な選択なのです。
2-4. セグメントの生成と回収の流れ
- セグメント内の空き空間が不足したら → 新しい空のセグメントを生成。
- GC実行時に死んだオブジェクトを記録。
- 1つのセグメントの全オブジェクトが死んでいる場合もあれば、
- 一部だけ死んで残りは生きている場合もあります。
- 死んだオブジェクトの空間はフリーリストに登録 → 新規割り当て時に再利用。
- 空き空間が小さすぎると活用不可能。
2-5. 空だけど「ダーティ」なセグメント
- ほとんどの場合問題ありません — 新しい割り当てのためにすぐ再利用されるからです。
- 問題になるケース:ダーティページを持つセグメントが長期間空のまま放置されると、物理メモリを不必要に占有します。
リマッピング戦略:GCが6回実行されるたびに古い空のセグメントをドロップし、同じサイズの新しいセグメントを割り当てます。ダーティページで構成されたセグメントを破棄した後、クリーンページのセグメントを新たに配置する概念です。
Windowsでは明示的なデコミットをサポートしており、GCがセグメント自体を解放せずに該当ページだけをデコミットすることができます。
2-6. GC.Collect()の複数回呼び出し禁止
- セグメントリリースを強制的に実行してはいけません。
- 一時的に空になっていたセグメントまでデコミットするのは本来の目的ではありません。
- 強制デコミット時に性能改善はなく、CPU コストだけが発生します。
- 一時的に空だったセグメントまですべてデコミットされますが、そのほとんどは次のフレームで即座に再利用されます。
2-7. Total Reserved Managed Heap
- Unityの全予約マネージドヒープは大きくなるだけで縮みません。
- これはBoehm GCの元々の特性なので、最適化の対象ではありません。
- 気にすべきはダーティページと常駐メモリです。
- フレームワークやプラグインが仮想メモリを使っても、変わらないデータはクリーンページなのでメモリ圧迫には大きく寄与しません。
3. LOH(Large Object Heap)
- 大型オブジェクトはGCが小型オブジェクトとは異なるコードで特別管理します。
- Unityの基準:ページサイズの半分(モバイル8KB / デスクトップ2KB)を超えると大型オブジェクト。
- LOHは複数のセグメントを合わせて大型オブジェクトを収容できるほど大きな1つのコンテナを構成します。
- 各大型オブジェクト専用で管理され、管理コードも小型とは実装が異なります。
- セグメントの空き空間を他の小さい割り当てに活用できないため、メモリの断片化を引き起こします。
4. UnityのIncremental Boehm GC
4-1. 保守的(Conservative) GC
- 安定性 > 性能の哲学。
- ポインタのように見えるすべての値を参照として扱います。
- GC中にオブジェクトの位置を変更しない → コンパクション(compaction)非対応。
- 保守的GCは本当のポインタかどうかを正確に判別できないため、オブジェクトを移動させると誤ったポインタが壊れる危険があり、根本的にコンパクションが不可能です。
- 結果としてメモリの断片化に弱いです。
- 世代別(Generational) GCも非対応 — すべてのオブジェクトを同等に扱い、ヒープ全体をスキャンします。
4-2. コンパクションとは?
生きているオブジェクトをメモリの一方に寄せて、空き空間を連続的に確保する手法です。断片化を解消して大きなオブジェクトも割り当て可能になります。Boehm GCでは使えません。
5. CoreCLR GC(Unityの未来の方向性)
Unityは従来Monoのレガシーフレームワークをベースにしていたため、.NETの最新変更に追従するのが難しい状況でしたが、CoreCLRの導入でこれを解消できます。
CoreCLR GCの利点
| 特性 | Boehm GC | CoreCLR GC |
|---|---|---|
| GCタイプ | 保守的(Conservative) | 正確(Precise) — マネージド参照を正確に判別 |
| コンパクション | 非対応 | 対応 — メモリ断片化の減少 |
| 世代別回収 | 非対応 | 対応 |
| .NET互換性 | 最新変更の反映が困難 | .NETの変更にすぐ追従可能 |
世代別GC
オブジェクトの寿命に応じて回収周期を分けて最適化します:
- Gen0:新しく生成されたオブジェクト → 最も頻繁に回収
- Gen1:Gen0で生き残ったオブジェクト → 中程度の頻度
- Gen2:長期生存したオブジェクト → 最も稀に回収
ほとんどのオブジェクトは短い寿命を持つため、Gen0だけを頻繁に回収するだけでほとんどのガベージが処理でき、ヒープ全体を毎回スキャンするよりはるかに効率的です。
Boehm GCでは世代別GCに対応していません。保守的GCの特性上ポインタを正確に識別できないため、オブジェクトを世代別に分類して移動させることが構造的に不可能だからです。すべてのオブジェクトを同等に扱ってヒープ全体をスキャンすることが、Boehm GCとCoreCLR GCの核心的な違いです。
まとめ
- GCを実行してもメモリ使用量がすぐに減らない理由:GCは死んだオブジェクトを「記録」するだけであり、その空間はOS視点ではダーティメモリのまま残ります。
-
GC.Collect()の複数回呼び出しは無意味:CPUコストだけが発生し、デコミットされたセグメントはほとんどが次のフレームで即座に再利用されます。 - Total Reserved Managed Heapが縮まないのは正常:Boehm GCの設計上の特性であり、最適化の対象ではありません。
- 本当に気にすべきはダーティページとメモリフットプリントです。
- モバイルにはスワップがない:メモリ圧迫時にアプリが強制終了されるため、モバイルゲームではメモリ管理が特に重要です。