Go语言学习笔记:深入对象模型之内置类型

也许是从C++转过来的习惯,学习一门语言,我总是比较喜欢先弄明白它的对象模型,所以自己就做了一些简单的实验,看看Go的对象模型~


1. 前置准备

为了查看对象的内存布局,我们这里先写一个非常简单的小函数,利用反射递归地遍历对象的所有成员,然后输出其内存位置,从而了解其在内存中的真实状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
func PrintObjectDumpTableHeader() {
fmt.Printf("%-12s%-15s%-20s%-10s %-11s %-4s\n", "Var", "Type", "Address", "RootOffset", "LocalOffset", "Size")
}

func DumpObject(name string, p reflect.Value) {
v := p.Elem()
dumpObject(name, v, v.UnsafeAddr(), v.UnsafeAddr())
}

func dumpObject(path string, v reflect.Value, rootBaseAddr uintptr, localBaseAddr uintptr) {
dumpObjectDetail(path, v, rootBaseAddr, localBaseAddr)

switch v.Kind() {
case reflect.Struct:
childLocalBaseAddr := v.UnsafeAddr()

for i := 0; i < v.NumField(); i++ {
fieldPath := fmt.Sprintf("%s.%s", path, v.Type().Field(i).Name)
dumpObject(fieldPath, v.Field(i), rootBaseAddr, childLocalBaseAddr)
}
}
}

func dumpObjectDetail(path string, v reflect.Value, rootBaseAddr uintptr, localBaseAddr uintptr) {
fmt.Printf("%-12s%-15s0x%016x %10v %11v %4v\n", path, v.Type().String(), v.UnsafeAddr(),
v.UnsafeAddr() - rootBaseAddr, v.UnsafeAddr() - localBaseAddr, v.Type().Size())
}

这样,我们就可以非常简单的调用这个函数来查看任何对象的内存布局了,如下:

1
2
3
4
PrintObjectDumpTableHeader()

var b bool
DumpObject("b", reflect.ValueOf(&b))

2. 基础类型

首先,我们先来看看最简单的基础数据类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Var         Type           Address             RootOffset LocalOffset Size
b bool 0x000000c000114368 0 0 1
i int 0x000000c000114378 0 0 8
i8 int8 0x000000c000114388 0 0 1
i16 int16 0x000000c000114398 0 0 2
i32 int32 0x000000c0001143a8 0 0 4
i64 int64 0x000000c0001143b8 0 0 8
u8 uint8 0x000000c0001143c8 0 0 1
u16 uint16 0x000000c0001143d8 0 0 2
u32 uint32 0x000000c0001143e8 0 0 4
u64 uint64 0x000000c0001143f8 0 0 8
p uintptr 0x000000c000114408 0 0 8
f32 float32 0x000000c000114418 0 0 4
f64 float64 0x000000c000114428 0 0 8
c64 complex64 0x000000c000114438 0 0 8
c128 complex128 0x000000c000114450 0 0 16
s string 0x000000c0001086f0 0 0 16
r int32 0x000000c000114460 0 0 4

这里非常的直接,毕竟Go是一门编译语言,所以每个类型的大小都和它的描述一样。这里只有三个地方需要注意:

  • 与C++不同,int是8个字节的
  • 字符串会占用16个字节,无论其长度是多少,因为其在runtime中的定义就是一个包含有buffer指针和长度的struct
    1
    2
    3
    4
    type stringStruct struct {
    str unsafe.Pointer
    len int
    }
  • 一般会被简单理解成其他语言的char类型的rune,其实只是int32的别名,会占用4个字节,而不是1个字节。这是因为Go的字符串对Unicode的支持,如果觉得这里奇怪的朋友可以看一看Go官方对于string的解释,这里就不再赘述了。

3. 复合数据类型

复合数据类型虽然稍稍复杂一点但是也相对直接:

3.1. 数组

首先是数组,和C/C++的数据一样,数组的大小取决于元素的大小与个数,而拥有0个元素的数组也是合法的。

