切片和数组是Golang中面试经常会问到的问题,值得深入学习。
数组和切片的区别
- 数组是在编译过程使用new创建的,长度固定,无法动态增减。切片在运行过程使用make创建,可以动态地增减长度
- 数组传递参数时是以值的拷贝形式传递。切片传递参数时是以引用形式传递。
- 切片可以使用append或copy等进行增加长度,而数组不能
Slice结构
1 2 3 4 5
| slice struct { array unsafe.Pointer len int cap int }
|
slice是一个特殊的引用类型,但是它自身也是个结构体
属性array表示引用的数组元素的指针
属性len表示可用元素数量,读写操作不能超过这个限制,不然就会panic
属性cap表示最大扩张容量,当然这个扩张容量也不是无限的扩张,它是受到了底层数组array的长度限制,超出了底层array的长度就会panic
Slice扩容
Go
标准库 runtime/slice.go
当中有详细的 slice
增长策略的逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78
| func growslice(et *_type, old slice, cap int) slice { .....
newcap := old.cap doublecap := newcap + newcap if cap > doublecap { newcap = cap } else { if old.len < 1024 { newcap = doublecap } else { for 0 < newcap && newcap < cap { newcap += newcap / 4 } if newcap <= 0 { newcap = cap } } }
var overflow bool var lenmem, newlenmem, capmem uintptr switch { case et.size == 1: lenmem = uintptr(old.len) newlenmem = uintptr(cap) capmem = roundupsize(uintptr(newcap)) overflow = uintptr(newcap) > maxAlloc newcap = int(capmem) case et.size == sys.PtrSize: lenmem = uintptr(old.len) * sys.PtrSize newlenmem = uintptr(cap) * sys.PtrSize capmem = roundupsize(uintptr(newcap) * sys.PtrSize) overflow = uintptr(newcap) > maxAlloc/sys.PtrSize newcap = int(capmem / sys.PtrSize) case isPowerOfTwo(et.size): var shift uintptr if sys.PtrSize == 8 { shift = uintptr(sys.Ctz64(uint64(et.size))) & 63 } else { shift = uintptr(sys.Ctz32(uint32(et.size))) & 31 } lenmem = uintptr(old.len) << shift newlenmem = uintptr(cap) << shift capmem = roundupsize(uintptr(newcap) << shift) overflow = uintptr(newcap) > (maxAlloc >> shift) newcap = int(capmem >> shift) default: lenmem = uintptr(old.len) * et.size newlenmem = uintptr(cap) * et.size capmem = roundupsize(uintptr(newcap) * et.size) overflow = uintptr(newcap) > maxSliceCap(et.size) newcap = int(capmem / et.size) }
......
var p unsafe.Pointer if et.kind&kindNoPointers != 0 { p = mallocgc(capmem, nil, false) memmove(p, old.array, lenmem) memclrNoHeapPointers(add(p, newlenmem), capmem-newlenmem) } else { p = mallocgc(capmem, et, true) if !writeBarrier.enabled { memmove(p, old.array, lenmem) } else { for i := uintptr(0); i < lenmem; i += et.size { typedmemmove(et, add(p, i), add(old.array, i)) } } }
return slice{p, old.len, newcap} }
|
扩容操作主要有三个步骤:计算新的容量、分配新的数组、拷贝数据到新数组
- 当
slice
的长度超过其容量,会分配新的数组,并把旧数组上的值拷贝到新的数组
- 逐个元素添加到
slice
并设置其容量:
- 如果
selic
的容量小于1024个元素,那么扩容的时候 slice
的 cap
就翻番,乘以2;
- 一旦元素个数超过1024个元素,增长因子就变成1.25,即每次增加原来容量的四分之一。
- 批量添加元素,当新的容量高于旧容量的两倍,就会分配比新容量稍大一些,并不会按上面第二条的规则扩容。
- 当
slice
发生扩容,引用新数组后,slice
操作不会再影响旧的数组,而是新的数组(社区经常讨论的传递 slice
容量超出后,修改数据不会作用到旧的数据上),所以往往设计函数如果会对长度调整都会返回新的 slice
,例如 append
方法。
Slice引用
如果把 slice
传递给一个函数或者赋值给另一个变量会发生什么呢,slice
是引用类型,会有新的内存被分配吗?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| package main
import ( "fmt" "strings" "unsafe" )
func main() { s := make([]int, 10, 20)
size := unsafe.Sizeof(0) fmt.Printf("%p\n", &s) fmt.Printf("%x\n", *(*uintptr)(unsafe.Pointer(&s))) fmt.Println(*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + size))) fmt.Println(*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + size*2)))
slice(s) fmt.Println(strings.Repeat("-", 20)) s1 := s fmt.Printf("%p\n", &s1) fmt.Printf("%x\n", *(*uintptr)(unsafe.Pointer(&s1))) fmt.Println(*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s1)) + size))) fmt.Println(*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s1)) + size*2))) }
func slice(s []int) { size := unsafe.Sizeof(0) fmt.Printf("%p\n", &s) fmt.Printf("%x\n", *(*uintptr)(unsafe.Pointer(&s))) fmt.Println(*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + size))) fmt.Println(*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + size*2))) }
|
这个代码的输出结果是:
1 2 3 4 5 6 7 8 9 10 11 12 13
| 0x140000ae000 140000b0000 10 20 0x140000ae018 140000b0000 10 20 -------------------- 0x140000ae030 140000b0000 10 20
|
可以看到,在这个例子里面调用函数传递 slice
其变量的地址发生了变化, 但是引用数组的地址,slice
的长度和容量都没有变化。这说明是对 slice
的浅拷贝,拷贝 slice
的三个属性创建一个新的变量,虽然引用底层数组还是一个,但是变量并不是一个。
创建 s1
变量,使用 s
为其赋值,发现 s1
和函数调用一样也是 s
的浅拷贝。
优点:
采用浅拷贝的方式可以使得切片的属性各自独立,而不会相互影响,这样可以有一定的隔离性。
缺点:
因为两个变量都引用同一个数组, 在不发生扩容的情况下,如果同时 append
, 则总是最后一个 append
的结果被保留,可能引起一些编程上疑惑。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| package main
import ( "fmt" "strings" "unsafe" )
func main() { s1 := make([]int, 0, 10) s2 := s1 s1 = append(s1, []int{1, 2, 3}...) s2 = append(s2, []int{3, 4, 5}...) fmt.Println(s1) fmt.Println(s2) fmt.Println(strings.Repeat("-", 20)) s3 := s2 size := unsafe.Sizeof(0) *(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s2)) + size)) += 3 s3 = append(s3, []int{6, 7, 8}...) fmt.Println(s2) fmt.Println(s3) fmt.Println(strings.Repeat("-", 20)) s4 := s2 *(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s2)) + size)) += 3 s4 = append(s4, []int{8, 8, 8, 8, 8}...) fmt.Println(s2) fmt.Println(s4) }
|
执行结果:
1 2 3 4 5 6 7 8
| [3 4 5] [3 4 5] -------------------- [3 4 5 6 7 8] [3 4 5 6 7 8] -------------------- [3 4 5 6 7 8 0 0 0] [3 4 5 6 7 8 8 8 8 8 8]
|
为了理解这个特性更清晰,我做了如上的实验:
先拷贝s1
给s2
,并同时append
,输出结果:只有最后一次append
生效。
分析:因为两者引用同个数组,但是属性不相同。s1
的len在append
后变为3,而s2
在s1
操作后并没有改变len,所以对s2
进行append
时,s2
的数组指针从[0:0]变成[0:2],并修改相应位置的值,所以会导致两次append
重复操作同一个位置。
先拷贝s2
给s3
,对s3
进行append
并对s2
的len进行强制修改(自增3),输出结果:两个切片输出一致。
分析:进一步验证了两个切片引用的同一个数组。
先拷贝s2
给s4
,对s4
进行append
并并对s2
的len进行强制修改(自增3),注意此时s4
的容量超出,发生了扩容,输出结果:s2
不变,s4
发生了改变。
分析:因为s4
的容量超出,发生了扩容,返回了一个新的数组,所以s4
与s2
引用的数组不相同,对s4
操作不会影响s2
Slice状态
slice
有三种状态:零切片、空切片、nil切片。
零切片
所有的类型都有零值,如果 slice
所引用数组元素都没有赋值,就是所有元素都是类型零值,那这就是零切片。
空切片
空切片可以理解就是切片的长度为0,就是说 slice
没有元素。
nil切片
nil切片没有引用任何底层数组,底层数组的地址为nil就是nil切片。一个nil切片等于 nil
值,且进行 json
序列化时其值为 null
,nil切片还可以通过赋值为 nil
获得。
下面进行代码演示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| package main
import ( "fmt" "reflect" "unsafe" )
func main() { fmt.Println("------s1:零切片------") s1 := make([]int, 10) fmt.Println(s1) fmt.Printf("%x\n", *(*reflect.SliceHeader)(unsafe.Pointer(&s1))) fmt.Println("------s2:空切片------") s2 := make([]int, 0, 10) fmt.Println(s2) fmt.Printf("%x\n", *(*reflect.SliceHeader)(unsafe.Pointer(&s2))) fmt.Println("------s3:空切片(zerobase)------") s3 := make([]int, 0) fmt.Println(s3) fmt.Printf("%x\n", *(*reflect.SliceHeader)(unsafe.Pointer(&s3))) fmt.Println("------s4:空切片(zerobase)------") s4 := make([]string, 0, 0) fmt.Println(s4) fmt.Printf("%x\n", *(*reflect.SliceHeader)(unsafe.Pointer(&s4))) fmt.Println("------s5:零切片------") s5 := make([]string, 10) fmt.Println(s5) fmt.Printf("%x\n", *(*reflect.SliceHeader)(unsafe.Pointer(&s5))) }
|
结果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| ------s1:零切片------ [0 0 0 0 0 0 0 0 0 0] {1400001e0f0 a a} ------s2:空切片------ [] {1400001e140 0 a} ------s3:空切片(zerobase)------ [] {102426d50 0 0} ------s4:空切片(zerobase)------ [] {102426d50 0 0} ------s5:零切片------ [ ] {14000070000 a a}
|
若切片长度为0,或者底层容量也为0,则数组就会指向 zerobase
, 这样就不会发生内存分配
slice 与数组的应用场景总结
slice
和数组有些差别,特别是应用层上,特性差别很大,那什么时间使用数组,什么时间使用切片呢。 之前做了性能测试,在1000以内性能几乎一致,只有10000~1000000时才会出现数组性能好于 slice
,由于数组在编译时确定长度,也就是再编写程序时必须确认长度,所有往常不会用到更大的数组,大多数都在1000以内的长度。我认为如果在编写程序是就已经确定数据长度,建议用数组,而且尽可能是局部使用的位置建议用数组(避免传递产生值拷贝),比如一天24小时,一小时60分钟,ip是4个 byte
这种情况是可以用时数组的。
为什么推荐用数组,只要能在编写程序是确定数据长度我都会用数组,因为其类型会帮助阅读理解程序,dayHour := [24]Data
一眼就知道是按小时切分数据存储的,如要传递数组时可以考虑传递数组的指针,当然会带来一些操作不方便,往常我使用数组都是不需要传递给其它函数的,可能会在 struct
里面保存数组,然后传递 struct
的指针,或者用 unsafe
来反解析数组指针到新的数组,也不会产生数据拷贝,并且只增加一句转换语句。slice
会比数组多存储三个 int
的属性,而且指针引用会增加 GC
扫描的成本,每次传递都会对这三个属性进行拷贝,如果可以也可以考虑传递 slice
的指针,指针只有一个 int
的大小。
对于不确定大小的数据只能用 slice
,否则就要自己做扩容很麻烦, 对于确定大小的集合建议使用数组。