由于没有找到合适的中文词来表达slice的确切含义,本文将直接使用slice这个词。实际上,切片代表数组的一部分,可以称为数组片段。Arraysinmemory研究数组和数组类型在内存中的表示。切片依赖于数组而存在。本文继续在数组的基础上学习切片。分片内存结构示意图数据指针不一定指向底层数组的起始位置,可以指向数组的任意元素地址。但是对于切片本身,数据指针指向数组的开头。环境操作系统:Ubuntu20.04.2LTS;x86_64Go:goversiongo1.16.2linux/amd64声明了不同的操作系统、处理器架构和Go版本,可能导致相同源代码编译运行时内存地址和数据结构不同。本文仅保证当前环境下学习过程中分析数据的准确性和有效性。代码清单packagemainimport"fmt"funcmain(){vara=[10]int{1,2,3,4,5,6,7,8,9,10}vars=a[:5]PrintInterface(s)}//go:noinlinefuncPrintInterface(vinterface{}){fmt.Println("it=",v)}变量a是声明并初始化的数组,变量s是通过数组a创建的切片。深入内存动态调试,在main函数入口处下断点,查看程序指令:数组初始化从上图的指令可以看出,数组的声明和初始化分两步实现.vara=[10]int{1,2,3,4,5,6,7,8,9,10}数组创建main函数分配栈帧后,立即调用runtime.newObject函数分配数组,其参数为0x4a2ae0。在内存中的数组中,我们看到小数组直接分配在栈内存中,大数组分配在堆内存中。这里小数组也是直接在堆内存中通过动态分配创建的。猜测这应该与代码执行上下文有关。reflect/type.go源码文件中定义了数组类型结构,如下:刚刚创建的数组长度为10,占用80字节内存,名称为[10]int,与代码清单一致。数组赋值代码清单中声明的??数组数据在代码编译后保存在可执行文件的.rodata段中。程序运行时,数组数据的内存地址为:0x4da948。数组创建后,数组元素的值全部为零。初始化赋值操作是通过调用runtime.duffcopy函数复制地址0x4da948的数据来实现的。关于DAF设备,我们后面会详细介绍。slice结构slice是通过runtime.convTslice函数创建的。从源码可以看出,这个函数和之前看到的其他runtime.convTx函数类似,从栈内存中复制一个slice对象到堆内存中;区别在于切片对象被复制为[]byte类型的数据。同时,在源码中看到一个*slice类型也是令人兴奋的。在runtime/slice.go源码文件中,找到了runtime.slice结构体的定义:typeslicestruct{arrayunsafe.Pointerlenintcapint}slice结构体由三部分组成:指向数组的指针:数组保存了具体的数据长度:即切片包含元素的数量:即数组的长度。从结构上看,它与Java中的java.util.ArrayList非常相似。在调用runtime.convTslice函数的指令处设置断点并观察其参数。从上图可以看出,runtime.convTslice函数的参数本身就是一个位于栈顶的runtime.slice结构,函数会将这个结构的数据复制到堆内存中:0x000000c00007a000//数组的地址0x0000000000000005//切片的长度0x000000000000000a//切片容量(数组的长度)我们来看runtime.convTslice函数的返回值。返回值通过栈内存,存放在参数旁边的位置,值为0x000000c00000c030;this是一个指针,指向的数据和参数完全一样,最后作为PrintInterface函数的参数打印出数据。通过查看Golang源码,发现slice结构体有多个定义,在内存中是等价的(虽然有细微差别):reflect/value.go源码文件中的SliceHeader结构体typeSliceHeaderstruct{DatauintptrLenintCapint}内部/unsafeheader/unsafeheader.go源码文件中的Slice结构typeSlicestruct{Dataunsafe.PointerLenintCapint}slice类型定义在Golang源码reflect/type.go文件中。//sliceTyperepresentsaslicetype.typesliceTypestruct{rtypeelem*rtype//sliceelementtype}在调用PrintInterface函数的指令处设置断点,观察切片类型信息。一个rtype.sizeslice对象占用0x18(24)个字节。指针:8字节长度:8字节容量:8字节rtype.ptrdata8字节(类型中可以包含指针的字节数)。slice结构的第一个域是指针类型,length和capacity域不是指针类型,所以只有8个字节包含指针。前面的学习都是学习了简单数据类型,不包含指针,所以其类型的ptrdata都是0。rtype.hash值为0x1bf9668e。rtype.tflag0x02=reflect.tflagExtraStar请查看rtype.str字段值。rtype.align8字节对齐。rtype.fieldAlign用作结构字段时的8字节对齐。rtype.kind的值为0x17(23)。rtype.equal值为零。指示切片对象不执行相等比较。reflect.Type接口声明了一个Comparable()bool方法,用于检测该类型的数据是否可以比较。具体实现如下,两者的关系一目了然。func(t*rtype)Comparable()bool{returnt.equal!=nil}rtype.str表示的值为:*[]int。rtype.ptrToThis的值为零。sliceType.elem指针指向的数据类型为int类型(rtype.kind=reflect.Int)。在计算机科学领域,达夫装置(英语:Duff'sdevice)是串行拷贝(serialcopy)的优化实现,是汇编语言程序设计中实现展开循环,提高执行效率的常用方法。Duff的设备是如何工作的?在Golang中,runtime.duffcopy函数声明如下,实际上是通过Golang汇编实现的。x86_64的具体实现位于源码的runtime/duff_amd64.s文件中。这个函数的实现一共322行,太长了。我们在这里截取一部分,了解其实现细节,学习其优秀的设计思想。如果不了解DAF设备,第一眼看到这个功能的代码可能会有两种错觉:实现这个功能的程序员很可能很懒。写个循环不好吗?实现这个功能的程序员工这么喜欢复制粘贴代码,是按代码行数付钱的吗?实际情况是函数实现经过精心设计,以优化内存中的数据复制操作。不过这个函数很可能是通过复制粘贴来实现的,总共包含64个这样的代码块:MOVUPS(SI),X0ADDQ$16,SIMOVUPSX0,(DI)ADDQ$16,DI这个代码块(以下简称“copyunit”)就是将16个字节的数据从源地址复制到目的地址。也就是说这4条指令一次可以复制2个int值。也就是说,如果从头到尾执行runtime.duffcopy函数:一共可以复制1024(64*16)个字节,一共可以复制128(64*2)个int值。在本文的示例中,我们的数组只包含10个int元素,总共80个字节。于是问题接二连三地冒了出来:调用runtime.duffcopy函数不是多复制了944字节吗?过度复制的数据会不会覆盖附近区域的正常数据导致程序混乱?为什么程序没有异常崩溃(segmentationfault)?写一个“for”循环不是很好吗?像遇到内存数组那样使用repmovsq机器指令不是很好吗?其实在这个例子中,数组数据并没有从runtime.duffcopy函数第一行代码开始执行,而是跳过了59个复制单元,直接从第60个复制单元开始执行,一共执行了5个复制单元,复制10个int数组元素,然后返回到main函数中。如果创建[20]int数组,则从runtime.duffcopy函数的第55个复制单元开始复制数据。如果创建一个[128]int数组,在复制数据时,会从runtime.duffcopy函数的第一个复制单元开始执行,即从第一行代码开始执行。当然,Golang编译器决定应该执行哪条指令,而不是调用者本身,也不是runtime.duffcopy函数。因此,在整个数据拷贝过程中,runtime.duffcopy函数没有条件判断,没有内存跳转,顺序执行。这是一个非常有效的操作,是一个很好的指令优化。此外,还有三个细节优化:1、调用runtime.duffcopy函数时,直接使用rdi和rsi寄存器保存两个地址参数;在数据拷贝过程中,使用ADD指令修改两个寄存器的值,实现内存地址递增。这是我在Golang中遇到的第一个完全使用寄存器来保存参数的函数。按照常规的编程约定:第一个参数存放在rdi寄存器中,第一个参数存放在rsi寄存器中。所以它的函数声明可以这样理解:funcduffcopy(dst[1024]byte,src[1024]byte)。2、调用者为runtime.duffcopy函数分配8字节的栈帧内存,用于保存rbp寄存器的值,并负责销毁栈帧,使其专注于数据拷贝,而不用做任何其他事情。(实际上,栈帧可能没有分配。)(这让我想起了红色区域。)3.使用movups指令和xmm0寄存器有效地压缩了指令数,从而提高了执行效率。总而言之,runtime.duffcopy函数是一个高度优化的“duff设备”。最后还有两个问题:1.如果int数组的长度是奇数怎么办?答案是:先用movq指令复制第一个元素,用runtime.duffcopy函数复制剩余的偶数个数组元素。当数组长度为11时,vara=[11]int{1,2,3,4,5,6,7,8,9,10},机器指令如下:2.如果int数组的长度超过128之类的?答案是:使用repmovsq指令代替runtime.duffcopy函数。这是意料之中的。本文仔细研究了切片类型和切片对象在内存中的存储结构。本文转载自微信公众号「记忆中的Golang」