1
2
3
4
5
Var         Type           Address             RootOffset LocalOffset Size
a0 [0]int 0x0000000000d15c88 0 0 0
a1 [1]int 0x000000c00000a448 0 0 8
a5 [5]int 0x000000c00000c4b0 0 0 40
b5 [5]bool 0x000000c00000a460 0 0 5

3.2. Slice

然后是Slice,Slice无论类型,都是24个Byte。

1
2
3
Var         Type           Address             RootOffset LocalOffset Size
si []int 0x000000c000004520 0 0 24
si32 []int32 0x000000c000004540 0 0 24

这是因为Slice的实现是三个成员变量:一个指向内部数组的指针,一个int类型的Slice长度,一个int类型的Slice的cap。三个变量每个都是8个字节,所以就是24个字节了。其定义在runtime中如下:

1
2
3
4
5
type slice struct {
array unsafe.Pointer
len int
cap int
}

关于具体的实现和内部机制,Go的官方博客介绍的非常详细:Go Slices: usage and internals。耗子哥也写了一篇中文版在这里:GO编程模式:切片,接口,时间和性能

3.2.1. 理解Go的引用类型

众所周知,Go有4个引用类型:Slice,Map,Chan和functions/methods,而在函数调用时,这些类型将作为引用传递,然而我们知道Go里面我们是无法自己定义引用类型的,所以Go会专门为这几个类型单独开后门么?这里我们就来看一看Slice在参数传递时的真正行为:

这里我们先写了一个简单的测试代码,如下:

1
2
3
4
5
6
7
8
9
10
11
func CheckSliceInternal() {
var s []int
UpdateSlice(s)
fmt.Printf("AfterReturn: %v\n", s)
}

//go:noinline
func UpdateSlice(s []int) {
s = append(s, 1)
fmt.Printf("AfterUpdate: %v\n", s)
}

如果slice的传引用和其他语言一样,那么我们应该会看到在调用UpdateSlice之后,CheckSliceInternal中定义的s也会发生变化,但是实际上却不然:

1
2
3
=== RUN   TestObjectModelFunctionCallWithSlice
AfterUpdate: [1]
AfterReturn: []

这是为什么呢?没关系,汇编语言永远都是我们的好朋友,我们来看看调用UpdateSlice的时候究竟发生了什么?

1
2
3
4
5
6
7
// GOSSAFUNC=CheckSliceInternal go build -gcflags "-S -N" object_def.go object_dump.go object_model_exp.go
// ......
00010 (+96) MOVQ $0, (SP) // Load s.array to SP -> Copy s.array
00011 (96) XORPS X0, X0 // Zero out X0
00012 (96) MOVUPS X0, 8(SP) // Load X0 (128bit) to SP+8 -> Copy s.len and s.cap
00013 (+96) CALL "".UpdateSlice(SB) // Call UpdateSlice(s)
// ......

我们知道Slice是由24个字节组成的,而且我们定义的s是一个空Slice,所以我们可以从上面的代码看到Go将在堆栈上对我们的Slice进行了一次值拷贝!也就是说,虽然在UpdateSlice函数中,s依然指向原数组,但是CheckSliceInternal中的s是不会发生改变的,所谓的传引用,其实是传值

我们把代码稍稍改一下,把指针打印出来,可以看到我们的理论是正确的(代码改动过于简单,这里就不列了):

1
2
3
=== RUN   TestObjectModelFunctionCallWithSlice
AfterUpdate: [1] (Ptr = 0xc00010e4e0)
AfterReturn: [] (Ptr = 0xc00010e4c0)

同样的道理,我们马上也可以看到Map的传引用的行为也是一样。

3.3. Map

接下来是map,无论类型,大小都是8个字节。

1
2
3
4
Var         Type           Address             RootOffset LocalOffset Size
mii map[int]int 0x000000c000006038 0 0 8
mi32i map[int32]int 0x000000c000006040 0 0 8
mii32 map[int]int32 0x000000c000006048 0 0 8

这是因为map的真正实现其实是一个指向hashmap的指针,runtime中叫做:hmap。这里我们同样也来写一段小代码,看看内部的实现:

1
2
var m map[int]int = make(map[int]int)
m[5] = 3

把编译生成的汇编代码拿出来之后就能看到如下内容(为了清楚只截取了部分代码演示map的原理):

