欢迎光临
我们一直在努力

深入浅出理解计算机核心知识系列【操作系统合集-内存管理篇】

本人志在持续更新计算机系统、计算机网络、C++语言的核心知识点的系列合集,以易懂、全面的方式讲解底层知识。对于正在准备面试八股的朋友来说,本系列涵盖了本人面试中遇到的所有考点以及许多相关拓展知识,读完后能帮助你从容面对大部分面试拷打;对于想要深入学习计算机知识的朋友来说,本系列比较系统地介绍了操作系统和网络等重点内容,也举了不少例子,大大有助于你从底层的视角去理解计算机系统。
先说明,本系列恐怕不是计算机小白或是想速通期末的朋友们的目标,它需要一定系统和语言基础,也并不是面向教材和考试要求去讲解,所以更适合那些实操过代码、了解一些计算机系统知识、并且想要深入底层和扎实基础的朋友们去耐心学习。如果你是这样的人,欢迎阅读该系列文章,并分享自己的理解或提出文章中的模糊、错误的地方(不排除有)。
想要阅读系列中其他内容或想要持续关注本系列更新可移步:https://github.com/feiyangyang11/Cpp-Core-CS-Interview-Guide.git。


基于C++探索内存管理

程序的内存布局

一个 C++ 程序被加载到内存后是如何分区、如何运行的?

进程虚拟地址空间一览

进程详细知识已在 操作系统合集-进程篇中 有详细讲述,此处简单介绍

一个 C++ 进程的虚拟地址空间大致如下(实际物理地址并非如此,但是系统用虚拟地址来模拟出这样一个完整的逻辑内存空间):

高地址

├───────────────
│ Stack(栈)

├───────────────

│ ↓

│ 空闲区域

│ ↑

├───────────────
│ Heap(堆)

├───────────────

│ mmap区域

├───────────────

│ .bss

├───────────────

│ .data

├───────────────

│ .rodata

├───────────────

│ .text

低地址

代码段 .text

存放 程序所有函数编译后的机器指令,这部分是只读的

int add(int a,int b){
return a+b;
}
//诸如上面这样的函数,被编译成下列汇编指令之后,再变为机器码以二进制的形式存在 .text 段中
push rbp
mov rbp,rsp
mov eax,edi
add eax,esi
pop rbp
ret

只读数据段 .rodata

存放所有在程序运行期间不会变化的数据——常量、字面量

//如 “hello” 本身就会被存放在 .rodata
//但 s 是一个指针变量,它本身在栈区,它的值就是 "hello" 的地址值
const char* s="hello";

数据段 .data

要存放具有静态或全局性的、并且需要需要从磁盘读取初始值的非零初值初始化的可写数据

int g = 10;//被初始化为非零值的全局变量会被存放在 .data,生命周期贯穿整个程序,运行时允许修改

int main() {
}

static int x = 20;

void func() {
static int x = 20;
}
//静态变量可以写在函数内或函数外,这决定了它的作用域
//但无论如何,它的生命周期同样从程序一启动开始,贯穿整个程序
//被初始化的,也会存放在 .data

但存在一种特殊情况

const int g4 = 20;//被声明为常量的全局/静态变量,可能会被放到 .rodata,甚至可能变为立即数直接嵌入机器码 .text
int main() {}

.bss段

与 .data 相似,但要存放具有静态或全局性的、并且未初始化或初始化值为0的可写数据

.bss 段整个空间都是 000000……未初始化的全局/静态变量放在这,也就代表默认初始化为 0 了

为什么要和 .data 区分出来?

因为这样可以减少磁盘上可执行文件的存储大小。存放一大堆值为 0 的变量,不如将它们集中到一起,然后记录需要多大空间即可。程序加载时,为它们准备这么一片空间,然后全部初始化为 0

栈段、堆段、mmap

接下来单独详细展开

栈段保存局部变量和函数调用/返回地址,其根本目的是为了支持函数调用

为什么需要栈

