黑马程序员技术交流社区

标题: 【上海校区】golang-slice底层分析 [打印本页]

作者: 不二晨    时间: 2018-12-18 17:12
标题: 【上海校区】golang-slice底层分析

切片是 Go 中的一种基本的数据结构,使用这种结构可以用来管理数据集合。切片的设计想法是由动态数组概念而来,为了开发者可以更加方便的使一个数据结构可以自动增加和减少。但是切片本身并不是动态数据或者数组指针。切片常见的操作有 reslice、append、copy。与此同时,切片还具有可索引,可迭代的优秀特性。

一. 切片和数组

关于切片和数组怎么选择?接下来好好讨论讨论这个问题。

在 Go 中,与 C 数组变量隐式作为指针使用不同,Go 数组是值类型,赋值和函数传参操作都会复制整个数组数据。

Go

打印结果:

Go

可以看到,三个内存地址都不同,这也就验证了 Go 中数组赋值和函数传参都是值复制的。那这会导致什么问题呢?

假想每次传参都用数组,那么每次数组都要被复制一遍。如果数组大小有 100万,在64位机器上就需要花费大约 800W 字节,即 8MB 内存。这样会消耗掉大量的内存。于是乎有人想到,函数传参用数组的指针。

Go

打印结果:

Go

这也就证明了数组指针确实到达了我们想要的效果。现在就算是传入10亿的数组,也只需要再栈上分配一个8个字节的内存给指针就可以了。这样更加高效的利用内存,性能也比之前的好。

不过传指针会有一个弊端,从打印结果可以看到,第一行和第三行指针地址都是同一个,万一原数组的指针指向更改了,那么函数里面的指针指向都会跟着更改。

切片的优势也就表现出来了。用切片传数组参数,既可以达到节约内存的目的,也可以达到合理处理好共享内存的问题。打印结果第二行就是切片,切片的指针和原来数组的指针是不同的。

由此我们可以得出结论:

把第一个大数组传递给函数会消耗很多内存,采用切片的方式传参可以避免上述问题。切片是引用传递,所以它们不需要使用额外的内存并且比使用数组更有效率。

但是,依旧有反例。

Go

我们做一次性能测试,并且禁用内联和优化,来观察切片的堆上内存分配的情况。

Go

输出结果比较“令人意外”:

vim

BenchmarkArray-4          500000              3637 ns/op               0 B/op          0 alloc s/op  BenchmarkSlice-4          300000              4055 ns/op            8192 B/op          1 alloc s/op

解释一下上述结果,在测试 Array 的时候,用的是4核,循环次数是500000,平均每次执行时间是3637 ns,每次执行堆上分配内存总量是0,分配次数也是0 。

而切片的结果就“差”一点,同样也是用的是4核,循环次数是300000,平均每次执行时间是4055 ns,但是每次执行一次,堆上分配内存总量是8192,分配次数也是1 。

这样对比看来,并非所有时候都适合用切片代替数组,因为切片底层数组可能会在堆上分配内存,而且小数组在栈上拷贝的消耗也未必比 make 消耗大。

二. 切片的数据结构

切片本身并不是动态数组或者数组指针。它内部实现的数据结构通过指针引用底层数组,设定相关属性将数据读写操作限定在指定的区域内。切片本身是一个只读对象,其工作机制类似数组指针的一种封装。

切片(slice)是对数组一个连续片段的引用,所以切片是一个引用类型(因此更类似于 C/C++ 中的数组类型,或者 Python 中的 list 类型)。这个片段可以是整个数组,或者是由起始和终止索引标识的一些项的子集。需要注意的是,终止索引标识的项不包括在切片内。切片提供了一个与指向数组的动态窗口。

给定项的切片索引可能比相关数组的相同元素的索引小。和数组不同的是,切片的长度可以在运行时修改,最小为 0 最大为相关数组的长度:切片是一个长度可变的数组。

Slice 的数据结构定义如下:

Go

切片的结构体由3部分构成,Pointer 是指向一个数组的指针,len 代表当前切片的长度,cap 是当前切片的容量。cap 总是大于等于 len 的。

如果想从 slice 中得到一块内存地址,可以这样做:

Go

如果反过来呢?从 Go 的内存地址中构造一个 slice。

Go

构造一个虚拟的结构体,把 slice 的数据结构拼出来。

当然还有更加直接的方法,在 Go 的反射中就存在一个与之对应的数据结构 SliceHeader,我们可以用它来构造一个 slice

Go

三. 创建切片

make 函数允许在运行期动态指定数组长度,绕开了数组类型必须使用编译期常量的限制。

创建切片有两种形式,make 创建切片,空切片。

1. make 和切片字面量Go

还有一个 int64 的版本:

Go

实现原理和上面的是一样的,只不过多了把 int64 转换成 int 这一步罢了。

上图是用 make 函数创建的一个 len = 4, cap = 6 的切片。内存空间申请了6个 int 类型的内存大小。由于 len = 4,所以后面2个暂时访问不到,但是容量还是在的。这时候数组里面每个变量都是0 。

