原文链接:详解内存对齐

前言

哈喽,大家好,我是asong。好久不见,上周停更了一周,因为工作有点忙,好在这周末闲了下来,就连忙来肝文喽。明天咱们来聊一聊一道常见的面试八股文——内存对齐,咱们平时在业务开发中基本不care内存对齐,然而在面试中,这就是一个高频考点,明天咱们就一起来看一看到底什么是内存对齐。

前情概要

在理解内存对齐之前,先来明确几个对于操作系统的概念,更加方面咱们对内存对齐的了解。

  • 内存治理:咱们都晓得内存是计算中重要的组成之一,内存是与CPU进行沟通的桥梁,用于暂存CPU中的运算数据、以及与硬盘等内部存储器替换的数据。晚期,程序是间接运行在物理内存上的,间接操作物理内存,然而会存在一些问题,比方应用效率低、地址空间不隔离等问题,所以就呈现了虚拟内存,虚拟内存就是在程序和物理内存之间引入了一个中间层,这个中间层就是虚拟内存,这样就达到了对过程地址和物理地址的隔离。在linux零碎中,将虚拟内存划分为用户空间内核空间,用户过程只能拜访用户空间的虚拟地址,只有通过零碎调用、外设中断或异样能力拜访内核空间,咱们次要来看一下用户空间,用户空间被分为5个不同内存区域:

    • 代码段:寄存可执行文件的操作指令,只读
    • 数据段:用来寄存可执行文件中已初始化全局变量,寄存动态变量和全局变量
    • BSS段:用来存未初始化的全局变量
    • 栈区:用来存长期创立的局部变量
    • 堆区:用来存动态分配的内存段

    内存的常识先介绍个大略,对于本文的了解应该够了,咱们接着介绍操作系统几个其余概念。

  • CPU:地方处理单元(Cntral Pocessing Unit)的缩写,也叫处理器;CPU是计算机的运算外围和管制外围,咱们人类靠着大脑思考,电脑就是靠着CPU来运算、管制,起到协调和管制作用,从性能来看,CPU 的外部由寄存器、控制器、运算器和时钟四局部组成,各局部之间通过电信号连通。
  • CPU和内存的工作关系:当咱们执行一个程序时,首先由输出设施向CPU收回操作指令,CPU接管到操作指令后,硬盘中对应的程序就会被间接加载到内存中,尔后,CPU 再对内存进行寻址操作,将加载到内存中的指令翻译进去,而后发送操作信号给操作控制器,实现程序的运行或数据的解决。存在于内存中的目标就是为了CPU可能过总线进行寻址,取指令、译码、执行取数据,内存与寄存器交互,而后CPU运算,再输入数据至内存。

  • osos全称为Operating System,也就是操作操作系统,是一组主管并管制计算机操作、使用和运行硬件、软件资源和提供公共服务组织用户交互的互相关联的系统软件,同时也是计算机系统的内核与基石。
  • 编译器:编译器就是将“一种语言(通常为高级语言)”翻译为“另一种语言(通常为低级语言)”的程序。一个古代编译器的次要工作流程:源代码 (source code) → 预处理器(preprocessor) → 编译器 (compiler) → 指标代码 (object code) → 链接器 (Linker) → 可执行程序(executables)。

写在最初的一个知识点:

计算机中,最小的存储单元为字节,实践上任意地址都能够通过总线进行拜访,每次寻址能传输的数据大小就跟CPU位数无关。常见的CPU位数有8位,16位,32位,64位。位数越高,单次操作执行的数据量越大,性能也就越强。os的位数个别与CPU的位数相匹配,32CPU能够寻址4GB内存空间,也能够运行32位的os,同样情理,64位的CPU能够运行32位的os,也能够运行64位的os

何为内存对齐

以下内容来源于网络总结:

古代计算机中内存空间都是依照字节(byte)进行划分的,所以从实践上讲对于任何类型的变量拜访都能够从任意地址开始,然而在理论状况中,在拜访特定类型变量的时候常常在特定的内存地址拜访,所以这就须要把各种类型数据依照肯定的规定在空间上排列,而不是依照程序一个接一个的排放,这种就称为内存对齐,内存对齐是指首地址对齐,而不是说每个变量大小对齐。

为何要有内存对齐

次要起因能够归结为两点:

  • 有些CPU能够拜访任意地址上的任意数据,而有些CPU只能在特定地址拜访数据,因而不同硬件平台具备差异性,这样的代码就不具备移植性,如果在编译时,将调配的内存进行对齐,这就具备平台能够移植性了
  • CPU每次寻址都是要生产工夫的,并且CPU 拜访内存时,并不是一一字节拜访,而是以字长(word size)为单位拜访,所以数据结构应该尽可能地在天然边界上对齐,如果拜访未对齐的内存,处理器须要做两次内存拜访,而对齐的内存拜访仅须要一次拜访,内存对齐后能够晋升性能。举个例子:
