MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Go语言切片slice底层结构的详细剖析

2022-12-052.9k 阅读

Go 语言切片 slice 底层结构的详细剖析

1. 切片是什么

在 Go 语言中,切片(slice)是一种动态数组,它的长度可以在运行时动态变化。切片是基于数组类型构建的,提供了比数组更灵活、强大的功能。与数组不同,切片的类型只由它所包含的元素类型决定,而不包含长度信息。例如,[]int 表示一个元素类型为 int 的切片。

2. 切片的基本使用

下面通过一些简单的代码示例来展示切片的基本操作。

package main

import "fmt"

func main() {
    // 声明一个空切片
    var s1 []int
    fmt.Printf("s1: %v, len: %d, cap: %d\n", s1, len(s1), cap(s1))

    // 使用 make 函数创建切片
    s2 := make([]int, 5, 10)
    fmt.Printf("s2: %v, len: %d, cap: %d\n", s2, len(s2), cap(s2))

    // 直接初始化切片
    s3 := []int{1, 2, 3, 4, 5}
    fmt.Printf("s3: %v, len: %d, cap: %d\n", s3, len(s3), cap(s3))

    // 切片的追加操作
    s3 = append(s3, 6)
    fmt.Printf("s3 after append: %v, len: %d, cap: %d\n", s3, len(s3), cap(s3))

    // 切片的截取操作
    s4 := s3[1:3]
    fmt.Printf("s4: %v, len: %d, cap: %d\n", s4, len(s4), cap(s4))
}

在上述代码中,首先声明了一个空切片 s1,其长度和容量都为 0。然后使用 make 函数创建了一个长度为 5、容量为 10 的切片 s2。接着直接初始化了一个切片 s3,其长度和容量都为 5。通过 append 函数向 s3 中追加一个元素后,其长度变为 6,容量可能会根据具体情况进行调整。最后,通过截取操作从 s3 中得到一个新的切片 s4,其长度为 2,容量为 4(因为从 s3 的索引 1 开始截取,s3 剩余的容量为 4)。

3. 切片的底层结构

Go 语言切片的底层结构由三个部分组成:指针(指向底层数组)、长度(切片当前包含的元素个数)和容量(从切片的起始位置到底层数组末尾的元素个数)。在 Go 语言的源码中,切片的结构体定义如下:

type slice struct {
    array unsafe.Pointer
    len   int
    cap   int
}
  • array:这是一个指向底层数组的指针。底层数组是切片数据的实际存储位置。
  • len:表示切片当前的长度,即切片中包含的元素个数。
  • cap:表示切片的容量,即从切片的起始位置到底层数组末尾的元素个数。

4. 切片与底层数组的关系

切片是基于底层数组实现的。当通过 make 函数或者直接初始化创建一个切片时,会同时创建一个底层数组来存储切片中的元素。例如,当执行 s := make([]int, 5, 10) 时,会创建一个长度为 10 的底层数组,而切片 s 指向这个底层数组的起始位置,其长度为 5,容量为 10。

切片的截取操作并不会创建新的底层数组,而是基于原有的底层数组创建一个新的切片结构体。例如,当执行 s2 := s1[1:3] 时,s2s1 共享同一个底层数组,s2 的指针指向 s1 底层数组的索引 1 位置,长度为 2,容量为 s1 的容量减去 1(即 cap(s1) - 1)。

下面通过代码示例来验证切片与底层数组的关系:

package main

import (
    "fmt"
    "unsafe"
)