1
2
3
4
5
6
7
// ......
00009 (+96) LEAQ type.map[int]int(SB), AX // Load map type info to AX
00011 (96) MOVQ AX, (SP) // Load map type into to SP+0 (First arg)
00012 (96) MOVQ $0, 8(SP) // Load hmap to SP+8 (Second arg)
00013 (96) MOVQ $5, 16(SP) // Load key (5) to SP+16 (Third arg)
00014 (96) CALL runtime.mapassign_fast64(SB) // Call runtime.mapassign
// ......

通过对比Go Runtime中的mapassign_fast64函数,我们可以看到参数中t是这个map的类型信息,h便是我们真正的map对象实例了。

1
func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer

所以map说到底只是一个指针,所以只占用8个字节。这也是为什么Map和Slice不同,Slice可以定义了之后就可以马上使用,但是map则必须显式创建!比如,如下代码会导致运行时错误,因为真正的map还没有创建:

1
2
var m map[int]int // This means "hmap *m;" in C
m[5] = 3
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
panic: assignment to entry in nil map [recovered]
panic: assignment to entry in nil map

goroutine 19 [running]:
testing.tRunner.func1.1(0x526fa0, 0x575a20)
C:/Tools/Go/src/testing/testing.go:1072 +0x310
testing.tRunner.func1(0xc000104480)
C:/Tools/Go/src/testing/testing.go:1075 +0x43a
panic(0x526fa0, 0x575a20)
C:/Tools/Go/src/runtime/panic.go:969 +0x1c7
objmodelexp.CheckMapInternal()
X:/Code/learning-golang/src/objmodelexp/object_model_exp.go:117 +0x71
objmodelexp.TestObjectModelFunctionCallWithMap(0xc000104480)
X:/Code/learning-golang/src/objmodelexp/object_def_test.go:20 +0x27
testing.tRunner(0xc000104480, 0x554ff8)
C:/Tools/Go/src/testing/testing.go:1123 +0xef
created by testing.(*T).Run
C:/Tools/Go/src/testing/testing.go:1168 +0x2b3

注:也许我说Slice定义之后就可以马上使用会让你出乎意料,因为就连Go的Tutorial上都专门有一章说Slice判空的问题,但是目前我还没有找到反例,所有的API都可以在不需要做任何额外的nil check的情况下接受空Slice作为参数:

  • 比如append函数,只要做一个nil check就可以让它表现的像是能使用,但是其实在growslice的里面并不会判断slice是不是nil,只要长度不够就足够了
  • 比如len函数,其实现就是直接读取slice中的len字段:MOVQ 8(AX), AX
  • 比如for range,最后的实现就是用len函数读取长度,然后使用下标生成for循环:For_range_statement::lower_range_slice
  • 比如没创建的时候直接访问内部元素,得到的panic是下标越界而不是Access Violation:panic: runtime error: index out of range [1] with length 0

那对于参数传递呢?我们也可以用和Slice同样的方法来验证,把指针输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
func CheckMapInternal() {
var m map[int]int = make(map[int]int)
m[5] = 3
fmt.Printf("BeforeCall: %v (Ptr = %p)\n", m, &m)
UpdateMap(m)
fmt.Printf("AfterReturn: %v (Ptr = %p)\n", m, &m)
}

//go:noinline
func UpdateMap(m map[int]int) {
m = make(map[int]int)
fmt.Printf("AfterUpdate: %v (Ptr = %p)\n", m, &m)
}
1
2
3
4
=== RUN   TestObjectModelFunctionCallWithMap
BeforeCall: map[5:3] (Ptr = 0xc000006038)
AfterUpdate: map[] (Ptr = 0xc000006040)
AfterReturn: map[5:3] (Ptr = 0xc000006038)

可以看到,虽然map是一个指针,但是在传参的时候依然是以传值的方式进行的。


4. Channel

Channel和Map非常类似,无论类型,同样都是8个字节,实现方法也非常相似,内部使用runtime中的hchan结构的指针实现。

1
2
3
Var         Type           Address             RootOffset LocalOffset Size
ci chan int 0x000000c000006050 0 0 8
cb chan bool 0x000000c000006058 0 0 8

