一篇文章带你了解内存中的Slice

开发 前端
slice表示的是数组的一部分,可以称为数组片段。slice是依赖数组而存在的,本文在 数组 基础上继续学习slice。

[[417122]]

因为没找到一个合适的中文词来表示slice的确切含义,所以文中将直接使用slice这个单词。

实际上,slice表示的是数组的一部分,可以称为数组片段。

内存中的数组一文学习研究了数组及数组类型在内存中的表现形式。

slice是依赖数组而存在的,本文在 数组 基础上继续学习slice。

slice内存结构示意图

data指针并不一定指向底层数组的起始位置,可以指向数组的任何一个元素地址。

但是对于slice本身来说,data指针指向一个数组的开始。

环境

  1. OS : Ubuntu 20.04.2 LTS; x86_64 
  2. Go : go version go1.16.2 linux/amd64 

声明

操作系统、处理器架构、Go版本不同,均有可能造成相同的源码编译后运行时的内存地址、数据结构不同。

本文仅保证学习过程中的分析数据在当前环境下的准确有效性。

代码清单

  1. package main 
  2.  
  3. import "fmt" 
  4.  
  5. func main() { 
  6.     var a = [10]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10} 
  7.     var s = a[:5] 
  8.     PrintInterface(s) 
  9.  
  10. //go:noinline 
  11. func PrintInterface(v interface{}) { 
  12.     fmt.Println("it =", v) 

变量a是一个声明并初始化的数组,变量s是通过数组a创建的slice。

深入内存

动态调试,在 main 函数的入口处设置断点,查看程序指令:

数组初始化

从上图中指令可以看出,数组的声明和初始化是分两步实现的。

  1. var a = [10]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10} 

数组创建

在分配 main 函数的栈帧之后,立即调用 runtime.newObject 函数分配了一个数组,其参数是0x4a2ae0。

在内存中的数组中我们看到小数组直接分配在栈内存,大数组分配在堆内存。而在这里,小数组也直接通过动态分配的方式创建在堆内存。猜测这应该是与代码执行上下文有关。

