【Go-SliceHeader:slice如何高效处理数据】

Go集合类型 一文中,我们学习了slice(切片),并且学习了如何使用,接下来我们介绍一下slice的原理,学一下底层的设计。

数组Array

​ 在讲slice之前,我们先介绍一下数组。几乎所有编程语言里都存在数组,Go也不例外。那为什么Go语言除了数组之外又设计了slice呢? 我们先来看一下数组的局限性

​ 我们知道,一个数组由两部分构成:数组的大小和数组内的元素类型一旦一个数组被声明,它的大小和内部元素类型就不能改变,不能随意的向数组添加任意多个元素。这是数组的第一个限制

// 数组结构伪代码
array {
    len
    item type
}

// [1]string  和 [2]string, 这是两个类型,因为数组的大小也是数组类型的一部分,只有数组内部元素类型和大小一致时,两个数组才是同一类型。

​ 既然数组的大小是固定的,如果需要使用数组存储大量的数据,就需要提前指定一个合适的大小,比如10W。这样虽然可以解决问题,但是又带来了另一个问题,那就是内存占用。因为在Go语言中,函数间的传参是值传递的,数组作为参数在各个函数之间被传递的时候,同样的内容会被一遍遍复制,这就会造成大量的内存浪费这是数组的第二个限制。虽然数组有限制,但是它是Go语言非常重要的底层数据结构,比如slice切片的底层数据就存储在数组中。

slice 切片

​ 数组虽然不错,但是操作上有很多限制,为了解决这些限制,Go语言创造了slice,也就是切片。切片是对数组的抽象和封装,它的底层是一个数组存储所有的元素,但是它可以动态的添加元素,容量不足时还可以自动扩容,完全可以把切片理解为动态数组。在Go语言中,除了明确需要指定长度大小的类型需要数组来完成,大多数情况下都是使用切片的。

动态扩容

​ 通过内置append方法,可以向一个切片中追加任意多个元素,所以就可以解决数组的第一个限制。当通过append函数追加元素时,如果切片的容量不足,append会自动扩容。可以通过内置的len函数获取切片的长度,通过cap函数获取切片的容量。

提示:append 自动扩容的原理是新创建一个底层数组,把原来切片内元素拷贝到新数组中,然后在返回一个指向新数组的切片。

数据结构

​ 在Go语言中,切片其实是一个结构体,其定义如下所示:

type SliceHeader struct{
    Data uintptr
    Len int 
    Cap int
}

SliceHeader 是切片在运行时的表现形式,它有三个字段Data、Len和Cap。通过这三个字段,可以把一个数组抽象成一个切片,便于更好的操作,所以不同切片对应的底层Data 指向的可能是同一个数组。

	1. Data 用来指向存储切片元素的数组
	2. Len代表切片的长度
	3. Cap代表切片的容量
func main() {
    a1 := [2]string{"zhangsna", "lisi "}
    s1 := a1[0:1]
    s2 := a1[:]
    // 打印s1和s2的data值,是一样的。 通过unsafe.Pointer 把他们转为 *reflect.SliceHeader指针,打印data值
    fmt.Println((*reflect.SliceHeader)(unsafe.Pointer(&s1).Data))
    fmt.Println((*reflect.SliceHeader)(unsafe.Pointer(&s2).Data))
}

// 输出结果
824634159527
824634159527

// 从输出结果看,这两个切片共用一个数组,所以在切片赋值、重新进行切片操作时,使用的还是同一个数组,没有赋值原来的元素。这样可以减少内存的占用,提高效率。

注意: 多个切片共用一个底层数组虽然可以减少内存占用,但是如果有一个切片修改内部元素,其他切片也会受到影响。所以在切片作为参数在函数间传递的时候要小心,尽可能不要修改原切片内的元素。

