简介

在 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],不知道你是否答对了。此题有两个注意点:

  1. golang 中切片底层的数据结构是数组。当使用 s1[1:] 获得切片 s2,和 s1 共享同一个底层数组,这会导致 s2[1] = 4 语句影响 s1。
  2. 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. 不指定索引,动态扩容并发向切片添加数据
 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))
}

通过打印数据发现每次的结果都不一致。

  1. 指定索引,指定容量并发向切片添加数据

只需将上述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并发读是安全的,但是并发写是不安全的。为了解决这个问题,通常有两种解法:

  1. 加锁
  2. 使用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虽然实现复杂一点,但是性能更好。

参考