Golang中的slice

390 阅读5分钟

slice的底层数据结构

  • 根据Effective go中的描述,slice就是go对数组的一个包装,提供了更通用、方便、强大的对数组序列的操作。它在运行时的类型是结构体,包含3个属性:
// SliceHeader is the runtime representation of a slice.
// It cannot be used safely or portably and its representation may
// change in a later release.
// Moreover, the Data field is not sufficient to guarantee the data
// it references will not be garbage collected, so programs must keep
// a separate, correctly typed pointer to the underlying data.
type SliceHeader struct {
	Data uintptr
	Len  int
	Cap  int
}

slice数据结构源代码 其中Data是一片连续的内存空间,用于存储切片中的元素,Len指的是初始化时切片的长度,Cap表示的是整个切片的容量

slice的扩容

slice因为底层存储是一片连续的内存空间,那么,在slice的容量不够的时候,就会触发底层容量的扩容。通常,触发slice的扩容,一般都是对slice的append的操作,比如:

var a []int
append(a, 1, 2, 3, 4)

那append之后的底层逻辑是怎样的呢?

// append converts an OAPPEND node to SSA.
// If inplace is false, it converts the OAPPEND expression n to an ssa.Value,
// adds it to s, and returns the Value.
// If inplace is true, it writes the result of the OAPPEND expression n
// back to the slice being appended to, and returns nil.
// inplace MUST be set to false if the slice can be SSA'd.
func (s *state) append(n *Node, inplace bool) *ssa.Value {
	// If inplace is false, process as expression "append(s, e1, e2, e3)":
	//
	// ptr, len, cap := s
	// newlen := len + 3
	// if newlen > cap {
	//     ptr, len, cap = growslice(s, newlen)
	//     newlen = len + 3 // recalculate to avoid a spill
	// }
	// // with write barriers, if needed:
	// *(ptr+len) = e1
	// *(ptr+len+1) = e2
	// *(ptr+len+2) = e3
	// return makeslice(ptr, newlen, cap)
	//
	//
	// If inplace is true, process as statement "s = append(s, e1, e2, e3)":
	//
	// a := &s
	// ptr, len, cap := s
	// newlen := len + 3
	// if uint(newlen) > uint(cap) {
	//    newptr, len, newcap = growslice(ptr, len, cap, newlen)
	//    vardef(a)       // if necessary, advise liveness we are writing a new a
	//    *a.cap = newcap // write before ptr to avoid a spill
	//    *a.ptr = newptr // with write barrier
	// }
	// newlen = len + 3 // recalculate to avoid a spill
	// *a.len = newlen
	// // with write barriers, if needed:
	// *(ptr+len) = e1
	// *(ptr+len+1) = e2
	// *(ptr+len+2) = e3

	et := n.Type.Elem()
	pt := types.NewPtr(et)

    ......
}

通过源代码中注释的伪代码可以看到,append里面包含2个逻辑:

  • 内存空间不足的时候需要进行扩容
  • 扩容后需要将新的数据元素加入到切片

通过append的参数inplace也可以看到,append操作,本身也包含2种情况: 第一种情况是append之后,没有重新赋值给旧的slice

var a []int
fmt.Println(append(a, 1, 2, 4))

第二种情况是append之后,赋值回原来的slice,例如:

var a []int
a = append(a, 1, 2, 4)

这2种不同不同的情况,底层的实现也是不一样的。它们之间最本质的区别就是: 如果append之后重新赋值给了原来的变量a,则go的底层实现逻辑会将原来a的指针修改为扩容后的新地址,新的切片直接覆盖回去,这样就避免了将旧的元素拷贝回新的元素导致的性能问题。

slice的扩容策略

slice在内存扩容时,针对当前slice的容量大小有不同的扩容策略:

  • 如果申请的容量大于了2倍的当前容量,则会使用申请的容量
  • 如果当前slice的长度小于1024,则以double当前容量的形式进行扩容
  • 如果当前slice的长度大于1024,则会以当前容量 * 25%递进式的扩容,直到扩容的容量大于申请的容量

slice扩容源代码

slice使用需要注意的问题

	// 首先定义一个固定长度和容量的数组
	fixa := make([]int, 4, 6)
	fmt.Printf("fixa: %+v, addr: %p \n", fixa, fixa)

	// 对fixa使用下标初始化切片得到fixb
	fixb := fixa[0:2]
	fmt.Printf("fixb: %+v, addr: %p \n", fixb, fixb)

	// 此时fixb不会拷贝原切边fixa中的数据,它只是go创建的一个指向原数组的切片结构体,此时
	//修改 fixb也会修改fixa中的数据, 因为它们的底层指向同一个内存地址
	fixb[0] = 111
	fmt.Printf("修改后fixb: %+v, addr: %p \n", fixb, fixb)
	// 修改后fixb: [111 0], addr: 0xc0000b6000
	fmt.Printf("修改后fixa: %+v, addr: %p \n", fixa, fixa)
	// 修改后fixa: [111 0 0 0], addr: 0xc0000b6000

	// 往fixb中append数据, 不超过容量大小, fixa, fixb还是指向同一个底层的数组
	fixb = append(fixb, 1, 2)
	fmt.Printf("append后fixb: %+v, addr: %p \n", fixa, fixa)
	fmt.Printf("append后fixa: %+v, addr: %p \n", fixb, fixb)
	// append后fixb: [111 0 1 2], addr: 0xc0000b6000
	// append后fixa: [111 0 1 2], addr: 0xc0000b6000

	// 继续往fixb中append数据, 超过容量大小
	fixb = append(fixb, 3, 4, 5)
	fmt.Printf("append超出容量后的fixb: %+v, addr: %p \n", fixb, fixb)
	fmt.Printf("fixa: %+v, addr: %p \n", fixa, fixa)
	// append超出容量后fixb: [111 0 1 2 3 4 5], addr: 0xc00008c060
	// fixa: [111 0 1 2], addr: 0xc0000b6000
	// append超出容量大小 此时fixb已经是一个growslice后被重写分配内存的的新的数组切片, 但是fixa的地址还是原来的那个

	fixb[0] = 222
	fmt.Printf("修改后fixb: %+v, fixa: %+v \n", fixb, fixa)
	// 修改fixb指向的底层数组不再和fixa共享同一块内存地址,所以修改fixb的值fixa的值还是111

结论:

  1. slice切片,当我们使用b := a[0:4]这种方式截取获取一个新的切片b的时候,它其实还是跟a共享同一块内存地址,如果我们要修改b的元素,要尤其注意会影响到a对象中的元素
  2. 针对切片的操作,如果没有超出cap的容量的大小,不会出现扩容。如果进行了操作后,出现了扩容,一定要注意它们之间的影响