前言
日常开发中最常用的数据结构应该就是切片Slice和Map了,本文围绕着Slice的数据结构、扩容方法以及使用过程中需要注意的地方做了深入的介绍
介绍Slice
Slice切片表示一个具有相同数据类型元素的的序列(当然数据类型也可以是interface{}),切片的长度可变,底层使用数组实现。通常写成[]T,其中元素的类型都是T
数据结构
切片在编译期是 cmd/compile/internal/types/type.go 包下的Slice类型,而它的运行时的数据结构位于 reflect.SliceHeader
type SliceHeader struct {
Data uintptr
Len int
Cap int
}
复制代码
看一下内存占用,SliceHeader结构占用24个字节
log.Printf("reflect.SliceHeader结构占用的内存大小为:[%d]",unsafe.Sizeof(reflect.SliceHeader{}))
复制代码
编译期间使用cmd/compile/internal/types/type.go包下的NewSlice方法创建切片
func NewSlice(elem *Type) *Type {
if t := elem.Cache.slice; t != nil {
if t.Elem() != elem {
Fatalf("elem mismatch")
}
return t
}
t := New(TSLICE)
t.Extra = Slice{Elem: elem}
elem.Cache.slice = t
return t
}
复制代码
获得切片地址和底层数组的指针地址
日常开发不会经常使用到,但可以做了解
getSlicePtr() 函数用于打印切片地址和切片底层数组地址,下文会经常用到
const(
basePrintInfo = "%s切片的指针地址:[%v],切片数组地址:[%v]"
)
func getSlicePtr(name string, s1 *[]int) { // 需要传slice的指针
s2 := (*reflect.SliceHeader)(unsafe.Pointer(s1))
log.Printf(basePrintInfo, name, unsafe.Pointer(s1), unsafe.Pointer(s2.Data))
}
复制代码
如何定义切片
使用语法糖
s1 := []int{}
复制代码
使用make函数
第一个参数[]int表示切片的类型是int,第二三个参数分别表示len和cap ,cap 大于等于 len
s2 := make([]int , 0 ,0)
复制代码
var定义切片
使用var声名的切片其实是一个nil切片,它与nil比较返回true
而使用语法糖或者make声名的切片是一个空切片,他们与ni比较返回false
var s3 []int
复制代码
nil切片指向的数组地址为nil,但一个nil切片依然占24字节大小
至今不太理解nil切片的意义,欢迎讨论。
从现有切片复制
s1 := []int{} // 先定义一个现有切片
s1 = append(s1 , 1)
s4 := s1[:1] // 复制现有的切片
复制代码
此处需要注意,s1和s4的地址不同,但他们底层使用相同的数组 ,分别打印他们的地址
再往s1中添加几条数据,看看结果
s1 := []int{}
s1 = append(s1, 1)
s4 := s1[:1]
log.Println("append之前")
getSlicePtr("s1 ", &s1)
getSlicePtr("s4 ", &s4)
s1 = append(s1, 2, 3, 4, 5, 6, 7, 8)
log.Println("append之后")
getSlicePtr("s1 ", &s1)
getSlicePtr("s4 ", &s4)
复制代码
- 可以看出来,随着s1切片大小的扩容,s1切片的地址未发生变化,可是它的底层数组已经指向了另一个数组 ,s4切片的地址和底层数组地址都没有发生变化
- 由于数组大小是不可变的,随着切片的扩容,切片底层数组的指向会发生变化
- 同时可以看到,s1和s4切片的地址之间相差24(控制台打印的是16进制,转换成10进制为24),正好是一个SliceHeader结构体的大小
使用反射生成切片
返回reflect.Value类型,需要后续处理,比较麻烦,不建议使用
myType := reflect.TypeOf(i)
slice := reflect.MakeSlice(reflect.SliceOf(myType), 10, 10).Interface()
p := slice.([]int)
p[0]= 1
复制代码
重要方法
切片的扩容
切片使用runtime/slice.go 包下的growslice()
方法进行扩容
如果当前切片的容量小于 1024,那么新切片的容量翻倍;
如果当前切片的容量大于 1024,那么新切片的容量每次增加25%
newcap := old.cap
doublecap := newcap + newcap
if cap > doublecap {
newcap = cap
} else {
if old.cap < 1024 {
newcap = doublecap // * 2
} else {
for 0 < newcap && newcap < cap { // 25%
newcap += newcap / 4
}
if newcap <= 0 {
newcap = cap
}
}
}
复制代码
可是切片扩容的时候真的是那么死板的 翻倍,扩容25%么?当然不是。
切片作为Golang中最基本和最常用的数据结构之一,自然是少不了内存优化的.
原来 newcap
只是一个我们的预期容量,实际的容量需要根据切片中的元素大小对齐内存
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):
***
default:
***
}
复制代码
最后,会根据切片元素的大小和新容量计算内存,将超出切片长度的内存清空,并拷贝旧切片的内存数据到新申请的内存中,最后返回
var p unsafe.Pointer
if et.ptrdata == 0 {
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(old.array), lenmem-et.size+et.ptrdata)
}
}
// 将原数组内存中的内容拷贝到新申请的内存中
memmove(p, old.array, lenmem)
return slice{p, old.len, newcap}
复制代码
使用时需要注意
切片是非线程安全的
并发场景使用切片时需要加锁
type SafeSlice struct {
s []int
lock *sync.RWMutex
}
var (
nums = 1000 // 协程数
times = 1000 //循环次数
)
func Benchmark_AppendWithLock(b *testing.B) {
wg := &sync.WaitGroup{}
safeSlice := &SafeSlice{
s: []int{},
lock: new(sync.RWMutex),
}
wg.Add(nums)
testFunc := func(wg *sync.WaitGroup, safeSlice *SafeSlice, time int) {
defer wg.Done()
for i := 0; i < time; i++ {
safeSlice.lock.Lock()
safeSlice.s = append(safeSlice.s, i)
safeSlice.lock.Unlock()
}
}
b.ResetTimer()
for i := 0; i < nums; i++ {
go testFunc(wg, safeSlice, times)
}
wg.Wait()
//log.Println("length: ",len(safeSlice.s), " cap: " ,cap(safeSlice.s))
return
}
复制代码
切片作为参数传递
当切片作为参数传递时,其实只是传了一个实参的拷贝,实参和形参的地址不同,但切片的底层数组会指向同一个数组
func enterTransfer() {
s := []int{1, 2, 3}
getSlicePtr("before.s ", &s)
log.Println("before.s.Data: ",s)
transferTest1(s)
getSlicePtr("after.s ",&s)
log.Println("after.s.Data: ",s)
}
func transferTest(s []int) {
log.Println("into-transferTest1")
getSlicePtr("before.s ", &s)
log.Println("before.s.Data: ", s)
s[0] = 999
for i := 0; i < 100; i++ {
s = append(s, i)
}
getSlicePtr("after.s ",&s)
log.Println("out-transferTest1")
}
复制代码
- 我们看到,进入
transferTest
方法后,切片地址其实已经发生了变化,但是他们底层引用的同一个数组。 - 当使用
s[0]=999
修改了底层数组的数据时,也影响了enterTransfer
方法中的切片数据 transferTest
发生扩容后,切片地址没有变化,可是底层数组的指向已经发生了改变,并且这个改变并没有影响到enterTransfer
方法中的切片
那么,我们有没有办法使得transferTest
在扩容时后,同时影响enterTransfer
切片呢? 当然有的,我们只需要将切片地址作为参数传递就好了
func enterTransfer() {
s := []int{1, 2, 3}
getSlicePtr("before.s ", &s)
log.Println("before.s.Data: ",s)
transferTest1V2(&s)
getSlicePtr("after.s ",&s)
log.Println("after.s.Data: ",s)
}
func transferTest(s []int) {
log.Println("into-transferTest1")
getSlicePtr("before.s ", &s)
log.Println("before.s.Data: ", s)
s[0] = 999
for i := 0; i < 100; i++ {
s = append(s, i)
}
getSlicePtr("after.s ",&s)
log.Println("out-transferTest1")
}
复制代码
- 我们可以看到,调用
transferTest
后,enterTransfer
中,切片数组的地址已经被transferTest
影响了
内存逃逸
有关内存逃逸的简单介绍可以戳这里
内存逃逸的本质是内存被分配到了堆上 ,对于堆内存的分配不如栈内存快,且需要gc进行内存回收
由于Slice底层还是一个指针,当指针类型作为返回时,会发生内存逃逸,我们来验证一下.
可见,切片作为函数返回值时,会发生内存逃逸,因此我们不要通过函数传递过大的切片,会导致内存分配到堆上,增加gc压力