第一次接触Go程序,是一个朋友想让帮忙看看那个可执行文件实现了什么功能。
当时打开看了一眼就蒙圈了,看起来和C/C++程序编译的可执行文件完全不一样,连程序的入口都不一样。
很久之后,因工作需要,使用Golang语言进行开发,Golang很多特性引起了我的兴趣。
简洁的语法使得开发工作变得高效,就像甜甜的糖果吃起来很美味,但是它是怎么运行的呢?
于是从简单的开始,深入探索其运行时,审视其最终的样子。
环境
OS : Ubuntu 20.04.2 LTS; x86_64
Go : go version go1.16.2 linux/amd64
操作系统不同、处理器架构不同、Golang版本不同均可导致内存分配不同,因此不保证本文中分析结果在其它环境下也始终一致。
空接口类型
interface{} 称为空接口。
Golang 的 HelloWorld 程序使用以下方法打印字符串到标准输出。
其中 interface{} 类型的形式参数可以接收任何类型的数据作为实际参数。
这是一件很神奇的事。
而且 interface{} 类型的变量可以引用任何类型的数据,代码如下:
在Java语言中,Object类型的变量可以引用任何类型的对象;因为任何Java类都 extends java.lang.Object,运行时生成的动态代理类也不例外。
在C语言中,void * 号称万能指针, 表示一个内存地址引用,可以指向任何类型的数据,但是只能接收指针类型的赋值操作。
在Go语言中,interface{} 就厉害了,任何类型数据都敢接、都能接。
代码清单
备注:
编译器为了提高程序运行效率,减少寻址和内存跳转,会对代码进行优化;
PrintInterface函数体太小,在编译之后二进制可执行文件中,不会被分配单独的内存空间;
使用//go:noinline禁止编译器对该函数进行优化。
定义PrintInterface函数是为了更清晰的查看数据类型的转变、参数传递,也就是查看int类型数据如何转变为interface{}类型数据,以及interface{}数据在内存中是什么样子。
编译代码
代码清单中,定义了两个函数。编译过程中它们的名字被重新定义,如图中红框标记所示。
编译后的函数名称由两部分组成:
-
"."之前的部分(main)是原包名
-
"."之后的部分(main和PrintInterface)是原函数名
深入内存
函数 main
上图就是编译之后的main函数。三处标记描述如下:
①分配栈帧
main函数的栈帧分配非常小,共0x18个字节。在64位操作系统中,也就三个寄存器大小。执行①指令后,main函数栈帧结构如下图蓝色的区域所示:
②把123放入内存
movq $0x7b,(%rsp)
该指令将123放入栈顶。
紧接着调用了 runtime.convT64 函数;
而在源代码清单中,并没有调用该函数,怎么编译后多了个函数调用呢?
实际上,runtime.convT64 函数只是把一个64位的无符号整数转变为一个指向该整数值的指针,源码如下图所示,逻辑非常简单易懂:
代码中,runtime.staticuint64s 缓存了[0,256)之间的整数,减少动态分配内存,减少垃圾回收。
我们再看一下编译后的 runtime.convT64 函数,执行流程和源代码一致;只是我们能更加清晰的了解其执行细节。
因为123(0x7b)小于255(0xff),所以本次调用 runtime.convT64 函数虽然分配了栈帧,但是并没有使用该栈帧存储数据。
在 runtime.convT64 函数执行完成返回 main 函数之前,将函数返回值540AB8保存到 main 函数的栈帧中,方法调用栈如下图所示:
虽然 runtime.convT64 函数只有一个参数,仍然使用栈内存传递,而不是使用寄存器(例如:rdi、rsi、rdx、rcx、r8、r9)。
虽然 runtime.convT64 函数只有一个返回值,仍然没有是使用寄存器(例如:rax)保存返回值,而是直接把返回值写入调用者的栈帧内。
这样做有很明显的好处:
-
方便实现函数的多个返回值存储;
-
方便函数的调用者直接使用其返回值。
在本例中,返回值紧挨着参数保存。所有调用 runtime.convT64 函数的地方,都应该是这样。
③调用 PrintInterface 函数
在调用PrintInterface函数之前,两条指令将4A2140保存到栈顶,覆盖了原值123。
lea 0xaacf(%rip),%rax # 4a2140 <type.*+0xa140>
mov %rax,(%rsp)
由于123已经从栈内转移到了栈外,并可以通过指针值540AB8找到,所以此处覆盖是没问题的。此时,main函数的栈帧如下:
函数 PrintInterface
查看指令如下:
在打印参数到标准输出之前,先从 main 函数的栈帧中读取两个参数,并保存到当前栈帧内存中。
根据调用约定,调用PrintInterface函数时,传递了两个参数:
-
4A2140作为第一个参数
-
540AB8作为第二个参数,指向真实的整数值123
而在代码清单中,只传递了一个interface{}类型的参数。
✌✌✌
到这里,interface{}的本质似乎比较清晰了,看起来就是两个指针变量。到底是不是,我们还要阅读Golang源码。
源码阅读
在第一次了解到Go语言具有反射机制时,就曾猜测它一定保存了表示数据类型的数据结构,否则如何解析类型信息呢?
在反射包中,查看 reflect.TypeOf 函数源码,直接就能找到相关的信息。
原来,interface{}实际上就是:
果然,interface{}就是两个连续存放的指针,与上述分析结果一致。
-
第一个指针指向数据类型
-
第二个指针指向数据本身,本例中是123(0x7b)
正因为Golang编译器将源代码中的“数据”包装成类型和数据两部分,并通过指针引用它们,所以,interface{}变量可以引用任何类型的数据,interface{}类型的形式参数可以接收任何类型的实际参数。
实际上,interface{}还等同于Golang源码 internal/reflectlite/value.go 中的emptyInterface结构体。
实际上,interface{}还等同于Golang源码 runtime/runtime2.go 中的eface结构体。
总结
-
interface{}编译之后就是两个连续存放的指针
-
第一个指针指向数据类型
-
第二个指针指向数据本身
-
-
Golang缓存了[0,256)之间的整数,运行时不再在堆中分配该范围内的整数,有助于减少内存分配和垃圾回收。
-
runtime.staticuint64s 存放在可执行文件的 .noptrdata section
-
-
Golang使用栈内存传递参数和返回值,而没有使用寄存器。