文章目录
- GoLang之图解装箱
-
- 1.问题引入
- 2.问题探索
- 3.探索结论
- 4.值型装箱是否会堆放分配?
GoLang之图解装箱
1.问题引入
无论是空接口,我们都介绍了接口的数据结构interface{}或非空接口,本质上是两个指针:一个与类型元数据有关,另一个与接口装载数据有关。 有一个问题需要仔细探索:data字段是指针,那么它是如何接受一种值类型的赋值的呢?就像以下两个代码:
(1)给空接口类型给空接口类型变量e1:
n := 10 var e1 interface{
} = n
(2)将整形变量地址赋值空接口类型变量e2
n := 10 var e2 interface{
} = &n
现在,请考虑:e1和e分别长什么样? e2比较简单,_type指向int*类型元数据,data存储变量n的地址。 e1不一样,_type指向int类型元数据没有问题,但是data什么是指针存储?不能存储。n,因为是整形,而且data是个指针。 既然猜测不好,我们来探索一下反编译,亲眼看看。data存储什么?
2.问题探索
func v2e(n int) (e interface{
}) {
e = n return }
反编译上述代码后,得到以下汇编代码:
$ go tool objdump -S -s '^gom.v2e$' eface.o TEXT gom.v2e(SB) gofile..eface.go func v2e(n int) (e interface{
}) {
0xb0a 65488b0c2528000000 MOVQ GS:0x28, CX 0xb13 488b8900000000 MOVQ 0(CX), CX [3:7]R_TLS_LE 0xb1a 483b6110 CMPQ 0x10(CX), SP 0xb1e 763c JBE 0xb5c 0xb20 4883ec18 SUBQ $0x18, SP 0xb24 48896c2410 MOVQ BP, 0x10(SP) 0xb29 488d6c2410 LEAQ 0x10(SP), BP e = n 0xb2e 488b442420 MOVQ 0x20(SP), AX 0xb33 48890424 MOVQ AX, 0(SP) 0xb37 e800000000 CALL 0xb3c [1:5]R_CALL:runtime.convT64 0xb3c 488b442408 MOVQ 0x8(SP), AX return 0xb41 488d0d00000000 LEAQ 0(IP), CX [3:7]R_PCREL:type.int 0xb48 48894c2428 MOVQ CX, 0x28(SP) 0xb4d 4889442430 MOVQ AX, 0x30(SP) 0xb52 488b6c2410 MOVQ 0x10(SP), BP 0xb57 4883c418 ADDQ $0x18, SP 0xb5b c3 RET func v2e(n int) (e interface{
}) {
0xb5c e800000000 CALL 0xb61 [1:5]R_CALL:runtime.morestack_noctxt 0xb61 eba7 JMP gom.v2e(SB)
代码篇幅不太长,还是转换成等价的伪代码比较容易理解:
func v2e(n int) (e eface) {
entry:
gp := getg()
if SP <= gp.stackguard0 {
goto morestack
}
e.data = runtime.convT64(n)//关键1
e._type = &type.int //关键2
return
morestack:
runtime.morestack_noctxt()
goto entry
}
忽略掉栈增长相关代码,我们真正感兴趣的就是为e的两个成员赋值的这两行代码: 1、先把变量n的值作为参数调用了runtime.convT64,并把返回值赋给了e.data,所以data存储的是runtime.convT64的返回值。 2、把type.int的地址赋给了e._type。这倒是比较容易理解,接下来就要看runtime.convT64的逻辑了:
func convT64(val uint64) (x unsafe.Pointer) {
if val < uint64(len(staticuint64s)) {
x = unsafe.Pointer(&staticuint64s[val])
} else {
x = mallocgc(8, uint64Type, false)
*(*uint64)(x) = val
}
return
}
主要逻辑:当val的值小于staticuint64s的长度时,直接返回staticuint64s中第val项的地址。否则就通过mallocgc分配一个uint64,把val的值赋给它并返回它的地址。这个staticuint64s是个长度为256的uint64数组,每个元素的值都跟下标一致,存储了0~255这256个值,主要是用来避免常用数字频繁堆分配。 整体来看convT64的功能,实际上就是堆分配一个uint64,并且将val参数作为初始值赋给它,然后返回它的地址。
3.探索结论
1、interface{}被设计成一个容器,但它本质上是指针,可以直接装载地址,用来实现装载值的话,实际的内存要分配在别的地方,并把内存地址存储在这里。(convT64的作用就是分配这个存储值的内存空间,实际上runtime中有一系列的这类函数,如convT32、convTstring和convTslice等。) 2、通过staticuint64s这种优化方式,能够反向推断出:被convT64分配的这个uint64,它的值在语义层面是不可修改的,是个类似const的常量,这样设计主要是为了跟interface{}配合来模拟“装载值”。 3、至于为什么这个值不可修改,因为interface{}只是一个容器,它支持把数据装入和取出,但是不支持直接在容器里修改。这有些类似于Java和C#里的自动装箱,只不过interface{}是个万能包装类。
4.值类型装箱就一定会堆分配吗?
这个问题也需要验证。既然已经知道逃逸会造成堆分配,那就构造一个值类型装箱但不逃逸的场景,就是如下代码中的fn函数:
func fn(n int) bool {
return notNil(n)
}
func notNil(a interface{
}) bool {
return a != nil
}
编译时需要禁止内联优化,编译器还是能够通过notNil函数的代码实现判定没有发生逃逸,反编译fn得到如下汇编代码:
$ go tool objdump -S -s '^gom.fn$' eface.o
TEXT gom.fn(SB) gofile..eface.go
func fn(n int) bool {
0xfd6 65488b0c2528000000 MOVQ GS:0x28, CX
0xfdf 488b8900000000 MOVQ 0(CX), CX [3:7]R_TLS_LE
0xfe6 483b6110 CMPQ 0x10(CX), SP
0xfea 764a JBE 0x1036
0xfec 4883ec28 SUBQ $0x28, SP
0xff0 48896c2420 MOVQ BP, 0x20(SP)
0xff5 488d6c2420 LEAQ 0x20(SP), BP
return notNil(n)
0xffa 488b442430 MOVQ 0x30(SP), AX
0xfff 4889442418 MOVQ AX, 0x18(SP)
0x1004 488d0500000000 LEAQ 0(IP), AX [3:7]R_PCREL:type.int
0x100b 48890424 MOVQ AX, 0(SP)
0x100f 488d442418 LEAQ 0x18(SP), AX
0x1014 4889442408 MOVQ AX, 0x8(SP)
0x1019 e800000000 CALL 0x101e [1:5]R_CALL:gom.notNil
0x101e 0fb6442410 MOVZX 0x10(SP), AX
0x1023 88442438 MOVB AL, 0x38(SP)
0x1027 488b6c2420 MOVQ 0x20(SP), BP
0x102c 4883c428 ADDQ $0x28, SP
0x1030 c3 RET
func fn(n int) bool {
0x1031 0f1f440000 NOPL 0(AX)(AX*1)
0x1036 e800000000 CALL 0x103b [1:5]R_CALL:runtime.morestack_noctxt
0x103b eb99 JMP gom.fn(SB)
把上面的代码转换为等价的伪代码: 注意伪代码中的局部变量v,它实际上是被编译器隐式分配的(可没在堆上哦~),被用作n的值拷贝。
func fn(n int) bool {
entry:
gp := getg()
if SP <= gp.stackguard0 {
goto morestack
}
v := n
return notNil(eface{
_type: &type.int, data: &v})
morestack:
runtime.morestack_noctxt()
goto entry
}
所以,interface{}装载值的时候,必须单独拷贝一份,而不能直接让data存储原始变量的地址,但是否堆分配还是要经过逃逸分析:值类型装箱后又涉及到逃逸的情况时,才会用到runtime中的一系列convT函数。