Go语言源码分析之unsafe

1.什么是unsafe

unsafe 库让 golang 可以像C语言一样操作计算机内存,但这并不是golang推荐使用的,能不用尽量不用,就像它的名字所表达的一样,它绕过了golang的内存安全原则,是不安全的,容易使你的程序出现莫名其妙的问题,不利于程序的扩展与维护。

先简单介绍下Golang指针类型:

  1. *类型:普通指针,用于传递对象地址,不能进行指针运算。
  2. unsafe.Pointer:通用指针类型,用于转换不同类型的指针,不能进行指针运算。
  3. uintptr:用于指针运算,GC 不把 uintptr 当指针,uintptr 无法持有对象,uintptr 类型的目标会被回收

unsafe.Pointer 可以和 普通指针 进行相互转换。

unsafe.Pointer 可以和 uintptr 进行相互转换。

也就是说 unsafe.Pointer 是桥梁,可以让任意类型的指针实现相互转换,也可以将任意类型的指针转换为 uintptr 进行指针运算。

unsafe底层源码如下:

两个类型:

// go 1.14 src/unsafe/unsafe.go
type ArbitraryType int
type Pointer *ArbitraryType

ArbitraryType是int的一个别名,在Go中对ArbitraryType赋予特殊的意义。代表一个任意Go表达式类型。

Pointer 是 int指针类型 的一个别名,在Go中可以把Pointer类型,理解成任何指针的父类型。

三个函数:

func Sizeof(x ArbitraryType) uintptr
func Offsetof(x ArbitraryType) uintptr
func Alignof(x ArbitraryType) uintptr

通过分析发现,这三个函数的参数均是ArbitraryType类型,就是接受任何类型的变量。

  1. Sizeof 返回类型 x 所占据的字节数,但不包含 x 所指向的内容的大小。例如,对于一个指针,函数返回的大小为 8 字节(64位机上),一个 slice 的大小则为 slice header 的大小。
  2. Offsetof返回变量指定属性的偏移量,这个函数虽然接收的是任何类型的变量,但是有一个前提,就是变量要是一个struct类型,且还不能直接将这个struct类型的变量当作参数,只能将这个struct类型变量的属性当作参数。
  3. Alignof返回变量对齐字节数量

2.unsafe包的操作

2.1大小Sizeof

unsafe.Sizeof函数返回的就是uintptr类型的值,表示所占据的字节数(表达式,即值的大小):

package main

import (
"fmt"
"reflect"
"unsafe"
) func main() {
var a int32
var b = &a
fmt.Println(reflect.TypeOf(unsafe.Sizeof(a))) // uintptr
fmt.Println(unsafe.Sizeof(a)) // 4
fmt.Println(reflect.TypeOf(b).Kind()) // ptr
fmt.Println(unsafe.Sizeof(b)) // 8
}

对于 a来说,它是int32类型,在内存中占4个字节,而对于b来说,是*int32类型,即底层为ptr指针类型,在64位机下占8字节。

2.2偏移Offsetof

对于一个结构体,通过 Offset 函数可以获取结构体成员的偏移量,进而获取成员的地址,读写该地址的内存,就可以达到改变成员值的目的。

这里有一个内存分配相关的事实:结构体会被分配一块连续的内存,结构体的地址也代表了第一个字段的地址。

举个例子:

package main

import (
"fmt"
"unsafe"
) type user struct {
id int32
name string
age byte
} func main() {
var u = user{
id: 1,
name: "xiaobai",
age: 22,
}
fmt.Println(u)
fmt.Println(unsafe.Offsetof(u.id)) // 0 id在结构体user中的偏移量,也是结构体的地址
fmt.Println(unsafe.Offsetof(u.name)) // 8
fmt.Println(unsafe.Offsetof(u.age)) // 24 // 根据偏移量修改字段的值 比如将id字段改为1001
// 因为结构体的地址相当于第一个字段id的地址
// 直接用unsafe包自带的Pointer获取id指针
id := (*int)(unsafe.Pointer(&u))
*id = 1001 // 更加相对于id字段的偏移量获取name字段的地址并修改其内容
// 需要用到uintptr进行指针运算 然后再利用unsafe.Pointer这个媒介将uintptr类型转换成一般的指针类型*string
name := (*string)(unsafe.Pointer(uintptr(unsafe.Pointer(&u)) + unsafe.Offsetof(u.name)))
*name = "花花" // 同理更改age字段
age := (*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&u)) + unsafe.Offsetof(u.age)))
*age = 33 fmt.Println(u)
}

