1 前言

切片 slice 是 golang 中的一个非常经典的数据结构,它的定位可以类比于其它编程语言中的数组。本篇文章介绍的内容会分为源码实现以及使用 slice 会遇到的问题,走读的源码为 go v1.22.5。

2 源码实现

2.1 数据结构

type slice struct {
	array unsafe.Pointer // 指向起点的地址
	len   int // 切片的长度
	cap   int // 切片的容量
}
  • array :指向了内存地址空间的起点。因为 slice 是存放在连续的内存空间上面,当我们需要获取 slice 中的值时,可以通过索引 index ,在起点的基础上快速进行地址偏移,从而定位到目标元素。
  • len :切片的长度,指的是 slice 逻辑意义上实际存放的长度。
  • cap :切片的容量,指的是 slice 实际物意义上分配了多少个元素的内存空间。

2.2 初始化

func makeslice(et *_type, len, cap int) unsafe.Pointer {
 	// 通过每个元素占用的内存和 cap 计算出所需要的内存总大小
	mem, overflow := math.MulUintptr(et.Size_, uintptr(cap))
	// 倘若容量超限,len 取负值或者 len 超过 cap,直接 panic
	if overflow || mem > maxAlloc || len < 0 || len > cap {
		mem, overflow := math.MulUintptr(et.Size_, uintptr(len))
		// 倘若长度超限,len 取负值或者 len 超过 cap,直接 panic
		if overflow || mem > maxAlloc || len < 0 {
			panicmakeslicelen()
		}
		panicmakeslicecap()
	}

	return mallocgc(mem, et, true)
}

上述方法的核心步骤是:

  1. 通过 math.MulUintptr 方法,根据每个元素的大小以及切片的 cap ,计算出初始化切片所需的内存空间大小。
  2. 如果内存空间超出限制,则直接 panic
  3. 调用 runtime/malloc.go 中的 mallocgc 方法进行内存的分配。

2.3 引用传递

首先需要捋清楚引用传递和值传递的两个区别。

引用传递就是将实例对象的地址信息传递到方法内部去,这样在方法里面就可以通过地址追溯到实例所在的位置,因此执行的一些修改操作就会直接影响到原实例。

值传递,指的就是对实例对象进行一次拷贝,得到一个副本,然后将这个副本传递到方法中去,这样在方法里面所修改的只是对这个副本进行操作,不会影响到原实例对象。

在本次聊的 slice 就是属于引用传递:

func changeSlice(s []int) {
	s[0] = 0
}

func main() {
	s := []int{1, 2, 3}
	fmt.Println(s)
	changeSlice(s)
	fmt.Println(s)
}

代码运行的结果为:

[1 2 3]
[0 2 3]

可以看到,通过方法里面进行修改切片元素,原实例里面的数据也发生了变化。

产生这个结果的原因就在于 slice 的传递是引用传递,并不是值传递,因为每一个切片都对应一个数据结构 slice header ,其中存储了三个字段:

  • array :切片内存空间的起始地址 。
  • len :切片长度。
  • cap :切片容量。

在每次在方法之间传递切片的时候,会对 slice header 进行一次值拷贝,然后将 slice header 传递到方法中去,但是这个 slice header 副本中的 array 和原 slice 指向同一片内存空间,因此在方法内部进行修改的时候,也会对原切片进行修改。

2.4 切片扩容

发生切片扩容的情况是:当前 slicelencap 的值相同时,下一次 append 操作就会发生一次切片的扩容。

func main() {
	s := []int{1, 2, 3}
	fmt.Printf("before len %d, cap %d\n", len(s), cap(s))
	s = append(s, 4)
	fmt.Printf("after len %d, cap %d\n", len(s), cap(s))
}

代码运行的结果为:

before len 3, cap 3
after len 4, cap 6

通过运行结果可以看出,在 lencap 的值相同时,发生了扩容操作。

