内存管理 2022-03-29

2022-03-29  本文已影响0人  9_SooHyun

虚拟内存

虚拟内存(Virtual Memory)是指计算机呈现出要比实际拥有的内存大得多的内存量,这允许程式员编制并运行比实际系统拥有的Memory大得多的程式。一个非常恰当的比喻是:你不必非常长的轨道就能让一列火车从上海开到北京。你只需要足够长的铁轨(比如说3公里)就能完成这个任务。采取的方法是把后面的铁轨即时铺到火车的前面,只要你的操作足够快并能满足需求,列车就能象在一条完整的轨道上运行

进程占用的内存空间在“进程的逻辑”上看是连续的,也就是【虚拟地址】连续。但不同进程在真实物理地址上分散

虚拟内存是计算机系统内存管理的一种技术。它使得应用程序认为它拥有连续可用的内存(一个连续完整的地址空间),而实际上物理内存通常被分隔成多个内存碎片,还有部分暂时存储在外部磁盘存储器上,在需要时进行数据交换。对虚拟内存的定义是基于对地址空间的重定义的,即把地址空间定义为“连续的虚拟内存地址”,以借此“欺骗”程序,使它们以为自己正在使用一大块的“连续”地址。把内存扩展到磁盘只是使用虚拟内存技术的一个结果,它的作用也可以通过覆盖或者把处于不活动状态的程序以及它们的数据全部交换到磁盘上等方式来实现

Linux内核将虚拟地址空间分成了两部分:

虚拟地址解决的问题

虚拟内存到物理内存的映射

映射是谁控制的呢?操作系统。映射经历了从内存分段到内存分页的转变

内存分段

虚拟内存分段映射,它的思想是以段为单位在虚拟地址空间和物理地址空间之间做一一映射。比如说虚拟地址空间中某个 10M 大小的空间映射到物理地址空间中某个 10M 大小的空间。

物理地址空间都是彼此分开的。通过分段这种方式,就实现了进程间的地址隔离。以实例说明,假设有两个进程 A 和 B ,进程 A 所需内存大小为 10M ,其虚拟地址空间分布在 0x00000000 到 0x00A00000 ,进程 B 所需内存为 100M ,其虚拟地址空间分布为 0x00000000 到 0x06400000 。那么按照分段的映射方法,进程 A 在物理内存上映射区域为 0x00100000 到 0x00B00000 ,,进程 B 在物理内存上映射区域为0x00C00000 到 0x07000000 。于是进程 A 和进程 B 分别被映射到了不同的内存区间,彼此互不重叠,实现了地址隔离。

但是分段的方法,当内存不足而又需要运行新的程序时,需要把运行中的程序占用的内存释放一部分,将数据先转移到硬盘上,这就需要一段一段内存地转移数据。按段进行数据转移还是太重了,于是进化出了【内存分页】

内存分页

页/页帧是操作系统层面的概念

内核使用struct page作为基本单位来管理物理内存,在内核看来,所有的RAM都被划分成了固定长度的页帧。每一个页帧包含了一个页,也就是说一个页帧的长度和一个页的长度相同。页帧是主存的一部分,是一个存储区域。页和页帧的区别在于,页是抽象的、描述页帧的数据结构,可以存放在任意地方,而页帧是真实的存储区域。

操作系统把物理内存按照固定大小划分为页帧(Page Frame),在逻辑内存中对应为页(Page),外存(磁盘等)按照同样固定大小划分为块(Block)

一个程序被划分为 x 个block,存储在硬盘上,内存被划分为 n 个页,内存中的页和程序的block大小对应

一个程序可能最后一个block不会刚好占用一整个页的存储,这并不影响。在运行某个程序时,将它的页加载到内存中即可

根据页计算物理地址物理地址 = 物理页框号 * 页面大小 + 页内偏移量

通常一页为4KB,在使用4KB大小的页的情况下,4GB的虚拟内存就需要10^6(1M)个页表。即便每个页表项大小为4字节也需要4MB内存

页表:

虚拟存储器地址到主存地址的变换是由放在主存的页表实现的
页表中每一个虚存逻辑页号有一条记录,记录包含该逻辑页对应的主存页面地址(物理页号)。用它作为实存地址的高字段,与虚存地址的页内行地址字段相拼接,产生完整的实主存地址,据此来访问主存

Single-level page table vs Multi-level page table
首先要知道,每个进程都维护了一份自己的页表
在Single-level page table的设计下,每个进程都维护了一张全局的页表。这也就是说,每个进程的页表,既存储了自身需要的address entry,也存储了大量无关的address entry(例如其他进程的address entry或者一些空闲的address entry)。对每个进程而言,它只关心自身占用的address entry,其他的都是无用的,存储整个全局页表造成了内存的浪费
例如:
假设一个内存系统的页表一共有00-99共100个entry,使用Single-level page table,则每个进程都存储了这100个entry,设存储页表消耗100个内存单位。如果进程P的实际内存占用只需要00-09这10页,那剩下的90个entry将造成90个内存单位的浪费

于是,我们引出了Multi-level page table 多级页表。这本质上是两种解决问题的思想:分治 + 以时间换空间
多级页表是将大页表拆分成了树形结构,只有被使用到的页表项才会加载进内存
还是上面的例子,对于00、23、54、78、99这些entry,我们可以按位分片——十位相同的收归一个entry,得到0-9共10个一级entry(构成一级页表),每个一级entry再映射到10个二级entry,如 0 -> [00, 01, ..., 09]。这样一来,如果进程P的实际内存占用只需要存储含有10个一级entry的一级页表 + 含有10个二级entry的二级页表即可,共占用20个内存单位,比100小了很多。但需要注意的是,一次完整的地址转换涉及的查找次数增加了,以时间换来了空间,不过也是由n变成3n 4n而已,数量级不变,效率仍然可以保证

