Golang源码探究 —— Slice

Golang中的切片算是在代码中使用频率最高的数据结构了,因此了解切片的底层实现可以让我们对切片的使用可以更加熟练和灵活。

go的版本:go version go1.16.8 windows/amd64

1、切片的数据结构

Golang中的切片定义在runtime包下的slice.go中,它在底层为一个结构体:

type slice struct {
	array unsafe.Pointer        // 指向数据缓冲区的指针
	len   int                   // 当前数据缓冲区使用的size
	cap   int                   // 当前缓冲区的容量
}

slice中包含三个字段,array是指向数据数组的指针,len是当前数组使用的大小,cap是整个数组的大小。

在下面的代码中打印slice的内存大小,可以看到slice占用的内存大小为24byte,三个字段各占8byte的内存。

package main

import (
    "fmt"
    "unsafe"
)

func main() {
    var s []int 
    fmt.Println(unsafe.Sizeof(s))

    var a int
    fmt.Println(unsafe.Sizeof(a))
}

// 打印结果
24
8

由于slice定义在runtime包中,我们无法使用,因此可以自己定义一个Slice来将sliec强转为我们的Slice:

package main

import (
    "fmt"
    "unsafe"
)

type Slice struct {
    array unsafe.Pointer
    len int
    cap int
}

func main() {
    s := []int{1, 2, 3, 4, 5}
    for i, v := range s {
        fmt.Printf("index:%d, value:%d\n", i, v)
    }

    sptr := (*Slice)(unsafe.Pointer(&s))
    fmt.Println(sptr.len)
    fmt.Println(sptr.cap)

    arr := (*[5]int)(sptr.array)
    for i := 0; i < 5; i++ {
        fmt.Printf("%d ", arr[i])
    }
    fmt.Println()
}

// 打印结果
index:0, value:1
index:1, value:2
index:2, value:3
index:3, value:4
index:4, value:5
5
5
1 2 3 4 5 

 

2、创建切片

makeslice函数用于创建一个切片。

func makeslice(et *_type, len, cap int) unsafe.Pointer {
    // 该函数的作用是计算需要的空间大小,并检查是否溢出,
    // 使用et.size * cap,mem为相乘的结果,overflow为是否溢出,bool类型
	mem, overflow := math.MulUintptr(et.size, uintptr(cap))
	if overflow || mem > maxAlloc || len < 0 || len > cap {
		mem, overflow := math.MulUintptr(et.size, uintptr(len))
		if overflow || mem > maxAlloc || len < 0 {
			panicmakeslicelen()
		}
		panicmakeslicecap()
	}

    // 分配内存 
    // 小对象从当前P的cache中空闲数据中分配
    // 大的对象 (size > 32KB) 直接从heap中分配
    // runtime/malloc.go
	return mallocgc(mem, et, true)
}

func makeslice64(et *_type, len64, cap64 int64) unsafe.Pointer {
	len := int(len64)
	if int64(len) != len64 {
		panicmakeslicelen()
	}

	cap := int(cap64)
	if int64(cap) != cap64 {
		panicmakeslicecap()
	}

	return makeslice(et, len, cap)
}

可以看到在makeslice中做的事情就是先检查传入的参数的合法性,以及计算需要的内存是否合法,不合法就panic。最后分配内存,返回指针。但是在上面的代码中应该只是创建了底层数组的内存,slice结构体并没有创建。

 

2、Slice的动态增长策略

growslice函数处理append期间的数组动态增长。

