
こんにちは。よっしーです(^^)
本日は、Go言語のガベージコレクターついて解説しています。
背景
Goのガベージコレクター(GC)は、多くの開発者にとって「ブラックボックス」のような存在です。メモリ管理を自動で行ってくれる便利な仕組みである一方、アプリケーションのパフォーマンスに大きな影響を与える要因でもあります。「なぜ突然レスポンスが遅くなるのか?」「メモリ使用量が想定より多いのはなぜか?」「GCの停止時間をもっと短くできないか?」—— こうした疑問は、Goで高性能なアプリケーションを開発する上で避けて通れない課題です。
本記事では、Go公式ドキュメントの「ガベージコレクションガイド」を日本語で紹介します。このガイドは、GCの動作原理を理解し、その知見を活用してアプリケーションのリソース使用効率を改善することを目的としています。特筆すべきは、このドキュメントがガベージコレクションの前提知識を一切要求しない点です。Go言語の基本的な知識さえあれば、誰でもGCの仕組みを深く理解できるよう設計されています。
なぜ今、GCの理解が重要なのでしょうか。クラウドネイティブ時代において、リソースの効率的な活用はコスト削減に直結します。また、マイクロサービスアーキテクチャでは、各サービスのレイテンシが全体のユーザー体験に影響するため、GCによる一時停止を最小限に抑えることが求められます。このガイドを通じて、「なんとなく動いている」から「理解して最適化できる」レベルへとステップアップし、より高品質なGoアプリケーションの開発を目指しましょう。
GCサイクル
GoのGCはマークスイープGCであるため、広く2つのフェーズで動作します。マークフェーズとスイープフェーズです。この文は同語反復のように思えるかもしれませんが、重要な洞察を含んでいます。それは、すべてのメモリがトレースされるまで、メモリを割り当て可能な状態に解放することができないということです。なぜなら、まだスキャンされていないポインタがオブジェクトを生かし続けている可能性があるからです。結果として、スイーピングの動作はマーキングの動作から完全に分離されなければなりません。さらに、GC関連の作業が何もない場合、GCはまったくアクティブでない場合もあります。GCは、GCサイクルとして知られるもので、スイーピング、オフ、マーキングという3つのフェーズを継続的に循環します。このドキュメントの目的上、GCサイクルはスイーピングから始まり、オフになり、その後マーキングが行われると考えてください。
次のいくつかのセクションでは、ユーザーが自分の利益のためにGCパラメータを調整できるよう、GCのコストに関する直感を構築することに焦点を当てます。
GCサイクルとは?
GCサイクルは、Goのガベージコレクタが繰り返し実行する一連の処理のサイクルです。このサイクルは3つのフェーズから成り立っています。
3つのフェーズ
┌─────────────┐
│ Sweep │ スイープ(片付け)
│ (掃除) │
└──────┬──────┘
↓
┌─────────────┐
│ Off │ オフ(休止)
│ (休憩) │
└──────┬──────┘
↓
┌─────────────┐
│ Mark │ マーク(印付け)
│ (点検) │
└──────┬──────┘
↓
(サイクルを繰り返す)
フェーズ1: スイープ(Sweep) – 掃除フェーズ
前回のマークフェーズで「不要」と判明したメモリを実際に回収します。
// スイープフェーズでGCが行うこと
for obj in heap {
if !obj.marked {
// マークされていない = もう使われていない
freeMemory(obj) // メモリを解放して再利用可能にする
} else {
// 次回のGCサイクルのためにマークをクリア
obj.marked = false
}
}
例え話: 図書館の返却本コーナーを片付ける作業です。
- 前回の点検で「もう借りられていない」とマークされた本を書庫に戻す
- 書架のスペースを空けて、新しい本を置けるようにする
フェーズ2: オフ(Off) – 休止フェーズ
GCが何もしていない期間です。アプリケーションが普通に動作しています。
// オフフェーズ
// GCは休憩中
// アプリケーションは普通に動作
// 新しいオブジェクトを作成可能
user := &User{Name: "太郎"} // ←自由に作成できる
data := make([]byte, 1000) // ←自由に割り当て可能
例え話: 図書館が通常営業している時間です。
- 利用者は自由に本を借りたり返したりできる
- 図書館員は点検や整理をしていない
- 普段通りのサービス提供
フェーズ3: マーク(Mark) – 点検フェーズ
どのオブジェクトが「使用中(生きている)」かを調査し、マークをつけます。
// マークフェーズでGCが行うこと
// 1. すべてのルート(グローバル変数、ローカル変数など)を見つける
roots := findAllRoots()
// 2. 各ルートから辿れるすべてのオブジェクトをマーク
for root in roots {
markReachable(root) // ←「生きている」と印をつける
}
例え話: 図書館の蔵書点検の時間です。
- 現在貸出中の本をすべてチェック
- 予約されている本をチェック
- 展示中の本をチェック
- 「使われている」本すべてに付箋を貼る
重要な制約: マークが完了するまでスイープできない
// これは間違い!
func wrongApproach() {
// ❌ マーク中にスイープしてはいけない
mark(someObjects)
sweep() // まだすべてをマークしていない!
mark(moreObjects) // 遅すぎる!
}
// 正しいアプローチ
func correctApproach() {
// ✅ すべてをマークしてからスイープ
markAllReachableObjects() // まずすべてを点検
sweepUnmarkedObjects() // それから片付け
}
なぜ?
すべてのポインタをスキャンする前に片付けを始めると、まだ使われているオブジェクトを誤って削除してしまう可能性があります。
例で理解する:
type Node struct {
Value int
Next *Node
}
func example() {
// リンクリスト
head := &Node{Value: 1}
head.Next = &Node{Value: 2}
head.Next.Next = &Node{Value: 3}
// もしマークの途中でスイープしたら...
// headはマーク済み、Next以降はまだ未マーク
// → Next以降が誤って削除される!
// 正しい順序:
// 1. head, Next, Next.Nextすべてをマーク
// 2. マークされていないものだけを削除
}
GCサイクルの具体的な流れ
package main
func main() {
// === オフフェーズ ===
// 通常の処理を実行中
users := make([]*User, 0)
for i := 0; i < 1000; i++ {
users = append(users, &User{ID: i})
}
// ヒープメモリがある閾値に達すると...
// GCがトリガーされる!
// === マークフェーズ開始 ===
// GC: 「どのオブジェクトが使われているか調査します」
// - usersスライスを発見(ルート)
// - 各User構造体をマーク
// - マーク完了!
// === スイープフェーズ開始 ===
// GC: 「マークされていないオブジェクトを回収します」
// - ヒープをスキャン
// - 未マークのメモリを解放
// - スイープ完了!
// === オフフェーズに戻る ===
// 通常の処理を再開
// ... しばらくして、また閾値に達したら
// 次のGCサイクルが始まる
}
各フェーズの時間配分
時間軸 →
[Off][Off][Off][Mark][Mark][Sweep][Off][Off][Off][Off][Mark][Mark][Sweep]...
└─────────┘└────────┘└────┘└──────────────┘└────────┘└────┘
通常動作 点検 片付け 通常動作 点検 片付け
理想的な状態:
- オフフェーズが長い = アプリケーションが自由に動作
- マーク/スイープフェーズが短い = GCのオーバーヘッドが小さい
GCがトリガーされるタイミング
1. メモリ使用量が閾値に達した時
// デフォルト: 前回のGC後の2倍のメモリを使ったらGC開始
// 前回GC後: 10MB
// 現在: 20MB → GCトリガー!
2. 一定時間が経過した時
// 2分間GCが実行されていない場合、強制的にGC実行
3. 手動で呼び出した時
import "runtime"
func cleanup() {
// 明示的にGCを実行
runtime.GC()
}
GCサイクルのコスト
コスト = マークの時間 + スイープの時間 + アプリケーションの一時停止時間
影響する要因:
- ヒープサイズ – 大きいほどスキャンに時間がかかる
- オブジェクト数 – 多いほど処理が重い
- ポインタの数 – 多いほどトレースに時間がかかる
- GC頻度 – 頻繁すぎるとオーバーヘッド増大
実際の監視例
package main
import (
"fmt"
"runtime"
"time"
)
func main() {
var stats runtime.MemStats
// 処理前の状態
runtime.ReadMemStats(&stats)
fmt.Printf("GC実行回数: %d\n", stats.NumGC)
// メモリを大量に使う処理
data := make([][]byte, 0)
for i := 0; i < 100; i++ {
data = append(data, make([]byte, 1024*1024)) // 1MB
time.Sleep(10 * time.Millisecond)
}
// 処理後の状態
runtime.ReadMemStats(&stats)
fmt.Printf("GC実行回数: %d\n", stats.NumGC)
fmt.Printf("総GC一時停止時間: %v\n", time.Duration(stats.PauseTotalNs))
}
GCサイクルを最適化するヒント
1. オブジェクトの再利用
// 悪い例: 毎回新しいオブジェクトを作成
func processData() {
for i := 0; i < 1000; i++ {
buffer := make([]byte, 1024) // GCの負荷増大
// ... 処理 ...
}
}
// 良い例: sync.Poolで再利用
var bufferPool = sync.Pool{
New: func() interface{} {
return make([]byte, 1024)
},
}
func processDataBetter() {
for i := 0; i < 1000; i++ {
buffer := bufferPool.Get().([]byte)
// ... 処理 ...
bufferPool.Put(buffer) // 再利用のため返却
}
}
2. 大きなスライスの事前割り当て
// 悪い例: 徐々に拡張
slice := make([]int, 0)
for i := 0; i < 10000; i++ {
slice = append(slice, i) // 何度も再割り当て
}
// 良い例: 最初から十分なサイズを確保
slice := make([]int, 0, 10000)
for i := 0; i < 10000; i++ {
slice = append(slice, i) // 再割り当てなし
}
3. ポインタの削減
// ポインタが多い: GCのスキャンコスト高
type DataWithPointers struct {
A *int
B *string
C *float64
}
// ポインタが少ない: GCのスキャンコスト低
type DataWithValues struct {
A int
B string
C float64
}
まとめ
| 概念 | 説明 | 目的 |
|---|---|---|
| GCサイクル | 3つのフェーズの繰り返し | メモリを継続的に管理 |
| スイープ | 不要なメモリを回収 | メモリを再利用可能に |
| オフ | GCが休止 | アプリが自由に動作 |
| マーク | 使用中を識別 | 何を残すか決定 |
重要なポイント:
- ✅ マークが完了してからスイープ – この順序は絶対
- ✅ オフフェーズが長い = 良いパフォーマンス
- ✅ GCサイクルは自動的にトリガーされる
- ✅ パラメータの調整で最適化可能
次のセクションでは、GCのコストをより詳しく理解し、パフォーマンスチューニングの方法を学びます!
おわりに
本日は、Go言語のガベージコレクターについて解説しました。

何か質問や相談があれば、コメントをお願いします。また、エンジニア案件の相談にも随時対応していますので、お気軽にお問い合わせください。
それでは、また明日お会いしましょう(^^)

コメント