Go のスライスとメモリ:大きなスライスの縮小と GC の関係

スライスは内部的にポインタ・長さ・容量の 3 つで構成されています。この仕組みを理解していないと、不要になったはずのメモリがいつまでも解放されない問題に遭遇します。

スライスのメモリ構造をおさらいする

スライスは背後に配列を持ち、その配列の一部分を参照しています。

s := make([]int, 3, 10)

この場合、長さ 10 の配列がヒープに確保され、スライス s はその先頭 3 要素を参照しています。容量は 10 なので、append で要素を追加しても 10 個までは新しい配列が確保されません。

ポインタ背後の配列の先頭アドレス
長さ(len)現在使っている要素数
容量(cap)背後の配列の総サイズ

問題は、スライスの長さを減らしても背後の配列は縮まらないことです。長さ 3 のスライスでも、容量が 100 万なら 100 万要素分のメモリが保持され続けます。

大きなスライスを縮小するときの罠

ファイルを読み込んで処理し、結果だけを保持するような処理を考えてみましょう。

func loadAndFilter(path string) []Record {
	records := loadAll(path) // 100 万件読み込み
	var result []Record

	for _, r := range records {
		if r.IsActive {
			result = append(result, r)
		}
	}

	return result // 1000 件だけ返す
}

一見問題なさそうですが、result の背後の配列は append の過程で確保されたもので、容量は 1000 件に近い値になっています。これは問題ありません。

問題が起きるのは、スライシングで縮小した場合です。

func getFirstTen(data []int) []int {
	return data[:10]
}

返されるスライスの長さは 10 ですが、背後の配列は元の data と同じです。元の data が 100 万要素なら、たった 10 要素しか使っていないのに 100 万要素分のメモリが GC に回収されません。

スライシングで縮小

背後の配列を共有するため、元のスライスが GC されない。メモリリークの原因になる。

copy で新しいスライスを作る

新しい配列が確保され、元の配列への参照が切れる。元のスライスは GC の対象になる。

copy で参照を切り離す

解決策はシンプルです。copy で新しいスライスに中身をコピーすれば、元の配列への参照がなくなります。

func getFirstTen(data []int) []int {
	result := make([]int, 10)
	copy(result, data[:10])
	return result
}

result は長さ・容量ともに 10 の新しい配列を持ちます。元の data への参照が切れるため、data の背後にある大きな配列は GC で回収できるようになります。

Go 1.21 以降では slices.Clone を使うとさらに簡潔に書けます。

func getFirstTen(data []int) []int {
	return slices.Clone(data[:10])
}

slices.Clone は内部で make + copy を行うため、やっていることは同じです。

ポインタを含むスライスの注意点

構造体のスライスやポインタのスライスを縮小する場合、もう一つの問題があります。

type User struct {
	Name    string
	Profile *Profile // 大きなデータを持つ
}

func removeLastUsers(users []User) []User {
	return users[:len(users)-10]
}

スライスの長さは減りましたが、背後の配列には末尾 10 個の User が残っています。それらが *Profile を参照している場合、Profile のデータも GC されません。

func removeLastUsers(users []User) []User {
	n := len(users) - 10

	// 末尾の要素をゼロ値で上書きして参照を切る
	for i := n; i < len(users); i++ {
		users[i] = User{}
	}

	return users[:n]
}

スライスの長さを縮小

背後の配列に残った要素のポインタが生き続ける

ゼロ値で上書きしてポインタを nil にする

GC が Profile を回収できるようになる

これは intstring のスライスでは問題になりません。ポインタやインターフェースを含む構造体のスライスでのみ注意が必要です。

append の容量拡張とメモリ

append は容量が足りなくなると新しい配列を確保します。この拡張ポリシーは Go のバージョンによって異なりますが、おおむね以下の傾向があります。

s := []int{}
for i := 0; i < 20; i++ {
	s = append(s, i)
	fmt.Printf("len=%d cap=%d\n", len(s), cap(s))
}
// len=1  cap=1
// len=2  cap=2
// len=3  cap=4
// len=4  cap=4
// len=5  cap=8
// ...

容量が倍々で増えていくため、最終的な長さに対して容量が大幅に余ることがあります。あらかじめ要素数がわかっている場合は make で容量を指定するのが効率的です。

// 非効率: append のたびに拡張チェックが走る
func collectIDs(users []User) []int {
	var ids []int
	for _, u := range users {
		ids = append(ids, u.ID)
	}
	return ids
}

// 効率的: 最初から必要な容量を確保
func collectIDs(users []User) []int {
	ids := make([]int, 0, len(users))
	for _, u := range users {
		ids = append(ids, u.ID)
	}
	return ids
}

make([]int, 0, len(users)) は長さ 0・容量 len(users) のスライスを作ります。append で要素を追加しても配列の再確保が発生しないため、アロケーション回数が 1 回で済みます。

要素数が不明append に任せる(デフォルト)
要素数がわかっているmake で容量を指定
大きなスライスから小さいものを切り出すcopy か slices.Clone で参照を切る
ポインタを含むスライスを縮小不要な要素をゼロ値で上書き

スライスのメモリ問題は、小さなプログラムではまず顕在化しません。しかし長時間動くサーバーや大量データを扱うバッチ処理では、これらの知識が本番障害を防ぐ鍵になります。