目次
1. はじめに:なぜメモリ管理を知るべきか
メモリ管理(かんり)はすべてのプログラミング言語(げんご)の基盤(きばん)です。GC(ガベージコレクション)が自動的にメモリを管理してくれるとしても、内部(ないぶ)動作(どうさ)を理解(りかい)してこそパフォーマンス問題(もんだい)の診断(しんだん)やメモリリークの解決(かいけつ)ができます。
このガイドで扱う主要トピック:
- メモリ基礎(きそ):仮想(かそう)メモリ、ページ、TLB
- StackとHeapの構造と違い
- GCアルゴリズム:Reference Counting、Mark-and-Sweep、Generational
- V8(JavaScript)、JVM(Java)、Python、GoのGC戦略
- Rust所有権システム(GCなしのメモリ安全)
- メモリリーク検出(けんしゅつ)とデバッグ
メモリ管理 全体構造
====================
[プロセス仮想メモリ空間]
+---------------------------+ High Address
| Kernel Space |
+---------------------------+
| Stack (grows downward) | <-- 関数呼び出し、ローカル変数
| ... |
| ...free... |
| ... |
| Heap (grows upward) | <-- 動的割当 (new, malloc)
+---------------------------+
| BSS (uninitialized data) |
| Data (initialized data) |
| Text (code) |
+---------------------------+ Low Address
2. メモリ基礎
2.1 仮想メモリ(Virtual Memory)
仮想メモリ構造
==============
プロセスAの仮想アドレス空間 物理メモリ(RAM)
+-------------------+ +-------------------+
| Page 0: 0x0000 | -------> | Frame 5 |
| Page 1: 0x1000 | -------> | Frame 12 |
| Page 2: 0x2000 | --+ | Frame 3 |
| Page 3: 0x3000 | | | ... |
+-------------------+ | +-------------------+
|
+-----> [Disk (Swap)]
Page Table(プロセスごと):
+-------+-------+-------+-------+
| VPN 0 | VPN 1 | VPN 2 | VPN 3 |
| PFN:5 | PFN:12| Swap | PFN:8 |
| V=1 | V=1 | V=0 | V=1 |
+-------+-------+-------+-------+
V=1: 物理メモリに存在
V=0: ディスク(swap)に存在 -> Page Fault発生
2.2 TLB(Translation Lookaside Buffer)
TLBの動作
=========
CPUが仮想アドレスにアクセス:
1. TLBを確認(キャッシュ)
- Hit: すぐに物理アドレス取得(1-2サイクル)
- Miss: Page Table走査(数十〜数百サイクル)
2. Page Table検索
- ページがメモリにある: PFNを返す
- ページがない: Page Fault -> OSがディスクからロード
TLBのヒット率は99%以上でなければ正常なパフォーマンスとは言えない
2.3 Memory-Mapped I/O
mmapの動作原理
==============
通常のファイルI/O:
[プロセス] -> read() -> [カーネルバッファ] -> [ユーザバッファ]
データコピーが2回発生!
Memory-Mapped I/O:
[プロセス仮想メモリ] <--直接マッピング--> [ファイル]
利点:
- カーネル/ユーザ間のデータコピーを排除
- OSページキャッシュを活用
- 大容量ファイル処理に効率的
3. Stack(スタック)
3.1 Call Stack構造
Call Stackの動作
================
void main() {
int x = 10;
foo(x); // <-- 現在の実行ポイント
}
void foo(int a) {
int y = 20;
bar(a, y);
}
void bar(int p, int q) {
int z = p + q;
}
Stack(下から上に成長):
+------------------------+
| barのStack Frame | <-- Stack Pointer (SP)
| z = 30 |
| q = 20 |
| p = 10 |
| Return Address (foo) |
+------------------------+
| fooのStack Frame |
| y = 20 |
| a = 10 |
| Return Address (main)|
+------------------------+
| mainのStack Frame |
| x = 10 |
| Return Address (OS) |
+------------------------+ <-- Stack Base
3.2 Stack Frame詳細
Stack Frame構造 (x86-64)
==========================
High Address
+----------------------------+
| Argument N | (レジスタに入らない引数)
| ... |
| Argument 7 |
+----------------------------+
| Return Address | (CALL命令がpush)
+----------------------------+
| Saved Base Pointer (RBP) | <-- Frame Pointer
+----------------------------+
| Local Variable 1 |
| Local Variable 2 |
| ... |
| Saved Registers |
+----------------------------+ <-- Stack Pointer (RSP)
Low Address
アクセス方式:
- ローカル変数: [RBP - offset]
- 関数引数: [RBP + offset]
3.3 Stack Overflow
Stack Overflowの発生原因
========================
1. 無限再帰(最も一般的な原因)
void infinite() {
infinite(); // Stack Frameが積み上がり続ける
}
2. 大きすぎるローカル変数
void largeLocal() {
int arr[10000000]; // スタック上に40MB!
}
3. 深すぎる再帰
int fibonacci(int n) {
if (n <= 1) return n;
return fibonacci(n-1) + fibonacci(n-2);
// fib(50)は数十億回の再帰呼び出し
}
Stackサイズ制限:
- Linuxデフォルト: 8MB (ulimit -s)
- Windowsデフォルト: 1MB
- スレッドごとに別のStack割当
解決策:
- 再帰をループに変換
- Tail Call Optimization (TCO) 活用
- スタックサイズの増加(一時的対処)
4. Heap(ヒープ)
4.1 動的メモリ割当
Heap割当プロセス
=================
[プロセスHeap領域]
+------------------------------------------+
| [Used: 32B] [Free: 64B] [Used: 128B] |
| [Free: 256B] [Used: 16B] [Free: 512B] |
+------------------------------------------+
malloc(100)呼び出し:
1. Free Listで100B以上のブロックを検索
2. [Free: 256B]ブロックを発見
3. 100B割当 + 残り156BをFree Listに維持
結果:
+------------------------------------------+
| [Used: 32B] [Free: 64B] [Used: 128B] |
| [Used: 100B] [Free: 156B] [Used: 16B] ...|
+------------------------------------------+
4.2 メモリフラグメンテーション
メモリフラグメンテーションの種類
================================
External Fragmentation(外部断片化):
+---+----+---+--------+---+------+---+
|Use|Free|Use| Free |Use| Free |Use|
|32B| 8B |64B| 16B |32B| 12B |16B|
+---+----+---+--------+---+------+---+
Free合計: 36Bだが連続最大16B!
-> 20B割当不可(十分な連続空間なし)
Internal Fragmentation(内部断片化):
要求: 100B、割当: 128B(16Bアライメント)
-> 28B無駄
解決方法:
- Compaction: 使用中のブロックを一方に寄せる(移動コスト高)
- Buddy System: 2のべき乗サイズに分割
- Slab Allocator: 固定サイズオブジェクトプール
4.3 メモリアロケータ比較
メモリアロケータ比較
====================
1. glibc malloc (ptmalloc2)
- Linuxデフォルトアロケータ
- スレッドごとのarenaでロック競合を軽減
- 小さい割当: fastbin(LIFO、lock-free)
- 大きい割当: mmap直接使用
2. jemalloc (Facebook/Meta)
- FreeBSDデフォルト、Redisが使用
- Thread Cache -> Bin -> Run -> Chunk
- サイズクラス別の細分類で断片化最小化
- 優れたプロファイリング/統計機能
3. tcmalloc (Google)
- Thread-Caching Malloc
- Thread Local Cache: 小オブジェクトのlock-free割当
- Central Free List: スレッド間共有
- Page Heap: OSから大ブロック割当
- Goランタイムのメモリアロケータの基盤
パフォーマンス比較(相対的):
+----------+---------+---------+--------------+
| | 速度 | メモリ | マルチスレッド |
+----------+---------+---------+--------------+
| ptmalloc | 普通 | 普通 | 普通 |
| jemalloc | 速い | 良好 | 良好 |
| tcmalloc | 非常速 | 良好 | 非常に良好 |
+----------+---------+---------+--------------+
5. GCアルゴリズム基礎
5.1 Reference Counting
Reference Countingの動作
=========================
let a = new Object(); // Objectのref_count = 1
let b = a; // Objectのref_count = 2
a = null; // Objectのref_count = 1
b = null; // Objectのref_count = 0 -> 即座に解放!
利点:
- 即時解放: 参照カウントが0になるとすぐにメモリ返還
- 予測不能な一時停止なし
- 実装が比較的シンプル
欠点(致命的):
- 循環参照を解決できない!
循環参照の例:
let a = {}; // a.ref_count = 1
let b = {}; // b.ref_count = 1
a.ref = b; // b.ref_count = 2
b.ref = a; // a.ref_count = 2
a = null; // a_obj.ref_count = 1(b.refがまだ参照)
b = null; // b_obj.ref_count = 1(a_obj.refがまだ参照)
// 両方ともref_count > 0だがアクセス不可 -> メモリリーク!
5.2 Mark-and-Sweep
Mark-and-Sweepの動作
=====================
Phase 1: Mark(マーキング)
- GC Root(スタック、グローバル変数、レジスタ)から開始
- 到達可能なすべてのオブジェクトにmarkビットを設定
Phase 2: Sweep(スイープ)
- ヒープ全体を走査し、markされていないオブジェクトを解放
GC Root --> [A] --> [B] --> [C]
|
+--> [D]
[E] --> [F] (GC Rootから到達不可)
^ |
+-------+ (循環参照だが到達不可 -> 回収される!)
Mark結果: A(marked), B(marked), C(marked), D(marked)
Sweep結果: E(解放), F(解放)
利点: 循環参照を処理可能
欠点: Stop-the-World一時停止、ヒープの断片化
5.3 Mark-Compact
Mark-Compactの動作
===================
Mark-and-Sweepの断片化問題を解決
Before:
[A][Free][B][Free][Free][C][Free][D]
After Mark-Compact:
[A][B][C][D][ Free ]
プロセス:
1. Mark: 到達可能オブジェクトをマーキング
2. Compact: 生存オブジェクトを一方に移動
3. Update References: 移動したオブジェクトの参照を更新
利点: 断片化排除、連続フリースペース確保
欠点: オブジェクト移動コスト、参照更新コスト
5.4 Copying GC
Copying GCの動作
=================
メモリを2つのセミスペースに分割
From-Space(現在使用中):
[A][garbage][B][garbage][C][garbage]
To-Space(空):
[ ]
Copyプロセス:
1. GC Rootから到達可能なオブジェクトのみTo-Spaceにコピー
2. From-SpaceとTo-Spaceの役割を交換
After:
From-Space(旧To-Space):
[A][B][C][ Free ]
利点: 断片化なし、割当が非常に高速(bump pointer)
欠点: メモリ使用量2倍、長寿命オブジェクトの繰り返しコピー
5.5 Tri-color Marking(三色マーキング)
Tri-color Marking
==================
色の定義:
- White(白): まだ訪問していない(GC対象候補)
- Gray(灰): 訪問したが子をまだ全て処理していない
- Black(黒): 訪問完了、すべての子も処理済み
初期状態: Rootのみ灰色、それ以外はすべて白
Step 1: [Root:Gray] -> [A:White] -> [B:White]
[C:White]
Step 2: Rootを処理、子Aを灰色に
[Root:Black] -> [A:Gray] -> [B:White]
[C:White]
Step 3: Aを処理、子Bを灰色に
[Root:Black] -> [A:Black] -> [B:Gray]
[C:White]
Step 4: Bを処理(子なし)
[Root:Black] -> [A:Black] -> [B:Black]
[C:White]
結果: Cは白のまま -> 回収対象
利点: インクリメンタルGCが可能
Concurrent GCの基盤アルゴリズム
6. Generational GC(世代別GC)
6.1 弱い世代仮説
弱い世代仮説
=============
「ほとんどのオブジェクトは生成直後にすぐ使われなくなる」
オブジェクト寿命分布:
|
|*
|**
|****
|********
|*****************
|*******************************************
+------------------------------------------------->
短い 長い寿命
90%以上のオブジェクトが最初のGC前に死ぬ!
-> 若いオブジェクトを頻繁に、古いオブジェクトを稀に回収すれば効率的
6.2 JVM世代別GC構造
JVMヒープメモリ構造
===================
+---------------------------------------------------+
| Young Generation(全体の1/3) |
| +--------+----------+----------+ |
| | Eden | Survivor | Survivor | |
| | | S0 | S1 | |
| | (80%) | (10%) | (10%) | |
| +--------+----------+----------+ |
+---------------------------------------------------+
| Old Generation(全体の2/3) |
| +---------------------------------------------+ |
| | Tenured Space | |
| +---------------------------------------------+ |
+---------------------------------------------------+
| Metaspace(クラスメタデータ、ネイティブメモリ) |
+---------------------------------------------------+
オブジェクトのライフサイクル:
1. Edenで生成
2. Minor GC: Eden生存オブジェクト -> S0 (age=1)
3. 次のMinor GC: Eden+S0生存オブジェクト -> S1 (age++)
4. S0とS1を交互に使用(Copying GC)
5. ageが閾値(デフォルト15)に到達 -> Old Generationへ昇格
6. Old Generationが満杯 -> Major GC (Full GC)
6.3 Minor GC vs Major GC
GCタイプ比較
=============
Minor GC(Young GC):
- 対象: Young Generation (Eden + Survivor)
- 頻度: 非常に頻繁(秒〜分単位)
- 時間: 数ms〜数十ms
- アルゴリズム: Copying GC
- STW: 短い一時停止
Major GC(Old GC):
- 対象: Old Generation
- 頻度: 稀に(分〜時間単位)
- 時間: 数百ms〜数秒
- アルゴリズム: Mark-SweepまたはMark-Compact
- STW: 長い一時停止(問題!)
Full GC:
- 対象: Young + Old + Metaspace
- 頻度: 非常に稀に
- 時間: 数秒〜数十秒
- 発生しないようチューニングすべき!
7. V8 GC(JavaScript)
7.1 V8 Orinoco GCアーキテクチャ
V8 Orinoco GC構造
==================
V8 Heap:
+------------------+---------------------------+
| Young Generation | Old Generation |
| (Semi-space) | (Mark-Sweep/Mark-Compact) |
| | |
| [From] [To] | [Old Space] |
| 1-8MB 1-8MB | [Large Object Space] |
| | [Code Space] |
| | [Map Space] |
+------------------+---------------------------+
Young Gen: Scavenger (Copying GC)
Old Gen: Mark-Compact(主要)、Mark-Sweep(補助)
7.2 Scavenger(Young Generation GC)
Scavengerの動作
================
Semi-spaceモデル: From-SpaceとTo-Space
1. オブジェクトがFrom-Spaceに割当
2. From-Spaceが満杯になるとScavenge開始
3. 生存オブジェクトをTo-Spaceにコピー
4. 2回生き残ったオブジェクトはOld Generationへ昇格
5. From/To Spaceの役割を交換
割当(Bump Pointer):
[allocated | allocated | ... | --> free space ]
^
allocation pointer
次の割当: ポインタをサイズ分進める -> O(1)!
7.3 Major GC(Old Generation)
V8 Major GC最適化技法
=====================
1. Incremental Marking(段階的マーキング)
- 全体マーキングを小さな単位に分割
- アプリケーション実行と交互に実行
[App][Mark][App][Mark][App][Mark]...[Sweep]
vs. 従来:
[App]......[ Mark | Sweep ]......[App]
^--- 長いSTW pause ---^
2. Concurrent Marking(並行マーキング)
- 別スレッドでマーキングを実行
- メインスレッドをほぼブロックしない
3. Lazy Sweeping(遅延スイープ)
- すぐにすべてのメモリをスイープしない
- 新しい割当が必要な時に段階的にsweep
4. Idle-time GC
- requestIdleCallbackのようなアイドル時間を活用
- フレーム間の空き時間にGCを実行
8. JVM GC戦略
8.1 JVM GC種類比較
JVM GC比較表
=============
+-------------+----------+---------+--------+------------+
| GC | 世代別 | 並行性 | STW | 適合環境 |
+-------------+----------+---------+--------+------------+
| Serial | O | X | 長い | 小ヒープ |
| Parallel | O | 部分 | 中 | スループット|
| CMS | O | O | 短い | レイテンシ |
| G1 | Region | O | 短い | 汎用 |
| ZGC | Region | O | 極短 | 大容量ヒープ|
| Shenandoah | Region | O | 極短 | 大容量ヒープ|
+-------------+----------+---------+--------+------------+
8.2 G1 GC(Garbage First)
G1 GC構造
==========
ヒープを同じサイズのRegionに分割(1-32MB)
+---+---+---+---+---+---+---+---+
| E | S | O | O | H | E | E | |
+---+---+---+---+---+---+---+---+
| O | | E | S | O | O | | E |
+---+---+---+---+---+---+---+---+
E: Eden Region
S: Survivor Region
O: Old Region
H: Humongous Region(Regionサイズの50%超のオブジェクト)
(空白): Free Region
動作:
1. Young GC: Eden/Survivor Regionのみ収集
2. Concurrent Marking: ヒープ全体のliveness分析
3. Mixed GC: Young + ガベージが多いOld Regionを選択的に収集
-> "Garbage First" = ガベージが最も多いRegionから収集
設定:
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200 // 目標STW時間
-XX:G1HeapRegionSize=4m // Regionサイズ
8.3 ZGC
ZGCの核心特徴
=============
目標: どのヒープサイズでもSTWを1ms未満に維持
核心技術:
1. Colored Pointers(色付きポインタ)
- 64ビットポインタの上位ビットをメタデータとして活用
- アドレス空間: 4TB(42ビット)
2. Load Barriers(ロードバリア)
- オブジェクト参照を読むたびにバリアコードを実行
- オブジェクトが移動されたか確認し参照を更新
3. Concurrent Relocation(並行再配置)
- アプリケーション実行中にオブジェクトを移動
- Load Barrierが移動されたオブジェクトを即座に更新
設定:
-XX:+UseZGC
-XX:SoftMaxHeapSize=4g
-XX:ZCollectionInterval=5
8.4 GCチューニングガイド
JVM GCチューニング実践
=======================
1. ヒープサイズ設定
-Xms4g -Xmx4g // 初期/最大ヒープサイズを同一に
-XX:NewRatio=2 // Old:Young = 2:1
-XX:SurvivorRatio=8 // Eden:S0:S1 = 8:1:1
2. GC選択ガイド
- ヒープが小さくシンプル: Serial GC
- スループット最優先: Parallel GC
- レスポンス時間が重要: G1 GC
- 大容量ヒープ、超低レイテンシ: ZGCまたはShenandoah
3. GCログ分析
-Xlog:gc*:file=gc.log:time,uptime,level,tags
確認ポイント:
- GCの頻度と所要時間
- Young GCとOld GCの比率
- Allocation Rate vs Promotion Rate
- Full GCの発生有無(発生すると問題!)
4. 注意事項
- Full GCが繰り返されるとメモリリークの疑い
- Promotion Rateが高い場合Youngサイズ増加を検討
- Humongous割当が多い場合Regionサイズを増加
9. Python GC
9.1 Reference Counting + Generational Cycle Collector
Python GCの二重戦略
====================
1次: Reference Counting(デフォルト)
- すべてのオブジェクトに参照カウントを維持
- カウントが0になると即座に解放
- CPythonの中核メモリ管理
2次: Generational Cycle Collector(循環参照処理)
- 3つの世代: Generation 0, 1, 2
- 新しいオブジェクトはGen 0に割当
- 生存すると次の世代に昇格
収集頻度:
Gen 0: 非常に頻繁(700割当ごと)
Gen 1: 時々(Gen 0が10回収集されるごと)
Gen 2: 稀に(Gen 1が10回収集されるごと)
# Python循環参照の例
import gc
class Node:
def __init__(self, value):
self.value = value
self.next = None
# 循環参照を作成
a = Node(1)
b = Node(2)
a.next = b # a -> b
b.next = a # b -> a(循環!)
# Reference Countingでは解放不可
del a
del b
# Cycle Collectorが検出して解放
gc.collect()
# GC統計を確認
print(gc.get_stats())
10. Go GC
10.1 Concurrent Tri-color Mark-Sweep
Go GCの特徴
============
- Non-generational(世代別区分なし)
- Concurrent tri-color mark-sweep
- 非常に短いSTW(通常0.1ms未満)
- GOGCでGC頻度を調整
GCサイクル:
1. Mark Setup (STW): write barrier有効化 (~0.1ms)
2. Concurrent Mark: アプリケーションと同時実行
3. Mark Termination (STW): マーキング完了確認 (~0.1ms)
4. Concurrent Sweep: バックグラウンドでメモリ回収
10.2 GOGCチューニング
// Go GCチューニング
import "runtime/debug"
// GOGC: ヒープ成長率を設定(デフォルト100)
// 100 = ライブヒープが2倍になるとGC実行
// 50 = ライブヒープが1.5倍になるとGC実行
debug.SetGCPercent(100)
// GOMEMLIMIT: メモリ上限を設定(Go 1.19+)
debug.SetMemoryLimit(4 * 1024 * 1024 * 1024) // 4GB
// GC統計を確認
var stats runtime.MemStats
runtime.ReadMemStats(&stats)
fmt.Printf("Alloc: %d MB\n", stats.Alloc/1024/1024)
fmt.Printf("NumGC: %d\n", stats.NumGC)
11. Rust所有権システム
11.1 GCなしのメモリ安全
// Rust所有権ルール
// 1. 各値は1つの所有者(owner)のみを持つ
// 2. 所有者がスコープを抜けると値は解放される(RAII)
// 3. 所有権は移動(move)または借用(borrow)できる
fn main() {
let s1 = String::from("hello"); // s1が所有者
let s2 = s1; // 所有権がs2に移動(move)
// println!("{}", s1); // コンパイルエラー!s1はもう無効
println!("{}", s2); // OK
} // s2がスコープを抜ける -> Stringメモリ解放
// Stack vs Heap割当:
fn example() {
let x = 5; // Stack(Copy trait)
let y = x; // Stackコピー(Copy)
println!("{} {}", x, y); // 両方有効
let s1 = String::from("hello"); // Heap割当
let s2 = s1; // Move(所有権移動)
// s1はもう使用不可
}
11.2 借用(Borrowing)
// 不変借用(Immutable Borrow)- 複数可能
fn calculate_length(s: &String) -> usize {
s.len()
} // sは参照を借りただけなので元のデータに影響なし
// 可変借用(Mutable Borrow)- 1つのみ
fn change(s: &mut String) {
s.push_str(" world");
}
fn main() {
let mut s = String::from("hello");
// 不変借用: 複数OK
let r1 = &s;
let r2 = &s;
println!("{} {}", r1, r2);
// 可変借用: 1つのみ!
let r3 = &mut s;
// let r4 = &mut s; // コンパイルエラー!同時に2つの可変借用は不可
r3.push_str("!");
// 核心ルール:
// - 不変参照N個 OR 可変参照1個(同時に不可)
// - 参照は常に有効でなければならない(ダングリング参照防止)
}
11.3 ライフタイム(Lifetime)
// ライフタイムアノテーションで参照の有効範囲を明示
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
// 'aはxとyのうち短いライフタイムに従う
fn main() {
let string1 = String::from("long string");
let result;
{
let string2 = String::from("xyz");
result = longest(string1.as_str(), string2.as_str());
println!("{}", result); // OK: string2がまだ有効
}
// println!("{}", result); // コンパイルエラー!string2はすでに解放
}
11.4 Rust vs GC言語比較
Rust vs GC言語のメモリ管理比較
==============================
+------------------+-----------+----------+--------+
| | Rust | Java/Go | C/C++ |
+------------------+-----------+----------+--------+
| メモリ安全 | コンパイル | ランタイム| 手動 |
| GC pause | なし | あり | なし |
| メモリオーバーヘッド | 低い | 高い | 低い |
| Use-after-free | 不可 | 不可 | 可能! |
| Double free | 不可 | 不可 | 可能! |
| Memory leak | 可能* | 可能* | 可能 |
| 学習曲線 | 非常高 | 低い | 高い |
+------------------+-----------+----------+--------+
12. メモリリーク検出とデバッグ
12.1 JavaScriptメモリリークパターン
// パターン1: 削除されないイベントリスナー
class Component {
constructor() {
// リーク!コンポーネント削除時にリスナーが残る
window.addEventListener('resize', this.handleResize);
}
handleResize = () => {
// thisの参照によりComponentがGCされない
}
// 解決: クリーンアップメソッド
destroy() {
window.removeEventListener('resize', this.handleResize);
}
}
// パターン2: クロージャが外部変数をキャプチャ
function createLeak() {
const hugeData = new Array(1000000).fill('x');
// この関数が生きている限りhugeDataも解放されない
return function() {
console.log(hugeData.length);
};
}
const leakyFn = createLeak();
// パターン3: グローバルキャッシュの無限成長
const cache = {};
function addToCache(key, value) {
cache[key] = value; // キャッシュサイズ制限なし!
}
// 解決: WeakMapまたはLRU Cacheを使用
const weakCache = new WeakMap();
12.2 Chrome DevTools Heap Snapshot
Chrome DevToolsメモリ分析
==========================
1. Heap Snapshotの撮影
DevTools -> Memory -> Take Heap Snapshot
2. 比較分析(3 Snapshot Technique)
a) アプリの初期状態でSnapshot 1を撮影
b) 疑わしい操作を実行
c) Snapshot 2を撮影
d) 同じ操作を繰り返す
e) Snapshot 3を撮影
f) Snapshot 2と3の差分を分析
-> 繰り返すごとに増加するオブジェクト = リーク!
3. Allocation Timeline
DevTools -> Memory -> Allocation instrumentation on timeline
- 時間経過によるメモリ割当パターンを可視化
- 青い棒: 割当後まだ生存
- 灰色の棒: 割当後GC済み
4. Retainersの確認
特定オブジェクトを選択すると:
- Distance: GC Rootからの距離
- Retained Size: このオブジェクトがGCされると解放される総サイズ
- Retainers: このオブジェクトを参照している参照チェーン
12.3 Node.jsメモリデバッグ
// Node.jsメモリモニタリング
const v8 = require('v8');
function logMemory() {
const heap = v8.getHeapStatistics();
console.log({
total_heap_size: `${(heap.total_heap_size / 1024 / 1024).toFixed(2)} MB`,
used_heap_size: `${(heap.used_heap_size / 1024 / 1024).toFixed(2)} MB`,
heap_size_limit: `${(heap.heap_size_limit / 1024 / 1024).toFixed(2)} MB`
});
}
setInterval(logMemory, 5000);
// --inspectフラグで実行してChrome DevToolsを接続
// node --inspect app.js
// コードからHeap Snapshotを生成
const { writeHeapSnapshot } = require('v8');
if (process.memoryUsage().heapUsed > 500 * 1024 * 1024) {
writeHeapSnapshot();
console.log('Heap snapshot written!');
}
12.4 JVMメモリデバッグ
# jmap: ヒープダンプ生成
jmap -dump:format=b,file=heap_dump.hprof <PID>
# jstat: GC統計リアルタイムモニタリング
jstat -gcutil <PID> 1000
# S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
# 0.00 45.23 67.89 34.56 97.12 95.00 150 1.234 3 0.567 1.801
# カラムの意味:
# S0/S1: Survivor Space使用率
# E: Eden Space使用率
# O: Old Generation使用率
# YGC/YGCT: Young GC回数/時間
# FGC/FGCT: Full GC回数/時間(FGCが増加すると問題!)
VisualVM / Eclipse MAT分析
============================
1. Heap Dumpを開く(hprofファイル)
2. Dominator Treeの確認
- 最もメモリを保持しているオブジェクトを探す
- Shallow Size: オブジェクト自体のサイズ
- Retained Size: オブジェクト + このオブジェクトのみが参照する全オブジェクトのサイズ
3. Leak Suspects Report
- 自動的に疑わしいパターンを検出
4. OQL (Object Query Language)
SELECT * FROM java.util.HashMap WHERE size > 10000
-> 異常に大きなコレクションを探す
13. 一般的なメモリリークパターンまとめ
13.1 言語別の主要リーク原因
メモリリークの主要原因
=====================
JavaScript/Node.js:
1. グローバル変数にデータ蓄積
2. 削除されないイベントリスナー
3. setInterval/setTimeout未整理
4. クロージャが大きなオブジェクトをキャプチャ
5. Detached DOMノード
Java:
1. staticコレクションへの無限蓄積
2. ThreadLocal未整理
3. コネクション/ストリーム未クローズ
4. カスタムClassLoaderリーク
5. リスナー/コールバック未解放
Python:
1. 循環参照(gc.collect()で解決可能)
2. __del__メソッドがある循環参照(gcが回収できない場合あり)
3. C拡張モジュールのメモリ管理エラー
Go:
1. goroutineリーク(終了しないgoroutine)
2. time.Afterの繰り返し使用(timer蓄積)
3. スライスの基底配列参照の保持
4. sync.Poolの誤用
13.2 Goroutineリーク(Go)
// goroutineリークの例
func leakyFunction() {
ch := make(chan int)
go func() {
val := <-ch // このチャネルに誰も送信しなければ永遠に待機!
fmt.Println(val)
}()
// chに値を送信せずに関数終了
// goroutineは永遠に生存 -> リーク!
}
// 解決: contextを使ったキャンセル
func properFunction(ctx context.Context) {
ch := make(chan int)
go func() {
select {
case val := <-ch:
fmt.Println(val)
case <-ctx.Done():
return // contextキャンセル時にgoroutine終了
}
}()
}
14. プロファイリングツール比較
メモリプロファイリングツール比較
================================
JavaScript/Node.js:
+----------------------------+--------------------+
| ツール | 用途 |
+----------------------------+--------------------+
| Chrome DevTools Memory | ブラウザヒープ分析 |
| node --inspect | Node.jsリモートデバッグ|
| clinic.js | パフォーマンス分析 |
| heapdumpパッケージ | プログラム的アクセス|
+----------------------------+--------------------+
Java:
+----------------------------+--------------------+
| VisualVM | 汎用プロファイリング|
| Eclipse MAT | ヒープダンプ分析 |
| JFR (Flight Recorder) | 低オーバーヘッド |
| async-profiler | CPU+ヒーププロファイル|
+----------------------------+--------------------+
Go:
+----------------------------+--------------------+
| pprof | 内蔵プロファイラ |
| runtime.ReadMemStats | ランタイム統計 |
| go tool trace | 実行トレース |
+----------------------------+--------------------+
Rust:
+----------------------------+--------------------+
| Valgrind (Massif) | ヒーププロファイリング|
| heaptrack | ヒープ追跡 |
| DHAT | 動的ヒープ分析 |
+----------------------------+--------------------+
15. クイズ
メモリ管理とガベージコレクションの理解度をチェックしましょう。
Q1. Mark-and-Sweep GCがReference Countingより循環参照処理に優れている理由は?
A: Reference Countingは各オブジェクトの参照数を追跡するため、AとBが互いに参照し合うと両方の参照カウントが1以上を維持し、永遠に解放されません。一方、Mark-and-SweepはGC Root(スタック、グローバル変数)から到達可能なオブジェクトのみをマークするため、循環参照があってもGC Rootから到達できなければマークされず回収されます。つまり「到達可能性(reachability)」に基づくため、循環参照に関係なく使用中のオブジェクトのみを正確に識別します。
Q2. JVMでFull GCが頻発する原因と解決方法は?
A: Full GCが頻発する主な原因は以下の通りです。(1) Old Generationが小さすぎて頻繁に満杯になる。ヒープサイズ(-Xmx)を増やすかOld比率を調整します。(2) メモリリークによりOld Genにオブジェクトが蓄積し続ける。ヒープダンプを分析してリーク原因を特定します。(3) Young Generationが小さすぎてオブジェクトが早期昇格する。-XX:NewRatioや-XX:NewSizeを調整します。(4) Humongous割当が頻繁でOld Genが急速に埋まる(G1)。Regionサイズを増やします。
Q3. Rustの所有権システムで同時に可変借用を2つ許可しない理由は?
A: データレース(data race)をコンパイル時に防止するためです。データレースは、2つ以上のポインタが同じデータに同時にアクセスし、少なくとも1つが書き込みを行い、アクセスが同期されていない場合に発生します。Rustの「不変参照N個 OR 可変参照1個」というルールは、この3条件のうち1つを構造的に排除します。可変借用が1つだけであれば、他のコードが同時に同じデータを読み書きできないため、GCなしでメモリ安全性とスレッド安全性を同時に保証します。
Q4. V8のIncremental Markingが従来のMark-and-Sweepより優れている点は?
A: 従来のMark-and-Sweepはマーキングフェーズ全体を一度に実行するため、ヒープが大きい場合数百msのStop-the-World(STW)一時停止が発生します。これは60fps描画(フレームあたり16.6ms)で深刻なカクつきを引き起こします。Incremental Markingはマーキング作業を小さな単位に分割し、アプリケーション実行と交互に実行します。各マーキング単位は短時間しかかからないため、ユーザーが感じる一時停止が大幅に軽減されます。
Q5. Chrome DevToolsの3-Snapshot Techniqueでメモリリークを検出するプロセスを説明してください。
A: (1) アプリの初期状態で最初のヒープスナップショットを撮影します。(2) リークが疑われる操作(例:ページ遷移後に戻る)を実行します。(3) 2番目のヒープスナップショットを撮影します。(4) 同じ操作を再度繰り返します。(5) 3番目のヒープスナップショットを撮影します。(6) スナップショット2と3を比較し、操作を繰り返すたびに持続的に増加するオブジェクトを探します。そのオブジェクトがリークの原因です。Retainersパネルでどの参照チェーンがオブジェクトを保持しているか確認すれば、リークの根本原因を特定できます。
16. 参考資料
- Computer Systems: A Programmer's Perspective - Bryant & O'Hallaron (3rd Edition)
- The Garbage Collection Handbook - Jones, Hosking, Moss (2nd Edition, 2023)
- V8 Blog: Orinoco - https://v8.dev/blog/trash-talk
- JVM GC Tuning Guide - https://docs.oracle.com/en/java/javase/21/gctuning/
- The Rust Programming Language - https://doc.rust-lang.org/book/ch04-00-understanding-ownership.html
- Go GC Guide - https://tip.golang.org/doc/gc-guide
- Chrome DevTools Memory - https://developer.chrome.com/docs/devtools/memory-problems/
- ZGC Documentation - https://wiki.openjdk.org/display/zgc
- jemalloc - https://jemalloc.net/
- Python GC Documentation - https://docs.python.org/3/library/gc.html
- Understanding V8 Memory Structure - https://deepu.tech/memory-management-in-v8/
- Shenandoah GC - https://wiki.openjdk.org/display/shenandoah
- Valgrind Quick Start - https://valgrind.org/docs/manual/quick-start.html
현재 단락 (1/775)
メモリ管理(かんり)はすべてのプログラミング言語(げんご)の基盤(きばん)です。GC(ガベージコレクション)が自動的にメモリを管理してくれるとしても、内部(ないぶ)動作(どうさ)を理解(りかい)してこ...