这是一个简单的程序

int add(int a, int b)
{
int c = a + b;
return c;
}

int main()
{
int x = 10;
int y = 20;
int z = add(x, y);
}

假如 CPU 正执行到main(),接下来执行到add(x,y),那么如何保证在函数体中正确创建局部变量和函数调用返回时继续执行main()的下条指令?

自然是给执行中的函数一个私人空间,无论是main()还是add(),还是其他函数,都需要这样一个空间。进入函数,空间建立;离开函数,空间回收,在原有空间的原有位置继续执行,这就是——函数栈帧

栈帧

还是上面的例子,调用一次add(),栈上会产生一块新的区域,大体结构如下:

高地址

├───────────────
│ 上一个函数
├───────────────
│ 返回地址// main() 的下一条机器指令的地址存在这,寄存器 pc 跳转到 add() 的第一条指令
├───────────────
│ 保存的 rbp//把 main() 的起址从寄存器 rbp 中取出暂存在这里;把 add() 的起址(也就是此处)存到 rbp
├───────────────
│ 参数 b//它不是 add() 外的变量,而是它的另一份拷贝(如果是引用或指针,则另说),生命周期和作用域都在 add() 中
├───────────────
│ 参数 a//它不是 add() 外的变量,而是它的另一份拷贝(如果是引用或指针,则另说),生命周期和作用域都在 add() 中
├───────────────
│ 局部变量 c
├───────────────
│ 临时变量
└───────────────
低地址

当add()执行完毕返回时,rsp 直接弹栈回到 rbp保存的位置,也就是 add()起址,这里存了main()起址,rbp把这个地址存回自身,'rsp’继续弹栈,弹出了返回地址,pc把这个地址存回自身,下条指令就从这里开始执行,也就是继续走main()的原有逻辑。栈帧就此被回收

C++ 动态内存分配

动态内存主要指的是堆区上的内存,这块区域的内存主要依靠运行时程序动态申请,它不像栈是系统自动压栈弹栈,而是靠程序员的代码去主动申请的,并且生命周期完全取决于程序何时申请、何时释放,如果不释放就会一直伴随程序直到程序结束,导致内存泄漏

new/delete

这两个是C++提供的申请动态内存的关键字

new 分为两步执行

第一步:分配原始内存

void* addr = operator new(sizeof(Object));//里面是空内存,无对象,可能有脏数据

第二步:调用构造函数把对象构造在这片内存

Object* p = new(addr) Object();

delete 也分为两步执行

第一步:调用析构函数

第二步:释放内存,回到堆分配器

operator new 可以重载

比如 UE 对象管理中,为了支持对象池,通常会重载对象的 new,不再从系统分配内存构造对象,而是从已有对象池中取出对象

new/delete 和 malloc/free 区别

  • new/delete 除了分配/回收空间外,还会执行 构造/析构 函数
  • malloc/free 只分配/回收空间
  • new得到的是对象类型的指针,而malloc返回的是void*类型指针

RAII

Resource Acquisition Is Initialization,意思是资源获取即初始化

栈内存由系统自动分配和回收,而堆内存如果申请后忘记释放,会造成内存泄漏,那么是否能让堆内存像栈内存一样自动回收呢?

这就是 RAII 做的事,其核心思想是把资源的生命周期绑定到对象的生命周期上,这里资源指堆内存、socket、文件句柄……

做法是不让资源属于裸指针,而让资源属于对象。通俗讲,就是用一个栈上的对象来管理一个指向堆的裸指针,栈对象构造时显式申请堆内存(new),栈对象析构时显式释放堆内存(delete)

最经典的应用就是 C++11 提出的智能指针,也就是说 C++ 已经为我们封装好了几种可以智能管理裸堆区指针的类,这部分会在 操作系统合集-C++语法特性篇 中细讲

ptmalloc 的底层实现

glibc(GNU C Library)是 Linux 系统中最核心的 C 标准库实现,它为用户程序提供各种基础功能,其中 malloc/free 的实现就是由 glibc 提供的 ptmalloc