more see: https://stackoverflow.com/questions/29467510/how-does-multi-level-page-table-save-memory-space

cpu对内存的访问模式

CPU 访问内存时,并不是逐个字节访问,而是以字长(word size)为单位访问。比如 32 位的 CPU ,字长为 4 字节,那么 CPU 访问内存的单位也是 4 字节

程序中的内存对齐

golang可以使用 unsafe.Sizeof 计算出一个数据类型实例需要占用的字节数
unsafe.Alignof方法则可以返回一个类型的对齐值a,也可以叫做对齐系数或者对齐倍数。类型实例占据的空间总量必须是对齐值a的倍数

一个结构体实例所占据的空间等于各字段占据空间之和,再加上内存对齐的空间大小

// You can edit this code!
// Click here and start typing.
package main

import (
    "fmt"
    "unsafe"
)

func main() {
    type IntString struct {
        I int
        S string
    }
    type StringInt struct {
        S string
        I int
    }
    type Flag struct {
        num1 int16
        num2 int32
    }
    is := IntString{}
    si := StringInt{}
    f := Flag{}
    fmt.Println("size of is ", unsafe.Sizeof(is), "size of si ", unsafe.Sizeof(si), "size of f ", unsafe.Sizeof(f))
    // unsafe.Alignof 返回对齐系数
    fmt.Println(unsafe.Alignof(is), unsafe.Alignof(si), unsafe.Alignof(f))
}


size of is  24 size of si  24 size of f  8
8 8 4
see https://go.dev/play/p/0TngZEZsGub

Go 官方文档 Size and alignment guarantees - golang spec 描述了 unsafe.Alignof 的规则。

  1. For a variable x of any type: unsafe.Alignof(x) is at least 1.
  2. For a variable x of struct type: unsafe.Alignof(x) is the largest of all the values unsafe.Alignof(x.f) for each field f of x, but at least 1.
  3. For a variable x of array type: unsafe.Alignof(x) is the same as the alignment of a variable of the array’s element type.

结构体每个字段按照自身的对齐倍数来确定在内存中的偏移量,每个字段的起始地址必须满足
address mod m = 0, 其中 m = unsafe.Alignof(struct.field)
【每个字段的起始地址是其自身对齐系数的整倍数】,这样的设计是为了cpu和内存交互时可以快速定位

一个golang结构体一般包含多种不同类型的字段,不同类型所占用内存大小不同,其起始地址的要求也不同,因此一个结构体不同字段就可能存在内存间隙,也就是说结构体内部的字段在内存中的分配并不一定紧密连续。golang struct的合理布局可以减少内存占用:

// You can edit this code!
// Click here and start typing.
package main

import (
    "fmt"
    "unsafe"
)

type demo1 struct {
    a int8
    b int16
    c int32
}

type demo2 struct {
    a int8
    c int32
    b int16
}

func main() {
    fmt.Println(unsafe.Sizeof(demo1{})) // 8
    fmt.Println(unsafe.Sizeof(demo2{})) // 12

    fmt.Println(unsafe.Alignof(demo1{})) // 4
    fmt.Println(unsafe.Alignof(demo2{})) // 4
}

// see https://go.dev/play/p/2QTp6Vq3YPe

demo1{}和demo2{}同样的对齐值,同样的字段,只是顺序不一致,就导致了整个结构体占用的内存大小不同
回顾一下2个原则:

再看一个更具体详细的例子,理解struct内存对齐

// You can edit this code!
// Click here and start typing.
package main

import (
    "fmt"
    "unsafe"
)

type demo1 struct {
    a int8
    b int16
    c int32
    d string
}

type demo2 struct {
    d string
    a int8
    c int32
    b int16
}

func main() {
    de1 := demo1{}
    de2 := demo2{}

    fmt.Println(unsafe.Alignof(de1)) // 8
    fmt.Println(unsafe.Sizeof(de1))  // [1(+1), 2, 4, 16] = 24
    fmt.Printf("%p\n", &de1.a)       // 0xc00000c030
    fmt.Printf("%p\n", &de1.b)       // 0xc00000c032 b的起始地址是其自身对齐系数的整倍数,因此a后面需要补充1字节的间隙
    fmt.Printf("%p\n", &de1.c)       // 0xc00000c034
    fmt.Printf("%p\n", &de1.d)       // 0xc00000c038

    fmt.Println(unsafe.Alignof(de2))   // 8
    fmt.Println(unsafe.Alignof(de2.d)) // 8
    fmt.Println(unsafe.Sizeof(de2))    // [16, 1(+3), 4, 2(+6)] = 32
    fmt.Printf("%p\n", &de2.d)         // 0xc00005c020
    fmt.Printf("%p\n", &de2.a)         // 0xc00005c030
    fmt.Printf("%p\n", &de2.c)         // 0xc00005c034 c的起始地址是其自身对齐系数的整倍数,因此a后面需要补充3字节的间隙
    fmt.Printf("%p\n", &de2.b)         // 0xc00005c038 demo2结构体的对齐值是8,因此b实际占用了2+6=8字节,从而保证de2整体内存占用为8字节的倍数32字节
}

// see https://go.dev/play/p/Q2zWdyb0Yo-

参考文献:https://zhuanlan.zhihu.com/p/349049486

上一篇下一篇

猜你喜欢

热点阅读