go string与[]byte转换以及性能分析

2,004 阅读9分钟
  1. 转换方式
  2. 转换方式性能对比
  3. 性能差别原理剖析

一、转换方式

1、标准转换

// 字符串转字节切片

str := "hello world"
b := []byte(str)
fmt.Printf("%#v", b)

输出:[]byte{0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x20, 0x77, 0x6f, 0x72, 0x6c, 0x64}

// 字节切片转字符串

b := []byte{0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x20, 0x77, 0x6f, 0x72, 0x6c, 0x64}
str := string(b)
fmt.Printf("%#v", str)

输出:"hello world"

2、强转换(黑魔法)

// 字符串转字节切片

str := "hello world"
// unsafe.Pointer是类型转换的桥梁,任意类型都可以转换成它
// 这里是获取字符串的指针类型
strPointer := unsafe.Pointer(&str)
// 通过reflect.StringHeader,可以将字符串指针类型转换成字符串在GO中运行时状态
// 看源码分析可以知道,字符串GO源码实现是用的结构体,Data底层指向一个数组
strHeader := (*reflect.StringHeader)(strPointer)
// 构造切片在GO中运行时状态,三个属性中Data指向一个数组(字符串底层数组),Len和Cap用字符串的长度填充
bit := reflect.SliceHeader{
        Data: strHeader.Data,
        Len:  strHeader.Len,
        Cap:  strHeader.Len,
}
// 任意类型都可以转换成Pointer,因为它是转换的桥梁
// 先将bit的运行时状态转换成Pointer,然后再通过这个桥梁转换成[]byte
fmt.Printf("%#v", *(*[]byte)(unsafe.Pointer(&bit)))

输出:[]byte{0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x20, 0x77, 0x6f, 0x72, 0x6c, 0x64}

// 字节切片转字符串

bit := []byte{0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x20, 0x77, 0x6f, 0x72, 0x6c, 0x64}
bitPointer := unsafe.Pointer(&bit)
bitHeader := (*reflect.SliceHeader)(bitPointer)
strHeader := reflect.StringHeader{
    Data: bitHeader.Data,
    Len:  bitHeader.Len,
}
str := *(*string)(unsafe.Pointer(&strHeader))
fmt.Printf("%#v", str)

输出:"hello world"

二、转换方式性能对比

1、性能测试

在标准转换方法中,x变量的分配字符长度>32个字符

package main

import (
	"reflect"
	"testing"
	"unsafe"
)

func String2Bytes(s string) []byte {
	sh := (*reflect.StringHeader)(unsafe.Pointer(&s))
	slice := reflect.SliceHeader{
		Data: sh.Data,
		Len:  sh.Len,
		Cap:  sh.Len,
	}
	return *(*[]byte)(unsafe.Pointer(&slice))
}

func Bytes2String(s []byte) string {
	return *(*string)(unsafe.Pointer(&s))
}

// 测试标准转换string()性能
func Benchmark_NormalBytes2String(b *testing.B) {
	x := []byte("Hello Gopher! Hello Gopher! Hello Gopher!")
	for i := 0; i < b.N; i++ {
		_ = string(x)
	}
}

// 测试强转换[]byte到string性能
func Benchmark_Byte2String(b *testing.B) {
	x := []byte("Hello Gopher! Hello Gopher! Hello Gopher!")
	for i := 0; i < b.N; i++ {
		_ = Bytes2String(x)
	}
}

// 测试标准转换[]byte性能
func Benchmark_NormalString2Bytes(b *testing.B) {
	x := "Hello Gopher! Hello Gopher! Hello Gopher!"
	for i := 0; i < b.N; i++ {
		_ = []byte(x)
	}
}

// 测试强转换string到[]byte性能
func Benchmark_String2Bytes(b *testing.B) {
	x := "Hello Gopher! Hello Gopher! Hello Gopher!"
	for i := 0; i < b.N; i++ {
		_ = String2Bytes(x)
	}
}

在标准转换方法中,x变量的分配字符长度<32个字符

package main

import (
	"reflect"
	"testing"
	"unsafe"
)

func String2Bytes(s string) []byte {
	sh := (*reflect.StringHeader)(unsafe.Pointer(&s))
	slice := reflect.SliceHeader{
		Data: sh.Data,
		Len:  sh.Len,
		Cap:  sh.Len,
	}
	return *(*[]byte)(unsafe.Pointer(&slice))
}

func Bytes2String(s []byte) string {
	return *(*string)(unsafe.Pointer(&s))
}

// 测试强转换功能
// func TestBytes2String(t *testing.T) {
// 	x := []byte("Hello Gopher!")
// 	y := Bytes2String(x)
// 	z := string(x)
//
// 	if y != z {
// 		t.Fail()
// 	}
// }
//
// // 测试强转换功能
// func TestString2Bytes(t *testing.T) {
// 	x := "Hello Gopher!"
// 	y := String2Bytes(x)
// 	z := []byte(x)
//
// 	if !bytes.Equal(y, z) {
// 		t.Fail()
// 	}
// }

