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

Go语言数组与切片的性能对比与优化

2021-06-017.2k 阅读

Go语言数组

在Go语言中,数组是一种固定长度的同类型元素序列。它在内存中是连续存储的,这意味着数组中的元素在内存地址上是紧密相邻的。这种连续存储的特性使得数组在访问元素时具有非常高的效率,因为通过数组的起始地址和元素的索引,可以直接计算出每个元素的内存地址,从而快速访问到相应元素。

数组的声明与初始化

声明一个数组的基本语法如下:

var arr [n]type

其中,n 是数组的长度,type 是数组元素的类型。例如,声明一个长度为5,元素类型为 int 的数组:

var numbers [5]int

初始化数组可以在声明时直接进行,有以下几种常见方式:

  • 完全初始化:
var numbers [5]int = [5]int{1, 2, 3, 4, 5}
  • 部分初始化:
var numbers [5]int = [5]int{1, 2}

此时,未显式初始化的元素会被初始化为其类型的零值,在 int 类型中就是0。所以 numbers 实际为 [1, 2, 0, 0, 0]

  • 自动推断长度初始化:
numbers := [...]int{1, 2, 3}

这里 [...] 告诉编译器根据初始化的值来自动推断数组的长度,此时 numbers 是长度为3的 int 数组。

数组的内存布局

数组在内存中的布局是连续的。假设我们有一个 [3]int 类型的数组 [10, 20, 30],在内存中,它可能像这样存储(假设每个 int 占用4个字节):

