关于slice扩容性能损耗的探究

文章探讨了Go语言中slice扩容对程序性能的影响,通过基准测试比较了预分配容量与动态扩容的差异。结果显示,预分配容量的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

从测试结果来看,不扩容的测试组性能上,内存上,比起扩容的测试组,领先优势起码拉开了一个身位。

  1. 1000这个档位,速度上不扩容比扩容快约30%, 内存上不扩容比扩容省50%
  2. 10,000这个档位,速度上不扩容比扩容快约36%, 内存上不扩容比扩容省42%
  3. 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进行一定的优化的,比如:

  1. 如果已经知道了要处理的数据量,可以直接申请足够大的空间来处理。
  2. 如果不知道数据量,可以把处理流程改成将数据一个个进行处理。
<think>首先,用户的问题是关于Go语言中slice切片的扩容原理及机制。我需要基于提供的引用内容来回答,这些引用来自不同的资料。 关键引用内容总结: - 引用[1]:讨论了避免频繁扩容性能损耗,通过预分配容量来提高效率。[^1] - 引用[2]:详细描述了slice扩容的规则。当slice扩容时:如果新大小是当前大小的2倍以上,则直接增长到新大小;否则,如果当前大小小于1024,每次按2倍增长;否则,每次按当前大小的1/4增长,直到达到或超过新大小。[^2] - 引用[3]:深入解析了Go切片的底层结构、初始化、共享机制和扩容策略。[^3],但具体内容未详细给出。 - 引用[4]:展示了一个旋转切片的函数。[^4],与扩容无关,可以不重点考虑。 用户明确说:“我想了解slice切片扩容的相关知识 请问slice切片扩容原理及机制”,所以我需要聚焦在扩容原理和机制上。 从系统级指令: - 所有数学表达式必须用$...$格式,但由于主题是Go切片,可能不需要数学表达式,除非有公式。 - 独立公式用$$...$$,但同样,这里可能不适用。 - 使用中文回答。 - 生成相关问题。 - 回答中引用的段落末尾自然地添加引用标识。 结构应该清晰:逐步解决问题,确保真实可靠。 大纲回答结构: 1. **介绍slice的基本概念**:简要说明slice是什么,为什么需要扩容。 2. **扩容机制原理**:详细解释扩容规则,基于引用[2]。 - 规则:如果新大小 >= 当前大小 * 2,则直接扩容到新大小。 - 否则,如果当前大小 < 1024,按2倍增长;否则,按1/4增长,直到满足需求。 3. **性能影响**:引用[1]中的内容,强调预分配容量以避免性能损耗。 - 示例代码展示了使用make预分配cap的性能优势。 4. **底层实现**:引用[3]提到底层结构,但内容不详。我可以补充一些知识:slice底层是数组,扩容时会创建一个新数组并复制数据。 5. **总结和最佳实践**:建议使用预先分配容量来减少扩容次数。 然后,生成相关问题。 确保引用标识自然添加:在引用具体段落末尾添加[^1], [^2], etc. 例如: - 当描述扩容规则时,说:“扩容遵循特定规则:如果新的大小是当前大小2倍以上,则大小增长为新大小;否则,如果当前大小小于1024,按每次2倍增长,否则每次按当前大小1/4增长,直到增长的大小超过或等于新大小。[^2]” - 当讨论性能时,说:“频繁扩容会导致性能损耗,因为每次扩容都需要分配新内存和复制数据,因此建议在已知数据最大长度时预先声明切片的容量。[^1]” 最后,相关问题: 基于主题,生成2-3个相关问题。 例如: 1. 如何避免slice切片扩容带来的性能问题? 2. Go slice的底层数据结构是什么? 3. slice扩容时如何影响内存使用? 现在,撰写回答。 </think>### Go语言Slice切片扩容原理及机制 在Go语言中,切片(slice)是一个动态数组,底层基于数组实现。它是一个包含三个字段的结构:指针(指向底层数组的起始位置)、长度(当前元素个数)和容量(底层数组的总大小)。当切片的元素数量超过容量时,就需要扩容,这会触发底层数组的重新分配和数据复制。扩容机制设计用于平衡内存使用和性能,但频繁扩容会导致性能损耗。下面我将基于引用内容,逐步解析扩容原理和机制,确保回答真实可靠。 #### 1. **扩容的基本原理** - 切片的扩容发生在使用`append`函数添加元素时,如果当前长度(len)超过了容量(cap),就会触发扩容。 - 扩容的核心是创建一个更大的底层数组,将旧数据复制到新数组,并更新切片的指针、长度和容量。这确保了切片可以动态增长,但涉及内存分配和数据复制操作,可能带来开销[^3]。 - 底层实现中,扩容不是简单地按固定比例增长,而是采用一个智能的增长规则,以减少内存浪费和避免频繁扩容[^2]。 #### 2. **扩容的具体机制** 扩容规则遵循一个优化算法,旨在平衡增长速度和内存效率。基于引用[2]的描述,规则如下: - **规则1**:如果新大小(即新长度)是当前容量(cap)的2倍以上,则直接扩容到新大小。例如,当前容量为5,新大小(所需容量)为12(12 > 5×2=10),则容量直接设置为12。 - **规则2**:如果新大小小于当前容量的2倍,则采用以下循环增长策略: - 如果当前容量小于1024,则每次按2倍增长(例如,从cap=10增长到20)。 - 如果当前容量大于或等于1024,则每次按当前容量的1/4增长(例如,从cap=2000增长到2500,因为2000 × 1.25 = 2500)。 - 增长过程会重复,直到增长后的容量达到或超过新大小。 - 这个规则的核心在于:对于小切片(<1024),以较快的倍数增长(2倍)来减少扩容次数;对于大切片(≥1024),以较慢的比例增长(1.25倍,因为1/4增长相当于增加25%)来避免内存浪费[^2]。 举个例子说明: - 假设当前切片容量为10,使用`append`添加元素后所需新大小为15。 - 新大小15 < 当前容量×2 = 20(15 < 20),因此不触发规则1。 - 当前容量10 < 1024,按规则2以2倍增长:容量增长到20(10 × 2 = 20)。 - 最终容量为20,满足新大小15。 - 另一个例子:当前容量为1000,新大小为1100。 - 新大小1100 < 1000×2=2000,因此不触发规则1。 - 当前容量1000 < 1024,按规则2以2倍增长:容量增长到2000。 - 但2000 > 1100,满足需求。 #### 3. **扩容性能影响和优化建议** - **性能损耗**:扩容涉及内存分配和数据复制,可能导致性能下降。频繁扩容会引发“扩容抖动”,即多次分配新数组和复制数据,增加CPU和内存开销。引用[1]中的性能测试对比了两种场景: - 使用空切片(未预分配容量):var zeroSlice []int,频繁扩容导致性能较差。 - 使用预分配容量的切片:capSlice := make([]int, 0, maxNum),避免了多次扩容,提升了效率。 - 测试结果显示,预分配切片性能更好,因为减少了底层数组切换的次数[^1]。 - **最佳实践**:在已知数据最大长度的情况下,使用`make`函数预先声明切片的容量(cap)。这样可以避免运行时扩容的开销,提升程序效率。例如: ```go // 预分配容量,避免扩容 data := make([]int, 0, 1000) // 初始长度0, 容量1000 for i := 0; i < 1000; i++ { data = append(data, i) // 添加元素时无需扩容 } ``` 引用[1]强调,这种方法尤其适用于高性能场景,如循环添加大量元素[^1]。 #### 4. **底层实现细节** - 切片的底层是一个数组指针,扩容时会调用Go运行时的内部函数(如`growslice`)。该函数根据上述规则计算新容量,然后分配新数组并复制数据。 - 扩容后,新切片与旧切片共享部分数据(如果旧数组未被回收),但指针指向新数组。这可能导致旧切片被垃圾回收,影响内存管理[^3]。 - 扩容机制在Go源码(如`runtime/slice.go`)中有详细实现,确保高效处理各种大小场景。 #### 总结 Go语言slice扩容机制是一个智能的、基于规则的增长策略,优先保证内存效率,同时减少扩容频率。核心规则是:新大小超过当前容量2倍时直接扩容到新大小;否则,对小切片按2倍增长,对大切片按1.25倍(或1/4增量)循环增长。预分配容量是优化性能的关键,建议在开发中根据数据规模提前设置cap,以最小化扩容损耗[^1][^2][^3]。 如果您有更多具体场景或代码示例,我可以进一步分析优化建议。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值