​ 切片的本质是SliceHeader,又因为函数的参数是值传递,所以传递的是SliceHeader 的副本,而不是底层数组的副本。这时候切片的优势就体现出来了,因为SliceHeader 的副本内存占用非常少,即使是一个非常大的切片(底层数组由很多元素),最多也就占24个字节的内存,这就解决了大数组在传参时内存浪费的问题

提示:SliceHeader三个字段的类型分别是: uintptr、int和int,在64位的机器上,这三个字段最多也就是int64类型,一个int64占8个自己,三个int64占用24个字节的内存。

​ 要获取切片数据结构的三个字段值,也可以不使用SliceHeader,而是完全自定义一个结构体,只要字段和SliceHeader一样就可以了。不过我们还是尽可能的使用SliceHeader,因为这是Go语言提供的标准,可以保持统一,便于理解。

type slice struct{
    Data uintptr
    Len int
    Cap int
}
sh1 := (*slcie)(unsafe.Pointer(&s1))
fmt.Println(sh1.Data, sh1.Len, sh1.Cap)

高效的原因

​ 如果从集合类型的角度考虑,数组、切片和map都是结合类型,因为它们都可以存放元素,但是数组和切片的取值和赋值操作要更高效,因为它们是连续的内存操作,通过索引就可以快速找到元素存储的地址。进一步对比,在数组和切片中, 切片又是高效的,因为它在赋值、函数传参时,并不会把所有的元素都复制一遍,而只是赋值SliceHeader 的三个字段就可以了,共用的还是同一个底层数组。

提示: 当然map的价值也很大,因为它的 Key 可以是很多类型,比如int、int64、string等,但是数组和切片的索引只能是整数。

func main(){
    a1 := [2]string{"zhangsan", "lisi"}
    fmt.Println("main函数数组指针: %p\n", &a1)
    
    arrayF(a1)
    s1 := a1[0:1]
    fmt.Println((*reflect.SliceHeader)(unsafe.Pointer(&s1).Data))
    sliceF(s1)
}

func arrayF(a [2]string){
    fmt.Println("arrayF 函数 数组指针:%p\n", &a)
}

func sliceF(s []string) {
    fmt.Println("sliceF函数 Data:%d\n", (*reflect.SliceHeader)(unsafe.Pointer(&s)).Data)
}


// 输出结果
main函数数组指针: 0xc0000a9527
rrayF 函数 数组指针:0xc0000a9527
824634400800
sliceF函数 Data:824634400800

// 我们发现,同一个数组在main函数中的指针和在arrayF函数中的指针是不一样的,这说明数组在传参时被复制了,又产生了一个新数组。而slice切片的底层Data是一样的,这说明不管是在mian函数还是slcieF函数中,这两个切片共用的还是同一个底层数组,底层数组没有被复制。

提示: 切片的高效还体现在for range循环中,因为循环得到的临时变量也是一个值拷贝,所以在遍历大数组时,切片的效率更高。

切片基于指针的封装是它效率高的根本原因,因为可以减少内存的占用,以及减少内存复制时的时间消耗。

string和[]byte互转

​ 我们通过string和[]byte互转的例子,进一步理解slice高效的原因。

s:= "奔跑的蜗牛"
b := []byte(s)
s2 := string(b)
fmt.Println(s, string(b), s2)

// 变量s是一个string字符串,它可以通过[]byte被强制转换为[]byte类型的变量b,又可以通过string()强制转换为string类型的变量s2。它们的值都是 "奔跑的蜗牛"

​ Go语言通过先分配一个内存再复制内容的方式,实现string和[]byte之前的强制转换。现在通过string和[]byte指向的真实内容的内存地址,来验证强制转换是采用重新分配内存的方式。

s := "独臂阿童木"
fmt.Println("s内存地址: %d\n", (*reflect.StringHeader)(unsafe.Pointer(&s)).Data)
b := []byte(s)
fmt.Println("b内存地址: %d\n", (*reflect.SliceHeader)(unsafe.Pointer(&b)).Data)
s2 := string(b)
fmt.Println("s2内存地址: %d\n", (*reflect.StringHeader)(unsafe.Pointer(&s2)).Data)

