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