除了 make 函数可以创建切片以外,字面量也可以创建切片。

这里是用字面量创建的一个 len = 6,cap = 6 的切片,这时候数组里面每个元素的值都初始化完成了。需要注意的是 [ ] 里面不要写数组的容量,因为如果写了个数以后就是数组了,而不是切片了。

还有一种简单的字面量创建切片的方法。如上图。上图就 Slice A 创建出了一个 len = 3,cap = 3 的切片。从原数组的第二位元素(0是第一位)开始切,一直切到第四位为止(不包括第五位)。同理,Slice B 创建出了一个 len = 2,cap = 4 的切片。

2. nil 和空切片

nil 切片和空切片也是常用的。

Go

nil 切片被用在很多标准库和内置函数中,描述一个不存在的切片的时候,就需要用到 nil 切片。比如函数在发生异常的时候,返回的切片就是 nil 切片。nil 切片的指针指向 nil。

空切片一般会用来表示一个空的集合。比如数据库查询,一条结果也没有查到,那么就可以返回一个空切片。

Go

空切片和 nil 切片的区别在于,空切片指向的地址不是nil,指向的是一个内存地址,但是它没有分配任何内存空间,即底层元素包含0个元素。

最后需要说明的一点是。不管是使用 nil 切片还是空切片,对其调用内置函数 append,len 和 cap 的效果都是一样的。

四. 切片扩容

当一个切片的容量满了,就需要扩容了。怎么扩,策略是什么?

Go

上述就是扩容的实现。主要需要关注的有两点,一个是扩容时候的策略,还有一个就是扩容是生成全新的内存地址还是在原来的地址后追加。

1. 扩容策略

先看看扩容策略。

Go

输出结果:

Go

用图表示出上述过程。

从图上我们可以很容易的看出,新的切片和之前的切片已经不同了,因为新的切片更改了一个值,并没有影响到原来的数组,新切片指向的数组是一个全新的数组。并且 cap 容量也发生了变化。这之间究竟发生了什么呢?

Go 中切片扩容的策略是这样的:

如果切片的容量小于 1024 个元素,于是扩容的时候就翻倍增加容量。上面那个例子也验证了这一情况,总容量从原来的4个翻倍到现在的8个。

一旦元素个数超过 1024 个元素,那么增长因子就变成 1.25 ,即每次增加原来容量的四分之一。

注意:扩容扩大的容量都是针对原来的容量而言的,而不是针对原来数组的长度而言的。

2. 新数组 or 老数组 ?

再谈谈扩容之后的数组一定是新的么?这个不一定,分两种情况。

情况一:

Go

打印输出:

Go

把上述过程用图表示出来,如下图。

通过打印的结果,我们可以看到,在这种情况下,扩容以后并没有新建一个新的数组,扩容前后的数组都是同一个,这也就导致了新的切片修改了一个值,也影响到了老的切片了。并且 append() 操作也改变了原来数组里面的值。一个 append() 操作影响了这么多地方,如果原数组上有多个切片,那么这些切片都会被影响!无意间就产生了莫名的 bug!

这种情况,由于原数组还有容量可以扩容,所以执行 append() 操作以后,会在原数组上直接操作,所以这种情况下,扩容以后的数组还是指向原来的数组。

这种情况也极容易出现在字面量创建切片时候,第三个参数 cap 传值的时候,如果用字面量创建切片,cap 并不等于指向数组的总容量,那么这种情况就会发生。

Go

上面这种情况非常危险,极度容易产生 bug 。

建议用字面量创建切片的时候,cap 的值一定要保持清醒,避免共享原数组导致的 bug。

情况二:

情况二其实就是在扩容策略里面举的例子,在那个例子中之所以生成了新的切片,是因为原来数组的容量已经达到了最大值,再想扩容, Go 默认会先开一片内存区域,把原来的值拷贝过来,然后再执行 append() 操作。这种情况丝毫不影响原数组。

所以建议尽量避免情况一,尽量使用情况二,避免 bug 产生。

五. 切片拷贝

Slice 中拷贝方法有2个。

Go

在这个方法中,slicecopy 方法会把源切片值(即 fm Slice )中的元素复制到目标切片(即 to Slice )中,并返回被复制的元素个数,copy 的两个类型必须一致。slicecopy 方法最终的复制结果取决于较短的那个切片,当较短的切片复制完成,整个复制过程就全部完成了。

举个例子,比如:

Go

还有一个拷贝的方法,这个方法原理和 slicecopy 方法类似,不在赘述了,注释写在代码里面了。

Go

再举个例子,比如:

Go

输出:

Go

说到拷贝,切片中有一个需要注意的问题。

Go

输出:

Go

从上面结果我们可以看到,如果用 range 的方式去遍历一个切片,拿到的 Value 其实是切片里面的值拷贝。所以每次打印 Value 的地址都不变。

