本系列的第一个部分,本文就来谈谈日常开发中几乎是最常用的,且在 Golang 中又有一定特殊性的数组和切片中有哪些值得注意的优化点
首先我们来谈一谈数组和切片的具体实现
Go 的切片(slice)是在数组(array)之上的抽象数据类型,数组类型定义了长度和元素类型,数组变量属于值类型(value type),因此当一个数组变量被赋值或者传递时,实际上会复制整个数组
由于数组固定长度,缺少灵活性,我们在大部分场景下会选择使用基于数组构建的功能更强大,使用更便利的切片类型
切片使用字面量初始化时和数组很像,但是不需要指定长度:
languages := []string{"Go", "Python", "C"}
或者使用内置函数 make 进行初始化,make 的函数定义如下:
func make([]T, len, cap) []T
第一个参数是 []T
,T 即元素类型,第二个参数是长度 len,即初始化的切片拥有多少个元素,第三个参数是容量 cap,容量是可选参数,默认等于长度。使用内置函数 len
和 cap
可以得到切片的长度和容量
容量是当前切片已经预分配的内存能够容纳的元素个数,如果往切片中不断地增加新的元素。如果超过了当前切片的容量,就需要分配新的内存,并将当前切片所有的元素拷贝到新的内存块上。因此为了减少内存的拷贝次数,容量在比较小的时候,一般是以 2 的倍数扩大的,例如 2 4 8 16 …,当达到 2048 时,会采取新的策略,避免申请内存过大,导致浪费。Go 语言源代码 runtime/slice.go 中是这么实现的,不同版本可能有所差异:
newcap := old.cap
doublecap := newcap + newcap
if cap > doublecap {
newcap = cap
} else {
if old.len < 1024 {
newcap = doublecap
} else {
// Check 0 < newcap to detect overflow
// and prevent an infinite loop.
for 0 < newcap && newcap < cap {
newcap += newcap / 4
}
// Set newcap to the requested cap when
// the newcap calculation overflowed.
if newcap <= 0 {
newcap = cap
}
}
}
切片和数组很相似,按照下标进行索引。切片本质是一个数组片段的描述,包括了数组的指针,这个片段的长度和容量(不改变内存分配情况下的最大长度)。
struct {
ptr *[]T
len int
cap int
}
切片操作并不复制切片指向的元素,创建一个新的切片会复用原来切片的底层数组,因此切片操作是非常高效的。下面的例子展示了这个过程:
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]
[2, 4)
,此时 nums 和 nums2 指向的是同一个数组搞清楚切片的本质及其和数组的关系后,我们来看具体的性能优化点
当我们确定一个 slice 的 capacity 时,直接使用 make 方法的第三个参数: make([]T, 0, len)
有时即使我们不能确切地知道一个 slice 的 capacity 时,如果这个 slice 的生命周期够短且在运行时不会持续增长,我们也可以给它分配一个足够大的 capacity,这样可以避免频繁的扩容带来的消耗
当我们想合并两个 slice 时,我们一般会直接使用 append(s1, s2),但append的行为其实是不确定的,当s1的capacity不够大时,append会创建一个新的 slice s3,和s1, s2都不共享底层数组;而当s1的capacity足够大时,append会直接让新的 s3 和 s1 共享底层数组,见下面的例子:
package main
import "fmt"
func main() {
s1 := make([]int, 2, 5) // s1的capacity是5
s1[0], s1[1] = 2, 3
s2 := []int{7, 8}
// 这里不会创建新的底层数组,因为s1的capacity足够大
s3 := append(s1, s2...)
fmt.Println(s1, s3)
s3[0] = 5
fmt.Println(s1, s3)
}
输出为:
output
[2 3] [2 3 7 8]
[5 3] [5 3 7 8]
这里由于共享了底层数组,所以 s10 的值也改变了
这里可以使用 slice 切片的第三个参数避免这个不确定的行为,保证分配新的底层数组:
package main
import "fmt"
func main() {
s1 := make([]int, 2, 5)
s1[0], s1[1] = 2, 3
s2 := []int{7, 8}
// slice切片的第三个参数为max capacity
// s1被切片后,其capacity等于其length,确保没有额外的空间,也就确保了一定会创建新的底层数组
s3 := append(s1[:len(s1):len(s1)], s2...)
fmt.Println(s1, s3)
s3[0] = 5
fmt.Println(s1, s3)
}
输出为:
output
[2 3] [2 3 7 8]
[2 3] [5 3 7 8]
但这里我们可以用更高效的方法——copy,并且我们还可以写一个可以 merge 多个数组(append你做得到吗?!)的泛型版本 merge:
func concatMultipleSlices[T any](slices [][]T) []T {
var totalLen int
// 计算总长度
for _, s := range slices {
totalLen += len(s)
}
result := make([]T, totalLen)
var i int
// 直接copy到指定内存
for _, s := range slices {
i += copy(result[i:], s)
}
return result
}
首先我们要知道,在Golang中函数传参都是传值而不是传引用
但当参数为slice
、map
和chan
时,其效果看上去像传引用,因为他们内部有指针或本身就是指针
它们都可以通过make
内置函数创建,那么我们去追踪一下make
函数的实现,看下其返回值,最终我们可以追踪到下面的源码:
func makeslice(et *_type, len, cap int) slice {
}
func makemap(t *maptype, hint int, h *hmap) *hmap {
}
func makechan(t *chantype, size int64) *hchan {
}
可以看到,make
函数对于slice
创建返回的是slice
的结构体实例,对于map
和chan
的创建则返回的是对应的header
指针,而slice
结构体的定义如下:
type `slice` struct {
array unsafe.Pointer
len int
cap int
}
slice
结构体里有一个指向底层数组array的指针,所以slice
在作为函数参数传递进去的时候,虽然和map
以及chan
一样可以修改其中的值,但是内部slice
若使用append之类的方法修改了大小,则这部分长度信息的变化不会反馈到外层slice
中,甚至会因为底层数组扩容导致内外slice
指向了不同的底层数组,进而后续的所有修改也将不会再影响到外部,使用的时候一定要小心;而map
和chan
因为本质上就是指针,故所有函数内的变动都会反馈到外面,除非在函数内部改变了这些指针指向的内存(这也是map
和chan
的copy
的实现方法)
所以当我们传参处理 slice 时,如果我们不需要 append 或是改变它的长度,而仅仅需要处理更新其内含的元素,那么我们可以直接在原slice上操作,甚至可以不返回它
在已有切片的基础上进行切片,不会创建新的底层数组。因为原来的底层数组没有发生变化,内存会一直占用不被 GC,直到没有变量引用该数组。因此很可能出现这么一种情况,原切片由大量的元素构成,但是我们在原切片的基础上切片,虽然只使用了很小一段,但底层数组在内存中仍然占据了大量空间,得不到释放。比较推荐的做法,使用 copy
替代 re-slice
func lastNumsBySlice(origin []int) []int {
return origin[len(origin)-2:]
}
func lastNumsByCopy(origin []int) []int {
result := make([]int, 2)
copy(result, origin[len(origin)-2:])
return result
}
上述两个函数的作用是一样的,取 origin 切片的最后 2 个元素。
我们可以写两个测试用例来比较这两种方式的性能差异:
在此之前呢,我们先实现 2 个辅助函数:
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
}
func printMem(t *testing.T) {
t.Helper()
var rtm runtime.MemStats
runtime.ReadMemStats(&rtm)
t.Logf("%.2f MB", float64(rtm.Alloc)/1024./1024.)
}
generateWithCap
用于随机生成 n 个 int 整数,64位机器上,一个 int 占 8 Byte,128 * 1024 个整数恰好占据 1 MB 的空间。printMem
用于打印程序运行时占用的内存大小。接下来分别为 lastNumsBySlice
和 lastNumsByCopy
实现测试用例:
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) }
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)。
如果我们在循环中,显示地调用 runtime.GC()
,效果会更加地明显:
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))
runtime.GC()
}
printMem(t)
_ = ans
}
lastNumsByCopy
内存占用直接下降到 0.15 MB。
$ go test -run=^TestLastChars -v
=== RUN TestLastCharsBySlice
--- PASS: TestLastCharsBySlice (0.37s)
slice_test.go:75: 100.14 MB
=== RUN TestLastCharsByCopy
--- PASS: TestLastCharsByCopy (0.34s)
slice_test.go:76: 0.15 MB
PASS
ok example 0.723s
对 Golang 的数组和切片,仅需牢记两点:
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。