func main() {
    s1 := make([]int, 5, 10)
    for i := 0; i < 5; i++ {
        s1[i] = i * 2
    }

    s2 := s1[1:3]

    // 获取切片 s1 的底层结构
    var sliceHeader1 struct {
        array unsafe.Pointer
        len   int
        cap   int
    }
    *(*unsafe.Pointer)(unsafe.Pointer(&sliceHeader1.array)) = *(*unsafe.Pointer)(unsafe.Pointer(&s1))
    sliceHeader1.len = len(s1)
    sliceHeader1.cap = cap(s1)

    // 获取切片 s2 的底层结构
    var sliceHeader2 struct {
        array unsafe.Pointer
        len   int
        cap   int
    }
    *(*unsafe.Pointer)(unsafe.Pointer(&sliceHeader2.array)) = *(*unsafe.Pointer)(unsafe.Pointer(&s2))
    sliceHeader2.len = len(s2)
    sliceHeader2.cap = cap(s2)

    fmt.Printf("s1 array: %p, len: %d, cap: %d\n", sliceHeader1.array, sliceHeader1.len, sliceHeader1.cap)
    fmt.Printf("s2 array: %p, len: %d, cap: %d\n", sliceHeader2.array, sliceHeader2.len, sliceHeader2.cap)
}

在上述代码中,通过反射机制获取了切片 s1s2 的底层结构,并打印出它们的指针、长度和容量。可以看到,s1s2 的指针指向同一个底层数组,只是 s2 的指针位置相对于 s1 有所偏移,长度和容量也不同。

5. 切片的内存分配与扩容机制

5.1 内存分配

当使用 make 函数创建切片时,会根据指定的长度和容量分配内存。例如,make([]int, 5, 10) 会分配一个足够容纳 10 个 int 类型元素的内存空间,并返回一个长度为 5 的切片。

5.2 扩容机制

当通过 append 函数向切片中追加元素时,如果当前切片的容量不足以容纳新的元素,就会触发扩容操作。Go 语言的切片扩容机制相对复杂,下面是其大致的流程:

  1. 如果新的容量(原容量 + 新增元素个数)小于 1024,则新容量会变为原容量的 2 倍。
  2. 如果新的容量大于等于 1024,则新容量会变为原容量的 1.25 倍。
  3. 扩容后,会将原切片中的元素复制到新的底层数组中。

下面通过代码示例来观察切片的扩容过程:

package main

import (
    "fmt"
)

func main() {
    s := make([]int, 0, 5)
    for i := 0; i < 20; i++ {
        s = append(s, i)
        fmt.Printf("len: %d, cap: %d\n", len(s), cap(s))
    }
}

在上述代码中,首先创建了一个初始容量为 5 的空切片 s。然后通过循环向切片中追加 20 个元素,每次追加后打印切片的长度和容量。可以看到,随着元素的不断追加,当容量不足时,切片会按照上述扩容机制进行扩容。

6. 切片的并发访问问题

由于切片本身不是线程安全的,在多个 goroutine 中同时对切片进行读写操作可能会导致数据竞争问题。例如,下面的代码就存在数据竞争风险:

package main

import (
    "fmt"
)

var s []int

func write() {
    for i := 0; i < 100; i++ {
        s = append(s, i)
    }
}

func read() {
    for _, v := range s {
        fmt.Println(v)
    }
}

func main() {
    go write()
    go read()
    select {}
}

在上述代码中,write 函数向切片 s 中追加元素,read 函数从切片 s 中读取元素,两个函数在不同的 goroutine 中并发执行,这可能会导致数据竞争问题,使得程序的运行结果不可预测。

