文章目录
Dig101: dig more, simplified more and know more
Slice作为go常用的数据类型,在日常编码中非常常见。
相对于数组的定长不可变,slice使用起来就灵活了许多。
0x01 slice 到底是什么?
首先我们看下源码中slice结构的定义
1 | // src/runtime/slice.go |
slice数据结构如上,Data指向底层引用的数组内存地址, len是已用长度,cap是总容量。
为验证如上所述,我们尝试声明一个slice a,获取 a的sliceHeader头信息,并用%p
获取&a, sh, a, a[0]
的地址
看看他们的地址是否相同。
1 | a := make([]int, 1, 3) |
结果发现a和&a[0]
地址相同。 这个好理解,切片指向地址即对应底层引用数组首个元素地址
而&a和sh及sh.Data
指向地址相同。这个是因为这三个地址是指slice自身地址。
这里【slice自身地址不同于slice指向的底层数据结构地址】, 清楚这一点对于后边的一些问题会更容易判断。
这里作为一个小插曲,我们看下当fmt.Printf("%p",a)
时发生了什么
内部调用链 fmtPointer -> Value.Pointer
然后根据Pointer方法对应slice的注释如下
1 | // If v's Kind is Slice, the returned pointer is to the first |
发现没,正是我们上边说的,slice不为空时,返回了第一个元素的地址
有点迷惑性是不是,但其实作为使用slice的我们,更关心的是底层指向的数据不是么。
再一点就是,基于go中所有赋值和参数传递都是值传递,对于大数组而言,拷贝一个指向他的slice就高效多了
上一篇Go之for-range排坑指南 有过描述, 详见 0x03 对大数组这样遍历有啥问题?
总结下, slice是一个有底层数组引用的结构里,有长度,有容量。
就这么简单? 不,光这样还不足以让它比数组更好用。
slice还支持非常方便的切片操作和append时自动扩容,这让他更加flexible
0x02 slice能比较么?
答案是【只能和nil比较】
1 | s := make([]int, 5) |
这个也其实好理解,当你比较两个slice,你是想比较他们自身呢?(必然不同啊,因为有值拷贝)
还是比较他们底层的数组?(那长度和容量也一起比较么)
确实没有什么意义去做两个slice的比较。
0x03 花样的切片操作
slice通过三段参数来操作:x[from:len:cap]
即对x从from
索引位置开始,截取len
长度,cap
大小的新切片返回
但是len和cap不能大于x原来的len和cap
三个参数都可省略,默认为x[0:len(x):cap(x)]
切片操作同样适用于array
如下都是通过src[:]
常规对切片(指向的底层数组)或数组的引用
1 | s:=make([]int,5) |
配合copy和append,slice的操作还有很多,官方wikiSlice Tricks 有更丰富的例子
比如更通用的拷贝b = append(a[:0:0], a...)
比如cut或delete时增加对不使用指针的nil标记释放(防止内存泄露)
1 | //Cut |
不熟悉的话,建议好好练习一下去感受
0x04 append 时发生了什么?
总的来说,append时会按需自动扩容
- 容量足够,无扩容则直接拷贝待 append 的数据到原 slice 底层指向的数组 之后(原slice的len之后),并返回指向该数组首地址的新slice(len改变)
- 容量不够,有扩容则拷贝原有 slice 所指向部分数据到新开辟的数组,并对待 append 的数据附加到其后,并返回新数组首地址的新slice(底层数组,len,cap均改变)
如下代码所示,容量不够时触发了扩容重新开辟底层数组,x 和 s 底层指向的数组已不是同一个
1 | s := make([]int, 5) |
0x05 append内部优化
具体查阅源码,你会发现编译时将append分为三类并优化
除按需扩容外
x = append(y, make([]T, y)...)
使用memClr提高初始化效率x = append(l1, l2...)
或者x = append(slice, string)
直接复制l2x = append(src, a, b, c)
确定待append数目下,直接做赋值优化
具体编译优化如下
注释有简化,详见internal/gc/walk.go: append
1 | switch { |
这里关于append实现有几点可以提下
扩容的策略是什么?
答案是【总的来说是至少返回要求的长度n最大则为翻倍】
具体情况是:
- len<1024时2倍扩容
- 大于且未溢出时1.25倍扩容
- 溢出则直接按申请大小扩容
- 最后按
mallocgc
内存分配大小适配来确定len. (n-2n之间)
扩容留出最多一倍的余量,主要还是为了减少可能的扩容频率。
mallocgc内存适配实际是go内存管理做了内存分配的优化, 当然内部也有内存对齐的考虑。
雨痕Go学习笔记第四章内存分配,对这一块有很详尽的分析,值得一读。
至于为啥要内存对齐可以参见Golang 是否有必要内存对齐?,一篇不错的文章。
扩容判断中uint
的作用是啥?
1 | //n为目标slice总长度,类型int,cap(s)类型也为int |
答案是【为了避免溢出的扩容】
int有正负,最大值math.MaxInt64 = 1<<63 - 1
uint无负数最大值math.MaxUint64 = 1<<64 - 1
uint正值是int正值范围的两倍,int溢出了变为负数,uint(n)
则必大于原s的cap,条件成立
到growslice内部,对于负值的n会panic,以此避免了溢出的扩容
内存清零初始化: memclrNoHeapPointers vs typedmemclr?
答案是【这个取决于待清零的内存是否已经初始化为type-safe(类型安全)状态,及类型是否包含指针】
具体来看,memclrNoHeapPointers
使用场景是
- 带清零内存是初始化过的,且不含指针
- 带清零内存未初始化过的,里边内容是“垃圾值”(即非type-safe),需要初始化并清零
其他场景就是typedmemclr
, 而且如果用于清零的Type(类型)包含指针,他会多一步WriteBarrier(写屏障),用于为GC(垃圾回收)运行时标记对象的内存修改,减少STW(stop the world)
所以memclrNoHeapPointers
第一个使用场景为啥不含指针就不用解释了。
想了解更多可以看看zero-initialization-versus-zeroing
以及相关源码的注释memclrNoHeapPointers和typedmemclr
本文代码见 NewbMiao/Dig101-Go
如有疑问,请文末留言交流或邮件:newbvirgil@gmail.com 本文链接 : https://newbmiao.github.io/2020/01/07/dig101-golang-slice.html