
こんにちは。よっしーです(^^)
本日は、Go言語のガベージコレクターついて解説しています。
背景
Goのガベージコレクター(GC)は、多くの開発者にとって「ブラックボックス」のような存在です。メモリ管理を自動で行ってくれる便利な仕組みである一方、アプリケーションのパフォーマンスに大きな影響を与える要因でもあります。「なぜ突然レスポンスが遅くなるのか?」「メモリ使用量が想定より多いのはなぜか?」「GCの停止時間をもっと短くできないか?」—— こうした疑問は、Goで高性能なアプリケーションを開発する上で避けて通れない課題です。
本記事では、Go公式ドキュメントの「ガベージコレクションガイド」を日本語で紹介します。このガイドは、GCの動作原理を理解し、その知見を活用してアプリケーションのリソース使用効率を改善することを目的としています。特筆すべきは、このドキュメントがガベージコレクションの前提知識を一切要求しない点です。Go言語の基本的な知識さえあれば、誰でもGCの仕組みを深く理解できるよう設計されています。
なぜ今、GCの理解が重要なのでしょうか。クラウドネイティブ時代において、リソースの効率的な活用はコスト削減に直結します。また、マイクロサービスアーキテクチャでは、各サービスのレイテンシが全体のユーザー体験に影響するため、GCによる一時停止を最小限に抑えることが求められます。このガイドを通じて、「なんとなく動いている」から「理解して最適化できる」レベルへとステップアップし、より高品質なGoアプリケーションの開発を目指しましょう。
トレーシングガベージコレクション
ガベージコレクションは、メモリを自動的に再利用する多くの異なる方法を指す場合があります。例えば、参照カウントなどです。このドキュメントの文脈では、ガベージコレクションはトレーシングガベージコレクションを指し、これはポインタを推移的に辿ることで、使用中の、いわゆるライブなオブジェクトを識別します。
これらの用語をより厳密に定義しましょう。
- オブジェクト – オブジェクトは、1つ以上のGo値を含む動的に割り当てられたメモリの一部です。
- ポインタ – オブジェクト内の任意の値を参照するメモリアドレスです。これには当然、
*Tという形式のGo値が含まれますが、組み込みのGo値の一部も含まれます。文字列、スライス、チャネル、マップ、およびインターフェース値はすべて、GCがトレースしなければならないメモリアドレスを含んでいます。
オブジェクトと他のオブジェクトへのポインタが一緒になって、オブジェクトグラフを形成します。ライブメモリを識別するために、GCはプログラムのルート、つまりプログラムによって確実に使用されているオブジェクトを識別するポインタから始めて、オブジェクトグラフを辿ります。ルートの2つの例は、ローカル変数とグローバル変数です。オブジェクトグラフを辿るプロセスはスキャンと呼ばれます。Goのドキュメントで見かけるかもしれない別のフレーズは、オブジェクトが到達可能かどうかというもので、これは単にオブジェクトがスキャンプロセスによって発見できることを意味します。また、1つの例外を除いて、メモリが到達不可能になると、到達不可能なままであることにも注意してください。
この基本的なアルゴリズムは、すべてのトレーシングGCに共通です。トレーシングGCが異なるのは、メモリがライブであることを発見した後に何をするかです。GoのGCはマークスイープ技術を使用します。これは、進捗を追跡するために、GCが遭遇した値をライブとしてマークすることを意味します。トレーシングが完了すると、GCはヒープ内のすべてのメモリを辿り、マークされていないすべてのメモリを割り当て可能にします。このプロセスはスイーピングと呼ばれます。
あなたが知っているかもしれない別の技術の1つは、実際にオブジェクトをメモリの新しい部分に移動し、後でアプリケーションのすべてのポインタを更新するために使用される転送ポインタを残すことです。このようにオブジェクトを移動するGCを移動GCと呼びます。Goは非移動GCです。
トレーシングガベージコレクションとは?
ガベージコレクション(GC)には様々な方式がありますが、Goが使っているのはトレーシング(追跡)方式です。これは「使われているものを追跡して見つけ出し、使われていないものを回収する」という方法です。
1. オブジェクト(Object)
オブジェクトは、ヒープ上に動的に割り当てられたメモリの塊で、1つ以上のGo値を含みます。
type User struct {
Name string
Age int
}
func main() {
// このUserがヒープ上の「オブジェクト」になる
user := &User{Name: "太郎", Age: 30}
// スライスの裏側の配列も「オブジェクト」
numbers := make([]int, 100)
// マップも「オブジェクト」
cache := make(map[string]int)
}
2. ポインタ(Pointer)
ポインタは、オブジェクトを指すメモリアドレスです。明示的なポインタ(*T)だけでなく、隠れたポインタもあります。
// 明示的なポインタ
var p *User = &User{Name: "花子"}
// 隠れたポインタを含む型
str := "Hello" // 文字列は内部的にデータへのポインタを持つ
slice := []int{1, 2, 3} // スライスは配列へのポインタを持つ
m := make(map[string]int) // マップは内部データへのポインタを持つ
ch := make(chan int) // チャネルも内部データへのポインタを持つ
例え話:
- オブジェクト = 家
- ポインタ = 住所
住所(ポインタ)を辿れば家(オブジェクト)にたどり着けます。
オブジェクトグラフ – つながりの地図
オブジェクトとポインタがつながって、オブジェクトグラフという「関係の地図」を作ります。
type Address struct {
City string
ZIP string
}
type User struct {
Name string
Address *Address // Userが→Addressを指す
}
func main() {
addr := &Address{City: "東京", ZIP: "100-0001"}
user := &User{Name: "次郎", Address: addr}
// オブジェクトグラフ:
// main関数 → user → Address
}
視覚化:
[グローバル変数] [ローカル変数]
↓ ↓
[オブジェクトA] → [オブジェクトB]
↓
[オブジェクトC]
ルート(Roots) – 探索の出発点
ルートは、「確実に使われている」と分かっているポインタです。
ルートの例:
- ローカル変数(関数内の変数)
- グローバル変数
- 実行中のgoroutineのスタック
- CPUレジスタ
var globalUser *User // ← ルート(グローバル変数)
func process() {
localUser := &User{Name: "三郎"} // ← ルート(ローカル変数)
// これらのルートから辿れるものはすべて「生きている」
}
スキャン(Scanning) – 探索プロセス
スキャンは、ルートから始めてオブジェクトグラフを辿る作業です。
例え話: 図書館の蔵書点検のようなものです。
- 貸出中の本のリストから開始(ルート)
- 各本が参照している関連書籍をチェック
- さらにその関連書籍の参照を辿る
- すべての「使われている本」をマークする
func example() {
// ルート
user := &User{
Name: "四郎",
Address: &Address{ // ← userから辿れる
City: "大阪",
},
}
// GCのスキャンプロセス:
// 1. userを発見(ルート)
// 2. userからAddressを発見
// 3. これらをすべて「生きている」とマーク
}
到達可能性(Reachability)
到達可能 = ルートから辿って到達できる
func main() {
// 到達可能な例
user1 := &User{Name: "五郎"} // ルートから直接到達可能
// 到達不可能な例
temp := &User{Name: "一時的"}
temp = nil // もう誰も参照していない → 到達不可能!
}
重要なルール: 一度到達不可能になったオブジェクトは、(例外を除いて)再び到達可能にはなりません。
マークスイープ(Mark-Sweep) – Goの方式
Goが使っているマークスイープ方式は、2つのフェーズに分かれます。
フェーズ1: マーク(Mark) – 使われているものを印をつける
// マークフェーズの流れ
func markPhase() {
// 1. すべてのルートを見つける
roots := findRoots()
// 2. 各ルートから辿って、到達可能なオブジェクトをマーク
for _, root := range roots {
markReachable(root) // 「生きている」と印をつける
}
}
例え話: 教室で使われている教科書をチェックする先生のようなものです。
- 生徒の机を1つずつ見て回る(ルートをスキャン)
- 使われている教科書に付箋を貼る(マーク)
フェーズ2: スイープ(Sweep) – 不要なものを片付ける
// スイープフェーズの流れ
func sweepPhase() {
// ヒープ内のすべてのメモリを見て、
// マークされていないものを「使用可能」にする
for _, obj := range heapObjects {
if !obj.isMarked {
obj.free() // メモリを解放
} else {
obj.unmark() // 次のGCサイクルのためにマークを外す
}
}
}
例え話:
- マークされていない教科書を回収する
- 次の授業のために教室を片付ける
実際の動作例
type Node struct {
Value int
Next *Node
}
func main() {
// リンクリストを作成
head := &Node{Value: 1}
head.Next = &Node{Value: 2}
head.Next.Next = &Node{Value: 3}
// オブジェクトグラフ:
// head → Node1 → Node2 → Node3
// 途中を切断
head.Next = nil
// 新しいグラフ:
// head → Node1
// Node2 → Node3 (到達不可能!)
// 次のGCで:
// マークフェーズ: head と Node1 だけマーク
// スイープフェーズ: Node2 と Node3 を回収
}
移動GC(Moving GC) – Goは使っていない
オブジェクトをメモリの別の場所に移動させる方式です。
移動前:
[使用中] [ゴミ] [使用中] [ゴミ] [使用中]
移動後:
[使用中][使用中][使用中] [空き領域........]
メリット: メモリの断片化を防げる
デメリット: すべてのポインタを更新する必要がある
非移動GC(Non-Moving GC) – Goが採用
オブジェクトを移動させず、その場で管理する方式です。
処理前:
[使用中] [ゴミ] [使用中] [ゴミ] [使用中]
処理後:
[使用中] [空き] [使用中] [空き] [使用中]
メリット:
- シンプル
- ポインタの更新が不要
- 低レイテンシ
デメリット:
- メモリの断片化の可能性
Goが非移動GCを選んだ理由
- 低レイテンシ – 移動のコストがない
- 並行実行 – アプリケーションと並行して動作しやすい
- Cとの相互運用 – CGOを使うときにポインタが移動すると問題
まとめ
| 概念 | 説明 | 例え |
|---|---|---|
| オブジェクト | ヒープ上のメモリの塊 | 家 |
| ポインタ | オブジェクトへの参照 | 住所 |
| オブジェクトグラフ | オブジェクトのつながり | 関係図 |
| ルート | 確実に使用中のポインタ | 探索の出発点 |
| スキャン | グラフを辿る作業 | 追跡調査 |
| 到達可能 | ルートから辿れる | 道がある |
| マーク | 使用中とマークする | 付箋を貼る |
| スイープ | 不要なものを回収 | ゴミを片付ける |
GoのGCの特徴:
- ✅ トレーシング方式
- ✅ マークスイープ
- ✅ 非移動
- ✅ 並行実行
おわりに
本日は、Go言語のガベージコレクターについて解説しました。

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

コメント