欢迎回到我们的公众号,今天,我们将带领你深入理解 Go 语言中的一个重要数据结构:Slice(动态数组)。
Slice 是 Go 语言中非常重要的数据类型,对于我们编写高效且易读的代码至关重要,让我们一起探索这个强大的工具。
什么是 Slice?
Slice 又称动态数组,依托底层数组实现。在 Go 语言中,Slice 是一个引用类型,它比数组更加灵活,更适合处理序列数据。它不仅可以动态地改变自己的大小,还可以进行方便的切片操作。Slice 的定义非常简洁,只有三个字段:指针、长度和容量。
type slice struct {
array unsafe.Pointer // 数组指针
len int // 长度
cap int // 容量
}
- array 是一个指向底层数组的指针。
- len 表示 Slice 当前的长度。
- cap 表示 Slice 的容量,也就是底层数组的长度。
Slice 的创建和初始化
Go 语言提供了多种创建和初始化 Slice 的方法,这些方法都很简洁且易于使用。
- 使用内置的 make 函数创建一个具有指定长度和容量的空 Slice:
var s1[]int // 变量声明
s2 := make([]int, 5, 10) // 创建一个长度为 5,容量为 10 的 int 类型的 Slice
- 使用字面量创建和初始化 Slice:
s1:=[]string{} // 空切片
s2 := []int{1, 2, 3, 4, 5} // 创建并初始化一个 int 类型的 Slice
- 切取初始化,切片表达式 [low:hight] 表示左闭右开区间,切取的长度是:hight - low
arr := [5]{1,2,3,4,5}
s1 := arr[0:2] // 从数组中切取:[1,2]
s2 := arr[0:1] // 从数组中切取:[1]
Slice 的简单操作
Slice 提供了一系列强大的操作,使我们能够方便地处理序列数据。
- 切片:我们可以使用 [:] 语法方便地对 Slice 进行切片操作,从而获取 Slice 的子序列。
s := []int{1, 2, 3, 4, 5}
sub := s[1:3] // sub 现在是 [2, 3]
- 追加元素:append() 函数可以将元素追加到 Slice 的末尾。如果 Slice 的容量不足以容纳更多的元素,append 函数会创建一个新的底层数组,并将所有元素复制到新数组中。
s := []int{1, 2, 3}
s = append(s, 4, 5) // s 现在是 [1, 2, 3, 4, 5]
- 复制元素:copy() 函数可以复制一个 Slice 的元素到另一个 Slice 中。
s1 := []int{1, 2, 3, 4}
s2 := make([]int, 3)
copy(s2, s1) // s2 现在是 [1, 2, 3]
slice copy 不会产生扩容
Slice 实现原理
数据结构
源码包中 src/runtime/slice.go: slice 定义了 slice 的数据结构
type slice struct {
array unsafe.Pointer
len int
cap int
}
如果使用 make() 创建 slice 时,可以同时指定长度和容量,创建时底层会分配一个数组,数组的长度即为容量。例如:slice := make(int,5,10) ,该长度长度时 5,容量是 10,可以使用 slice[0] ~ slice[4] 来操作里面的元素。
slice 扩容
append() 函数可以将元素追加到 Slice 的末尾。如果 Slice 的容量不足以容纳更多的元素,append 函数会创建一个新的底层数组,并将所有元素复制到新数组中,然后返回新 slice ,扩容后再将数据追加进去。实现步骤如下:
- 假如 slice 容量够用,则将新元素追加进去,slice.len ++,返回原 slice。
- 如果原 slice 不够用,则将 slice 先扩容,扩容后得到新slice。
- 将新元素追加进新 slice ,slice.len++,返回新的 slice。
扩容容量的选择遵循以下规则:
- 如果原 slice 容量 < 1024,则新的 slice 容量将扩大为原来的 2 倍。
- 如果原 slice 容量 >= 1024,则新的 slice 将扩容为原来的 1.25 倍。
- 如果扩容后的大小仍不能满足,那么新 Slice 容量等于所需的容量。
- 在以上计算完新 Slice 容量后,交由管理内存的组件申请内存,按照给出的表向上取整进行内存申请,申请出来的内存长度,作为Slice扩容后的容量。
扩容函数如下:
func growslice(et *_type, old slice, cap int) slice {
// ……
newcap := old.cap
doublecap := newcap + newcap
if cap > doublecap { //扩容规则就在这里
newcap = cap
} else {
if old.len < 1024 {
newcap = doublecap
} else {
for newcap < cap {
newcap += newcap / 4
}
}
}
// ……
// 如下两行做了内存对齐的操作,是经常导致结果不符合预期的原因,请记住这里
capmem = roundupsize(uintptr(newcap) * ptrSize)
newcap = int(capmem / ptrSize)
}
roundupsize 函数如下:
可以看到有3种分支进行不同的内存对齐,在内存对齐的过程中,向上取整,这里涉及到go的内存分配方式。
var overflow bool
var lenmem, newlenmem, capmem uintptr
switch {
case et.size == 1:
lenmem = uintptr(old.len)
newlenmem = uintptr(cap)
capmem = roundupsize(uintptr(newcap))
overflow = uintptr(newcap) > maxAlloc
newcap = int(capmem)
case et.size == sys.PtrSize:
lenmem = uintptr(old.len) * sys.PtrSize
newlenmem = uintptr(cap) * sys.PtrSize
capmem = roundupsize(uintptr(newcap) * sys.PtrSize)
overflow = uintptr(newcap) > maxAlloc/sys.PtrSize
newcap = int(capmem / sys.PtrSize)
case isPowerOfTwo(et.size):
...
default:
...
}
有一张规律表如下,第二列即是分配的内存大小。
// class bytes/obj bytes/span objects waste bytes
// 1 8 8192 1024 0
// 2 16 8192 512 0
// 3 32 8192 256 0
// 4 48 8192 170 32
// 5 64 8192 128 0
// 6 80 8192 102 32
// 7 96 8192 85 32
// 8 112 8192 73 16
// 9 128 8192 64 0
// 10 144 8192 56 128
// 11 160 8192 51 32
// 12 176 8192 46 96
// 13 192 8192 42 128
// 14 208 8192 39 80
// 15 224 8192 36 128
// 16 240 8192 34 32
// 17 256 8192 32 0
// 18 288 8192 28 128
// 19 320 8192 25 192
// 20 352 8192 23 96
// 21 384 8192 21 128
// 22 416 8192 19 288
// 23 448 8192 18 128
// 24 480 8192 17 32
// 25 512 8192 16 0
// 26 576 8192 14 128
// 27 640 8192 12 512
// 28 704 8192 11 448
// 29 768 8192 10 512
// 30 896 8192 9 128
// 31 1024 8192 8 0
// 32 1152 8192 7 128
// 33 1280 8192 6 512
// 34 1408 16384 11 896
// 35 1536 8192 5 512
// 36 1792 16384 9 256
// 37 2048 8192 4 0
// 38 2304 16384 7 256
// 39 2688 8192 3 128
// 40 3072 24576 8 0
// 41 3200 16384 5 384
// 42 3456 24576 7 384
// 43 4096 8192 2 0
// 44 4864 24576 5 256
// 45 5376 16384 3 256
// 46 6144 24576 4 0
// 47 6528 32768 5 128
// 48 6784 40960 6 256
// 49 6912 49152 7 768
// 50 8192 8192 1 0
// 51 9472 57344 6 512
// 52 9728 49152 5 512
// 53 10240 40960 4 0
// 54 10880 32768 3 128
// 55 12288 24576 2 0
// 56 13568 40960 3 256
// 57 14336 57344 4 0
// 58 16384 16384 1 0
// 59 18432 73728 4 0
// 60 19072 57344 3 128
// 61 20480 40960 2 0
// 62 21760 65536 3 256
// 63 24576 24576 1 0
// 64 27264 81920 3 128
// 65 28672 57344 2 0
// 66 32768 32768 1 0
Slice 复制 Copy
使用 copy() 内置函数拷贝两个切片时,会将源切片的数据逐个拷贝到目的切片指向的数组中,拷贝数量取两个切片长度的最小值,并且拷贝过程中不会发生扩容。
例如:长度为 20 的切片拷贝到长度为 5 的切片中时,将拷贝 5 个元素。
Slice 的内存管理
Slice 的内存管理是通过底层的数组完成的。当我们对 Slice 进行扩容操作时(例如,使用 append 函数添加元素),Go 语言会自动为我们分配一个新的底层数组,并将原有的元素复制到新数组中。这个过程是透明的,我们无需关心底层的细节。
但是,这个特性也意味着我们需要注意 Slice 的内存使用。如果我们长时间保持对一个很大的 Slice 的引用,那么底层数组的内存就不能被回收,可能会导致内存泄漏。因此,我们应该尽量减小 Slice 的生命周期,或者在不再需要大 Slice 时,将其长度设为 0,从而释放内存。
s := make([]int, 1e6) // 创建一个很大的 Slice
s = s[:0] // 当我们不再需要这个大 Slice 时,将其长度设为 0,从而释放内存
总结
Go 语言的 Slice 是一个非常强大的工具,它提供了灵活的动态数组,可以方便地处理序列数据。掌握 Slice 的使用和内部机制,对于我们编写高效且易读的 Go 代码至关重要。
我们在这篇文章中介绍了 Slice 的基本结构、创建和初始化方法、基本操作、实现原理以及内存管理。希望这篇文章能帮助你更深入地理解 Go 语言的 Slice。
- 每一个切片都指向了一个底层数组。
- 每一个切片都保存了当前切片的长度、底层数组的可用容量。
- 使用 len() 计算切片长度的时间复杂度时 O(1),不需要遍历切片。
- 使用 cap() 计算切片容量的时间复杂度时 O(1),不需要遍历切片。
- 通过函数传递切片时,不会拷贝整个切片,因为切片本身只是一个结构体而已。
- 使用 append() 向切片追加元素时有可能触发扩容,扩容后会生成新的切片。
- 创建切片时最好根据实际需要预分配容量,尽量避免在追加过程中的扩容操作,有利于提升性能。
- 切片拷贝时需要判断实际拷贝的元素个数
- 谨慎使用多个切片操作同一个数组,以防读写冲突。
如果你有任何问题或想要讨论的话题,欢迎在评论区留言,我们将尽快回复。在下一篇文章中,我们将继续探讨 Go 语言的更多知识,敬请期待!
感谢你的阅读,如果你觉得这篇文章对你有帮助,欢迎点赞和分享!
请关注公众号【Java千练】,更多干货文章等你来看!