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

娱乐2025-11-05 12:21:40949

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

实际上,解内slice表示的存中是数组的一部分,可以称为数组片段。篇文

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

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

slice内存结构示意图

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

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

环境

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

声明

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

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

代码清单

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

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

深入内存

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

数组初始化

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

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

数组创建

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

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

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

// arrayType represents a fixed array type. type arrayType struct {    rtype    elem  *rtype // array element type    slice *rtype // slice type    len   uintptr } 

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

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

数组赋值

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

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

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

slice结构体

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

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

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

type slice struct {   array unsafe.Pointer   len   int   cap   int } 

slice结构体由三部分组成:

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

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

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

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

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

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

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

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

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

slice类型

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

// sliceType represents a slice type. type sliceType struct {     rtype     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 方法,用于检测判断该类型的数据是否可以进行比较。具体实现如下,二者个关系便一目了然了。

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

rtype.str

表示的值为:*[]int。

rtype.ptrToThis

值为零。

sliceType.elem

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

达夫设备

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

How does Duffs device work?

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

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

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

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

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

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

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

MOVUPS  (SI), X0  ADDQ  $16, SI  MOVUPS  X0, (DI)  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」

本文地址:http://www.bhae.cn/html/012f29199696.html
版权声明

本文仅代表作者观点,不代表本站立场。
本文系作者授权发表,未经许可,不得转载。

全站热门

很多朋友都知道Ubuntu是一个非常不错的Linux发行版,要在官网下载到Ubuntu也非常简单。但下载好ISO之后大家要怎么来安装呢?当然,早年前我们都是通过记录DVD光盘的方式来进行安装,现在随着光驱逐步被市场所淘汰,Ubuntu同Windows一样与时俱进,同样也可以通过制作Ubuntu安装U盘的方式来进行安装。下面我们就来介绍下如何在Windows、Mac甚至Linux平台下,如何制作Ubuntu安装U盘的几种方式。Ubuntu版本的选择首先大家需要知道Ubuntu有LTS版本和“技术前沿版”,这两种版本都可以作为日常的桌面终端进行使用,但通常我们会认为LTS版本更加稳定, 而且可以获得至发行之日起为期5年的技术支持。而LTS版本之间发行的所谓“技术前沿版”仅有9个月的支持周期,到期之后用户就必需升级到新的版本下。再有就是32位和64位版本选择的问题。我个人比较建议大家都选择目前较主流的64位版本进行安装,当然,假如你的电脑太老旧或不能支持的话,还是安装32位吧!之前有一个比较流行的说法是内存小于3GB时就不要选择64位版本进行安装,其实这种说法可以忽略不计,64位可以更加充分的利用CPU支持,哪怕你的内存小于3GB。制作Ubuntu安装U盘一旦Ubuntu的ISO下载安装,我们就需要将其写入到U盘当中。其实无法你在哪种操作系统中制作Ubuntu安装U盘的方式都大相径庭,下面我们就分别进行介绍。1.Windows中制作Ubuntu安装U盘Universal USB Installer是一个Windows下制作Linux安装U盘非常流行和常用的一个工具,该工具是绿色版本不需要安装,支持当前主流的Linux发行版,当然也支持Ubuntu。打开Universal USB Installer,之后我们只需按上图所示选择好下载到Ubuntu镜像,再指定好我们当前U盘的盘符即可。为了保证操作过程中不出问题,建议大家勾选对U盘进行格式化。2.Mac中制作Ubuntu安装U盘在Mac下制作Ubuntu安装U盘对很多普通用户来说就比较棘手了,因为我们必需用到Mac的终端命令。当然好处就是不用下载那些杂七杂八又不常用的工具来占用空间了。打开终端,使用如下命令:先浏览到下载文件夹:复制代码代码如下:cd ~/Downloads然后执行如下命令:复制代码代码如下:hdiutil convert -format UDRW -o ubuntu.iso ubuntu-xxxxxx.iso最后一部分是你下载好的Ubuntu镜像的文件名,请执行前按你的情况替换好。该命令可以将ISO镜像转换成Mac更容易地实现。再执行,删除Mac版为镜像文件添加的.dmg扩展名:复制代码代码如下:mv ubuntu.iso.dmg ubuntu.iso下一步列出当前驱动器:复制代码代码如下:diskutil list然后插入U盘重新执行以上命令:复制代码代码如下:diskutil list找出之前没有的驱动器挂载点后执行:复制代码代码如下:diskutil unmountDisk /dev/diskN其中N是上条命令中找出的U盘挂载点号。执行如下命令开始写入Ubuntu镜像文件到U盘:复制代码代码如下:sudo dd if=./ubuntu.iso of=/dev/rdiskN bs=1m写入完成后,我们执行如下命令弹出U盘就制作完成了:复制代码代码如下:diskutil eject /dev/diskN3.Linux中制作Ubuntu安装U盘Linux下制作Ubuntu安装U盘的方式与Mac类似,都是通过终端命令来完成:先浏览到下载文件夹:复制代码代码如下:cd ~/Downloads然后使用如下命令开始写入:复制代码代码如下:sudo dd if=./ubuntu-iso-name.iso of=/dev/sdX其中X为U盘的挂载点,当然ubuntu-iso-name表示的是下载好Ubuntu镜像的名称,需要你自己改好。制作完成后使用如下命令推出U盘即可:复制代码代码如下:sudo eject /dev/sdX以上我们介绍了3种制作Ubuntu安装U盘的方式,相信大家按步骤来都可以制作完成,希望大家喜欢该文。

很可怕!NSA、GitHub 被恶搞:Windows 的锅

内存用量1/20,速度加快80倍,QQ提全新BERT蒸馏框架,未来将开源

好用哭了!8大开发员必备的网页应用程序

使用apt-get工具安装,linux测试版的chrome名称为chromium,由于发布已经有些时日,安装方法可以参见我给的连接,也可以到网上找,下面介绍如何使用chromium安装flash插件播放google音乐。复制代码代码如下:$:sudo locate libflashplayer.so复制代码代码如下:/usr/lib/adobe-flashplugin/libflashplayer.so复制代码代码如下:$sudo find -name chromium-browser,复制代码代码如下:$sudo cp /usr/lib/flashplugin-nonfree/libflashplayer.so /usr/lib/chromium-browser/plugins复制代码代码如下:cd /etc/fonts/conf.d/sudo rm 49-sansserif.conf

为什么整个互联网行业都缺前端工程师?

如何把awk脚本移植到Python

当我们谈容器的时候,我们在谈什么

热门文章

友情链接

滇ICP备2023000592号-9