Goのメモリ管理を眺めてみた

Golangでツールやアプリケーションを作ったりしてるが、
メモリ管理がどのようになっているのか気になったので眺めてみた。

メモリ領域

ざっくり復習のためメモリ領域についてまとめる。

メモリ領域は

メモリ領域 内容
プログラム領域 マシン語に変換されたプログラムが格納される。この機械語を1行ずつ実行することで、プログラムが実行される。
静的領域 グローバル変数や静的変数などが入る。プログラム実行時にメモリ領域が確保される。メモリサイズは固定される。
ヒープ領域 塊という意味。プログラム実行時に確保されるが、実行時にしかメモリサイズがわからないので任意のサイズとなる。C言語のmalloc関数やnew演算子などで確保・管理される。新たにデータ猟奇が必要になると、未使用のメモリ領域を(飛び飛びになってしまうこともある)統合し、ノードに還元する。あらかじめ大きな確保する場合などに使われる。
スタック領域 積み重ねるという意味。一時変数、関数の引数、返り値などが一時的に格納される。メモリのサイズは固定で最後に積まれたメモリから最初に解放される(後入れ先だし: FILO)に則る。変数を定義しすぎたりするとメモリ領域を超えてオーバーフローする(スタックオーバーフロー)。現在のスコープで必要としている領域だけ確保できれば良い。

ヒープで確保されたメモリが不要になった場合、プログラム側で開放できてないと猟奇が確保されたままになる(メモリリーク)。プログラムで開放するのではなく自動で開放する仕組みを持っているものもある。
この機能をガベージ・コレクトという。

Golangのメモリ管理

Golangのメモリ管理を見てみる。

goではビルド時に -gcflags -mフラグを渡すことでコードを解析できる。

ということで試してみる。

まずローカル変数を返すだけ

1
2
3
4
5
6
7
8
9
10
package main

func main() {
Test()
}

func Test() int {
v := 1
return v
}
1
2
3
4
# command-line-arguments
./main.go:7: can inline Test
./main.go:3: can inline main
./main.go:4: inlining call to Test

この場合はスタックを使っている。

つづいてメモリのアドレスを返してみる。

1
2
3
4
5
6
7
8
func main() {
Test()
}

func Test() *int {
v := 1
return &v
}
1
2
3
4
5
6
7
# command-line-arguments
./main.go:7: can inline Test
./main.go:3: can inline main
./main.go:4: inlining call to Test
./main.go:4: main &v does not escape
./main.go:9: &v escapes to heap
./main.go:8: moved to heap: v

この場合は、ヒープに置かれた。
アドレスからポインタの中身にアクセスする場合

1
2
3
4
5
6
7
8
9
func main() {
Test()
}

func Test() int {
v := 1
p := &v
return *p
}
1
2
3
4
5
6
# command-line-arguments
./main.go:7: can inline Test
./main.go:3: can inline main
./main.go:4: inlining call to Test
./main.go:4: main &v does not escape
./main.go:9: Test &v does not escape

この場合はスタックに置かれる。

1
2
3
4
5
6
7
8
9
func main() {
Test()
}

func Test() *int {
v := new(int)

return v
}
1
2
3
4
5
6
# command-line-arguments
./main.go:11: can inline Test
./main.go:7: can inline main
./main.go:8: inlining call to Test
./main.go:8: main new(int) does not escape
./main.go:12: new(int) escapes to heap

ポインタを返すのでヒープに置かれる。
new演算子でメモリを確保してポインタを返さない場合

1
2
3
4
5
6
7
8
9
10
11
func main() {
Test()
}

func Test() {
v := new(int)

if v == nil {
return
}
}
1
2
3
4
5
6
# command-line-arguments
./main.go:11: can inline Test
./main.go:7: can inline main
./main.go:8: inlining call to Test
./main.go:8: main new(int) does not escape
./main.go:12: Test new(int) does not escape

関数内でのみ使われるだけなので、スタックに置かれている。
ローカル変数でも他の関数に渡すと

1
2
3
4
5
6
7
8
func main() {
Test()
}

func Test() {
v := 1
fmt.Println(v)
}
1
2
3
# command-line-arguments
./main.go:11: v escapes to heap
./main.go:11: Test ... argument does not escape

ヒープに置かれている。

続いて構造体を使ってみる。
シンプルに構造体を返すだけ

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type Human struct {
Name string
}

func main() {
Test()
}

func Test() Human {
human := Human{
Name: "Bob",
}

return human
}
1
2
3
4
# command-line-arguments
./main.go:11: can inline Test
./main.go:7: can inline main
./main.go:8: inlining call to Test

スタックに置かれている。

1
2
3
4
5
6
7
8
9
10
func main() {
Test()
}

func Test() *Human {
human := new(Human)
human.Name = "Bob"

return human
}
1
2
3
4
5
6
# command-line-arguments
./main.go:11: can inline Test
./main.go:7: can inline main
./main.go:8: inlining call to Test
./main.go:8: main new(Human) does not escape
./main.go:12: new(Human) escapes to heap

ポインタを返すのでヒープに置かれている。
new演算子を使っても関数内で処理が終わってる場合

1
2
3
4
5
6
7
8
func main() {
Test()
}

func Test() {
human := new(Human)
human.Name = "Bob"
}
1
2
3
4
5
6
# command-line-arguments
./main.go:11: can inline Test
./main.go:7: can inline main
./main.go:8: inlining call to Test
./main.go:8: main new(Human) does not escape
./main.go:12: Test new(Human) does not escape

変数はスタックに置かれている。
ポインタを保存した変数を返す場合

1
2
3
4
5
6
7
8
func main() {
Test()
}

func Test() *Human {
human := &Human{"Tom"}
return human
}
1
2
3
4
5
6
# command-line-arguments
./main.go:11: can inline Test
./main.go:7: can inline main
./main.go:8: inlining call to Test
./main.go:8: main &Human literal does not escape
./main.go:12: &Human literal escapes to heap

ヒープに置かれる。

まとめ

Goの場合は、基本的にスタック領域を使うよう試みる。
ローカルの変数でも外部関数に渡されたり、ポインタを返す場合はヒープ領域が使われる。
これはローカル変数がスコープとなる関数の処理終了後も参照される可能性があるためらしい。

new演算子によるメモリの割り当ては必ずしもヒープを使うわけではない。

ということで、関数内で参照のみされる変数は実態を渡すことで、
アプリケーションとしてのパフォーマンスが良くなるということがわかった。

GCに関してはまた別で調べてみたい。

参考にしたページ

実践Go言語 - golang.jp
Go言語のメモリ管理

Comments