一个奇怪的golang等值判断问题

问题场景

分析一下,下面代码的输出是什么(判断a==c)的部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package main

import (
"fmt"
"runtime"
)

type obj struct{}

func main() {
a := &obj{}
fmt.Printf("%p\n", a)
c := &obj{}
fmt.Printf("%p\n", c)
fmt.Println(a == c)
}

很多人可能一看,a和c完全是2个不同的对象实例,便认为a和c具备不同的内存地址,故而判断a==c的结果为false。我也是一样。我们看一下实际输出:

1
2
3
0x1181f88
0x1181f88
true

问题分析

要分析上面的问题,就需要了解一些Golang内存分配,以及变量在内存逃逸的知识。上面的代码,有打印a和c的内存地址。倘若我们去掉任意一个(或者将打印内存的地址都去掉也一样),则 a==c 的判断输出,就是 false。再看一下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package main

import (
"fmt"
)

type obj struct{}

func main() {
a := &obj{}
//fmt.Printf("%p\n", a)
c := &obj{}
fmt.Printf("%p\n", c)
fmt.Println(a == c)
}

输出:

1
2
0x1181f88
false

那么,可以看出,是 fmt.Printf 影响了最终结果的判断。好吧,我们看一下,上面代码的内存逃逸情况分析:

1
go run -gcflags '-m -l' main.go
1
2
3
4
5
6
7
8
9
# command-line-arguments
./main.go:13:16: c escapes to heap
./main.go:12:10: &obj literal escapes to heap
./main.go:14:19: a == c escapes to heap
./main.go:10:10: main &obj literal does not escape
./main.go:13:15: main ... argument does not escape
./main.go:14:16: main ... argument does not escape
0x1181f88
false

可以看到,变量c从栈内存,逃逸到了堆内存上。而变量a没有逃逸(注意:上面代码中,有 fmt.Printf(“%p\n”, c),没有 fmt.Printf(“%p\n”, a) )。由此可以简单判断,是 fmt.Printf 导致变量产生了内存由栈向堆的逃逸。

回到最开始的问题上。

如果代码中,即打印 a,也打印b 的变量内存地址。则会导致 a 和 c,都逃逸到堆内存上。所以,我们的问题就来了。

  1. 为什么 fmt.Printf 会导致变量的内存逃逸?
  2. 为什么逃逸到了堆内存,2个变量就一样了?

问题1:为什么 fmt.Printf 会导致变量的内存逃逸?

其实,fmt.Printf 第二个参数,是一个 interface 类型。而 fmt.Printf 的内部实现,使用了反射 reflect,正是由于 reflect 才导致变量从栈向堆内存的逃逸成为可能(注意,并非所有reflect操作都会导致内存逃逸,具体还得看怎么使用reflect的)。我们简单总结为:

使用 fmt.Printf 由于其函数第二个参数是接口类型,而函数内部最终实现使用了 reflect 机制,导致变量从栈逃逸到堆内存。

问题2:为什么变量 a 和 c 逃逸到堆内存后,内存地址就一样了?

这是因为,堆上内存分配调用了 runtime 包的 newobject 函数。而 newobject 函数其实本质上会调用 runtime 包内的 mallocgc 函数。这个函数有点特别:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Allocate an object of size bytes.
// Small objects are allocated from the per-P cache's free lists.
// Large objects (> 32 kB) are allocated straight from the heap.
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
if gcphase == _GCmarktermination {
throw("mallocgc called with gcphase == _GCmarktermination")
}

// 关键部分,如果要分配内存的变量不占用实际内存,则直接用 golang 的全局变量 zerobase 的地址。
if size == 0 {
return unsafe.Pointer(&zerobase)
}

// ...
}

函数比较长,我做了截取。这函数内有一个判断。 如果要分配内存的变量不占用实际内存,则直接用 golang 的全局变量 zerobase 的地址。而我们的变量 a 和 变量 c 有一个共同特点,就是它们是“空 struct”,空 struct 是不占用内存空间的。

所以,a 和 c 是空 struct,再做内存分配的时候,使用了 golang 内部全局私有变量 zerobase 的内存地址。

如何验证 a 和 c 都使用的是 runtime包内的 zerobase 内存地址?

改一下 runtime 包中,mallocgc 函数所在的文件 runtime/malloc.go 增加一个函数 GetZeroBasePtr ,这个函数,专门用于返回 zerobase 的地址,如下:

1
2
3
4
5
6
// base address for all 0-byte allocations
var zerobase uintptr

func GetZeroBasePtr() unsafe.Pointer {
return unsafe.Pointer(&zerobase)
}

好了,我们回过头再改一下测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package main

import (
"fmt"
"runtime"
)

type obj struct{}

func main() {
a := &obj{}
// 打印 a 的地址
fmt.Printf("%p\n", a)
c := &obj{}
// 打印 c 的地址
fmt.Printf("%p\n", c)
fmt.Println(a == c)
// 打印 runtime 包内的 zerobase 的地址
ptr := runtime.GetZeroBasePtr()
fmt.Printf("golang inner zerobase ptr: %p\n", ptr)
}

重新编译:

1
2
// 注意,改了 golang 的源码,再编译的话,必须加 -a 参数
go build -a

结果输出如下:

1
2
3
4
0x1181f88
0x1181f88
true
golang inner zerobase ptr: 0x1181f88

问题得证。

参考:

  1. https://studygolang.com/topics/8655\#reply0
  2. https://golang.org/src/runtime/malloc.go
  3. https://studygolang.com/articles/5790
  4. http://legendtkl.com/2017/04/02/golang-alloc/
  5. http://reusee.github.io/post/escape_analysis/
Donate comment here