// et为切片存放的数据的类型,old为旧的slice,cap为期望的容量大小
func growslice(et *_type, old slice, cap int) slice {
    ...   // 调试相关

	if cap < old.cap {
		panic(errorString("growslice: cap out of range"))
	}

	if et.size == 0 {
		return slice{unsafe.Pointer(&zerobase), old.len, cap}
	}

    // newcap为新申请的元素个数
    // doublecap为原切片的容量的两倍
	newcap := old.cap
	doublecap := newcap + newcap
    // 期望的容量大于两倍旧片的容量,则分配期望的容量大小
	if cap > doublecap {
		newcap = cap
	} else {
        // 如果原切片的容量大小小于1024,直接分配两倍的原切片的cap大小的容量
        // 否则,则分配 1.25*原切片cap大小的容量
		if old.cap < 1024 {
			newcap = doublecap
		} else {
			for 0 < newcap && newcap < cap {
				newcap += newcap / 4
			}

			if newcap <= 0 {
				newcap = cap
			}
		}
	}
	
    // 上面只是计算了所需的元素的个数
    // 下面是计算需要的内存空间大小, cap * eletype.size
    // maxAlloc为最大申请的内存大小,如果要申请的内存大于maxAlloc,则overflow为true
	var overflow bool
	var lenmem, newlenmem, capmem uintptr
	switch {
	case et.size == 1:
		lenmem = uintptr(old.len)
		newlenmem = uintptr(cap)
		capmem = roundupsize(uintptr(newcap))
		overflow = uintptr(newcap) > maxAlloc
		newcap = int(capmem)
	case et.size == sys.PtrSize:
		lenmem = uintptr(old.len) * sys.PtrSize
		newlenmem = uintptr(cap) * sys.PtrSize
		capmem = roundupsize(uintptr(newcap) * sys.PtrSize)
		overflow = uintptr(newcap) > maxAlloc/sys.PtrSize
		newcap = int(capmem / sys.PtrSize)
	case isPowerOfTwo(et.size):
		var shift uintptr
		if sys.PtrSize == 8 {
			// Mask shift for better code generation.
			shift = uintptr(sys.Ctz64(uint64(et.size))) & 63
		} else {
			shift = uintptr(sys.Ctz32(uint32(et.size))) & 31
		}
		lenmem = uintptr(old.len) << shift
		newlenmem = uintptr(cap) << shift
		capmem = roundupsize(uintptr(newcap) << shift)
		overflow = uintptr(newcap) > (maxAlloc >> shift)
		newcap = int(capmem >> shift)
	default:
		lenmem = uintptr(old.len) * et.size
		newlenmem = uintptr(cap) * et.size
		capmem, overflow = math.MulUintptr(et.size, uintptr(newcap))
		capmem = roundupsize(capmem)
		newcap = int(capmem / et.size)
	}
	
    // 如果要申请的内存太大,panic
	if overflow || capmem > maxAlloc {
		panic(errorString("growslice: cap out of range"))
	}

    // 下面是分配内存
	var p unsafe.Pointer
	if et.ptrdata == 0 {
		p = mallocgc(capmem, nil, false)
		// The append() that calls growslice is going to overwrite from old.len to cap (which will be the new length).
		// Only clear the part that will not be overwritten.
		memclrNoHeapPointers(add(p, newlenmem), capmem-newlenmem)
	} else {
		// Note: can't use rawmem (which avoids zeroing of memory), because then GC can scan uninitialized memory.
		p = mallocgc(capmem, et, true)
		if lenmem > 0 && writeBarrier.enabled {
			// Only shade the pointers in old.array since we know the destination slice p
			// only contains nil pointers because it has been cleared during alloc.
			bulkBarrierPreWriteSrcOnly(uintptr(p), uintptr(old.array), lenmem-et.size+et.ptrdata)
		}
	}
	memmove(p, old.array, lenmem)

	return slice{p, old.len, newcap}
}

扩容时的操作是:

  • 先计算需要的数组的大小:

    (1) 如果需要容量(cap)大于原切片old.cap的两倍(newcap > 2 * oldcap),则直接扩容为cap;

    (2) 否则( oldcap < newcap < 2 * oldcap)

      如果原切片old.cap小于1024,则直接扩容为old.cap * 2的容量;
    
      如果原切片old.cap大于等于1024,则扩容为old.cap * 1.25 的容量。
    
  • 计算完需要的数组容量后,再计算需要的内存大小,也就是数组存放的元素的大小乘于容量。

  • 最后申请内容,拷贝旧内存。

注意:newcap并不一定是最终的新切片的容量,在申请内存时,要申请的内存为"容量"*"元素大小"也就是mem=newcap* size,而之后会对meme进行roundup,也就是对其进行一个向上的取整,使得其大小为8的整数倍。因此,最终的容量不一定为newcap,会大于等于newcap

旧版本的问题:旧版本的扩容策略存在一定的问题,比如当oldcap为1023时,则newcap=oldcap*2,即2046;而oldcap为1024时,newcap=1.25*oldcap,即1280,反而更小了,呈现出一个断崖式的下跌。也就是在[0,1024)之间是单调递增的,在[1024,+∞]之间是单调递增的,但是在[0, +∞]之间不是单调递增的
如下图所示:
在这里插入图片描述

在go1.18后,容量的计算发生了变化, 比如在1.20版本中:

	newcap := oldCap
	doublecap := newcap + newcap
	if newLen > doublecap {
		newcap = newLen
	} else {
		const threshold = 256
		if oldCap < threshold {
			newcap = doublecap
		} else {
			for 0 < newcap && newcap < newLen {
				newcap += (newcap + 3*threshold) / 4
			}
			if newcap <= 0 {
				newcap = newLen
			}
		}
	}