ptmalloc 本质是用户态内存管理器

malloc 实际上并不是系统调用,它实际上通过 ptmalloc 进行内存申请

因为如果每次申请内存都陷入系统调用,开销将会非常大、效率很低

所以 ptmalloc 会在程序第一次执行malloc(SIZE)时会向 OS 申请一大块内存,在此之前 ptmalloc 不具有任何可用内存。后续用户程序申请堆内存由 ptmalloc 全权分配这一块超大号内存

但 ptmalloc 向 OS 申请的并非物理内存,而是虚拟内存

chunk

ptmalloc 怎么管理内存?它将内存区分成一个个 chunk 来管理,chunk 是 ptmalloc 实际管理单位

组成:chunk = 头部 + 数据

+—————-+
| chunk header |
+—————-+
| user data |
+—————-+

头部是管理信息,包括 chunk 整体大小、状态位、邻居状态等。头部大小是固定的

数据是用户程序真正读、写、保存数据的地方

用户程序中通过malloc(SIZE)得到的内存指针,指向的就是数据部分的起址,因为用户代码中只关注数据部分,并不需要知道头部信息;要访问头部信息时,可以通过 (数据部分地址指针 – 头部大小) 获得头部指针,因为头部大小是固定的

内存对齐

内存对齐指让变量的起始地址满足某种边界要求,如通常要求 (int 类型变量的地址起址 % 4 == 0),double 则要求 8 字节对齐

为什么?因为现代CPU访问内存不是一个字节一个字节访问,而是依靠 CPU Cache Line 读取一整块。如果数据跨越边界,那么就代表读一个变量可能需要跨越两个 cache line 或 内存块 读取,效率下降

并且,C标准要求—— malloc 返回的地址必须适合存储任何基本类型对象,因为它可能被 cast 成任何类型。因此 64 位系统中通常要求 malloc 返回的地址 16 字节对齐,也就是 (addr % 16 == 0)

ptmalloc 如何保证对齐?ptmalloc 分配的内存块是在 chunk 头部 + 数据 的基础上要求对齐的,也就是整个 chunk 大小必须以 16 字节对齐。对于不满足的内存块,会进行向上取整,如 29 字节的内存块会被填充到 32 字节再分配出去

free时发生什么

用户程序 free 时只传入了数据部分指针,free 根据上述方法访问到 chunk 的头部信息,从而精准释放这片内存区域

事实上,free 释放内存时并非清空数据并把内存交还 OS,而是将头部信息中的used状态位改为free,然后放入空闲链表供下次分配

空闲链表

这是一种显式的空闲块管理方式,除此之外,还有一种古老的 隐式空闲块 管理方式,它没有空闲链表的设计,感兴趣的可以自行了解

空闲链表保存所有空闲 chunk,是一个双向链表。由于这些 chunk 数据部分并无作用,所以它用来保存指向前后空闲 chunk 的链表指针

如果只有一条空闲链表,会出现一个问题:大 chunk 和 小 chunk 串在一块,某些情况下要找到理想的空闲块可能比较困难

所以引入了 bin——bin 是空闲链表数组,每个数组元素是不同量级的空闲链表,同一空闲链表中管理大小相近的 chunk

如 0~16B 的 chunk 串成空闲链表 bin[0]、16~32B 的 chunk 串成空闲链表bin[1]……这样 malloc 就能根据申请的size计算直接定位目标块所在的空闲链表,然后快速遍历获取 chunk

bin 也不只有一种。主要分为 fastbin、smallbin、largebin,因为不同大小内存的处理方式不同

  • fastbin:小对象,它们大量出现、频繁申请和释放,所以释放后通常不马上合并,且结构是单向链表
  • smallbin:中等大小(几十~几百字节),使用双向链表,因为需要更精确地管理
  • largebin:大块内存(KB级别),数量少,但是大小差异巨大,所以需要排序
  • top chunk:除 bin 之外的一个特殊区域,它是堆最末端的大空闲区域,如果 bin 中无可用内存,ptmalloc 会从 top chunk 中切分