由于 Value 是值拷贝的,并非引用传递,所以直接改 Value 是达不到更改原切片值的目的的,需要通过 &slice[index] 获取真实的地址。

内存布局:

转载自:

https://ninokop.github.io/2017/10/25/Go-Slice%E4%B8%8EString%E5%86%85%E5%AD%98%E5%B8%83%E5%B1%80%E5%92%8C%E5%AE%9E%E7%8E%B0/

Go Slice与String内存布局和实现 Posted on 2017-10-25 |  In Golang

上一篇提到的关于gc性能的问题,对比slice和map的结构可以看出为了存储数据map用了更多的内存空间,并且可能存在链表,链表的每个节点在gc时都做为一个小对象对待,增加了扫描的时间,因此gc时间相对更长。

slice初始化与复制

slice通过内部指针和相关属性引用数组片段,来实现变长方案。实现方式和数据结构都类似C++中的vector。它本身是结构体,作为参数传递时传递的是slice本身而不是它引用的底层数组。len()可获得slice长度,cap()可获得slice容量。

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

slice可以通过数组初始化,也可以直接make。make时直接使用cap作为new的长度来创建底层数组,返回的是slice结构体。如果通过new([]int)来初始化,它返回的是一个指向slice结构体的指针,不能直接对它进行下标操作。

1
2
3
4
func makeslice(t *slicetype, len64, cap64 int64) slice {
p := newarray(t.elem, uintptr(cap))
return slice{p, len, cap}
}

遍历slice时经常用到range操作,range会复制range的对象。下面例子中在循环内部改变slice的属性,最终会作用到slice上导致最后输出[1 2 101]。但是并不会导致循环在第三次就结束,因为range s是从s的复本中读取i和n的。s的复本只复制了指针,底层元素仍指向同一片,因此可以在循环内改变slice元素的值并在循环期内可见。

1
2
3
4
5
6
7
8
9
10
11
func main() {
s := []int{1, 2, 3, 4, 5}
for i, n := range s {
if i == 0 {
s = s[:3]
s[2] = n + 100
}
fmt.Println(i, n) // 输出1 2;2 101;3 4;4 5
}
fmt.Println(s)//输出 1 2 101
}

reslice扩容

reslice的增长规则:如果新的size是当前size的2倍以上,则大小增长为新size。如果新的size不到当前size的2倍,则按当前size的不同有不同操作。当前size不超过1024,按每次2倍增长,否则按当前大小的1/4增长。

slice通过append元素使得元素达到cap,就会重新分配内存,复制内容并接着append,即便指向的数组还有空位。比如这个例子a初始化为长度和容量都是3的slice,再往a中append数据时a将在堆上重新分配空间并复制原始内容,因此这时原始数组的后几位已经看不到了。

1
2
3
4
5
func main() {
data := [6]int{0, 1, 2, 3, 4, 5}
a := data[:3]
a = append(a, 100) // output [0 1 2 100]
}

如果slice作为函数的入参,通常希望对slice的操作可以影响到底层数据,但是如果在函数内部append数据超过了cap,导致重新分配底层数组,这时入参a指向的底层数组跟调用方实参指向的不再是同一个。如下面的例子这样因为扩容导致与代码实现原意相违背,因此通常不建议在函数内部对slice有append操作,若有需要则显示的返回这个slice。

1
2
3
4
5
func main() {
a := []int{1} // afeter initialization len=1 cap=1
test(a) // call test to append slice, but a is [1], not [1 2]
}
func test(a []int) { a = append(a, 2) }

string内存分布和复制

string的结构和C++STL实现的string类似。都是由指向固定地址的str指针和len组成的结构体。对string的复制只是对指针和len的复制,作为函数参数时入参只不过是指向同一个底层数据的相同指针。

通常string常量是编译器分配到只读段的(.rodata),对应的数据地址不可写入。fmt.Sprintf生成的字符串分配在堆上,对应数据地址可修改。

1
2
3
4
struct string {
byte* str;
intgo len;
}

string与[]byte转化

平常使用中经常将两者互相转化,每次相互转化时都会发生底层数据的复制。如果是动态生成的字符串可以通过以下对指针的操作来直接转化数据,而不需要拷贝,性能好接近4倍。

1
2
3
4
5
6
7
8
9
//return GoString's buffer slice(enable modify string)
func StringBytes(s string) Bytes {
return *(*Bytes)(unsafe.Pointer(&s))
}

// convert b to string without copy
func BytesString(b []byte) String {
return *(*String)(unsafe.Pointer(&b))
}




【转载】仅作分享,侵删
链接:https://blog.csdn.net/m0_37579159/article/details/79344056




作者: 不二晨    时间: 2018-12-18 17:54
奈斯
作者: 梦缠绕的时候    时间: 2018-12-20 16:40





欢迎光临 黑马程序员技术交流社区 (http://bbs.itheima.com/) 黑马程序员IT技术论坛 X3.2