切片的扩容流程位于 runtime/slice.go 文件下面的 growslicenextslicecap 方法中,其中核心步骤如下:

  1. 如果新的长度小于 0,直接 panic
  2. 如果切片元素的大小为 0 (元素类型为 struct{}),则直接复用一个全局的 zerobase 实例,直接返回。
  3. 如果预期的容量超过原容量的两倍,直接设置新容量为预期容量。
  4. 如果原容量的大小小于 256 字节,则直接采用原容量的两倍作为新容量。
  5. 如果原容量的大小已经大于了 256,则在老容量的基础上扩容 1/4 的比例并且累加上 192 的数值,持续这样处理,直到得到的新容量已经大于等于预期的新容量为止。
  6. 通过 mallocgc 流程,对内存分配单元 mspan 的等级制度,推算得到实际需要申请的内存空间大小。
  7. 调用 mallocgc ,对新切片进行内存初始化。
  8. 调用 memmove 方法,将老切片中的内容拷贝到新切片中。
  9. 返回扩容后的新切片。

func nextslicecap(newLen, oldCap int) int {
	// 初始化新容量为旧容量
	newcap := oldCap
	// 计算两倍旧容量
	doublecap := newcap + newcap
	// 如果新长度大于两倍旧容量,直接返回新长度
	if newLen > doublecap {
		return newLen
	}
  
	// 定义一个阈值,用于区分小切片和大切片
	const threshold = 256
	// 如果老容量小于阈值,直接返回两倍的老容量
	if oldCap < threshold {
		return doublecap
	}
  
	// 对于大切片,使用平滑增长的公式
	for {
		// 平滑增长的公式:新容量增加 (新容量 + 3 * 阈值) 的 1/4
		newcap += (newcap + 3*threshold) >> 2

		if uint(newcap) >= uint(newLen) {
			break
		}
	}

	// 如果新容量计算溢出,设置新容量为新长度
	if newcap <= 0 {
		return newLen
	}
	return newcap
}
func growslice(oldPtr unsafe.Pointer, newLen, oldCap, num int, et *_type) slice {
	// 计算切片的旧长度,即新长度减去新增元素的数量
	oldLen := newLen - num
  
	// ...
  
	if newLen < 0 {
		panic(errorString("growslice: len out of range"))
	}

	if et.Size_ == 0 {
		// 倘若元素大小为 0,则无需分配空间直接返回
		return slice{unsafe.Pointer(&zerobase), newLen, newLen}
	}
  
	// 计算出新的容量
	newcap := nextslicecap(newLen, oldCap)

	var overflow bool
	var lenmem, newlenmem, capmem uintptr
	// 判断元素类型是否包含指针
	noscan := et.PtrBytes == 0
	// 根据元素类型的大小,计算旧长度、新长度和容量的内存大小
	switch {
	// 如果元素大小为 1 字节
	case et.Size_ == 1:
		// 计算旧长度的内存大小
		lenmem = uintptr(oldLen)
		// 计算新长度的内存大小
		newlenmem = uintptr(newLen)
		capmem = roundupsize(uintptr(newcap), noscan)
		// 计算新长度的内存大小
		overflow = uintptr(newcap) > maxAlloc
		newcap = int(capmem)
	// 如果元素大小为指针大小
	case et.Size_ == goarch.PtrSize:
		// 计算旧长度的内存大小
		lenmem = uintptr(oldLen) * goarch.PtrSize
		// 计算新长度的内存大小
		newlenmem = uintptr(newLen) * goarch.PtrSize
		// 计算新容量的内存大小,并向上取整
		capmem = roundupsize(uintptr(newcap)*goarch.PtrSize, noscan)
		// 检查新容量是否超过最大分配限制
		overflow = uintptr(newcap) > maxAlloc/goarch.PtrSize
		// 更新新容量
		newcap = int(capmem / goarch.PtrSize)

	// 如果元素大小是 2 的幂
	case isPowerOfTwo(et.Size_):
		// 计算元素大小的位移量
		var shift uintptr
		if goarch.PtrSize == 8 {
			// 计算 64 位系统下的位移量
			shift = uintptr(sys.TrailingZeros64(uint64(et.Size_))) & 63
		} else {
			// 计算 32 位系统下的位移量
			shift = uintptr(sys.TrailingZeros32(uint32(et.Size_))) & 31
		}
		// 计算旧长度的内存大小
		lenmem = uintptr(oldLen) << shift
		// 计算新长度的内存大小
		newlenmem = uintptr(newLen) << shift
		// 计算新容量的内存大小,并向上取整
		capmem = roundupsize(uintptr(newcap)<<shift, noscan)
		// 检查新容量是否超过最大分配限制
		overflow = uintptr(newcap) > (maxAlloc >> shift)
		// 更新新容量
		newcap = int(capmem >> shift)
		// 重新计算新容量的内存大小
		capmem = uintptr(newcap) << shift
	default:
		// 旧长度的内存大小
		lenmem = uintptr(oldLen) * et.Size_
		// 新长度的内存大小
		newlenmem = uintptr(newLen) * et.Size_
		// 新容量的内存大小
		capmem, overflow = math.MulUintptr(et.Size_, uintptr(newcap))
		// 根据 mallocgc 重新计算容量的内存大小
		capmem = roundupsize(capmem, noscan)
		// 新的容量大小
		newcap = int(capmem / et.Size_)
		capmem = uintptr(newcap) * et.Size_
	}

	// 检查内存空间是否超限
	if overflow || capmem > maxAlloc {
		panic(errorString("growslice: len out of range"))
	}

	var p unsafe.Pointer
	// 判断是否包含指针
	if et.PtrBytes == 0 {
		// 分配 capmem 容量的大小,但是不初始化内存,不处理写屏障
		p = mallocgc(capmem, nil, false)
		memclrNoHeapPointers(add(p, newlenmem), capmem-newlenmem)
	} else {
		p = mallocgc(capmem, et, true)
		if lenmem > 0 && writeBarrier.enabled {
			bulkBarrierPreWriteSrcOnly(uintptr(p), uintptr(oldPtr), lenmem-et.Size_+et.PtrBytes, et)
		}
	}
	// 将旧数组中的数据复制到新数组中
	memmove(p, oldPtr, lenmem)

	// 返回新的切片,包含新的底层数组指针、新长度和新容量
	return slice{p, newLen, newcap}
}