补充一点,现代 glibc 给每个线程分配一个小型空闲块缓存 tcache,线程可以减少竞争 bin ,提高了性能

可用内存不足怎么办

当某一次申请时,ptmalloc 的 bin 和 top chunk 都已无足够大小的内存可分配,就会向 OS 申请额外内存,有两种方式:

扩展 brk

brk 表示堆顶地址,即堆区的上限

当内存不足以分配时,ptmalloc 可以向 OS 申请扩展堆区大小,让 brk 变为更高的位置,从而拥有更多可用内存

这一步需要系统调用

mmap

mmap(memory map)是一个系统调用,用于把文件或匿名内存区域映射到进程的虚拟地址空间。同时进程虚拟地址空间中与堆区相邻的空间有一块区域叫 mmap 区,这是进程虚拟地址空间中专门用于动态映射的一片地址范围

当内存不足以分配且申请的内存十分巨大时,ptmalloc 可能直接通过 mmap()获得大片内存供用户程序使用,后续再用munmap()删除页表映射。因为mmap()前这片区域虽然是属于进程的,但是其实是一片超大的空白虚拟地址空间,页表中没有建立任何映射;mmap()之后在内核中建立虚拟内存区域(VMA)的描述,真正映射到的物理页通常在第一次访问时通过缺页异常建立。munmap()则直接删掉这段映射

通俗讲,进程地址空间中在堆和栈中间有一大片游离的无意义空白区域,它不明确属于堆还是栈。采用 brk 扩展时,heap 就往这片区域增大;采用 mmap 时,就在这片区域中找一片称为 mmap 区并正式启用它,让它能真正参与映射,并分配给用户程序

内存性能和常见问题

动态分配内存是慢的

虽然大多数情况下,一次malloc()都是通过用户态管理程序 ptmalloc 进行内存分配和回收,但是其步骤仍然繁琐:

malloc

ptmalloc

查tcache//遍历 tcache 查询合适空闲块

查bin//遍历空闲链表查询合适空闲块

切分chunk//切分 chunk,改变收尾空闲块指针指向

更新链表

必要时加锁//多线程竞态申请堆区内存时系统会加锁

必要时系统调用brk/mmap//系统调用涉及状态保存等等

频繁malloc/free的问题

  • 申请和回收都伴随一系列操作,越频繁开销越大
  • 内存碎片:malloc()申请的内存需要内存对齐,有时候分配的内存会大于实际使用的内存,形成了内部碎片;频繁的申请/释放可能会导致堆中出现不少小空闲内存块,其难以满足申请需求,且因为不连续所以无法与其他空闲块合并,形成了外部碎片
  • 内存泄漏:申请内存但忘记释放,被无效占用的堆内存越来越多导致耗尽
  • 悬空指针:指针变量指向的内存被释放,但后续仍在使用这个指针,可能导致程序崩溃,也可能误修改了其他数据

如何让程序拥有良好的内存性能

CPU 是不直接访问内存的,而是访问 cache,cache 一次加载内存以 cache line 为单位,连续分布的数据能让 cache line 保存的有效信息密度更大

  • 大量使用链表通常是损害性能的,因为链表节点的地址是随机的,即使是相邻节点,也不具有局部性,容易 cache miss
  • 连续数组更受 CPU 和 cache line 青睐,因为数组元素在逻辑和物理上一般都是连续分布的,一次读取就能访问整个数组

另外,对于高频创建和销毁堆对象的场景,对象池是很好的解决方法,通过重载operator new让程序创建对象时不再直接去申请堆内存,而是直接复用对象池里的对象,用完再归还,性能会有极大提升。但此处不再展开细讲

赞(0)
未经允许不得转载:171主机测评 » 深入浅出理解计算机核心知识系列【操作系统合集-内存管理篇】
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址