假如以后CPU32位的,并且没有内存对齐机制,数据能够任意寄存,当初有一个int32变量占4byte,寄存地址在0x00000002 - 0x00000005(纯假如地址,莫当真),这种状况下,每次取4字节的CPU第一次取到[0x00000000 - 0x00000003],只失去变量1/2的数据,所以还须要取第二次,为了失去一个int32类型的变量,须要拜访两次内存并做拼接解决,影响性能。如果有内存对齐了,int32类型数据就会依照对齐规定在内存中,下面这个例子就会存在地址0x00000000处开始,那么处理器在取数据时一次性就能将数据读出来了,而且不须要做额定的操作,应用空间换工夫,进步了效率。

没有内存对齐机制:

内存对齐后:

对齐系数

每个特定平台上的编译器都有本人的默认"对齐系数",罕用平台默认对齐系数如下:

  • 32位零碎对齐系数是4
  • 64位零碎对齐系数是8

这只是默认对齐系数,实际上对齐系数咱们是能够批改的,之前写C语言的敌人晓得,能够通过预编译指令#pragma pack(n)来批改对齐系数,因为C语言是预处理器的,然而在Go语言中没有预处理器,只能通过tags命名约定来让Go的包能够治理不同平台的代码,然而怎么批改对齐系数,感觉Go并没有凋谢这个参数,找了良久没有找到,等前面再认真看看,找到了再来更新!

既然对齐系数无奈更改,然而咱们能够查看对齐系数,应用Go语言中的unsafe.Alignof能够返回相应类型的对齐系数,应用我的mac(64位)测试后发现,对齐系数都合乎2^n这个法则,最大也不会超过8

func main()  {    fmt.Printf("string alignof is %d\n", unsafe.Alignof(string("a")))    fmt.Printf("complex128 alignof is %d\n", unsafe.Alignof(complex128(0)))    fmt.Printf("int alignof is %d\n", unsafe.Alignof(int(0)))}运行后果string alignof is 8complex128 alignof is 8int alignof is 8

留神:不同硬件平台占用的大小和对齐值都可能是不一样的。

构造体的内存对齐规定

一提到内存对齐,大家都喜爱拿构造体的内存对齐来举例子,这里要揭示大家一下,不要混同了一个概念,其余类型也都是要内存对齐的,只不过拿构造体来举例子能更好的了解内存对齐,并且构造体中的成员变量对齐有本人的规定,咱们须要搞清这个对齐规定。

C语言的对齐规定与Go语言一样,所以C语言的对齐规定对Go同样实用:

  • 对于构造体的各个成员,第一个成员位于偏移为0的地位,构造体第一个成员的偏移量(offset)为0,当前每个成员绝对于构造体首地址的offset都是该成员大小与无效对齐值中较小那个的整数倍,如有须要编译器会在成员之间加上填充字节。
  • 除了构造成员须要对齐,构造自身也须要对齐,构造的长度必须是编译器默认的对齐长度和成员中最长类型中最小的数据大小的倍数对齐。

举个例子

依据下面的对齐规定,咱们来剖析一个例子,加深了解:

// 64位平台,对齐参数是8type User struct {    A int32 // 4    B []int32 // 24    C string // 16    D bool // 1}func main()  {    var u User    fmt.Println("u1 size is ",unsafe.Sizeof(u))}// 运行后果u size is  56

这里我的mac64位的,对齐参数是8int32[]int32stringbool对齐值别离是4881,占用内存大小别离是424161,咱们先依据第一条对齐规定剖析User

  • 第一个字段类型是int32,对齐值是4,大小为4,所以放在内存布局中的第一位.
  • 第二个字段类型是[]int32,对齐值是8,大小为24,依照第一条规定,偏移量应该是成员大小24与对齐值8中较小那个的整数倍,那么偏移量就是8,所以4-7位会由编译进行填充,个别为0值,也称为空洞,第932位为第二个字段B.
  • 第三个字段类型是string,对齐值是8,大小为16,所以他的内存偏移值必须是8的倍数,因为user前两个字段就曾经排到了第32位,所以offset32正好是8的倍数,不要填充,从32位到48位是第三个字段C.
  • 第四个字段类型是bool,对齐值是1,大小为1,所以他的内存偏移值必须是1的倍数,因为user前两个字段就曾经排到了第48位,所以下一位的偏移量正好是48,正好是字段D的对齐值的倍数,不必填充,能够间接排列到第四个字段,也就是从48到第49位是第三个字段D.

依据第一条规定剖析后,当初构造所占大小为49字节,咱们再来依据第二条规定剖析:

  • 依据第二条规定,默认对齐值是8,字段中最大类型水平是24,所以求出构造体的对齐值是8,咱们目前的内存长度是49,不是8的倍数,所以须要补齐,所以最终的后果就是56,补了7位。