// 测试标准转换string()性能
func Benchmark_NormalBytes2String(b *testing.B) {
	// x := []byte("Hello")
	x := []byte("Hello")
	for i := 0; i < b.N; i++ {
		_ = string(x)
	}
}

// 测试强转换[]byte到string性能
func Benchmark_Byte2String(b *testing.B) {
	x := []byte("Hello")
	for i := 0; i < b.N; i++ {
		_ = Bytes2String(x)
	}
}

// 测试标准转换[]byte性能
func Benchmark_NormalString2Bytes(b *testing.B) {
	x := "Hello!"
	for i := 0; i < b.N; i++ {
		_ = []byte(x)
	}
}

// 测试强转换string到[]byte性能
func Benchmark_String2Bytes(b *testing.B) {
	x := "Hello"
	for i := 0; i < b.N; i++ {
		_ = String2Bytes(x)
	}
}

2、结果分析

强转换方式的性能会明显优于标准转换。 -benchmem可以提供每次操作分配内存的次数,以及每次操作分配的字节数

图1:image.png

图2: image.png

3、引发思考

  • 图2比图1,红框位置每次操作,少了1次内存分配?但是强制转换
  • 相同时间内,强制转换操作执行次数明显高于标准转换?

三、性能差别原理剖析

1、string 实现原理

首先看下string的定义,string类型定义在builtin包中,是8bit字符串的集合,通常但不一定代表 UTF-8 编码的文本,可以为空字符串,但是不会是nil,而且字符串的值是不可更改的

在runtime包中查看string的Go源码实现,

type stringStruct struct {
	str unsafe.Pointer
	len int
}

stringStruct代表的就是一个string对象,属性str的类型是unsafe.Pointer,因为unsafe.Pointer可以表示任意数据类型指针,str底层存储是字节数组,str指针指向的是某个数组的首地址,len代表的数组长度。那么这个数组是什么呢?我们可以在实例化stringStruct对象时找到答案。

//go:nosplit
func gostringnocopy(str *byte) string {
	ss := stringStruct{str: unsafe.Pointer(str), len: findnull(str)}
	s := *(*string)(unsafe.Pointer(&ss))
	return s
}

可以看到,str是字节数组

2、[]byte 实现原理

在runtime包中查看slice的Go源码实现,

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

属性array的类型是unsafe.Pointer,因为unsafe.Pointer可以表示任意数据类型指针,array底层存储是字节数组,array是底层数组的指针,len表示长度,cap表示容量。对于[]byte来说,array指向的就是byte数组。

3、string和[]byte底层区别

总体来看,string和[]byte的底层结构上很相近,切片要比字符串多了一个cap属性,因此它们在内存布局上是可对齐的,最大的区别其实是string是不可更改的

c := []byte("Hello World")
c[0] = 'Q'
fmt.Println(string(c))

输出:Qello World

a := "Hello World"
a[0] = 'Q'
fmt.Println(a[0])

输出:./str.go:7:7: cannot assign to a[0] (strings are immutable)

a := "Hello World"
a = "Qello World"
fmt.Println(a)

输出:Qello World

综上:虽然string不能被修改,但是string可以被替换,通过下图,可以看到切片和string在修改和替换的时候的内存变化 xx 切片在修改时,底层指向的内存地址并没有改变,string在被替换之后,底层指向的数组是一个新的数组,老的数组也会被gc释放掉,可以看到字符串的替换会发生一次内存重新分配,这也就是为什么字符串的替换会比切片的性能低的原因

4、标准转换实现细节

[]byte(string)的实现(源码在src/runtime/string.go中)

// The constant is known to the compiler.
// There is no fundamental theory behind this number.
const tmpStringBufSize = 32

type tmpBuf [tmpStringBufSize]byte

// 字符串转字节切片
func stringtoslicebyte(buf *tmpBuf, s string) []byte {
    var b []byte
    if buf != nil && len(s) <= len(buf) {
        *buf = tmpBuf{}
        b = buf[:len(s)]
    } else {
        b = rawbyteslice(len(s))
    }
    copy(b, s)
    return b
}

// rawbyteslice allocates a new byte slice. The byte slice is not zeroed.
func rawbyteslice(size int) (b []byte) {
    cap := roundupsize(uintptr(size))
    p := mallocgc(cap, nil, false)
    if cap != uintptr(size) {
        memclrNoHeapPointers(add(p, uintptr(size)), cap-uintptr(size))
    }

    *(*slice)(unsafe.Pointer(&b)) = slice{p, size, int(cap)}
    return
}

