本文转载自微信公众号《我的脑子是炸鱼》,作者陈建宇。转载本文请联系脑筋急转弯公众号。大家好,我是炸鱼。Go语言中总有一些奇怪的东西。乍一看感觉很眼熟,但不明白它们在Go代码中的实际含义。面试官爱问...今天要给大家介绍的是SliceHeader和StringHeader的结构。了解它是什么,有什么用,最后会为大家介绍一下0副本转换的内容。让我们一起愉快地开启熏鱼之路吧。SliceHeaderSliceHeader,顾名思义,Slice+Header,看起来很直观,但实际上是GoSlice(切片)的运行时表现。typeSliceHeaderstruct{DatauintptrLenintCapint}Data:指向具体的底层数组。Len:表示切片的长度。Cap:表示切片的容量。现在我们知道了切片的运行时性能,是否意味着我们可以自己创建一个?在日常程序中,我们可以使用标准库reflect提供的SliceHeader结构来创建一个:funcmain(){//初始化底层数组s:=[4]string{"brain","in","friedfish","out"}s1:=s[0:1]s2:=s[:]//构造SliceHeadersh1:=(*reflect.SliceHeader)(unsafe.Pointer(&s1))sh2:=(*reflect.SliceHeader)(unsafe.Pointer(&s2))fmt.Println(sh1.Len,sh1.Cap,sh1.Data)fmt.Println(sh2.Len,sh2.Cap,sh2.Data)}你认为输出是什么,将这两个新切片指向同一个底层数组的内存地址?输出:1482463433093644824634330936两个切片的Data属性指向的底层数组是一致的,Len属性的值不同,sh1和sh2分别是两个切片。问题是为什么两个新分片指向的Data在同一个地址?这其实是Go语言自己为了减少内存占用,提高整体性能而设计的。将切片复制到任何函数都不会影响底层数组的大小。复制时,只复制切片本身(按值传递),不触及底层数组。也就是说,在函数间传递切片时,只复制了24个字节(指针域8个字节,长度和容量各8个字节),效率很高。这种坑的设计也导致了新的问题。在平时s[i:j]生成的新切片中,两个切片的底层指向同一个底层数组。假设在不超过容量(cap)的情况下,对第二个切片的操作会影响第一个切片。这是很多Go开发者经常遇到的一个大“坑”,不知不觉就被查了很久。StringHeader除了SliceHeader,Go语言中还有一个典型的代表,就是字符串的运行时表现。typeStringHeaderstruct{DatauintptrLenint}数据:存储指针,指针指向特定的内存区域,用于存储数据。Len:字符串的长度。可以知道,“Hello”字符串的底层数据如下:vardata=[...]byte{'h','e','l','l','o',}底层存储图如下:true演示示例如下:funcmain(){s:="Thebrainisfried"s1:="Thebrainisfried"s2:="Thebrainisfried"[7:]fmt.Printf("%d\n",(*reflect.StringHeader)(unsafe.Pointer(&s)).Data)fmt.Printf("%d\n",(*reflect.StringHeader)(unsafe.Pointer(&s1)).数据)fmt。Printf("%d\n",(*reflect.StringHeader)(unsafe.Pointer(&s2)).Data)}你认为输出会是什么?变量s、s1和s2会指向相同的底层内存空间吗?输出结果:176082271760822717608234从输出结果看,变量s和s1指向同一个内存地址。尽管变量s2略有偏差,但它基本上指向同一个块。因为是字符串切片操作,所以从第7个索引开始,所以恰好17608234-17608227=7。即三个变量都指向同一个内存空间。为什么?这是因为在Go语言中,字符串是只读的。为了节省内存,字面值相同的字符串通常对应同一个字符串常量,从而指向同一个底层数组。0复制转换为什么人们会关注SliceHeader和StringHeader等运行时细节?多半是因为业内有开发者希望利用它来实现字符串到字节的零拷贝转换。常用的转换代码如下:funcstring2bytes(sstring)[]byte{stringHeader:=(*reflect.StringHeader)(unsafe.Pointer(&s))bh:=reflect.SliceHeader{Data:stringHeader.Data,Len:stringHeader.Len,Cap:stringHeader.Len,}return*(*[]byte)(unsafe.Pointer(&bh))}但这其实是错误的,官方明确说明:Data字段不足以保证其引用的数据不会被垃圾收集,所以程序必须保持一个单独的、正确类型的指针指向底层数据。SliceHeader和StringHeader的Data域是uintptr类型。由于Go语言只按值传递。因此,在上面的代码中,Data会被复制为一个值,这样就无法保证它所引用的数据不会被垃圾回收(GC)。应该使用如下转换方法:funcmain(){s:="Thebrainisfryingfish"v:=string2bytes1(s)fmt.Println(v)}funcstring2bytes1(sstring)[]byte{stringHeader:=(*reflect.StringHeader)(unsafe.Pointer(&s))varb[]bytepbytes:=(*reflect.SliceHeader)(unsafe.Pointer(&b))pbytes.Data=stringHeader.Datapbytes.Len=stringHeader.Lenpbytes.Cap=stringHeader.Lenreturnb}in程序必须保持一个单一的、正确类型的指向底层数据的指针。在性能方面,如果只期望简单的转换,对容量(cap)等字段值不敏感,也可以使用如下方法:funcstring2bytes2(sstring)[]byte{return*(*[]byte)(unsafe.Pointer(&s))}性能比较:string2bytes1-1000-43.746ns/op0allocs/opstring2bytes1-1000-43.713ns/op0allocs/opstring2bytes1-1000-43.969ns/op0allocs/opstring2bytes2-1000-42.445ns/opstring2bytes1/opstring2allocs1/op0allocs-ns/140bytes2/opstring2bytes2-1000-42.455ns/op0allocs/op会比较标准转换性能会稍微快一点,这个强制转换也会引起一个小问题。代码如下:funcmain(){s:="大脑在煎鱼"v:=string2bytes2(s)println(len(v),cap(v))}funcstring2bytes2(sstring)[]byte{return*(*[]byte)(unsafe.Pointer(&s))}输出结果:18824633927632这种强制转换会导致byteslice容量非常大,需要特别注意。一般推荐使用标准的SliceHeader和StringHeader方法,也便于后期维护者理解。总结在本文中,我们介绍了字符串和切片的两种运行时表示,即StringHeader和SliceHeader。同时,在了解了它的运行时性能后,我们也讲解了两者的地址指针和常见陷阱。最后,我们更进一步,对0-copy转换场景的性能进行了介绍和分析。您是否遇到过这方面的疑惑或问题?欢迎一起讨论!参考Go语言slice的精髓——SliceHeader数组、字符串和slice零拷贝实现字符串和字节的转换