2.3对齐Alignof

要了解这个函数,你需要了解数据对齐。简单的说,它让数据结构在内存中以某种的布局存放,是该数据的读取性能能够更加的快速。

CPU 读取内存是一块一块读取的,块的大小可以为 2、4、6、8、16 字节等大小。块大小我们称其为内存访问粒度。

普通字段的对齐值

fmt.Printf("bool align: %d\n", unsafe.Alignof(bool(true)))
fmt.Printf("int32 align: %d\n", unsafe.Alignof(int32(0)))
fmt.Printf("int8 align: %d\n", unsafe.Alignof(int8(0)))
fmt.Printf("int64 align: %d\n", unsafe.Alignof(int64(0)))
fmt.Printf("byte align: %d\n", unsafe.Alignof(byte(0)))
fmt.Printf("string align: %d\n", unsafe.Alignof("EDDYCJY"))
fmt.Printf("map align: %d\n", unsafe.Alignof(map[string]string{}))

输出结果:

bool align: 1
int32 align: 4
int8 align: 1
int64 align: 8
byte align: 1
string align: 8
map align: 8

在 Go 中可以调用 unsafe.Alignof 来返回相应类型的对齐系数。通过观察输出结果,可得知基本都是 2n,最大也不会超过 8。这是因为我们的64位编译器默认对齐系数是 8,因此最大值不会超过这个数。

对齐规则

  1. 结构体的成员变量,第一个成员变量的偏移量为 0。往后的每个成员变量的对齐值必须为编译器默认对齐长度#pragma pack(n))或当前成员变量类型的长度unsafe.Sizeof),取最小值作为当前类型的对齐值。其偏移量必须为对齐值的整数倍
  2. 结构体本身,对齐值必须为编译器默认对齐长度结构体的所有成员变量类型中的最大长度取最大数的最小整数倍作为对齐值

结合以上两点,可得知若编译器默认对齐长度超过结构体内成员变量的类型最大长度时,默认对齐长度是没有任何意义的

结构体的对齐值

下面来看一下结构体的对齐:

type part struct {
a bool // 1
b int32 //4
c int8 // 1
d int64 // 8
e byte // 1
} func main() {
var p part
fmt.Println(unsafe.Sizeof(p)) // 32
}

按照普通字段(结构体内成员变量)的对齐方式,我们可以计算得出,这个结构体的大小占1+4+1+8+1=15个字节,但是用unsafe.Sizeof计算发现part结构体32字节,是不是有点惊讶

这里面就涉及到了内存对齐,下面我们来分析一下:

成员变量 类型 偏移量 自身占用
a bool 0 1
数据对齐 - 1 3
b int32 4 4
c int8 8 1
数据对齐 - 9 7
d in64 16 8
e byte 24 1
数据对齐 - 25 7
总占用大小 - - 32
  • 对于变量a而言

    类型是bool;大小/对齐值本身为1字节;偏移量为0,占用了第0位;此时内存中表示为a

  • 对于变量b而言

    类型是int32;大小/对齐值本身为4字节;根据对齐规则一,偏移量必须为对齐值4的整数倍,故这里的偏移量为4,占用了第47位**,则**第13位用padding字节填充;此时内存中表示为a---|bbbb,(|只起到分隔作用,表示方便一些)

  • 对于变量c而言

    类型是int8;大小/对齐值本身为1字节;当前偏移量为8,无需扩充,占用了第8位;此时内存中表示为a---|bbbb|c

  • 对于变量d而言

    类型是int64;大小/对齐值本身为8字节;根据对齐规则一,偏移量必须为对齐值8的整数倍,故这理的偏移量为16,占用了第1623位**,则**第915为用padding字节填充;此时内存中表示为a---|bbbb|c---|----

  • 对于变量e而言

    类型是byte;大小/对齐值本身为1字节;当前偏移量为24,无需扩充,占用了第24位;此时内存中表示为a---|bbbb|c---|----|e

