0
点赞
收藏
分享

微信扫一扫

Golang-切片与数组的关系及性能

1. 切片的本质

在go语言当中,切片(slice)是使用最为频繁的数据结构之一,其原因在于它在处理同类型数据序列有着方便且高效的特点,所以今天我就来和大家聊聊我对切片的理解!

1.1 数组

首先,谈到切片,肯定是不能避开数组的,因为切片是在数组之上的抽象数据类型。数组由长度和类型两部分组成,如[3]int类型表示由 3 个 int 整型组成的数组。数组以索引方式进行访问,例如表达式 s[n] 访问数组的第 n 个元素。数组的长度是固定的,长度是数组类型的一部分。长度不同的 2 个数组是不可以相互赋值的,因为这 2 个数组属于不同的类型。例如下面的代码是不合法的:

a := [3]int{1, 2, 3}
b := [4]int{2, 4, 5, 6}
a = b // cannot use b (type [4]int) as type [3]int in assignment

​

其次,在go语言当中,数组变量是属于值类型,当一个数组类型变量进行赋值或者值传递时,实际上会复制整个数组。如下面代码所示,当把a赋值给b,此时修改b中的元素并不会改变b的元素。

​
a := [...]int{1, 2, 3} // ... 会自动计算数组长度
b := a
a[0] = 100
fmt.Println(a, b) // [100 2 3] [1 2 3]

​

为了避免复制数组,一般会传递指向数组的指针

func modify(p *[5]int) {
    (*p)[0] = 666
    fmt.Println("modify *a = ", *p)
}


func main() {
    a := [5]int{1, 2, 3, 4, 5} 
    modify(&a) 
    fmt.Println("main: a = ", a)
}

1.2 切片

由于数组长度固定,缺少灵活性,所以通常都使用相比数组功能更强大,使用更便利的切片来处理处理同类型数据序列。

(1)切片的初始化

var a = []int{1,2,3,4,5} //切片使用字面量初始化类似于数组,但不需要指定长度
或
var a = make([]int, len, cap) 
    //1.第一个参数是 []T,T 即元素类型,
    //2.第二个参数是长度 len,即初始化的切片拥有多少个元素,
    //3.第三个参数是容量 cap,容量是可选参数,默认等于长度。

实例:

func printLenCap(nums []int) {
	fmt.Printf("len: %d, cap: %d %v\n", len(nums), cap(nums), nums)
}

func TestSliceLenAndCap() {
	nums := []int{1}
	printLenCap(nums) // len: 1, cap: 1 [1]
	nums = append(nums, 2)
	printLenCap(nums) // len: 2, cap: 2 [1 2]
	nums = append(nums, 3)
	printLenCap(nums) // len: 3, cap: 4 [1 2 3]
	nums = append(nums, 4)
	printLenCap(nums) // len: 4, cap: 4 [1 2 3 4]
	nums = append(nums, 5)
	printLenCap(nums) // len: 5, cap: 8 [1 2 3 4 5]
}

func main() {
	TestSliceLenAndCap()
}

(2)切片的本质

切片的操作并不复制切片指向的元素,创建一个新的切片会复用原来切片的底层数组,因此切片操作是非常高效的。

nums := make([]int, 0, 8)
nums = append(nums, 1, 2, 3, 4, 5)
nums2 := nums[2:4]
printLenCap(nums)  // len: 5, cap: 8 [1 2 3 4 5]
printLenCap(nums2) // len: 2, cap: 6 [3 4]

nums2 = append(nums2, 50, 60)
printLenCap(nums)  // len: 5, cap: 8 [1 2 3 4 50]
printLenCap(nums2) // len: 4, cap: 6 [3 4 50 60]
  • nums2 执行了一个切片操作 [2,4],此时 nums 和 nums2 指向的是同一个数组。
  • nums2 增加 2 个元素 50 和 60 后,将底层数组下标 [4] 的值改为了 50,下标[5] 的值置为 60。
  • 因为 nums 和 nums2 指向的是同一个数组,因此 nums 被修改为 [1, 2, 3, 4, 50]。

        

2.  切片操作及性能

2.1 切片的复制

(1)通过copy()方法实现

	a := []int {1,2,3,4,5}
	b := make([]int,len(a)) 
	copy(b,a)  //结果:[1 2 3 4 5]

(2)通过append()实现

	a := []int {1,2,3,4,5}
	b := append([]int(nil), a...)   //结果:[1 2 3 4 5]

2.2 切片添加元素

	a := []int {1,2,3,4,5}
	a = append(a,5)  //结果:[1 2 3 4 5 5]
	b := []int {6,7,8}
	a := []int {1,2,3,4,5}
	a = append(a,b...) //结果:[1 2 3 4 5 6 7 8]

2.3 切片移除元素

	a := []int {1,2,3,4,5} 
	a = append(a[:3], a[4:]...) //例如移除下标为3的元素
    //结果:[1 2 3 5]