2.5 切片拷贝

slice 的拷贝分为简单拷贝和完整拷贝两种情况。

如果想要实现简单拷贝,只需要对切片的字面量进行赋值传递即可,这样就相当于创建了一个新的 slice header 的实例,但是其中的指针 array 、容量 cap 和长度 len 仍然和老的 slice header 实例相同。

func main() {
	s1 := []int{0, 1, 2, 3, 4}
	s2 := s1
	fmt.Printf("address of s1: %p, address of s2: %p", s1, s2)
}
显示运行结果
address of s1: 0x1400001c180, address of s2: 0x1400001c180

使用切片的截取操作也是属于简单拷贝,通过下面代码可以看出,s1s2 会使用同一个内存空间,只是地址起点位置偏移了一个元素的长度 s1s2 的地址,刚好差 8 个 byte

func main() {
	s1 := []int{0, 1, 2, 3, 4}
	s2 := s1[1:]
	fmt.Printf("address of s1: %p, address of s2: %p", s1, s2)
}
显示运行结果
address of s1: 0x14000128030, address of s2: 0x14000128038

有两种情况会创建完整的复制,也就是创建出一个和 slice 容量大小相等的内存区域,并将原 slice 中的元素一一拷贝到新空间中。

方法一:调用系统 copy 方法,可以看出 s1s2 的地址是相互独立的:

func main() {
	s1 := []int{0, 1, 2, 3, 4}
	s2 := make([]int, len(s1))
	copy(s2, s1)
	fmt.Println(s1, s2)
	fmt.Printf("address of s1: %p, address of s2: %p", s1, s2)
}
显示运行结果
[0 1 2 3 4] [0 1 2 3 4]
address of s1: 0x1400001c180, address of s2: 0x1400001c1b0

方法二:一开始 s1 赋值给 s2 是浅拷贝,s2lencap 分别为 4、4,当执行 append 操作之后,s2 发生了扩容操作,导致将原来切片中的数据赋值到了新的切片中,s1s2 的地址空间不一样了,修改 s2 也不会导致 s1 中的值发生变化。

