对象内存布局
要理解 unsafe,核心就是要理解 Go 中一个对象在内存中究竟是怎么布局的。
需要掌握:
- 计算地址
- 计算偏移量
- 直接操作内存
// PrintFieldOffset 用来打印字段偏移量
// 用于研究内存布局
// 只接受结构体作为输入
func PrintFieldOffset(entity any) {
typ := reflect.TypeOf(entity)
for i := 0; i < typ.NumField(); i++ {
fd := typ.Field(i)
fmt.Printf("%s: %d \n", fd.Name, fd.Offset)
}
}
func TestPrintFieldOffset(t *testing.T) {
fmt.Println(unsafe.Sizeof(types.User{}))
PrintFieldOffset(types.User{})
fmt.Println(unsafe.Sizeof(types.UserV1{}))
PrintFieldOffset(types.UserV1{})
fmt.Println(unsafe.Sizeof(types.UserV2{}))
PrintFieldOffset(types.UserV2{})
}
=== RUN TestPrintFieldOffset
64
Name: 0
age: 16
Alias: 24
Address: 48
64
Name: 0
age: 16
agev1: 20
Alias: 24
Address: 48
64
Name: 0
Alias: 16
Address: 40
age: 56
--- PASS: TestPrintFieldOffset (0.00s)
PASS
type User struct {
Name string
age int32
Alias []byte
Address string
}
type UserV1 struct {
Name string
age int32
agev1 int32
Alias []byte
Address string
}
type UserV2 struct {
Name string
Alias []byte
Address string
age int32
}
64 是结构体的总大小。
问题在于,为什么 Alias 的偏移量是 24,按照道理来说应该是 20?
按照字长对齐。因为 Go 本身每一次访问内存都是按照字长的倍数来访问的。
- 在 32 位字长机器上,就是按照 4 个字节对齐
- 在 64 位字长机器上,就是按照 8 个字节对齐
因为 Go 是按照字长来对齐的,所以在 64 位机器上, age + agev1 恰好一个字长,所以 Alias 的偏移量其实没有变。
代码演示 —— 使用 unsafe 来读写字段
注意点:unsafe 操作的是内存,本质上是对象的起始地址。
读:*(*T)(ptr)
,T 是目标类型,如果类型不知道,只能拿到反射的 Type,那么可以用 reflect.NewAt(typ, ptr).Elem()
。
写*: **(*T)(ptr) = T
,T 是目标类型。
ptr 是字段偏移量:ptr = 结构体起始地址 + 字段偏移量
type FieldAccessor interface {
Field(field string) (int, error)
SetField(field string, val int) error
}
type UnsafeAccessor struct {
fields map[string]FieldMeta
entityAddr unsafe.Pointer
}
func NewUnsafeAccessor(entity interface{}) (*UnsafeAccessor, error) {
if entity == nil {
return nil, errors.New("invalid entity")
}
val := reflect.ValueOf(entity)
typ := reflect.TypeOf(entity)
if typ.Kind() != reflect.Pointer || typ.Elem().Kind() != reflect.Struct {
return nil, errors.New("invalid entity")
}
fields := make(map[string]FieldMeta, typ.Elem().NumField())
elemType := typ.Elem()
for i := 0; i < elemType.NumField(); i++ {
fd := elemType.Field(i)
fields[fd.Name] = FieldMeta{offset: fd.Offset, typ: fd.Type}
}
return &UnsafeAccessor{entityAddr: val.UnsafePointer(), fields: fields}, nil
}
func (u *UnsafeAccessor) Field(field string) (int, error) {
fdMeta, ok := u.fields[field]
if !ok {
return 0, fmt.Errorf("invalid field %s", field)
}
ptr := unsafe.Pointer(uintptr(u.entityAddr) + fdMeta.offset)
if ptr == nil {
return 0, fmt.Errorf("invalid address of the field: %s", field)
}
res := *(*int)(ptr)
return res, nil
}
func (u *UnsafeAccessor) FieldAny(field string) (any, error) {
fdMeta, ok := u.fields[field]
if !ok {
return 0, fmt.Errorf("invalid field %s", field)
}
ptr := unsafe.Pointer(uintptr(u.entityAddr) + fdMeta.offset)
if ptr == nil {
return 0, fmt.Errorf("invalid address of the field: %s", field)
}
refVal := reflect.NewAt(fdMeta.typ, ptr).Elem()
return refVal.Interface(), nil
}
func (u *UnsafeAccessor) SetField(field string, val int) error {
fdMeta, ok := u.fields[field]
if !ok {
return fmt.Errorf("invalid field %s", field)
}
ptr := unsafe.Pointer(uintptr(u.entityAddr) + fdMeta.offset)
if ptr == nil {
return fmt.Errorf("invalid address of the field: %s", field)
}
*(*int)(ptr) = val
return nil
}
func (u *UnsafeAccessor) SetFieldAny(field string, val any) error {
fdMeta, ok := u.fields[field]
if !ok {
return fmt.Errorf("invalid field %s", field)
}
ptr := unsafe.Pointer(uintptr(u.entityAddr) + fdMeta.offset)
if ptr == nil {
return fmt.Errorf("invalid address of the field: %s", field)
}
refVal := reflect.NewAt(fdMeta.typ, ptr).Elem()
if !refVal.CanSet() {
return fmt.Errorf("field can't be set: %s", field)
}
refVal.Set(reflect.ValueOf(val))
return nil
}
type FieldMeta struct {
// offset 后期在我们考虑组合,或者复杂类型字段的时候,它的含义衍生为表达相当于最外层的结构体的偏移量
offset uintptr
typ reflect.Type
}
func TestUnsafeAccessor_Field(t *testing.T) {
testCases := []struct {
name string
entity interface{}
field string
wantVal int
wantErr error
}{
{
name: "normal case",
entity: &User{Age: 18},
field: "Age",
wantVal: 18,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
accessor, err := NewUnsafeAccessor(tc.entity)
if err != nil {
assert.Equal(t, tc.wantErr, err)
return
}
val, err := accessor.FieldAny(tc.field)
assert.Equal(t, tc.wantErr, err)
if err != nil {
return
}
assert.Equal(t, tc.wantVal, val)
})
}
}
func TestUnsafeAccessor_SetField(t *testing.T) {
testCases := []struct {
name string
entity *User
field string
newVal int
wantErr error
}{
{
name: "normal case",
entity: &User{},
field: "Age",
newVal: 18,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
accessor, err := NewUnsafeAccessor(tc.entity)
if err != nil {
assert.Equal(t, tc.wantErr, err)
return
}
err = accessor.SetField(tc.field, tc.newVal)
assert.Equal(t, tc.wantErr, err)
if err != nil {
return
}
assert.Equal(t, tc.newVal, tc.entity.Age)
})
}
}
type User struct {
Age int
}
unsafe.Pointer 和 uintptr
前面我们使用了 unsafe.Pointer 和 uintptr,这两者都代表指针,那么有什么区别?
- unsafe.Pointer:是 Go 层面的指针,GC 会维护 unsafe.Pointer 的值
- uintptr:直接就是一个数字,代表的是一个内存地址
type UnsafeAccessor struct {
fields map[string]FieldMeta
entityAddr unsafe.Pointer
}
type FieldMeta struct {
offset uintptr
}
假设说 GC 前一个 unsafe.Pointer 代表对象的指针,它此时指向的地址是 0xAAAA。
如果发生了 GC, GC 之后这个对象依旧存活,但是此时这个对象被复制过去了另外一个位置(Go GC 算法是标记 — 复制)。那么此时代表对象的 unsafe.Pointer 会被 GC 修正,指向新的地址 0xAABB。
如果使用 uintptr 来保存对象的起始地址,那么如果发生 GC 了,原本的代码会直接崩溃。
例如在 GC 前,计算到的 entityAddr = 0xAAAA,那么 GC 后因为复制的原因,实际上的地址变成了 0xAABB。
因为 GC 不会维护 uintptr 变量,所以 entityAddr 还是 0xAAAA,这个时候再用 0xAAAA 作为起始地址去访问字段,就不知道访问到什么东西了。
但是 uintptr 可以用于表达相对的量。
例如字段偏移量。这个字段的偏移量是不管怎么 GC 都不会变的。
如果怕出错,那么就只在进行地址运算的时候使用 uintptr,其它时候都用 unsafe.Pointer。
总结
- uintptr 和 unsafe.Pointer 的区别:前者代表的是一个具体的地址,后者代表的是一个逻辑上的指针。后者在 GC 等情况下,go runtime 会帮你调整,使其永远指向真实存放对象的地址。
- Go 对象是怎么对齐的?按照字长。有些比较恶心的面试官可能要你手动演示如何对齐,或者写一个对象问你怎么计算对象的大小。
- 怎么计算对象地址?对象的起始地址是通过反射来获取,对象内部字段的地址是通过起始地址 + 字段偏移量来计算。
- unsafe 为什么比反射高效?可以简单认为反射帮我们封装了很多 unsafe 的操作,所以我们直接使用 unsafe 绕开了这种封装的开销。有点像是我们不用 ORM 框架,而是直接自己写 SQL 执行查询。
本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。