这里计算后,发现总共占用25字节,哪里又来的32字节呢? :flushed:

再让我们回顾一下对齐原则的第二点,“结构体本身,对齐值必须为编译器默认对齐长度或结构体的所有成员变量类型中的最大长度,取最大数的最小整数倍作为对齐值”

  1. 这里编译器默认对齐长度为8字节(64位机)

  2. 结构体中所有成员变量类型的最大长度为int64,8字节

  3. 取二者最大数的最小整数倍作为对齐值,我们算的part结构体大小为25字节,不是8字节的整数倍,故还需要填充到32字节。

综上,part结构体在内存中表示为a---|bbbb|c---|----|e----|----

扩展

让我们改变一下part结构体中字段的顺序看看(part结构体完全相同)

type part struct {
a bool // 1
c int8 // 1
e byte // 1
b int32 //4
d int64 // 8
} func main() {
var p part
fmt.Println(unsafe.Sizeof(p)) // 16
}

这时候再用unsafe.Sizeof查看会发现,part结构体的内存占用只有16字节,瞬间减少了一般的内存空间,大家可以按照前面的步骤分析一下~

这里建议在构建结构体时,按照字段大小的升序进行排序,会减少一点的内存空间。

反射包的对齐方法

反射包也有某些方法可用于计算对齐值:

unsafe.Alignof(w)等价于reflect.TypeOf(w).Align
unsafe.Alignof(w.i)等价于reflect.Typeof(w.i).FieldAlign()

总结

  • unsafe 包绕过了 Go 的类型系统,达到直接操作内存的目的,使用它有一定的风险性。但是在某些场景下,使用 unsafe 包提供的函数会提升代码的效率,Go 源码中也是大量使用 unsafe 包。

  • unsafe 包定义了 Pointer 和三个函数:

    type ArbitraryType int
    type Pointer *ArbitraryType
    func Sizeof(x ArbitraryType) uintptr
    func Offsetof(x ArbitraryType) uintptr
    func Alignof(x ArbitraryType) uintptr

    通过三个函数可以获取变量的大小、偏移、对齐等信息。

  • uintptr 可以和 unsafe.Pointer 进行相互转换,uintptr 可以进行数学运算。这样,通过 uintptr 和 unsafe.Pointer 的结合就解决了 Go 指针不能进行数学运算的限制。

  • 通过 unsafe 相关函数,可以获取结构体私有成员的地址,进而对其做进一步的读写操作,突破 Go 的类型安全限制。

参考:

Go语言源码分析之unsafe的更多相关文章

  1. [编织消息框架][netty源码分析]7 Unsafe 实现类NioSocketChannelUnsafe职责与实现

    Unsafe 是channel的内部接口,从书写跟命名上看是不公开给开发者使用的,直到最后实现NioSocketChannelUnsafe也没有公开出去 public interface Channe ...

  2. u-boot源码分析之C语言段

    题外话: 最近一直在学习u-boot的源代码,从代码量到代码风格,都让我认识到什么才是真正的程序.以往我所学到的C语言知识和u-boot的源代码相比,实在不值一提.说到底,机器都是0和1控制的.感觉这 ...

  3. Linux内核源码分析--内核启动之(3)Image内核启动(C语言部分)(Linux-3.0 ARMv7)

    http://blog.chinaunix.net/uid-20543672-id-3157283.html Linux内核源码分析--内核启动之(3)Image内核启动(C语言部分)(Linux-3 ...

  4. Java并发编程笔记之Unsafe类和LockSupport类源码分析

    一.Unsafe类的源码分析 JDK的rt.jar包中的Unsafe类提供了硬件级别的原子操作,Unsafe里面的方法都是native方法,通过使用JNI的方式来访问本地C++实现库. rt.jar ...

  5. [源码分析]读写锁ReentrantReadWriteLock

    一.简介 读写锁. 读锁之间是共享的. 写锁是独占的. 首先声明一点: 我在分析源码的时候, 把jdk源码复制出来进行中文的注释, 有时还进行编译调试什么的, 为了避免和jdk原生的类混淆, 我在类前 ...

  6. 并发-AtomicInteger源码分析—基于CAS的乐观锁实现

    AtomicInteger源码分析—基于CAS的乐观锁实现 参考: http://www.importnew.com/22078.html https://www.cnblogs.com/mantu/ ...

  7. NIO-SocketChannel源码分析

    目录 NIO-SocketChannel源码分析 目录 前言 ServerSocketChannelImpl 创建ServerSocketChannel 绑定和监听 接收 SocketChannelI ...

  8. 超详细Go语言源码目录说明

    开源项目「go home」聚焦Go语言技术栈与面试题,以协助Gopher登上更大的舞台,欢迎go home~ 导读 学习Go语言源码的第一步就是了解先了解它的目录结构,你对它的源码目录了解多少呢?今天 ...

  9. Golang源码分析之目录详解

    开源项目「go home」聚焦Go语言技术栈与面试题,以协助Gopher登上更大的舞台,欢迎go home~ 导读 学习Go语言源码的第一步就是了解先了解它的目录结构,你对它的源码目录了解多少呢? 目 ...