func main() {
	s1 := []int{0, 1, 2, 3, 4}
	s2 := s1[1:]
	fmt.Printf("address of s1: %p, address of s2: %p\n", s1, s2)
	s2 = append(s2, 5)
	fmt.Printf("address of s1: %p, address of s2: %p\n", s1, s2)
	fmt.Println(s1, s2)
	s2[0] = 10
	fmt.Println(s1, s2)
}
显示运行结果
address of s1: 0x14000116030, address of s2: 0x14000116038
address of s1: 0x14000116030, address of s2: 0x14000120040
[0 1 2 3 4] [1 2 3 4 5]
[0 1 2 3 4] [10 2 3 4 5]

3 问题

3.1 问题1

  • 初始化切片 s 长度和容量均为 10。
  • 在 s 的基础上追加 append 一个元素。

请问经过上述操作后,切片s 的内容、长度以及容量分别是什么?

func main() {
	s := make([]int, 10)
	s = append(s, 10)
	fmt.Printf("s: %v, len of s: %d, cap of s: %d", s, len(s), cap(s))
}
显示运行结果
s: [0 0 0 0 0 0 0 0 0 0 10], len of s: 11, cap of s: 20

3.2 问题2

  • 初始化切片 s 长度为 0,容量为 10。
  • 在 s 的基础上追加 append 一个元素。

请问经过上述操作后,切片s 的内容、长度以及容量分别是什么?

func main() {
	s := make([]int, 0, 10)
	s = append(s, 10)
	fmt.Printf("s: %v, len of s: %d, cap of s: %d", s, len(s), cap(s))
}
显示运行结果
s: [10], len of s: 1, cap of s: 10

3.3 问题3

  • 初始化切片 s 长度为 10,容量为 11。
  • 在 s 的基础上追加 append 一个元素。

请问经过上述操作后,切片s 的内容、长度以及容量分别是什么?

func main() {
	s := make([]int, 10, 11)
	s = append(s, 10)
	fmt.Printf("s: %v, len of s: %d, cap of s: %d", s, len(s), cap(s))
}
显示运行结果
s: [0 0 0 0 0 0 0 0 0 0 10], len of s: 11, cap of s: 11

3.4 问题4

  • 初始化切片 s 长度为 10,容量为 12
  • 截取切片 s index = 8 往后的内容赋给 s1

请问 s1 的内容、长度以及容量分别是什么?

func main() {
	s := make([]int, 10, 12)
	s1 := s[8:]
	fmt.Printf("s1: %v, len of s1: %d, cap of s1: %d", s1, len(s1), cap(s1))
}
显示运行结果
s1: [0 0], len of s1: 2, cap of s1: 4

3.5 问题5

  • 初始化切片 s 长度为 10,容量为 12。
  • 截取切片 s index 为 [8,9) 范围内的元素赋给切片 s1。

请问 s1 的内容、长度以及容量分别是什么?

func main() {
	s := make([]int, 10, 12)
	s1 := s[8:9]
	fmt.Printf("s1: %v, len of s1: %d, cap of s1: %d", s1, len(s1), cap(s1))
}
显示运行结果
s1: [0], len of s1: 1, cap of s1: 4

3.6 问题6

  • 初始化切片 s 长度为 10,容量为 12。
  • 截取切片 s index = 8 往后的内容赋给 s1。
  • 修改 s1[0] 的值。

请问这个修改是否会影响到 s? 此时,s 的内容是什么?

func main() {
	s := make([]int, 10, 12)
	s1 := s[8:]
	s1[0] = -1
	fmt.Printf("s: %v", s)
}
显示运行结果
s: [0 0 0 0 0 0 0 0 -1 0]

3.7 问题7

  • 初始化切片 s 长度为 10,容量为 12

请问,访问 s[10] 是否会越界?

func main() {
	s := make([]int, 10, 12)
	v := s[10]
	fmt.Printf("v: %d", v)
}
显示运行结果
panic: runtime error: index out of range [10] with length 10