为了避免切片并发访问时的数据竞争问题,可以使用以下几种方法:

  • 互斥锁(sync.Mutex:通过互斥锁来保护对切片的读写操作,确保同一时间只有一个 goroutine 能够访问切片。
package main

import (
    "fmt"
    "sync"
)

var (
    s    []int
    lock sync.Mutex
)

func write() {
    lock.Lock()
    defer lock.Unlock()
    for i := 0; i < 100; i++ {
        s = append(s, i)
    }
}

func read() {
    lock.Lock()
    defer lock.Unlock()
    for _, v := range s {
        fmt.Println(v)
    }
}

func main() {
    var wg sync.WaitGroup
    wg.Add(2)

    go func() {
        write()
        wg.Done()
    }()

    go func() {
        read()
        wg.Done()
    }()

    wg.Wait()
}
  • 读写锁(sync.RWMutex:如果读操作远多于写操作,可以使用读写锁。读操作可以并发执行,而写操作会独占切片,防止其他读写操作。
package main

import (
    "fmt"
    "sync"
)

var (
    s    []int
    lock sync.RWMutex
)

func write() {
    lock.Lock()
    defer lock.Unlock()
    for i := 0; i < 100; i++ {
        s = append(s, i)
    }
}

func read() {
    lock.RLock()
    defer lock.RUnlock()
    for _, v := range s {
        fmt.Println(v)
    }
}

func main() {
    var wg sync.WaitGroup
    wg.Add(2)

    go func() {
        write()
        wg.Done()
    }()

    go func() {
        read()
        wg.Done()
    }()

    wg.Wait()
}
  • 通道(channel:使用通道来传递切片数据,避免多个 goroutine 直接对切片进行并发访问。
package main

import (
    "fmt"
    "sync"
)

func write(ch chan []int) {
    var s []int
    for i := 0; i < 100; i++ {
        s = append(s, i)
    }
    ch <- s
    close(ch)
}

func read(ch chan []int) {
    for s := range ch {
        for _, v := range s {
            fmt.Println(v)
        }
    }
}

func main() {
    var wg sync.WaitGroup
    ch := make(chan []int)

    wg.Add(2)

    go func() {
        write(ch)
        wg.Done()
    }()

    go func() {
        read(ch)
        wg.Done()
    }()

    wg.Wait()
}

7. 切片的性能优化

在使用切片时,合理的操作可以提高程序的性能。以下是一些性能优化的建议:

  • 预先分配足够的容量:在创建切片时,如果能够预先知道切片需要容纳的大致元素个数,可以通过 make 函数指定合适的容量,避免频繁的扩容操作。例如:
package main

import (
    "fmt"
    "time"
)

func main() {
    start := time.Now()
    s1 := make([]int, 0, 1000000)
    for i := 0; i < 1000000; i++ {
        s1 = append(s1, i)
    }
    elapsed1 := time.Since(start)

    start = time.Now()
    var s2 []int
    for i := 0; i < 1000000; i++ {
        s2 = append(s2, i)
    }
    elapsed2 := time.Since(start)

    fmt.Printf("With pre - allocated capacity: %s\n", elapsed1)
    fmt.Printf("Without pre - allocated capacity: %s\n", elapsed2)
}

在上述代码中,s1 预先分配了足够的容量,s2 没有预先分配容量。通过对比可以发现,预先分配容量的切片在追加大量元素时性能更好。

  • 避免不必要的内存复制:由于切片的截取操作不会创建新的底层数组,因此在进行切片操作时要注意避免不必要的内存复制。例如,在对切片进行处理时,可以尽量在原切片的基础上进行操作,而不是频繁地创建新的切片。
  • 合理使用 append 函数append 函数在扩容时会进行内存复制,因此要尽量减少 append 操作的次数。如果需要一次性追加多个元素,可以先将这些元素收集到一个临时切片中,然后再通过一次 append 操作将临时切片的元素追加到目标切片中。例如:
package main

import (
    "fmt"
)

func main() {
    s := make([]int, 0, 10)
    temp := []int{1, 2, 3, 4, 5}
    s = append(s, temp...)
    fmt.Println(s)
}

8. 总结切片底层结构剖析的要点

通过对 Go 语言切片底层结构的详细剖析,我们了解到切片是基于底层数组实现的动态数组,其底层结构包含指针、长度和容量三个部分。切片的截取操作基于原底层数组创建新的切片结构体,而扩容操作会根据一定的规则重新分配内存并复制原切片元素。在使用切片时,要注意并发访问的问题,通过合适的同步机制来避免数据竞争。同时,合理的性能优化可以提高程序的运行效率,如预先分配容量、避免不必要的内存复制和合理使用 append 函数等。深入理解切片的底层结构和特性,有助于我们在编写 Go 语言程序时更加高效、准确地使用切片。