Golang Slice扩容研究、实验及论证

简介

阅读、了解slice相关资料时,发现资料之间的内容各不相同,因此自己做个实验验证一下。

内容

一、验证设计

方案一:

  • 1、用空结构体 struct{} 做值,创建长度(len)、容量(cap)都是 0 的切片
  • 2、不停的通过 append 向切片里面追加数据,计算 容量(cap) 变化和比例

方案二:

  • 1、用基础类型 int 做值,创建长度(len)、容量(cap)都是 0 的切片
    方案一 的区别是使用 int、string 等基础数据类型。
  • 2、不停的通过 append 向切片里面追加数据,计算 容量(cap) 变化和比例

二、验证过程

实验环境:

  • Windows 11 x64
  • go version go1.18beta1 windows/amd64

2.1、方案一

验证代码:

package main

import "fmt"

func main() {
	s := make([]struct{}, 0)
	c1 := cap(s)
	tag1 := true
	tag2 := true
	for i := 0; i < 4096; i++ {
		s = append(s, struct{}{})
		if c1 != cap(s) {
			if c1 == 0 {
				fmt.Println("空数组")
			} else if (c1*2 == cap(s)) && tag1 {
				fmt.Printf("2倍成长 i=%d\r\n", i)
				tag1 = false
			} else if c1*2 > cap(s) && tag2 {
				fmt.Printf("%f倍成长 cap [%d -> %d], i=%d\r\n", float64(cap(s))/float64(c1), c1, cap(s), i)
				tag2 = false
			} else {
				fmt.Printf("%f倍成长 cap [%d -> %d], i=%d\r\n", float64(cap(s))/float64(c1), c1, cap(s), i)
			}
		}
		c1 = cap(s)
	}
}

输出内容:

空数组
2倍成长 i=1
1.500000倍成长 cap [2 -> 3], i=2
1.333333倍成长 cap [3 -> 4], i=3
1.250000倍成长 cap [4 -> 5], i=4
1.200000倍成长 cap [5 -> 6], i=5
... ... 
1.000244倍成长 cap [4093 -> 4094], i=4093
1.000244倍成长 cap [4094 -> 4095], i=4094
1.000244倍成长 cap [4095 -> 4096], i=4095

2.2、方案二

验证代码:

package main

import "fmt"

func main() {
	s := make([]int, 0)
	c1 := cap(s)
	tag1 := true
	tag2 := true
	for i := 0; i < 999999; i++ {
		s = append(s, 1)
		if c1 != cap(s) {
			if c1 == 0 {
				fmt.Println("空数组")
			} else if (c1*2 == cap(s)) && tag1 {
				fmt.Printf("2倍成长 i=%d\r\n", i)
				tag1 = false
			} else if c1*2 > cap(s) && tag2 {
				fmt.Printf("%f倍成长 cap [%d -> %d], i=%d\r\n", float64(cap(s))/float64(c1), c1, cap(s), i)
				tag2 = false
			} else {
				fmt.Printf("%f倍成长 cap [%d -> %d], i=%d\r\n", float64(cap(s))/float64(c1), c1, cap(s), i)
			}
		}
		c1 = cap(s)
	}
}

int 型切片输出内容:

空数组
2倍成长 i=1
2.000000倍成长 cap [2 -> 4], i=2
2.000000倍成长 cap [4 -> 8], i=4
2.000000倍成长 cap [8 -> 16], i=8
... ...
2.000000倍成长 cap [128 -> 256], i=128
2.000000倍成长 cap [256 -> 512], i=256
1.656250倍成长 cap [512 -> 848], i=512
1.509434倍成长 cap [848 -> 1280], i=848
... ...
1.250474倍成长 cap [539648 -> 674816], i=539648
1.250379倍成长 cap [674816 -> 843776], i=674816
1.251214倍成长 cap [843776 -> 1055744], i=843776

bool 型切片输出内容:

空数组
1.000000倍成长 cap [8 -> 8], i=1
2倍成长 i=8
2.000000倍成长 cap [16 -> 32], i=16
... ...
2.000000倍成长 cap [256 -> 512], i=256
1.750000倍成长 cap [512 -> 896], i=512
1.571429倍成长 cap [896 -> 1408], i=896
... ...
1.259259倍成长 cap [442368 -> 557056], i=442368
1.264706倍成长 cap [557056 -> 704512], i=557056
1.255814倍成长 cap [704512 -> 884736], i=704512
1.259259倍成长 cap [884736 -> 1114112], i=884736

string 型切片输出内容:

空数组
2倍成长 i=1
2.000000倍成长 cap [2 -> 4], i=2
2.000000倍成长 cap [4 -> 8], i=4
... ...
2.000000倍成长 cap [256 -> 512], i=256
1.656250倍成长 cap [512 -> 848], i=512
1.509434倍成长 cap [848 -> 1280], i=848
... ...
1.250903倍成长 cap [567296 -> 709632], i=567296
1.250361倍成长 cap [709632 -> 887296], i=709632
1.250433倍成长 cap [887296 -> 1109504], i=887296

float64 型切片输出内容:

空数组
2倍成长 i=1
2.000000倍成长 cap [2 -> 4], i=2
2.000000倍成长 cap [4 -> 8], i=4
... ...
2.000000倍成长 cap [256 -> 512], i=256
1.656250倍成长 cap [512 -> 848], i=512
1.509434倍成长 cap [848 -> 1280], i=848
... ...
1.250474倍成长 cap [539648 -> 674816], i=539648
1.250379倍成长 cap [674816 -> 843776], i=674816
1.251214倍成长 cap [843776 -> 1055744], i=843776

将计算次数调整为 2^32,输出内容:

空数组
2倍成长 i=1
2.000000倍成长 cap [2 -> 4], i=2
... ...
2.000000倍成长 cap [128 -> 256], i=128
2.000000倍成长 cap [256 -> 512], i=256
1.656250倍成长 cap [512 -> 848], i=512
... ...
1.250000倍成长 cap [547171328 -> 683964416], i=547171328
1.250001倍成长 cap [683964416 -> 854956032], i=683964416
1.250001倍成长 cap [854956032 -> 1068695552], i=854956032
runtime: VirtualAlloc of 10686963712 bytes failed with errno=1455
fatal error: out of memory

runtime stack:

三、实验结果

  • 1、通过对比 方案一方案二,可以发现:空结构体(struct{})的空间每次加1,不按比例增加。
  • 2、通过对比 方案二 的几次实验,可以发现:
    • 2.1、对go的基础类型来说,切片的空间,整体上是按比例增长的,最总趋近于 1.25(但不是绝对等于)
    • 2.2、对于不同的基础类型,空间增长时的比例变化,趋势(快慢)各不相同

结果表明:网上大多数博客、文章关于golang slice空间增长的相关都是有问题的,不能解释实验结果。

接下来需要分析源码。

四、源码分析

源码位置:go/src/runtime/slice.go
关键函数:func growslice(et *_type, old slice, cap int) slice
源码总结:

  • 1、 首先:容量翻倍
  • 2、 如果翻倍以后的容量,还是比目标切片的cap(容量)少:直接使用目标切片的cap(容量)
  • 3、 如果翻倍以后的容量足够大:
    • 3.1、 旧slice的容量小于1024,直接采用翻倍的值
    • 3.2、 旧silice的容量大于等于1024:
      • 3.2.1 容量按照 5/4 等比的方式增加,直到超过目标slice的容量
      • 3.2.2 经过上一步处理后(3.2.1),旧切片容量仍然小于等于0,使用目标切片的cap(容量)

关键源码解析:

func growslice(et *_type, old slice, cap int) slice {
   // ... 忽略上面代码 ...
   
	newcap := old.cap
	doublecap := newcap + newcap // 容量翻倍
	if cap > doublecap {
		// 1. 如果翻倍以后的容量,还是比目标切片的cap(容量)少,直接使用目标切片的cap(容量)
		newcap = cap
	} else {
		// 2. 翻倍以后的容量足够大
		if old.cap < 1024 {
			// 2.1 旧slice的容量小于1024,直接采用翻倍的值
			newcap = doublecap
		} else {
			// 2.2 旧slice的容量大于等于1024,如果:
			//    2.2.1 容量按照 `1/4` 等比数列的方式增加,直到超过目标slice的容量
			for 0 < newcap && newcap < cap {
				newcap += newcap / 4
			}
			//    2.2.2 旧切片容量小于等于0,使用目标切片的cap(容量)
			if newcap <= 0 {
				newcap = cap
			}
		}
	}
    
   //... 忽略下面代码 ... 
}

五、最终结论

  • 1、对于空结构体 struct {},空间增长每次增加 1
  • 2、对于基础数据类型:
    • 2.1、翻倍后的容量仍然不够,直接采用目标切片(slice)的容量
      这里要重点留意
    • 2.2、旧切片长度小于 1024 时,容量直接翻倍
    • 2.3、否则(旧切片长度大于等于 1024 时),
      • 2.3.1 容量按照 5/4 等比的方式增加,直到超过目标slice的容量

        这里可以 等价成 比例为 5/4 的等比数列,注意,并不是计算一次,而是反复乘以 5/4 直到容量超过目标值。

        所以严格来说新slice的空间并不是 1.25 倍,而是 1.25 为底的等比数列求和后的结果

      • 2.3.2 经过上一步处理后(2.3.2),旧切片容量仍然小于等于0,使用目标切片的cap(容量)