3.8 问题8

  • 初始化切片 s 长度为 10,容量为 12。
  • 截取 s 中 index = 8 后面的内容赋给 s1。
  • 在 s1 的基础上追加 []int{10,11,12} 3 个元素。

请问,经过上述操作时候,访问 s[10] 是否会越界?

func main() {
	s := make([]int, 10, 12)
	s1 := s[8:]
	s1 = append(s1, []int{10, 11, 12}...)
	v := s[10]
	fmt.Printf("v: %d", v)
}
显示运行结果
panic: runtime error: index out of range [10] with length 10

3.9 问题9

  • 初始化切片 s 长度为 10,容量为 12。
  • 截取切片 s index = 8 往后的内容赋给 s1。
  • 在方法 changeSlice 中,对 s1[0] 进行修改。

求问,经过上述操作之后,s 的内容是什么?

func changeSlice(s1 []int) {
	s1[0] = -1
}

func main() {
	s := make([]int, 10, 12)
	s1 := s[8:]
	changeSlice(s1)
	fmt.Printf("s: %v", s)
}
显示运行结果
s: [0 0 0 0 0 0 0 0 -1 0]

3.10 问题10

  • 初始化切片 s 长度为 10,容量为 12。
  • 截取切片 s index = 8 往后的内容赋给 s1。
  • 在方法 changeSlice 中,对 s1 进行 apend 追加操作。

请问,经过上述操作后,s 以及 s1 的内容、长度和容量分别是什么?

func changeSlice(s1 []int) {
	s1 = append(s1, 10)
}

func main() {
	s := make([]int, 10, 12)
	s1 := s[8:]
	changeSlice(s1)
	fmt.Printf("s: %v, len of s: %d, cap of s: %d\n", s, len(s), cap(s))
	fmt.Printf("s1: %v, len of s1: %d, cap of s1: %d\n", s1, len(s1), cap(s1))
}
显示运行结果
s: [0 0 0 0 0 0 0 0 0 0], len of s: 10, cap of s: 12
s1: [0 0], len of s1: 2, cap of s1: 4

3.11 问题11

  • 初始化切片 s,内容为 []int{0,1,2,3,4}。
  • 截取 s 中 index = 2 前面的内容(不含s[2]),并在此基础上追加 index = 3 后面的内容。

请问,经过上述操作后,s 的内容、长度和内容分别是什么?此时访问 s[4] 是否会越界?

func main() {
	s := []int{0, 1, 2, 3, 4}
	s = append(s[:2], s[3:]...)
	fmt.Printf("s: %v, len: %d, cap: %d\n", s, len(s), cap(s))
	v := s[4]
	fmt.Printf("v: %v\n", v)
}
显示运行结果
s: [0 1 3 4], len: 4, cap: 5
panic: runtime error: index out of range [4] with length 4

3.12 问题12

  • 初始化切片 s 长度和容量均为 512。
  • 在 s 的基础上追加 append 一个元素。

请问经过上述操作后,切片s 的内容、长度以及容量分别是什么?

func main() {
	s := make([]int, 512)
	s = append(s, 1)
	fmt.Printf("len of s: %d, cap of s: %d", len(s), cap(s))
}
显示运行结果
len of s: 513, cap of s: 848

4 总结

  • slice 是一个长度可变的连续数据序列,在实现上基于一个 slice header 组成,其中包含的字段包括:指向内存空间地址起点的指针 array、一个表示了存储数据长度的 len 和分配空间长度的 cap
  • 由于 slice 在传递过程中,本质上传递的是 slice header 实例中的内存地址 array,因此属于引用传递。
  • slice 在扩容时,遵循如下机制:
    • 如果扩容时预期的新容量超过原容量的两倍,直接取预期的新容量。
    • 如果原容量小于 256,直接取原容量的两倍作为新容量。
    • 如果原容量大于等于 256,在原容量 n 的基础上循环执行 n += (n+3*256)/4 的操作,直到 n 大于等于预期新容量,并取 n 作为新容量。
  • 当执行 copy 系统调用或者副本的 slice 发生了扩容的操作时,会将副本的 slice 拷贝到新的切片中。

5 参考