// 运行发现,打印出来的内存地址都不一样,这说明虽然内容相同,但是已经不是同一个字符串了,因为内存地址不同

提示: 可以通过查看runtime.stringtoslicebyte 和 runtime.slicebytetostring 两个函数的源代码,了解关于string 和 []byte类型互转的具体实现。

​ StringHeader和SliceHeader是一样的,代表的是字符串在程序运行似的真实结构,StringHeader的定义如下:

type StringHeader struct{
    Data uintptr
    Len int
}

// 说明在程序运行的时候,字符串和切片本质上是StringHeader 和 SliceHeader。 这两个结构体都有一个Data字段,用于存放指向真实内容的指针。所以打印出Data字段的值,就可以判断string 和 []byte 强制转换后是不是重新分配了内存。

​ 现在我们知道,[]byte 和 string 强制转换会重新拷贝一份字符串,如果字符串非常大,由于内存开销大,对于有高性能要去的程序来说,这种方式就无法满足了,需要进行性能优化。 那如何优化呢?既然是因为内存分配导致内存开销大,那么优化的思路因该是在不重新分配内存的情况下是实现类型转换

​ 我们观察到StringHeader 和 SliceHeader 这两个结构体,前两个字段一模一样,那么[]byte转string,就等于通过 unsafe.Pointer 把 *Sliceheader 转为 *StringHeader, 也就是 *[]byte 转 *string,原理和上面提到的把切片转换成一个自定义结构体类似。

s := "奔跑的蜗牛"
b := []byte(s)
// s2 := string(b)
s3 := *(*string)(unsafe.Pointer(&b))

// 示例中,s3 和s2的内容是一样的。不一样的是s4没有申请内存(零拷贝),它和变量s使用的是同一块内存,因为它们底层Data字段相同,这样就节省了内存,也达到了[]byte转string的目的。

​ SliceHeader 有Data、Len、Cap三个字段,StringHeader 有Data、Len两个字段,所以 *SliceHeader 通过 unsafe.Pointer 转为 *StringHeader 时没有问题,因为 *SliceHeader可以提供 *StringHeader 所需的 Data和Len字段的值。但是反过来不行,因为 *StringHeader 缺少 *SliceHeader所需的Cap字段,需要自行补上一个默认值。

s := "zhangsan"
// b:= []byte(s)
sh := (*reflect.SliceHeader)(unsafe.Pointer(&s))
sh.Cap = sh.Len
b1 := *(*[]byte)(unsafe.Pointer(sh))

提示: 通过unsafe.Pointer 把string转为 []byte后,不能对[]byte修改,比如不可以进行b[0] = 12这种操作,会报异常,导致程序崩溃。这是因为在Go语言中string内存是只读的。

​ 通过unsafe.Pointer进行类型转换,避免内存拷贝提升性能的方法在Go语言标准库中也有使用,比如string.Builder 这个结构体,它内部有buf字段存储内容,在通过 String 方法把[]byte 类型的buf转为string的时候,就使用 unsafe.Pointer提高了效率。

func (b *Builder) String() string{
    return *(*string)(unsafe.Pointer(&b.buf))
}

​ string 和 []byte 的互转就是一个很好的利用SliceHeader 结构体的示例,通过它可以实现零拷贝的类型转换,提升了效率,避免了内存浪费。

总结

​ 通过slice切片的分析,可以更深的感受Go的魅力,它把底层的指针、数组进行封装,提供了一个切片的概念给开发者,这样既可以方便使用、提高开发效,又可以提高程序的性能。

​ Go语言设计切片的思路非常有借鉴意义,我们也可以使用 uintptr 或者 slcie类型的字段来提升性能,就像 Go语言 SliceHeader 里Data.uintptr字段一样。


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值