2.4 切片插入在指定位置插入元素

	a := []int {1,2,3,4,5}
	a = append(a[:2],append([]int{2}, a[2:]...)...) //例如在下标2处插入2
    //结果:[1 2 2 3 4 5]

insert 和 append 类似。即在某个位置添加一个元素后,将该位置后面的元素再 append 回去。复杂度为 O(N)。因此,不适合大量随机插入的场景。

3.性能陷阱

3.1 大量内存得不到释放

在已有切片的基础上进行切片,不会创建新的底层数组。因为原来的底层数组没有发生变化,内存会一直占用,直到没有变量引用该数组。因此很可能出现这么一种情况,原切片由大量的元素构成,但是我们在原切片的基础上切片,虽然只使用了很小一段,但底层数组在内存中仍然占据了大量空间,得不到释放。

func main(){
	a := []int {1,2,3,4,5,6,7,8,9,10}
	fmt.Printf("使用copy():%v",lastNumsByCopy(a))
	fmt.Println()
	fmt.Printf("使用slice:%v",lastNumsBySlice(a))
}

func lastNumsByCopy(origin []int) []int {
	result := make([]int, 2)
	copy(result, origin[len(origin)-2:])
	return result
}

func lastNumsBySlice(origin []int) []int {
	return origin[len(origin)-2:]
}

/*
结果:
使用copy():[9 10]
使用slice:[9 10]
*/

上述两个函数的作用是一样的,取 origin 切片的最后 2 个元素。

  • 第一个函数直接在原切片基础上进行切片。
  • 第二个函数创建了一个新的切片,将 origin 的最后两个元素拷贝到新切片上,然后返回新切片。(推荐)

测试用例:

//generateWithCap()用于随机生成 n 个 int 整数,64位机器上,一个 int 占 8 Byte,128 * 1024 个整数恰好占据 1 MB 的空间
func generateWithCap(n int) []int { 
	rand.Seed(time.Now().UnixNano())
	nums := make([]int, 0, n)
	for i := 0; i < n; i++ {
		nums = append(nums, rand.Int())
	}
	return nums
}

//printMem() 用于打印程序运行时占用的内存大小。
func printMem(t *testing.T) {
	t.Helper()
	var rtm runtime.MemStats
	runtime.ReadMemStats(&rtm)
	t.Logf("%.2f MB", float64(rtm.Alloc)/1024./1024.)
}

func testLastChars(t *testing.T, f func([]int) []int) {
	t.Helper()
	ans := make([][]int, 0)
	for k := 0; k < 100; k++ {
		origin := generateWithCap(128 * 1024) // 1M
		ans = append(ans, f(origin))
	}
	printMem(t)
	_ = ans
}

func TestLastCharsBySlice(t *testing.T) { testLastChars(t, lastNumsBySlice) }
func TestLastCharsByCopy(t *testing.T)  { testLastChars(t, lastNumsByCopy) }
  • 以上测试用例内容为随机生成一个大小为 1 MB 的切片( 128*1024 个 int 整型,恰好为 1 MB)。
  • 分别调用 lastNumsBySlice 和 lastNumsByCopy 取切片的最后两个元素。
  • 最后然后打印程序所占用的内存。

运行结果如下:

$ go test -run=^TestLastChars  -v
=== RUN   TestLastCharsBySlice
--- PASS: TestLastCharsBySlice (0.31s)
    slice_test.go:73: 100.14 MB
=== RUN   TestLastCharsByCopy
--- PASS: TestLastCharsByCopy (0.28s)
    slice_test.go:74: 3.14 MB
PASS
ok      example 0.601s

通过以上测试,结果差异非常明显,lastNumsBySlice 耗费了 100.14 MB 内存,也就是说,申请的 100 个 1 MB 大小的内存没有被回收。因为切片虽然只使用了最后 2 个元素,但是因为与原来 1M 的切片引用了相同的底层数组,底层数组得不到释放,因此,最终 100 MB 的内存始终得不到释放。而 lastNumsByCopy 仅消耗了 3.14 MB 的内存。这是因为,通过 copy,指向了一个新的底层数组,当 origin 不再被引用后,内存会被垃圾回收(garbage collector, GC)。

4.总结

  1. GO 中的数组变量属于值类型,当数组变量被赋值或传递时,实际上会复制整个数组
  2. 切片本质是数组片段的描述,包括数组的指针,片段的长度和容量,切片操作并不复制切片指向的元素,而是复用原来切片的底层数组
    • 长度是切片实际拥有的元素,使用 len 可得到切片长度
    • 容量是切片预分配的内存能够容纳的元素个数,使用 cap 可得到切片容量
      • 当 append 之后的元素小于等于 cap,将会直接利用底层元素剩余的空间
      • 当 append 后的元素大于 cap,将会分配一块更大的区域来容纳新的底层数组,在容量较小的时候,通常是以 2 的倍数扩大
  3. 可能存在只使用了一小段切片,但是底层数组仍被占用,得不到使用,推荐使用 copy 替代默认的 re-slice
举报

相关推荐

0 条评论