一文带你读懂结构体内存分配

一个比较牛逼的博客,介绍了 如何优化字符串到字节数组的过程 ,避免了数据复制过程对程序性能的影响。

对此,我深感佩服。因为代码非常简单,简单到我根本看不懂!

package main
 
import (
    "fmt"
    "strings"
    "unsafe"
)
 
func str2bytes(s string) []byte {
    x := (*[2]uintptr)(unsafe.Pointer(&s))
    h := [3]uintptr{x[0], x[1], x[1]}
    return *(*[]byte)(unsafe.Pointer(&h))
}
 
func bytes2str(b []byte) string {
    return *(*string)(unsafe.Pointer(&b))
}
 
func main() {
    s := strings.Repeat("abc", 3)
    b := str2bytes(s)
    s2 := bytes2str(b)
    fmt.Println(b, s2)
}
复制代码

之所以这么做的原因是: 从 ptype 输出的结构来看,string 可看做 [2]uintptr,而 []byte 则是 [3]uintptr,这便于我们编写代码,无需额外定义结构类型。如此,str2bytes 只需构建 [3]uintptr{ptr, len, len},而 bytes2str 更简单,直接转换指针类型,忽略掉 cap 即可。

关于 string[]byte 结构可以看下图,我直接复制过来了,大家可以看看上述表达的根据。

打击

作者的判断呢,我是相信的。于是,我跃跃欲试的修改了代码,也想完成类似的不用复制变量、仅仅修改指针类型就可以的改变变量类型的过程。下面的代码主要想做的就是,通过修改指针从而完成变量由结构体 Num 到结构体 ReverseNum 的转变,代码内容如下:

type Num struct {
    name  int8
    value int8
}

type ReverseNum struct {
    value int8
    name  int8
}
func main() {
    n := Num{100, 10}
    z := (*[2]uintptr)(unsafe.Pointer(&n))
    h := [2]uintptr{z[1], z[0]}
    fmt.Println(*(*ReverseNum)(unsafe.Pointer(&h))) // print result is {0, 0}
}
复制代码

但是,结果并不如我所愿。因为打印的结果并不是 {10, 100} ,而是 {0, 0} 。我的自信心受到了挫折,这种转化到底是什么意思呢???

在反复思考没有结果之后,我在著名的 stackoverflow 贴出了我的疑问。然后就在我打算休息会儿的时候,就有人评论了,给予我深深的打击。

对于我的这种写法,人家列出了七点看法。在我缓了缓挫败的内心之后再看的时候,被人删除了六点,唯一剩下的一点就是因为 unsafe 不够安全。总的来说我就是在我对go不够熟悉的时候,不要接触或者使用 unsafe 包。我就在想,什么知识不都是从不熟悉到熟悉的?我就是不够熟悉所以才会在 stackoverflow 上提问,也就是不熟悉才想熟悉这个知识点并且尝试熟悉这个知识点的啊!

unsafe 确实不安全,但是并不妨碍我了解这个包啊。

然后我就放弃了,毕竟评论的都是大佬,我这种渣渣也许就真的不适合知道这种知识。

转机

机缘巧合之下,我又看到了一篇博客,是 介绍内存对齐的 。其实之前也是看过内存对齐的文章,只不过仅仅是了解下。这篇文章让我想起了之前的疑问,所以我就带着疑问来反复读的这篇博客。

得到的知识和之前看内存对齐的博客是一致的,只不过这次我有了新的感悟。结构体的内存分配肯定是和内存对齐相关的。为了得到内存对齐的展示效果,这次没有使用两个都是 int8 属性的结构体。而是使用了一个新的结构体 Student ,有两个属性,一个属性是 int8 ,另外一个是 int64

import (
    "fmt"
    "unsafe"
)

type Student struct {
    age    int8
    salary int64
}

type StudentReverse struct {
    salary int64
    age    int8
}

func main() {
    s := Student{18, 100000}
    x := (*[2]uintptr)(unsafe.Pointer(&s))
    fmt.Println("age is ", *(*int8)(unsafe.Pointer(&x[0])))// 18
}
复制代码

这样打印的结果就是我想要的了,和我在 Student 初始化的时候赋值一致。然后需要做的就是如何通过指针修改类型了,既然第一步做到了,那么第二步就简单了,根据大佬的博客照葫芦画瓢就好了。

tmp := [2]uintptr{x[1], x[0]}
    studentReverse := *(*StudentReverse)(unsafe.Pointer(&tmp))
    fmt.Println(studentReverse.salary, studentReverse.age)
复制代码

打印的结果和预期一致,新的 studentReverse 结构体变量就按照预期进行了结构体的变换。

但是这种做法没什么意义,因为 uintptr 其实就是一个通用的指针,在函数 str2bytes 中的用法比较trick,不仅仅把结构体 string 中的数组指针作为指针,还把底层数组的长度也作为了指针。而在把 Student 转化为 StudentReverse 的过程中,只不过是把 Student 中每个元素值复制了了一份,没有任何意义。

读者可以尝试下修改变量 s 的属性,看下 studentReverse 是否也对应的修改了

还剩下最后一个问题,为什么贴在 stackoverflow 的代码就没有成功的运行。还是因为内存对齐,这两个 int8 类型的变量,因为内存对齐,放到了一个64位的内存中去了(要看系统支持的位数,我的电脑是64位的)。为了验证正确性呢,可以看如下代码

import (
    "fmt"
    "unsafe"
)

type Test struct {
    a int8
    b int8
}

func main() {
    test := Test{2, 3}
    z := (*[2]int8)(unsafe.Pointer(&test))
    fmt.Println("z is ", z)//z is  &[2 3]
    fmt.Printf("totally as one result is %b\n", *(*int16)(unsafe.Pointer(&test)))//totally as one result is 1100000010
}

复制代码

代码运行的结果 z 中,就是一个长度为2的数组指针,包含有两个值,一个是2(也就是t.a的值),一个是3(也就是t.b的值)。如果把结构体转化为一个 int16 的变量并按照二进制进行打印,结果是 1100000010 ,如果看的仔细的话,就知道后八位是2,前两位是3。

总的来说就是每个结构体地址后面有一段的内存空间,用户存放此结构体的属性。所以就有了 unsafe 包可以操作地址,操作 (*[2]int8)(unsafe.Pointer(&test)) 就是把变量 test 的地址之后的16位转化为了长度为2的元素类型为 int8 的数组,这样就可以直接通过操作指针的方式来操作内存。

但是呢,这些属性因为内存对齐,并不是一个一个紧凑并且连续排列的。而内存对齐在不同的操作系统或者不同的硬件上的要求也是各不相同。为了避免例如你在这个64位系统可以正常运行的操作,到了32位系统就崩溃了,所以 go 就极其不建议使用 unsafe 包。