成员变量程序对内存对齐带来的影响

依据下面的规定咱们能够看出,成员变量的程序也会影响内存对齐的后果,咱们先来看一个例子:

type test1 struct {    a bool // 1    b int32 // 4    c string // 16}type test2 struct {    a int32 // 4    b string // 16    c bool // 1}func main()  {    var t1 test1    var t2 test2    fmt.Println("t1 size is ",unsafe.Sizeof(t1))    fmt.Println("t2 size is ",unsafe.Sizeof(t2))}

运行后果:

t1 size is  24t2 size is  32

test1的内存布局:

test2的内存布局:

)

通过以上剖析,咱们能够看出,构造体中成员变量的程序会影响构造体的内存布局,所以在日常开发中大家要留神这个问题,能够节俭内存空间。

空构造体字段对齐

Go语言中空构造体的大小为0,如果一个构造体中蕴含空构造体类型的字段时,通常是不须要进行内存对齐的,举个例子:

type demo1 struct {    a struct{}    b int32}func main()  {    fmt.Println(unsafe.Sizeof(demo1{}))}运行后果:4

从运行后果可知构造体demo1占用的内存与字段b占用内存大小雷同,所以字段a是没有占用内存的,然而空构造体有一个特例,那就是当 struct{} 作为构造体最初一个字段时,须要内存对齐。因为如果有指针指向该字段, 返回的地址将在构造体之外,如果此指针始终存活不开释对应的内存,就会有内存泄露的问题(该内存不因构造体开释而开释),所以当struct{}作为构造体成员中最初一个字段时,要填充额定的内存保障平安。

type demo2 struct {    a int32    b struct{}}func main()  {    fmt.Println(unsafe.Sizeof(demo2{}))}运行后果:8

思考内存对齐的设计

在之前的文章源码分析sync.WaitGroup剖析sync.waitgroup的源码时,应用state1来存储状态:

// A WaitGroup must not be copied after first use.type WaitGroup struct { noCopy noCopy // 64-bit value: high 32 bits are counter, low 32 bits are waiter count. // 64-bit atomic operations require 64-bit alignment, but 32-bit // compilers do not ensure it. So we allocate 12 bytes and then use // the aligned 8 bytes in them as state, and the other 4 as storage // for the sema. state1 [3]uint32}

state1这里总共被调配了12个字节,这里被设计了三种状态:

  • 其中对齐的8个字节作为状态,高32位为计数的数量,低32位为期待的goroutine数量
  • 其中的4个字节作为信号量存储

提供了(wg *WaitGroup) state() (statep *uint64, semap *uint32)帮忙咱们从state1字段中取出他的状态和信号量,为什么要这样设计呢?

因为64位原子操作须要64位对齐,然而32位编译器不能保障这一点,所以为了保障waitGroup32位平台上应用的话,就必须保障在任何时候,64位操作不会报错。所以也就不能分成两个字段来写,思考到字段程序不同、平台不同,内存对齐也就不同。因而这里采纳动静辨认以后咱们操作的64位数到底是不是在8字节对齐的地位下面,咱们来剖析一下state办法:

// state returns pointers to the state and sema fields stored within wg.state1.func (wg *WaitGroup) state() (statep *uint64, semap *uint32) { if uintptr(unsafe.Pointer(&wg.state1))%8 == 0 {  return (*uint64)(unsafe.Pointer(&wg.state1)), &wg.state1[2] } else {  return (*uint64)(unsafe.Pointer(&wg.state1[1])), &wg.state1[0] }}

当数组的首地址是处于一个8字节对齐的地位上时,那么就将这个数组的前8个字节作为64位值应用示意状态,后4个字节作为32位值示意信号量(semaphore)。同理如果首地址没有处于8字节对齐的地位上时,那么就将前4个字节作为semaphore,后8个字节作为64位数值。画个图示意一下:

)

总结

终于靠近序幕了,内存对齐始终面试中的高频考点,通过内存对齐能够理解面试者对操作系统常识的理解水平,所以这块常识还是比拟重要的,心愿这篇文章能帮忙大家答疑解惑,更好的忽悠面试官~。

文中代码已上传github:https://github.com/asong2020/... 欢送star;

文中有任何问题欢送留言区探讨~;

欢送关注公众号:Golang梦工厂

举荐往期文章:

  • 学习channel设计:从入门到放弃
  • 面试官:小松子来聊一聊内存逃逸
  • Go语言中new和make你应用哪个来分配内存?
  • 源码分析panic与recover,看不懂你打我好了!
  • 空构造体引发的大型打脸现场
  • [面试官:你能聊聊string和[]byte的转换吗?](https://mp.weixin.qq.com/s/jz...
  • 面试官:两个nil比拟后果是什么?
  • 面试官:你能用Go写段代码判断以后零碎的存储形式吗?