因此,和Map一样:

  • Channel必须使用make函数创建之后才能使用,其调用的是runtime中的makechan
  • 参数传递时依然是传值,该值为hchan的指针

5. 函数变量

函数变量有两种情况,一种就是普通的函数指针(Function Pointer),另外一种是闭包(Closure)。这里我们分开来看。

5.1. 函数指针(Function Pointer)

函数指针在Go中也只占用8个字节。

1
2
Var         Type           Address             RootOffset LocalOffset Size
f func() 0x000000c000006060 0 0 8

因为这种情况下该变量就是一个指针,所以让我们不由得不猜测这个是不是就是一个简单的地址,那我们就也来实验一次:

1
2
3
4
5
6
7
8
9
10
11
func CheckFunctionInternal() {
var f func() = CheckFunctionInternal
UpdateFunction(f)
fmt.Printf("AfterReturn: Ptr = %p\n", &f)
}

//go:noinline
func UpdateFunction(f func()) {
f = CheckChannelInternal
fmt.Printf("AfterUpdate: Ptr = %p\n", &f)
}

然后我们还是一样把汇编代码调出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
// ......
00007 (+143) LEAQ type.func()(SB), AX // Load function type info
00009 (143) MOVQ AX, (SP)
00010 (143) CALL runtime.newobject(SB) // Call runtime.newobject to allocate the object
00012 (143) MOVQ 8(SP), DI // Save the address of the new object (SP+8) to DI
00014 (143) MOVQ DI, "".&f-144(SP) // Save the address the new object to local variable
00017 (143) CMPL runtime.writeBarrier(SB), $0
00018 (143) JEQ 20
00019 (143) JMP 144
00020 (143) LEAQ "".CheckFunctionInternal·f(SB), AX // Load address of CheckFunctionInternal to AX
00021 (143) MOVQ AX, (DI) // Save address of CheckFunctionInternal to the newly allocated object
00022 (143) JMP 23
// ......

因为function类型在Go里面大小是8,所以runtime.newobject会申请一个大小为8的内存,所以函数变量其实是一个指向包含有指向原始函数地址的n内存的指针!

Function variable - Pointer

这里让人感觉非常奇怪,为什么不直接使用函数地址呢?我们来看一看函数是怎么被调用的,再确认一次:

1
2
3
4
5
6
// ...
00069 (+167) MOVQ "".&f-144(SP), AX // Load the address of the function pointer to AX
00071 (167) MOVQ (AX), DX // Load the function address to DX
00072 (167) MOVQ (DX), AX // Move the function address to AX
00074 (167) CALL AX // Call the actual function
// ...

所以还真的是这么实现的。这里我们先不着急,等看完之后的Closure实现,我们就明白了~

5.2. 闭包(Closure)

那闭包呢?闭包应该会创建上下文(Context),所以一个指针应该表示不了了,我们也来试一试。

1
2
Var         Type           Address             RootOffset LocalOffset Size
cls func() 0x000000c000006068 0 0 8

居然还是8个字节!我们这就来看看是怎么回事,老办法,先写个小代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func CheckClosureInternal() {
var i int = 1
var f func() = func() {
i = 2
fmt.Printf("i = %v\n", i)
}
UpdateClosure(f)
fmt.Printf("AfterReturn: Ptr = %p\n", &f)
}

//go:noinline
func UpdateClosure(f func()) {
var b bool = false
f = func() {
b = true
fmt.Printf("i = %v\n", b)
}
fmt.Printf("AfterUpdate: Ptr = %p\n", &f)
}