定义了一个阈值(threshold)为256
(1) 如果新的切片的长度大于原切片容量的两倍,则使用新的切片的长度作为新切片的容量
(2) 否则,则按照阈值(threshold = 256)进行扩容:

  • 如果旧的的容量小于阈值,则新的容量为两倍的旧的容量, newcap = 2 * oldcap
  • 否则,新的容量为旧的容量的1.25倍加上172, newcap = 1.25*oldcap + 172

如下图所示,新的容量计算方法增加更加平滑
在这里插入图片描述

3、切片的注意事项

  1. 切片的传递也是值传递,传递的是结构体的三个字段。将一个切片传递给一个函数的形参后,实参和形参中的array指向的数组空间是一样的。但是如果在函数中修改了切片,如果底层数组空间足够,就不会分配新的内存,因此就算在函数中添加了元素,形参和实参指向的数组是一样的。但是形参的len被修改了,而实参的len还是原来的数据。如果底层空间不足,那么就会分配新的空间,此时,形参和实参指向的底层数组也不一样了,len和cap也会不同。在一个函数中传递切片时,如果在函数中只是修改了切片的元素,没有添加或删除元素,可以直接传入切片;如添加或删除了元素,就要传入切片的指针或者将修改后的切片返回(就像append一样)来解决。
  2. nil slice底层指向数组的指针为nil,而empty slice底层数组指针指向一个长度为0的数组。因此在判断一个slice是否有数据时应该使用 len(s) == 0,而不是s == nil .一般的用法是nil slice表示切片不存在,empty slice表示切片中没有元素。

在reflect/value.go中定义了一个SliceHeader类型的结构提,它是切片的运行时表现:

type SliceHeader struct {
	Data uintptr                           // 底层数组的指针
	Len  int                               // 数组长度
	Cap  int                               // 数组容量
}

下面看几个代码来说明上面的第一个注意事项:

先看一段程序:

package main

import (
	"fmt"
)

func main() {

	s := []int{10, 20, 30}
	fmt.Println(s)

	changeSlice(s)

	fmt.Println(s)
}

func changeSlice(s []int) {
    
	for i := 0; i < 10; i++ {
		s = append(s, i)
	}

	fmt.Println(s)
}

// 运行结果
[10 20 30]
[10 20 30 0 1 2 3 4 5 6 7 8 9]
[10 20 30]

根据上面的程序可以看到,在changeSlice中对切片进行了修改,但是在main函数中打印的确实是没有修改的。我们可以猜测一下原因:因为在changeSlice函数中添加了数据,数组的空间不足,因此申请了新的空间,然后changeSlice中的s的Data指向了新的空间,但是在main函数中的s还是指向原来的空间,因此导致了man函数中的s还是原来的数据。如下图所示:

在这里插入图片描述

可以使用程序来证实一下:

package main

import (
	"fmt"
	"reflect"
	"unsafe"
)

func main() {

	s := []int{10, 20, 30}
	fmt.Println("--------main--------------")
	fmt.Println(s)
	fmt.Println(unsafe.Pointer(&s))
	sptr := (*reflect.SliceHeader)(unsafe.Pointer(&s))
	fmt.Println(unsafe.Pointer(sptr.Data))



	changeSlice(s)

	fmt.Println(s)

}

func changeSlice(s []int) {
	fmt.Println("--------changeSlice--------------")
	fmt.Println(s)
	fmt.Println(unsafe.Pointer(&s))
	sptr := (*reflect.SliceHeader)(unsafe.Pointer(&s))
	fmt.Println(unsafe.Pointer(sptr.Data))

	for i := 0; i < 10; i++ {
		s = append(s, i)
	}

	fmt.Println(s)
	fmt.Println(unsafe.Pointer(&s))
	sptr1 := (*reflect.SliceHeader)(unsafe.Pointer(&s))
	fmt.Println(unsafe.Pointer(sptr1.Data))
}

// 运行结果
--------main--------------
[10 20 30]
0xc000004078
0xc000014150
--------changeSlice--------------
[10 20 30]
0xc0000040a8
0xc000014150
[10 20 30 0 1 2 3 4 5 6 7 8 9]
0xc0000040a8
0xc000092000
[10 20 30]

可以看到,首先,main函数和changeSlice函数中的两个s的地址是不一样的,但是它们指向的底层的数据块的地址是一样的。而且在changeSlice函数中添加了数据之后,申请了新的内存。

但是如果我们提前设置足够的容量呢?那样就不会开辟新的数据,就算修改之后,两个s的Data指向的依然是同一块内存,因此在main函数打印修改后的数据应该是修改后的。事实真的是这样吗?

package main

import (
	"fmt"
	"reflect"
	"unsafe"
)