随机推荐

  1. 提升Idea启动速度与Tomcat日志乱码问题

    提升Idea启动速度与Tomcat日志乱码问题 前言 由于重装了一次Idea,所以有些设置时间就忘了,在此做个记录,以便以后忘记后可以来翻阅 Idea启动速度 一.将Idea所在的 安装文件夹 在wi ...

  2. visualvm工具远程对linux服务器上的JVM虚拟机进行监控与调优

    文/朱季谦 最近在做了一些JVM监控与调优的事情,算是第一次实践,还比较陌生,故而先把这一次经验简单记下笔记,这样,对后面学习调优方面时,不至于又想不起来了.本文档主要总结在window本地环境远程对 ...

  3. PAT (Basic Level) Practice (中文) 1050 螺旋矩阵 (25 分) 凌宸1642

    PAT (Basic Level) Practice (中文) 1050 螺旋矩阵 (25 分) 目录 PAT (Basic Level) Practice (中文) 1050 螺旋矩阵 (25 分) ...

  4. [SpringCloud教程]2. 版本选型和项目搭建

    Spring Cloud Alibaba 版本选型 建议先选择Spring Cloud Alibaba的大版本,方便兼容 选择 Spring Cloud Alibaba 大版本 访问链接,找到标题&q ...

  5. 全网最详细的Linux命令系列-iptrad-ng网络流量监测命令

    观察网络流量的工具:IPTRAF 想知道你的Linux系统上网络流量有多大吗?想知道是哪一块网卡承载着网络流量吗?想知道哪一个进程产生了网络流量吗?iptraf可以帮你做到.在最新的Linux rel ...

  6. Python基础(二十一):面向对象“类”第四课——魔法方法

    先划一下重点: 6个魔法方法: 动态操作属性的4个函数: 魔法方法 魔法方法的简单介绍 魔法方法的命名规则:方法名(前后各有2个下划线). 通常情况下,不会主动去调用魔法方法,而是在满足一定的条件下, ...

  7. python中栈的实现

    栈是一种线性数据结构,用先进后出或者是后进先出的方式存储数据,栈中数据的插入删除操作都是在栈顶端进行,常见栈的函数操作包括 empty() – 返回栈是否为空 – Time Complexity : ...

  8. Java(94-100)【数组、对象】

    1.数组作为方法的参数 任何数据类型都可以作为方法的参数 直接建立数组的方法,将数组作为方法的参数. 当调用方法的时候传递的是数组的地址. 2.数组作为方法的返回值 任何数据类型都可以作为方法的参数, ...

  9. 【笔记】《Redis设计与实现》chapter15 复制

    15.1 旧版复制功能的实现 同步 命令传播 旧版复制功能的缺陷 15.3 新版复制功能的实现 Redis2.8开始,使用PSYNC命令替代SYNC命令来执行复制时的同步操作 PSYNC命令具有完整重 ...

  10. Hadoop完整搭建过程(一):本地模式

    1 本地模式 本地模式是最简单的模式,所有模块都运行在一个JVM进程中,使用本地文件系统而不是HDFS. 本地模式主要是用于本地开发过程中的运行调试用,下载后的Hadoop不需要设置默认就是本地模式. ...