// 字节切片转字符串
func slicebytetostring(buf *tmpBuf, ptr *byte, n int) (str string) {
	if n == 0 {
		// Turns out to be a relatively common case.
		// Consider that you want to parse out data between parens in "foo()bar",
		// you find the indices and convert the subslice to string.
		return ""
	}
	if raceenabled {
		racereadrangepc(unsafe.Pointer(ptr),
			uintptr(n),
			getcallerpc(),
			funcPC(slicebytetostring))
	}
	if msanenabled {
		msanread(unsafe.Pointer(ptr), uintptr(n))
	}
	if n == 1 {
		p := unsafe.Pointer(&staticuint64s[*ptr])
		if sys.BigEndian {
			p = add(p, 7)
		}
		stringStructOf(&str).str = p
		stringStructOf(&str).len = 1
		return
	}

	var p unsafe.Pointer
	if buf != nil && n <= len(buf) {
		p = unsafe.Pointer(buf)
	} else {
		p = mallocgc(uintptr(n), nil, false)
	}
	stringStructOf(&str).str = p
	stringStructOf(&str).len = n
	memmove(p, unsafe.Pointer(ptr), uintptr(n))
	return
}

不论谁转谁,当被转的字符长度大于32时,go需要调用mallocgc分配一块新的内存,所以当被转数据较大时,标准转换方式会有一次分配内存的操作

5、强转换(黑魔法)转换实现细节

  • 使用unsafe.Pointer指针
  • 内存布局

在go中,任何类型的指针T都可以转换为unsafe.Pointer类型的指针,它可以存储任何变量的地址。同时,unsafe.Pointer类型的指针也可以转换回普通指针,而且可以不必和之前的类型T相同。另外,unsafe.Pointer类型还可以转换为uintptr类型,该类型保存了指针所指向地址的数值,从而可以使我们对地址进行数值计算。以上就是强转换方式的实现依据。

而string和slice在reflect包中,对应的结构体是reflect.StringHeader和reflect.SliceHeader,它们是string和slice的运行时表达。

type StringHeader struct {
    Data uintptr
    Len  int
}

type SliceHeader struct {
    Data uintptr
    Len  int
    Cap  int
}

从string和slice的运行时表达可以看出,除了SilceHeader多了一个int类型的Cap字段,Date和Len字段是一致的。所以,它们的内存布局是可对齐的,这说明我们就可以直接通过unsafe.Pointer进行转换。

[]byte转string图解 s

a := []byte("Hello")
b := *(*string)(unsafe.Pointer(&a))
b[0] = 'Q'
fmt.Println(b)
// `./str.go:28:7: cannot assign to b[0] (strings are immutable)`

切片转换成了字符串,因为字符串不可修改,所以上面的执行失败

string转[]byte图解 s

a := "Hello"
b := *(*[]byte)(unsafe.Pointer(&a))
b[1] = 'Q'
fmt.Println(b)

// 输出:此处报错是不能被被捕获的
unexpected fault address 0x10c7845
fatal error: fault
[signal SIGBUS: bus error code=0x2 addr=0x10c7845 pc=0x10a315b]

虽然字符串转换成了字节切片,但是转换后的切片仍然不能进行修改,延续了字符串不能修改的特性,因为切片底层的数组执行的内存空间是只读的,所以即使转换成了切片仍然不能修改

四、回答几个问题

1、强转换(黑魔法)性能为什么优于标准转换?

对于标准转换,无论是从[]byte转string还是string转[]byte都会涉及底层数组的拷贝。而强转换是直接替换指针的指向,从而使得string和[]byte指向同一个底层数组。这样,当然后者的性能会更好。

2、当x的数据较大时,标准转换方式会有一次分配内存的操作,从而导致其性能更差,而强转换方式却不受影响?

标准转换时,当数据长度大于32个字节时,需要通过mallocgc申请新的内存,之后再进行数据拷贝工作。而强转换只是更改指针指向。所以,当转换数据较大时,两者性能差距会愈加明显。

3、既然强转换方式性能这么好,为啥go语言提供给我们使用的是标准转换方式?

首先,我们需要知道Go是一门类型安全的语言,而安全的代价就是性能的妥协。但是,性能的对比是相对的,这点性能的妥协对于现在的机器而言微乎其微。另外强转换的方式,会给我们的程序带来极大的安全隐患。

a是string类型,前面我们讲到它的值是不可修改的。通过强转换将a的底层数组赋给b,而b是一个[]byte类型,它的值是可以修改的,所以这时对底层数组的值进行修改,将会造成严重的错误(通过defer+recover也不能捕获)。

4、为啥string要设计为不可修改的?

我认为有必要思考一下该问题。string不可修改,意味它是只读属性,这样的好处就是:在并发场景下,我们可以在不加锁的控制下,多次使用同一字符串,在保证高效共享的情况下而不用担心安全问题。

五、场景如何取舍

  • 在你不确定安全隐患的条件下,尽量采用标准方式进行数据转换
  • 当程序对运行性能有高要求,同时满足对数据仅仅只有读操作的条件,且存在频繁转换(例如消息转发场景),可以使用强转换