func main() {

	s := make([]int, 3, 13)
	s[0] = 10
	s[1] = 20
	s[2] = 30

	fmt.Println("--------main--------------")
	fmt.Println(s)
	fmt.Println(unsafe.Pointer(&s))
	sptr := (*reflect.SliceHeader)(unsafe.Pointer(&s))
	fmt.Println(unsafe.Pointer(sptr.Data))

	changeSlice(s)

	fmt.Println(s)

}

func changeSlice(s []int) {
	fmt.Println("--------changeSlice--------------")
	fmt.Println(s)
	fmt.Println(unsafe.Pointer(&s))
	sptr := (*reflect.SliceHeader)(unsafe.Pointer(&s))
	fmt.Println(unsafe.Pointer(sptr.Data))

	for i := 0; i < 10; i++ {
		s = append(s, i)
	}

	fmt.Println(s)
	fmt.Println(unsafe.Pointer(&s))
	sptr1 := (*reflect.SliceHeader)(unsafe.Pointer(&s))
	fmt.Println(unsafe.Pointer(sptr1.Data))
}

// 运行结果
--------main--------------
[10 20 30]
0xc000004078
0xc00001a0e0
--------changeSlice--------------
[10 20 30]
0xc0000040a8
0xc00001a0e0
[10 20 30 0 1 2 3 4 5 6 7 8 9]
0xc0000040a8
0xc00001a0e0
[10 20 30]

在上面的代码中,在创建切片时,就指定了容量为13 ,但是,结果出乎意料。可以看到,打印的依然是原来的数据。而且在changeSlice函数中添加了数据之后,指向的内存地址并没有改变,那么为什么打印的还是只有三个元素呢?

可以再来猜测一下,虽然底层数据是修改了,但是由于只在changeSlice中修改了s中的Len和Cap,而main函数中的s的Len和Cap并没有改变,因此虽然数据是添加成功了,但是main函数中的s的Len和Cap依然是3和13,因此就只打印了三个元素。如下图:

在这里插入图片描述

同样使用代码来证实一下:

package main

import (
	"fmt"
	"reflect"
	"unsafe"
)

func main() {

	s := make([]int, 3, 13)
	s[0] = 10
	s[1] = 20
	s[2] = 30

	fmt.Println("--------main--------------")
	fmt.Println(s)
	fmt.Println(unsafe.Pointer(&s))
	sptr := (*reflect.SliceHeader)(unsafe.Pointer(&s))
	fmt.Println(unsafe.Pointer(sptr.Data))

	changeSlice(s)

	fmt.Println("--------main--------------")
	fmt.Println(s)
	fmt.Println(sptr.Len)
	fmt.Println(sptr.Cap)

	sptr.Len = 13
	fmt.Println(s)
}

func changeSlice(s []int) {
	fmt.Println("--------changeSlice--------------")
	fmt.Println(s)
	fmt.Println(unsafe.Pointer(&s))
	sptr := (*reflect.SliceHeader)(unsafe.Pointer(&s))
	fmt.Println(unsafe.Pointer(sptr.Data))

	for i := 0; i < 10; i++ {
		s = append(s, i)
	}

	fmt.Println(s)
	fmt.Println(unsafe.Pointer(&s))
	fmt.Println(unsafe.Pointer(sptr.Data))

	fmt.Println(sptr.Len)
	fmt.Println(sptr.Cap)
}

// 运行结果
--------main--------------
[10 20 30]
0xc000004078
0xc00001a0e0
--------changeSlice--------------
[10 20 30]
0xc0000040a8
0xc00001a0e0
[10 20 30 0 1 2 3 4 5 6 7 8 9]
0xc0000040a8
0xc00001a0e0
13
13
--------main--------------
[10 20 30]
3
13
[10 20 30 0 1 2 3 4 5 6 7 8 9]

可以看到,在调用完changeSlice之后,main函数中的s的Len依然是3,但是底层的数据已经被改变了。因此手动将main函数中的s的Len改为13后也成功打印了预期的结果。这也是为什么go内置的append函数在追加了数据之后要返回一个切片的原因。

在一个函数中通过参数传递的方式添加或删除切片的元素时要传入指针或者返回修改后的切片:

package main

import "fmt"

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

   //changeSlice1(&s)
   s = changeSlice2(s)

    fmt.Println(s)
}

func changeSlice1(s *[]int) {
    for i := 0; i < 10; i++ {
        *s = append(*s, i)
    }

    fmt.Println(*s)
}

func changeSlice2(s []int) []int {
    for i := 0; i < 10; i++ {
        s = append(s, i)
    }

    fmt.Println(s)

    return s
}

// 调用 changeSlice1 和 changeSlice2打印的结果都为:
[1 2 3 0 1 2 3 4 5 6 7 8 9]
[1 2 3 0 1 2 3 4 5 6 7 8 9]
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值