内存对齐影响切片容量

上面说的都是切片的容量(cap),实际分配的内存和容量还受到内存对齐的影响。

关键源码函数:func makeslicecopy(et *_type, tolen int, fromlen int, from unsafe.Pointer) unsafe.Pointer

通过基础测试代码初步发现,并通过进阶测试代码进一步证实append时的内存对齐规律:

  • 1、空结构体做为值时,追加元素不会触发内存对齐
  • 2、基础类型做为值时,内存按照:
    • 2.1、int64: cap按照2的倍数对齐
    • 2.2、int32:cap按照2的倍数对齐
    • 2.3、bool:cap按照8的倍数对齐
    • 2.4、string:没发现规律
      string进行2048次运算以后,cap增长的过程:
      5 6 7 8 9 10 11 12 13 14 15 16 18 20 22 24 26 28 30 32 36 40 44 48 56 64 72 80 88 96 112 128 144 168 192 200 216 256 304 336 384 408 424 432 512 592 608 640 680 768 848 896 1024 1152 1192 1280 1360 1536 1704 1792 2048 2560
      

基础测试代码:

package main

import "fmt"

type Null struct {
}

func main() {
	s0 := []Null{Null{}, Null{}}
	s0 = append(s0, 
		[]Null{Null{}, Null{}, Null{}, Null{}, Null{}, Null{}, Null{}}... // 追加的切片,测试时里面的元素数量可以一个个的增加
	)
	fmt.Printf("len=%d, cap=%d\r\n", len(s0), cap(s0))

	s := []int64{1, 2}
	s = append(s, []int64{3, 4, 5, 6, 7, 8, 9}...)
	fmt.Printf("len=%d, cap=%d\r\n", len(s), cap(s))

	s2 := []bool{true, true}
	s2 = append(s2, []bool{true, true, true, true, true, true, true}...)
	fmt.Printf("len=%d, cap=%d\r\n", len(s2), cap(s2))
}

进阶测试代码:

package main

import "fmt"

type Null struct {
}

func main() {
	n := 2048
	s0 := []Null{Null{}, Null{}}
	for i := 3; i < n; i++ {
		if ap1(s0, make([]Null, i)) {
			fmt.Printf("Null => 找到不满足条件的数据,在长度 %d 次时触发\r\n", i)
			return
		}
	}

	s1 := []int64{1, 2}
	for i := 3; i < n; i++ {
		if ap2(s1, make([]int64, i)) {
			fmt.Printf("int64 => 找到不满足条件的数据,在追加长度 %d 时触发\r\n", i)
			return
		}
	}

	s3 := []int32{1, 2}
	for i := 3; i < n; i++ {
		if ap3(s3, make([]int32, i)) {
			fmt.Printf("int32 => 找到不满足条件的数据,在追加长度 %d 次时触发\r\n", i)
			return
		}
	}

	s2 := []bool{true, true}
	for i := 3; i < n; i++ {
		if ap4(s2, make([]bool, i)) {
			fmt.Printf("bool => 找到不满足条件的数据,在追加长度 %d 次时触发\r\n", i)
			return
		}
	}

	s4 := []string{"a", "b"}
	for i := 3; i < n; i++ {
		if ap5(s4, make([]string, i)) {
			fmt.Printf("string => 找到不满足条件的数据,在追加长度 %d 次时触发\r\n", i)
			return
		}
	}
}

func ap1(old, new []Null) bool {
	old = append(old, new...)
	//fmt.Printf("struct{} : len=%d, cap=%d\r\n", len(old), cap(old))
	return cap(old) != len(old)
}

func ap2(old, new []int64) bool {
	old = append(old, new...)
	//fmt.Printf("int64 : len=%d, cap=%d\r\n", len(old), cap(old))
	return (cap(old) % 2) != 0
}

func ap3(old, new []int32) bool {
	old = append(old, new...)
	//fmt.Printf("int32 : len=%d, cap=%d\r\n", len(old), cap(old))
	return (cap(old) % 2) != 0
}

func ap4(old, new []bool) bool {
	old = append(old, new...)
	//fmt.Printf("bool : len=%d, cap=%d\r\n", len(old), cap(old))
	return (cap(old) % 8) != 0
}

func ap5(old, new []string) bool {
	old = append(old, new...)
	//fmt.Printf("string : len=%d, cap=%d\r\n", len(old), cap(old))
	return cap(old) != len(old)
}

参考资料及附录


本文由 qingchuwudi 译制或原创,除非另有声明,在不与原著版权冲突的前提下,本作品采用署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0) 进行许可。

知识共享许可协议

posted @ 2022-02-18 15:40  qingchuwudi  阅读(201)  评论(0编辑  收藏  举报