slice扩容性能损耗探究
背景
如果让我评选最伟大的数据结构,在我心中答案只有两个,数组和哈希表,这两个是我的程序的重要组成部分,同时也是我饭碗的重要组成部分。slice和map简洁明了的API很容易让我们有一种他们提供了无限大的空间,可以容纳无限多的数据。然而,我们内心都有一面明镜,知道他们这些岁月静好的背后是通过扩容操作替我们负重前行。在nutsdb有slice和map来构建关键的数据结构或者处理数据,为了探究slice和map的使用对性能有没有影响,有多大影响,由此评估需不需要对这两个数据结构的使用方式进行优化。于是对slice和map扩容对性能的影响这个问题做了一些探究。总结出了一些文章。这是这个系列的第一篇文章。对slice扩容对性能的影响的研究。分享给大家。
1. Slice扩容对性能的影响
Slice是Go提供给我们的数据结构,基本上也是我们开发中最常用的数据结构了,在开发中使用过程一般是下面这样:
func TestSliceBaseUsage(t *testing.T) {
var slice []int
slice = append(slice, 1, 2, 3)
}
func TestSizedSliceBaseUsage(t *testing.T) {
slice := make([]int, 10)
slice = append(slice, 1, 2, 3)
}
第一种用法就是不指定切片的容量,用到哪里是哪里,第二种就是指定了容量,先申请一片空间,等用到了一定程度再继续扩容。那么他们两个之间到底有怎样的差异呢?我们来看看下面这段Benchmark测试。
func BenchmarkSlickGrow(b *testing.B) {
// 要测试的切片长度
var lengths = []int{1000, 10 * 1000, 100 * 1000, 1000 * 1000}
for _, length := range lengths {
// 直接申请空间的切片 性能测试
nameOfNotGrowBM := fmt.Sprintf("test_slice_not_grow_%d", length)
b.Run(nameOfNotGrowBM, func(b *testing.B) {
b.ReportAllocs()
b.StartTimer()
for i := 0; i < b.N; i++ {
value := 1
slice := make([]int, length)
for i := 0; i < length; i++ {
slice = append(slice, value)
}
}
})
// 从一开始就不申请空间,一路append的切片 性能测试
nameOfGrowBM := fmt.Sprintf("test_slice_grow_%d", length)
b.Run(nameOfGrowBM, func(b *testing.B) {
b.ReportAllocs()
b.StartTimer()
for i := 0; i < b.N; i++ {
value := 1
var slice []int
for i := 0; i < length; i++ {
slice = append(slice, value)
}
}
})
}
}
这个benchmark测试了从长度数量级为一千到一百万的切片,直接申请空间然后逐渐添加元素和不申请空间通过append添加元素这两种操作之间的性能对比。我们跑一下这个代码来看看结果:
goos: darwin
goarch: arm64
pkg: go-learn/go
BenchmarkSlickGrow
BenchmarkSlickGrow/test_slice_not_grow_1000
BenchmarkSlickGrow/test_slice_not_grow_1000-10 242797 4759 ns/op 38912 B/op 3 allocs/op
BenchmarkSlickGrow/test_slice_grow_1000
BenchmarkSlickGrow/test_slice_grow_1000-10 304522 3619 ns/op 25208 B/op 12 allocs/op
BenchmarkSlickGrow/test_slice_not_grow_10000
BenchmarkSlickGrow/test_slice_not_grow_10000-10 16395 71704 ns/op 507905 B/op 4 allocs/op
BenchmarkSlickGrow/test_slice_grow_10000
BenchmarkSlickGrow/test_slice_grow_10000-10 22346 52807 ns/op 357626 B/op 19 allocs/op
BenchmarkSlickGrow/test_slice_not_grow_100000
BenchmarkSlickGrow/test_slice_not_grow_100000-10 1620 729987 ns/op 6635538 B/op 5 allocs/op
BenchmarkSlickGrow/test_slice_grow_100000
BenchmarkSlickGrow/test_slice_grow_100000-10 2632 468636 ns/op 4101390 B/op 28 allocs/op
BenchmarkSlickGrow/test_slice_not_grow_1000000
BenchmarkSlickGrow/test_slice_not_grow_1000000-10 308 3843628 ns/op 65708071 B/op 5 allocs/op
BenchmarkSlickGrow/test_slice_grow_1000000
BenchmarkSlickGrow/test_slice_grow_1000000-10 360 3247562 ns/op 41678130 B/op 38 allocs/op
PASS
从测试结果来看,不扩容的测试组性能上,内存上,比起扩容的测试组,领先优势起码拉开了一个身位。
- 1000这个档位,速度上不扩容比扩容快约30%, 内存上不扩容比扩容省50%
- 10,000这个档位,速度上不扩容比扩容快约36%, 内存上不扩容比扩容省42%
- 100,000这个档位,速度上不扩容比扩容快约18%, 内存上不扩容比扩容省20%
为什么会造成这个样子的结果呢?让我们来看看slice的扩容原理。
func growslice(et *_type, old slice, cap int) slice {
newcap := old.cap
doublecap := newcap + newcap
if cap > doublecap {
newcap = cap
} else {
const threshold = 256
if old.cap < threshold {
newcap = doublecap
} else {
// Check 0 < newcap to detect overflow
// and prevent an infinite loop.
for 0 < newcap && newcap < cap {
// Transition from growing 2x for small slices
// to growing 1.25x for large slices. This formula
// gives a smooth-ish transition between the two.
newcap += (newcap + 3*threshold) / 4
}
// Set newcap to the requested cap when
// the newcap calculation overflowed.
if newcap <= 0 {
newcap = cap
}
}
}
}
这个是go1.8的growslice函数,这里面实现的slice扩容原理是这样的,在容量小于256的时候,执行成本扩容,在容量大于256的时候,将执行1.25倍的扩容。另外扩容的时候会申请一片长度为扩容后的容量的内存把数据都搬迁过去,迁移之后原来的内存就无用了,会一直在内存中飘荡等待GC的回收。
总结
所以在我们在使用slice处理数据的时候要留意一下他的扩容问题。乍看下来还是有一定影响的。在数据量大的情况下,如果要优化内存和执行速度,是可以考虑对slice进行一定的优化的,比如:
- 如果已经知道了要处理的数据量,可以直接申请足够大的空间来处理。
- 如果不知道数据量,可以把处理流程改成将数据一个个进行处理。
转载自:https://juejin.cn/post/7206508940639764539