- 转换方式
- 转换方式性能对比
- 性能差别原理剖析
一、转换方式
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:
图2:
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在修改和替换的时候的内存变化
切片在修改时,底层指向的内存地址并没有改变,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图解
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图解
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不可修改,意味它是只读属性,这样的好处就是:在并发场景下,我们可以在不加锁的控制下,多次使用同一字符串,在保证高效共享的情况下而不用担心安全问题。
五、场景如何取舍
- 在你不确定安全隐患的条件下,尽量采用标准方式进行数据转换
- 当程序对运行性能有高要求,同时满足对数据仅仅只有读操作的条件,且存在频繁转换(例如消息转发场景),可以使用强转换