再拿一下汇编:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// ......
00007 (+162) LEAQ type.int(SB), AX // Load int type info to AX
00009 (162) MOVQ AX, (SP)
00010 (162) CALL runtime.newobject(SB) // Create a int
00012 (162) MOVQ 8(SP), AX // Save the address of the int to AX
00014 (162) MOVQ AX, "".&i-152(SP) // Save the address of the int to stack SP+(&i-152)
00016 (162) MOVQ $1, (AX) // Set the value of the newly allocated int
00018 (+163) LEAQ type.func()(SB), AX // Load function func() type info to AX
00020 (163) MOVQ AX, (SP)
00021 (163) CALL runtime.newobject(SB) // Allocate the buffer for holding the func object
00023 (163) MOVQ 8(SP), AX
00026 (163) MOVQ AX, "".&f-144(SP) // Save the func address to SP+(&f-144)
00028 (163) LEAQ type.noalg.struct { F uintptr; "".i *int }(SB), AX // Load the closure context type info, which captures the pointer to the int.
00030 (163) MOVQ AX, (SP)
00031 (163) CALL runtime.newobject(SB) // Create the closure context
00033 (163) MOVQ 8(SP), AX
00035 (163) MOVQ AX, ""..autotmp_15-168(SP) // Save the closure context address to temp variable SP+(""..autotmp_15-168)
00036 (163) LEAQ "".CheckClosureInternal.func1(SB), CX // Load the annoymous function address
00038 (163) MOVQ CX, (AX) // Update the function address in closure context
00040 (163) MOVQ ""..autotmp_15-168(SP), AX
00041 (163) TESTB AX, (AX)
00044 (163) MOVQ "".&i-152(SP), CX // Load the address of the int
00046 (163) LEAQ 8(AX), DI // Load the address of the int pointer in the closure context
00049 (163) CMPL runtime.writeBarrier(SB), $0
00050 (163) JEQ 52
00051 (163) JMP 190
00052 (163) MOVQ CX, 8(AX) // Update the int pointer in the closure context to the int it created above
00053 (163) JMP 54
00056 (163) MOVQ "".&f-144(SP), DI // Load the function address back to DI
00059 (163) MOVQ ""..autotmp_15-168(SP), AX // Load the closure context address back to AX
00062 (163) CMPL runtime.writeBarrier(SB), $0
00063 (163) JEQ 65
00064 (163) JMP 188
00065 (163) MOVQ AX, (DI) // Save the closure context as the function address
00066 (163) JMP 67
// ......

从上面的汇编我们可以看到,闭包的实现是:

  1. 为闭包创建一个上下文,其第一个变量是函数地址,之后跟着所有捕获的变量指针
  2. 将堆上申请一个指针,然后这个闭包的上下文的地址保存在上面
  3. 该新申请的指针就是我们的函数对象了

Function variable - Closure

这里我们发现了一个好玩的事情,虽然普通的函数指针需要取两次地址才能调用,然而调用普通函数指针的代码同样也能调用Closure!

呃…………等等,我们好像忘记了什么…………那Closure里面捕获下来的int在Closure里面要怎么获取获取到呢?我们再来看看闭包的实现:

1
2
3
// ......
00007 (163) MOVQ 8(DX), AX
// ......

还记得调用函数对象时DX中间保存的是什么吗?DX就是这个上下文的地址呀,所以DX+8就是捕获下来的int变量的指针了!

所以本质上来说,Go是为不同的函数指针也创建了一个调用上下文,只不过这个上下文中只有这个函数指针!用这个方法,普通函数指针和闭包的操作便统一起来了。


6. 结论

  1. 基础数值类型的变量,占用的空间和其定义的大小一致
  2. Go语言,函数调用时不存在传引用的参数,所有的参数都是传值,需要修改外部变量必须通过指针来进行操作。
  3. String和Slice本质上都是struct,由于Go的参数都是传值,所以调用时会有一次拷贝,会在栈上占用16字节和24字节。
  4. Map,Channel,Function的表示都是一个指针,所以他们都只占8个字节,并且由于其是指针,调用之前必须要创建对应的对象或者赋值以避免空值。
  5. 函数指针和闭包都会创建调用上下文,只是函数指针的上下文只有被调用函数的指针,而闭包中还有被捕获的变量的指针。

为了让代码更加可读,本文中所有的汇编代码都将PCDATA的指令删除了。另外本文所有的相关代码都可以在这个Github的Repo中找到,如果有问题,欢迎大家指正,非常感谢~


同系列文章:
原创文章,转载请标明出处:Soul Orbit
本文链接地址:Go语言学习笔记:深入对象模型之内置类型