【注意】最后更新于 December 28, 2020,文中内容可能已过时,请谨慎使用。
简介
在 Go 语言中,切片是一个引用类型,它是对底层数组的一个视图。切片的底层数组是由 Go 语言的内存分配器自动分配的,并且切片本身也存储在堆上。
切片包含三个域:指向底层数组的指针、切片的长度和切片的容量。这三个域的结构体定义如下:
1
2
3
4
5
|
type slice struct {
array unsafe.Pointer
len int
cap int
}
|
切片的长度是切片所包含的元素个数,容量是切片所占用的空间大小。在使用切片时,你应该注意这两个值的区别。
当对切片进行修改时,切片可能会在内存中重新分配空间。这种情况下,切片的指针、长度和容量都会发生改变,并且原来的底层数组可能会被丢弃。因此,应该注意切片的内存分配情况,以免出现内存泄漏的问题。我们看下下面两个知识点:
切片的截取操作
切片 a、b、c 的长度和容量分别是多少?
1
2
3
4
5
6
|
func main() {
s := [3]int{1, 2, 3}
a := s[:0]
b := s[:2]
c := s[1:2:cap(s)]
}
|
答案:a、b、c 的长度和容量分别是 0 3、2 3、1 2。
解析:截取操作有带 2 个或者 3 个参数,形如:[i:j] 和 [i:j:k],假设截取对象的底层数组长度为 l。
- 在操作符 [i:j] 中,如果 i 省略,默认 0,如果 j 省略,默认底层数组的长度,截取得到的切片长度和容量计算方法是 j-i、l-i。
- 操作符 [i:j:k],k 主要是用来限制切片的容量,但是不能大于数组的长度 l,截取得到的切片长度和容量计算方法是 j-i、k-i。
切片的扩容
下面代码输出什么?
1
2
3
4
5
6
7
8
|
func main() {
s1 := []int{1, 2, 3}
s2 := s1[1:]
s2[1] = 4
fmt.Println(s1)
s2 = append(s2, 5, 6, 7)
fmt.Println(s1)
}
|
上述程序输出[1 2 4] [1 2 4],不知道你是否答对了。此题有两个注意点:
- golang 中切片底层的数据结构是数组。当使用 s1[1:] 获得切片 s2,和 s1 共享同一个底层数组,这会导致 s2[1] = 4 语句影响 s1。
- append 操作会导致底层数组扩容,生成新的数组,因此追加数据后的 s2 不会影响 s1。切片的扩容规则请参考这篇文章。
我这里再给出一个示例,如果你能通过自己掌握的知识推算出正确的答案,那么相信切片的原理和需要注意的坑你都能hold住。
1
2
3
4
5
6
7
8
9
10
11
12
|
func change(s ...int) {
s = append(s,3)
}
func main() {
slice := make([]int,5,5)
slice[0] = 1
slice[1] = 2
change(slice...)
fmt.Println(slice)
change(slice[0:2]...)
fmt.Println(slice)
}
|
并发读写
我们先来看两个示例:
- 不指定索引,动态扩容并发向切片添加数据
1
2
3
4
5
6
7
8
9
10
11
12
13
|
func main() {
s := make([]int,100)
wg := sync.WaitGroup{}
for index := 0; index < 100; index++ {
wg.Add(1)
go func(num int) {
s = append(s, num)
wg.Done()
}(index)
}
wg.Wait()
fmt.Printf("final len(s)=%d cap(s)=%d\n", len(s), cap(s))
}
|
通过打印数据发现每次的结果都不一致。
- 指定索引,指定容量并发向切片添加数据
只需将上述s = append(s, num)改为s[num] = num
1
2
3
4
5
6
7
8
9
10
11
12
13
|
func main() {
s := make([]int, 100)
wg := sync.WaitGroup{}
for index := 0; index < 100; index++ {
wg.Add(1)
go func(num int) {
s[num] = num
wg.Done()
}(index)
}
wg.Wait()
fmt.Printf("final len(s)=%d cap(s)=%d\n", len(s), cap(s))
}
|
通过结果我们可以发现符合我们的预期,长度和容量都是100。
小结
我们都知道slice是对数组一个连续片段的引用,当slice长度增加的时候,可能底层的数组会被换掉。当出在换底层数组之前,切片同时被多个goroutine拿到,并执行append操作。那么很多goroutine的append结果会被覆盖,导致n个gouroutine append后,长度小于n。我们可以认为slice并发读是安全的,但是并发写是不安全的。为了解决这个问题,通常有两种解法:
- 加锁
- 使用channel串行化
加读写锁保护切片示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
import "sync"
var mu sync.RWMutex
func readSlice(s []int) {
mu.RLock()
// 在这里读取切片
mu.RUnlock()
}
func updateSlice(s []int) {
mu.Lock()
// 在这里对切片进行修改
mu.Unlock()
}
|
使用通道来保护切片示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
import "sync"
var wg sync.WaitGroup
func updateSlice(s []int, ch chan struct{}) {
ch <- struct{}{}
s = append(s, 1)
<-ch
wg.Done()
}
func main() {
s := []int{}
ch := make(chan struct{}, 1)
wg.Add(2)
go updateSlice(s, ch)
go updateSlice(s, ch)
wg.Wait()
}
|
使用互斥锁或读写锁保护切片的操作是非常常见的,但是应该尽量减少使用它们,因为它们会使程序的并发性能降低。用channel虽然实现复杂一点,但是性能更好。
参考