内存地址内容
0x100010(int
0x100420(int
0x100830(int

这种连续的内存布局使得数组的遍历和随机访问效率极高。例如,访问数组的第 i 个元素,其内存地址可以通过公式 起始地址 + i * 元素大小 快速计算得出。

数组的性能特点

  1. 访问性能:由于数组在内存中连续存储,根据索引访问元素的时间复杂度为 O(1),非常高效。这是因为通过简单的数学运算就可以直接定位到元素在内存中的位置。例如:
package main

import "fmt"

func main() {
    numbers := [5]int{1, 2, 3, 4, 5}
    fmt.Println(numbers[2]) // 直接访问第3个元素,时间复杂度O(1)
}
  1. 遍历性能:使用 for 循环遍历数组也非常高效,因为内存的连续性使得CPU的缓存命中率较高。现代CPU在读取内存时,会预取相邻的内存块到缓存中,以便后续访问。由于数组元素连续存储,遍历数组时几乎每次访问都能命中缓存,减少了从主存读取数据的次数,从而提高了遍历效率。示例代码如下:
package main

import (
    "fmt"
    "time"
)

func main() {
    numbers := [1000000]int{}
    start := time.Now()
    for i := 0; i < len(numbers); i++ {
        numbers[i] = i
    }
    elapsed := time.Since(start)
    fmt.Printf("遍历并赋值数组耗时: %s\n", elapsed)
}
  1. 传递性能:在Go语言中,数组是值类型。当将一个数组作为参数传递给函数时,会完整地复制一份数组。这意味着如果数组较大,传递数组的开销会很大,不仅会消耗更多的内存,还会影响性能。例如:
package main

import (
    "fmt"
)

func printArray(arr [1000000]int) {
    fmt.Println(arr[0])
}

func main() {
    numbers := [1000000]int{}
    printArray(numbers)
}

在上述代码中,printArray 函数接收一个长度为1000000的 int 数组作为参数,传递过程中会复制整个数组,这在内存和时间上都有较大开销。

Go语言切片

切片(Slice)是Go语言中一种动态数组,它基于数组实现,但提供了更灵活的操作方式。切片本身并不是数组,而是对数组的一个引用,它包含三个部分:指向底层数组的指针、切片的长度(len)和切片的容量(cap)。

切片的声明与初始化

切片有多种声明和初始化方式:

  • 使用 make 函数:
s := make([]int, 5) // 创建一个长度为5,容量也为5的int切片,元素初始化为0

这里 make 函数的第一个参数是切片的类型,第二个参数是切片的长度,容量默认为长度。也可以显式指定容量:

s := make([]int, 5, 10) // 创建一个长度为5,容量为10的int切片
  • 基于数组创建切片:
arr := [5]int{1, 2, 3, 4, 5}
s := arr[1:3] // 基于数组arr创建一个切片,从索引1开始(包含),到索引3结束(不包含)

此时 s 的值为 [2, 3],它的底层数组是 arr,长度为2,容量为4(因为从索引1开始到数组末尾有4个元素)。

  • 直接初始化:
s := []int{1, 2, 3} // 直接初始化一个切片

切片的内存布局

切片的内存布局相对复杂一些。切片本身是一个结构体,包含三个字段:指向底层数组的指针、长度和容量。例如,对于切片 s := []int{1, 2, 3},其内存布局可能如下:

切片结构体字段内容
指针指向底层数组 [1, 2, 3] 的起始地址
长度(len)3
容量(cap)3

而底层数组在内存中依然是连续存储的,与普通数组类似。

切片的性能特点

  1. 访问性能:切片基于数组实现,通过索引访问元素时,同样是直接根据指针和索引计算内存地址,时间复杂度也是 O(1)。例如:
package main

import "fmt"

func main() {
    s := []int{1, 2, 3}
    fmt.Println(s[1]) // 直接访问切片的第2个元素,时间复杂度O(1)
}
  1. 遍历性能:与数组类似,由于底层数组的连续性,遍历切片时CPU缓存命中率也较高,遍历效率也很高。示例代码如下:
package main

import (
    "fmt"
    "time"
)

func main() {
    s := make([]int, 1000000)
    start := time.Now()
    for i := 0; i < len(s); i++ {
        s[i] = i
    }
    elapsed := time.Since(start)
    fmt.Printf("遍历并赋值切片耗时: %s\n", elapsed)
}
  1. 传递性能:切片是引用类型,当将切片作为参数传递给函数时,实际上传递的是切片的结构体,只包含指针、长度和容量,大小固定(在64位系统上通常为24字节)。这使得传递切片的开销非常小,即使切片对应的底层数组很大。例如:
package main

import (
    "fmt"
)

func printSlice(s []int) {
    fmt.Println(s[0])
}

func main() {
    s := make([]int, 1000000)
    printSlice(s)
}

在上述代码中,printSlice 函数接收一个切片作为参数,传递的只是切片结构体,而不是底层数组的副本,性能开销很小。 4. 动态增长性能:切片的一个重要特性是可以动态增长。当切片的容量不足时,会重新分配内存,创建一个新的底层数组,并将原切片的内容复制到新数组中。这个过程涉及内存分配和数据复制,开销较大。例如:

package main

import (
    "fmt"
)

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

在上述代码中,初始切片 s 的容量为5,当添加第6个元素时,会重新分配内存,通常新的容量会变为原来的2倍(如果原来的容量小于1024),然后将原切片的内容复制到新数组中。因此,在使用 append 时,如果能提前预估切片的大小,预先分配足够的容量,可以减少内存重新分配和数据复制的次数,提高性能。

性能对比与优化

  1. 访问性能对比
    • 数组:数组的访问性能极高,因为其内存连续存储,通过索引直接计算内存地址,时间复杂度为 O(1)
    • 切片:切片基于数组实现,同样通过索引直接计算内存地址,时间复杂度也是 O(1)。在单纯的元素访问性能上,数组和切片几乎没有差别。例如,下面的代码分别测试数组和切片的访问性能:
package main

import (
    "fmt"
    "time"
)

func accessArray() {
    arr := [1000000]int{}
    for i := 0; i < len(arr); i++ {
        _ = arr[i]
    }
}

func accessSlice() {
    s := make([]int, 1000000)
    for i := 0; i < len(s); i++ {
        _ = s[i]
    }
}

func main() {
    start := time.Now()
    accessArray()
    elapsedArray := time.Since(start)

    start = time.Now()
    accessSlice()
    elapsedSlice := time.Since(start)

    fmt.Printf("访问数组耗时: %s\n", elapsedArray)
    fmt.Printf("访问切片耗时: %s\n", elapsedSlice)
}

在实际运行中,两者的耗时差异极小,可以忽略不计。

  1. 遍历性能对比
    • 数组:数组的连续性使得CPU缓存命中率高,遍历效率很高。
    • 切片:切片底层依赖数组,同样具有良好的连续性,遍历效率也很高。在遍历性能上,两者差异不大。例如,下面的代码对比数组和切片的遍历性能:
package main

import (
    "fmt"
    "time"
)

func traverseArray() {
    arr := [1000000]int{}
    for i := 0; i < len(arr); i++ {
        arr[i] = i
    }
}

func traverseSlice() {
    s := make([]int, 1000000)
    for i := 0; i < len(s); i++ {
        s[i] = i
    }
}

func main() {
    start := time.Now()
    traverseArray()
    elapsedArray := time.Since(start)

    start = time.Now()
    traverseSlice()
    elapsedSlice := time.Since(start)

    fmt.Printf("遍历数组耗时: %s\n", elapsedArray)
    fmt.Printf("遍历切片耗时: %s\n", elapsedSlice)
}

实际运行结果显示,两者的遍历耗时相近。

  1. 传递性能对比
    • 数组:数组是值类型,传递时会复制整个数组。如果数组较大,传递的开销会很大,不仅占用更多内存,还会影响性能。
    • 切片:切片是引用类型,传递的是切片结构体(包含指针、长度和容量),大小固定,开销很小。例如,下面的代码对比数组和切片作为函数参数传递的性能:
package main

import (
    "fmt"
    "time"
)

func passArray(arr [1000000]int) {
    _ = arr[0]
}

func passSlice(s []int) {
    _ = s[0]
}

func main() {
    arr := [1000000]int{}
    s := make([]int, 1000000)

    start := time.Now()
    passArray(arr)
    elapsedArray := time.Since(start)

    start = time.Now()
    passSlice(s)
    elapsedSlice := time.Since(start)

    fmt.Printf("传递数组耗时: %s\n", elapsedArray)
    fmt.Printf("传递切片耗时: %s\n", elapsedSlice)
}

运行结果表明,传递切片的耗时远远小于传递数组的耗时。

  1. 动态增长性能对比
    • 数组:数组长度固定,一旦声明后无法动态增长,不具备动态增长性能。
    • 切片:切片可以通过 append 函数动态增长。但当容量不足时,会重新分配内存并复制数据,开销较大。为了优化切片的动态增长性能,可以在初始化切片时尽量预估其大小,预先分配足够的容量。例如:
package main

import (
    "fmt"
    "time"
)

func growSliceWithoutPrealloc() {
    s := make([]int, 0)
    start := time.Now()
    for i := 0; i < 1000000; i++ {
        s = append(s, i)
    }
    elapsed := time.Since(start)
    fmt.Printf("未预分配容量增长切片耗时: %s\n", elapsed)
}

func growSliceWithPrealloc() {
    s := make([]int, 0, 1000000)
    start := time.Now()
    for i := 0; i < 1000000; i++ {
        s = append(s, i)
    }
    elapsed := time.Since(start)
    fmt.Printf("预分配容量增长切片耗时: %s\n", elapsed)
}

func main() {
    growSliceWithoutPrealloc()
    growSliceWithPrealloc()
}

运行结果显示,预分配容量的切片在动态增长时耗时明显小于未预分配容量的切片。

  1. 优化建议
    • 对于数组
      • 如果数据量固定且较小,并且需要高效的随机访问和遍历,可以使用数组。
      • 避免在函数间传递大数组,尽量传递数组的指针,这样可以减少复制开销。例如:
package main

import (
    "fmt"
)

func passArrayPointer(arr *[1000000]int) {
    _ = (*arr)[0]
}

func main() {
    arr := [1000000]int{}
    passArrayPointer(&arr)
}
  • 对于切片
    • 如果数据量不确定,需要动态增长,或者需要在函数间高效传递数据,应使用切片。
    • 在使用 append 函数前,尽量预估切片的最终大小,通过 make 函数预先分配足够的容量,以减少内存重新分配和数据复制的次数。
    • 当切片不再需要增长时,可以使用 slice = slice[:cap(slice)] 将切片的长度调整为容量,以释放不再使用的内存空间。例如:
package main

import (
    "fmt"
)

func main() {
    s := make([]int, 0, 10)
    for i := 0; i < 5; i++ {
        s = append(s, i)
    }
    s = s[:cap(s)]
    fmt.Printf("调整后的切片长度: %d, 容量: %d\n", len(s), cap(s))
}

通过深入理解Go语言数组和切片的性能特点,并采取相应的优化措施,可以在编写高效的Go程序时充分发挥它们的优势。无论是选择数组还是切片,都应根据具体的应用场景和性能需求来决定。在处理固定大小的数据且追求极致的访问效率时,数组是不错的选择;而在数据量动态变化或者需要高效传递数据的情况下,切片则更为合适。同时,合理的内存分配和使用方式,如预先分配切片容量等,能够进一步提升程序的性能。