数组类型结构定义在reflect/type.go源码文件中,如下所示:

  1. // arrayType represents a fixed array type. 
  2. type arrayType struct { 
  3.    rtype 
  4.    elem  *rtype // array element type 
  5.    slice *rtype // slice type 
  6.    len   uintptr 

我们来看看该数组的类型:

刚刚创建的数组长度是10,占用80个字节的内存,名称是[10]int,与代码清单一致。

数组赋值

代码清单中声明的数组数据,在代码编译之后保存在可执行文件的 .rodata section。程序运行时,数组数据的内存地址是:0x4da948。

在数组创建之后,数组元素的值全部都是零。初始化赋值操作是通过调用 runtime.duffcopy 函数复制0x4da948地址处的数据实现的。

关于达夫设备,稍后详细介绍。

slice结构体

slice的创建是通过 runtime.convTslice 函数实现的。

通过源码可以看出,该函数和之前看到的其他 runtime.convTx 函数类似,复制栈内存一个slice对象到堆内存;不同的是,把slice对象作为[]byte类型的数据进行复制。

同时,源码中可以看到一个 *slice 类型,这很令人兴奋。在 runtime/slice.go 源码文件中,找到了runtime.slice结构体的定义:

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

slice结构体由三部分组成:

  1. 指向数组的指针:该数组保存着具体的数据
  2. 长度:也就是slice包含元素的数量
  3. 容量:也就是数组的长度

从其结构来看,与Java中的java.util.ArrayList非常类似。

在调用 runtime.convTslice 函数的指令处下断点,观察其参数。

从上图可以看出,runtime.convTslice函数的参数,本身就是位于栈顶的一个runtime.slice结构体,该函数会把这个结构体数据复制到堆内存:

  1. 0x000000c00007a000  // 数组的地址 
  2. 0x0000000000000005  // slice的长度 
  3. 0x000000000000000a  // slice的容量(数组的长度) 

我们再看runtime.convTslice函数的返回值。

返回值是通过栈内存传递的,保存在紧挨参数的位置,值是0x000000c00000c030;这是一个指针,指向的数据与参数完全相同,最终作为PrintInterface函数的参数,用于打印输出数据。

通过查看Golang源代码,发现有多处定义了slice结构体,它们在内存中是等价的(虽然有细微差别):

  • 在 reflect/value.go 源码文件中的SliceHeader结构体
  1. type SliceHeader struct { 
  2.       Data uintptr 
  3.       Len  int 
  4.       Cap  int 
  • 在internal/unsafeheader/unsafeheader.go 源码文件中的Slice结构体
  1. type Slice struct { 
  2.       Data unsafe.Pointer 
  3.       Len  int 
  4.       Cap  int 

slice类型

slice类型的定义在Golang源码 reflect/type.go 文件中。

  1. // sliceType represents a slice type. 
  2. type sliceType struct { 
  3.     rtype 
  4.     elem *rtype // slice element type 

在调用PrintInterface函数的指令处下断点,观察slice类型信息。

rtype.size

slice对象占0x18(24)个字节。

  • 指针:8字节
  • 长度:8字节
  • 容量:8字节

rtype.ptrdata

8字节(number of bytes in the type that can contain pointers)。

slice结构体的第一个字段是指针类型,长度和容量字段不是指针类型,所以只有8字节包含指针。

在前面的学习中,研究的都简单数据类型,不包含指针,所以其类型的ptrdata都是零。

rtype.hash

值为 0x1bf9668e 。

rtype.tflag

0x02 = reflect.tflagExtraStar

请看 rtype.str 字段值。

rtype.align

8字节对齐。

rtype.fieldAlign

作为结构体字段时8字节对齐。

rtype.kind

值为0x17(23)。

rtype.equal

值为零。说明slice对象不进行相等性比较。

reflect.Type 接口中声明了一个 Comparable() bool 方法,用于检测判断该类型的数据是否可以进行比较。具体实现如下,二者个关系便一目了然了。

  1. func (t *rtype) Comparable() bool { 
  2.     return t.equal != nil 

rtype.str

表示的值为:*[]int。

rtype.ptrToThis

值为零。

sliceType.elem

该指针指向的数据类型是 int 类型(rtype.kind=reflect.Int)。

达夫设备

在计算机科学领域,达夫设备(英文:Duff's device)是串行复制(serial copy)的一种优化实现,通过汇编语言编程时一种常用方法,实现展开循环,进而提高执行效率。

How does Duff's device work?

在Golang中,runtime.duffcopy函数声明如下,实际是通过Golang汇编实现的。

x86_64的具体实现位于源码的 runtime/duff_amd64.s 文件中。

该函数的实现共322行,实在是太长了,我们在这里截取一部分,以便了解其实现细节和学习其优秀的设计思想。

在不了解达夫设备的情况下,看到该函数代码的第一眼,可能会产生两种错觉:

  1. 实现这个函数的程序员估计是很懒,写个循环不香吗?
  2. 实现这个函数的程序员这么喜欢复制粘贴代码,是按代码行数领工资的吗?

实际情况是,该函数实现是经过精心设计的,用于优化内存中的数据复制操作。

不过,该函数很可能就是通过复制粘贴实现的,共包含64个这样的代码块:

  1. MOVUPS  (SI), X0 
  2.  ADDQ  $16, SI 
  3.  MOVUPS  X0, (DI) 
  4.  ADDQ  $16, DI 

该代码块(以下称为“复制单元”)的作用是:从源地址复制16字节的数据到目的地址。也就是说这四条指令,一次可以复制2个int值。

那么意味着,如果runtime.duffcopy函数从头到尾完整执行下来:

  • 一共可以复制1024(64*16)个字节
  • 一共可以复制128(64*2)个 int 值

在本文示例中,我们的数组只包含10个 int 元素,共80个字节。

于是一个个疑问冒出来:

  • 调用runtime.duffcopy函数岂不是多复制了944个字节?
  • 多复制的数据覆盖了附近区域的正常数据岂不是要导致程序混乱?
  • 为什么程序没有异常崩溃(segmentation fault)?
  • 写个 "for" 循环不像吗?
  • 像在内存中的数组遇到的那样使用rep movsq机器指令不香吗?

实际上,在本文示例中,复制数组数据时,并不是从runtime.duffcopy函数的第一行代码开始执行的,而是跳过了59个复制单元,直接从第60个复制单元开始执行,共执行了5个复制单元,复制了10个 int 数组元素,然后返回到 main 函数中。

如果创建一个[20]int数组,复制数据时就会从runtime.duffcopy函数的第55个复制单元开始执行。

如果创建一个[128]int数组,复制数据时就会从runtime.duffcopy函数的第1个复制单元开始执行,也就是从第一行代码开始执行。

当然,到底该从那条指令开始执行,是Golang编译器决定的,并不是调用方自己决定的,也不是runtime.duffcopy函数决定的。

所以,runtime.duffcopy函数在整个的数据复制过程中,没有一处条件判断,没有一处内存跳转,完全是顺序执行。这是非常高效的操作,是很棒的指令优化。

另外还有三处细节优化:

1.在调用runtime.duffcopy函数时,直接使用rdi、rsi寄存器保存两个地址参数;在数据复制过程中,使用ADD指令修改两个寄存器的值实现内存地址递增。

  • 这是我在Golang中遇到的第一个完全使用寄存器保存参数的函数。
  • 按照常规的编程约定:第一个参数保存在rdi寄存器,第一个参数保存在rsi寄存器。
  • 所以可以这样理解其函数声明:func duffcopy(dst [1024]byte, src [1024]byte)。

2.调用方为runtime.duffcopy函数分配8字节的栈帧内存用于保存rbp寄存器的值,并负责销毁该栈帧,使其能够专注于数据复制,不做其他任何事情。(实际也可以不分配该栈帧。)(这让我想起了 red zone。)

3.使用 movups指令和 xmm0寄存器,有效压缩了指令数量,从而提高执行效率。

总而言之,runtime.duffcopy函数是一个高度优化的“达夫设备”。

最后,还有两个问题:

1.如果 int 数组长度是奇数会怎么样?

答案是:先使用movq指令复制第一个元素,剩下偶数个数组元素使用runtime.duffcopy函数复制。

当数组长度为11时,var a = [11]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10},机器指令如下:

2.如果 int 数组长度超过128会怎么样?

答案是:使用rep movsq指令代替runtime.duffcopy函数。这个在意料之中。

在本文中,仔细研究了slice类型和slice对象在内存中的存储结构。

本文转载自微信公众号「Golang In Memory」

 

责任编辑:姜华 来源: Golang In Memory
相关推荐

2021-06-30 00:20:12

Hangfire.NET平台

2023-05-12 08:19:12

Netty程序框架

2021-01-29 18:41:16

JavaScript函数语法

2020-11-10 10:48:10

JavaScript属性对象

2021-02-02 18:39:05

JavaScript

2021-06-04 09:56:01

JavaScript 前端switch

2024-01-30 13:47:45

2023-09-06 14:57:46

JavaScript编程语言

2021-02-26 20:01:57

SVG滤镜元素

2021-01-26 23:46:32

JavaScript数据结构前端

2021-05-18 08:30:42

JavaScript 前端JavaScript时

2021-06-24 09:05:08

JavaScript日期前端

2024-04-19 14:23:52

SwitchJavaScript开发

2023-07-30 15:18:54

JavaScript属性

2021-03-09 14:04:01

JavaScriptCookie数据

2021-03-05 18:04:15

JavaScript循环代码

2023-05-08 08:21:15

JavaNIO编程

2021-09-27 09:18:30

ListIterato接口方法

2020-12-08 08:09:49

SVG图标Web

2022-01-15 10:02:03

Java Hashtable类 Java 基础
点赞
收藏

51CTO技术栈公众号