Linux内存管理(一):综述

1. Linux进程内存布局

2、为什么要限制栈的大小

  2.1 进程栈

  2.2 线程栈    

3. 操作系统内存布局相关函数

  3.1 Heap 操作相关函数

  3.2 Mmap 映射区域操作相关函数

4. 内存管理方法

  4.1. C 风格的内存管理程序

  4.2. 池式内存管理

  4.3. 引用计数

  4.4. 垃圾收集

5. 常见的C内存管理程序


    内存管理不外乎三个层面,用户程序层,C运行时库层,内核层。allocator 正是值C运行时库的内存管理模块, 它响应用户的分配请求, 向内核申请内存, 然后将其返回给用户程序。为了保持高效的分配, allocator 一般都会预先分配一块大于用户请求的内存, 并通过某种算法管理这块内存. 来满足用户的内存分配要求, 用户 free 掉的内存也并不是立即就返回给操作系统, 相反, allocator 会管理这些被 free 掉的空闲空间, 以应对用户以后的内存分配要求. 也就是说, allocator 不但要管理已分配的内存块, 还需要管理空闲的内存块, 当响应用户分配要求时, allocator 会首先在空闲空间中寻找一块合适的内存给用户, 在空闲空间中找不到的情况下才分配一块新的内存。

1. Linux进程内存布局

    Linux 系统在装载 elf 格式的程序文件时,会调用 loader 把可执行文件中的各个段依次载入到从某一地址开始的空间中(载入地址取决 link editor(ld)和机器地址位数,在 32 位机 器上是 0x8048000,即 128M 处)。如下图所示:

       

    以 32 位机器为例,首先被载入的是.text 段, 然后是.data 段,最后是.bss 段。这可以看作是程序的开始空间。程序所能访问的最后的地 址是 0xbfffffff,也就是到 3G 地址处,3G 以上的 1G 空间是内核使用的,应用程序不可以直接访问。

    应用程序的堆栈从最高地址处开始向下生长,.bss 段与堆栈之间的空间是空闲的, 空闲空间被分成两部分,一部分为 heap,一部分为 mmap 映射区域,mmap 映射区域一般 从 TASK_SIZE/3 的地方开始,但在不同的 Linux 内核和机器上,mmap 区域的开始位置一般是 不同的。Heap 和 mmap 区域都可以供用户自由使用,但是它在刚开始的时候并没有映射到 内存空间内,是不可访问的。在向内核请求分配该空间之前,对这个空间的访问会导致 segmentation fault。用户程序可以直接使用系统调用来管理 heap 和 mmap 映射区域,但更 多的时候程序都是使用 C 语言提供的 malloc()和 free()函数来动态的分配和释放内存。Stack 区域是唯一不需要映射,用户却可以访问的内存区域,这也是利用堆栈溢出进行攻击的基础。

    上图这种布局是 Linux 内核 2.6.7 以前的默认进程内存布局形式,mmap 区域与栈区域相对增 长,这意味着堆只有 1GB 的虚拟地址空间可以使用,继续增长就会进入 mmap 映射区域, 这显然不是我们想要的。这是由于 32 模式地址空间限制造成的,所以内核引入了另一种虚 拟地址空间的布局形式,将在后面介绍。但对于 64 位系统,提供了巨大的虚拟地址空间, 这种布局就相当好。Linux内核2.6.7之后,进程的内存布局如下图所示:

    

    从上图可以看到,栈至顶向下扩展,并且栈是有界的。堆至底向上扩展,mmap 映射区 域至顶向下扩展,mmap 映射区域和堆相对扩展,直至耗尽虚拟地址空间中的剩余区域,这种结构便于 C 运行时库使用 mmap 映射区域和堆进行内存分配。上图的布局形式是在内核 2.6.7 以后才引入的,这是 32 位模式下进程的默认内存布局形式。

    在 64 位模式下各个区域的起始位置是什么呢?对于 AMD64 系统,内存布局采用经典 内存布局,text 的起始地址为 0x0000000000400000,堆紧接着 BSS 段向上增长,mmap 映射 区域开始位置一般设为 TASK_SIZE/3。

#define TASK_SIZE_MAX ((1UL << 47) - PAGE_SIZE)

#define TASK_SIZE  (test_thread_flag(TIF_IA32) ? IA32_PAGE_OFFSET : TASK_SIZE_MAX)

#define STACK_TOP TASK_SIZE

#define TASK_UNMAPPED_BASE (PAGE_ALIGN(TASK_SIZE/3))

    计算一下可知,mmap 的开始区域地址为 0x00002AAAAAAAA000,栈顶地址为0x00007FFFFFFFF000,下图为64位的进程内存布局:

     

    上图是 X86_64 下 Linux 进程的默认内存布局形式,这只是一个示意图,当前内核默认 配置下,进程的栈和 mmap 映射区域并不是从一个固定地址开始,并且每次启动时的值都 不一样,这是程序在启动时随机改变这些值的设置,使得使用缓冲区溢出进行攻击更加困难。 当然也可以让进程的栈和 mmap 映射区域从一个固定位置开始,只需要设置全局变量 randomize_va_space 值为 0,这个变量默认值为 1。用户可以通过设置 /proc/sys/kernel/randomize_va_space 来停用该特性,也可以用如下命令:

sudo sysctl -w kernel.randomize_va_space=0

2、为什么要限制栈的大小

2.1 进程栈

    进程栈的初始化大小是由编译器和链接器计算出来的,但是栈的实时大小并不是固定的,Linux 内核会根据入栈情况对栈区进行动态增长(其实也就是添加新的页表)。但是并不是说栈区可以无限增长,它也有最大限制 RLIMIT_STACK (一般为 8M),我们可以通过 ulimit 来查看或更改 RLIMIT_STACK 的值。

2.2 线程栈    

   从 Linux 内核的角度来说,其实它并没有线程的概念。Linux 把所有线程都当做进程来实现,它将线程和进程不加区分的统一到了 task_struct 中。线程仅仅被视为一个与其他进程共享某些资源的进程,而是否共享地址空间几乎是进程和 Linux 中所谓线程的唯一区别。线程创建的时候,加上了 CLONE_VM 标记,这样 线程的内存描述符 将直接指向 父进程的内存描述符。虽然线程的地址空间和进程一样,但是对待其地址空间的 stack 还是有些区别的。对于 Linux 进程或者说主线程,其 stack 是在 fork 的时候生成的,实际上就是复制了父亲的 stack 空间地址,然后写时拷贝 (cow) 以及动态增长。然而对于主线程生成的子线程而言,其 stack 将不再是这样的了,而是事先固定下来的,创建线程时使用 mmap 系统调用给线程分配栈,线程栈的起始地址跟大小保存在pthread_attr_t。

    栈需要存储在连续的内存位置。这意味着不能根据需要随机分配栈,但至少需要为此保留虚拟地址。保留的虚拟地址空间越大,可以执行的线程就创建的越少。用于例如,32位应用程序通常具有2GB的虚拟地址空间。这意味着如果栈大小是2MB(在pthreads中是默认值),那么最多可以创建1024个线程。对于诸如web服务器之类的应用程序来说,这可能很小。将栈大小增加到100MB(即保留100MB,但不一定立即将100MB分配给栈)会将线程数限制在20个左右,即使对于简单的GUI应用程序也是如此。一个有趣的问题是,为什么我们在64位平台上仍有此限制。我不知道答案,但我假设人们已经习惯了一些“堆栈最佳实践”:小心在栈上分配巨大的对象,如果需要,手动增加栈大小。因此,没有人发现在64位平台上添加“巨大”栈支持是有用的。

3. 操作系统内存布局相关函数

    上节提到 heap 和 mmap 映射区域是可以提供给用户程序使用的虚拟内存空间,如何获 得该区域的内存呢?操作系统提供了相关的系统调用来完成相关工作。对 heap 的操作,操 作系统提供了 brk()函数,C 运行时库提供了 sbrk()函数;对 mmap 映射区域的操作,操作系 统提供了 mmap()和 munmap()函数。sbrk(),brk() 或者 mmap() 都可以用来向我们的进程添 加额外的虚拟内存。Glibc 同样是使用这些函数向操作系统申请虚拟内存。

    这里要提到一个很重要的概念,内存的延迟分配,只有在真正访问一个地址的时候才建 立这个地址的物理映射,这是 Linux 内存管理的基本思想之一。Linux 内核在用户申请内存的 时候,只是给它分配了一个线性区(也就是虚拟内存),并没有分配实际物理内存;只有当 用户使用这块内存的时候,内核才会分配具体的物理页面给用户,这时候才占用宝贵的物理 内存。内核释放物理页面是通过释放线性区,找到其所对应的物理页面,将其全部释放的过程。

3.1 Heap 操作相关函数

    Heap 操作函数主要有两个,brk()为系统调用,sbrk()为 C 库函数。系统调用通常提供一种最小功能,而库函数通常提供比较复杂的功能。Glibc 的 malloc 函数族(realloc,calloc 等) 就调用 sbrk()函数将数据段的下界移动,sbrk()函数在内核的管理下将虚拟地址空间映射到内 存,供 malloc()函数使用。内核数据结构 mm_struct 中:

  • start_code 和 end_code 是进程代码段的起始和终止地址;
  • start_data 和 end_data 是进程数据段的起始和终止地址;
  • start_stack 是进程堆栈段起始地址;
  • start_brk 是进程动态内存分配起始地址(堆的起始地址),
  • brk 是堆的当前最后地址,就是动态内存分配当前的终止地址。

    C 语言的动态内存分配基本函数是 malloc(),在 Linux 上的实现是通过内核的 brk 系统调用。brk()是一个非常简单的系统调用, 只是简单地改变 mm_struct 结构的成员变量 brk 的值。这两个函数的定义如下:

#include <unistd.h>

int brk(void *addr);

void *sbrk(intptr_t increment);

    需要说明的是,但 sbrk()的参数 increment 为 0 时,sbrk()返回的是进程的当前 brk值, increment 为正数时扩展 brk 值,当 increment 为负值时收缩 brk 值。    

3.2 Mmap 映射区域操作相关函数

    mmap()函数将一个文件或者其它对象映射进内存。文件被映射到多个页上,如果文件的 大小不是所有页的大小之和,最后一个页不被使用的空间将会清零。munmap 执行相反的操 作,删除特定地址区域的对象映射。函数的定义如下:

#include <sys/mman.h>

void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset); 
// 若映射成功则返回映射区的内存起始地址,否则返回MAP_FAILED(-1),错误原因存于errno 中。

int munmap(void *addr, size_t length);

   参数:

  • addr:指向欲映射的内存起始地址,通常设为 NULL,代表让系统自动选定地址,映射成功后返回该地址;
  • length:映射区的长度;
  • prot:映射区域的保护方式,不能与文件的打开模式冲突。是以下的某个值,可以通过 or 运算合理地组合在一起。Ptmalloc 中主要使用了如下的几个标志:
  • PROT_EXEC    // 页内容是可以被执行的,ptmalloc 中没有使用
  • PROT_READ    // 页内容是可以被读取的,ptmalloc 直接用 mmap 分配内存并立即返回给用户时 设置该标志
  • PROT_WRITE   // 页是可以被写入的,ptmalloc 直接用 mmap 分配内存并立即返回给用户时设 置该标志
  • PROT_NONE    // 页是不可访问的,ptmalloc 用 mmap 向系统“批发”一块内存进行管理时设置 该标志
  • flags:指定映射对象的类型,映射选项和映射页是否可以共享。它的值可以是一个或者 多个以下位的组合体
  • MAP_FIXED     // 使用指定的映射起始地址,如果由 start 和 len 参数指定的内存区重叠于现存的映射空间,重叠部分将会被丢弃。如果指定的起始地址不可用,操作将会失败。并且起始地址必须落在页的边界上。Ptmalloc 在回收从系统中“批发”的内存时设置该标志。
  • MAP_PRIVATE   //  建立一个写入时拷贝的私有映射。内存区域的写入不会影响到原文件。这个标志和以上标志是互斥的,只能使用其中一个。Ptmalloc 每次调用 mmap 都设置该标志。
  • MAP_NORESERVE   // 不要为这个映射保留交换空间。当交换空间被保留,对映射区修改 的可能会得到保证。当交换空间不被保留,同时内存不足,对映射区的修改会引起段违例信号。Ptmalloc 向系统“批发”内存块时设置该标志。
  • MAP_ANONYMOUS   // 匿名映射,映射区不与任何文件关联。Ptmalloc 每次调用 mmap都设置该标志。
  • fd:有效的文件描述词。如果 MAP_ANONYMOUS 被设定,为了兼容问题,其值应为-1。有些系统不支持匿名内存映射,则可以使用fopen打开/dev/zero文件,然后对该文件进行映射,可以同样达到匿名内存映射的效果。
  • offset:被映射对象内容的起点,通常设置为0,代表从文件最前方开始对应,offset必须是分页大小的整数倍。

4. 内存管理方法

4.1. C 风格的内存管理程序

    C 风格的内存管理程序主要实现 malloc()和 free()函数。内存管理程序主要通过调用 brk() 或者 mmap()进程添加额外的虚拟内存。Doug Lea Malloc,ptmalloc,BSD malloc,Hoard, TCMalloc都属于这一类内存管理程序。

    基于 malloc()的内存管理器仍然有很多缺点,不管使用的是哪个分配程序。对于那些需 要保持长期存储的程序使用 malloc()来管理内存可能会非常令人失望。如果有大量的不固定 的内存引用,经常难以知道它们何时被释放。生存期局限于当前函数的内存非常容易管理, 但是对于生存期超出该范围的内存来说,管理内存则困难得多。因为管理内存的问题,很多 程序倾向于使用它们自己的内存管理规则。

4.2. 池式内存管理

    内存池是一种半内存管理方法。内存池帮助某些程序进行自动内存管理,这些程序会经历一些特定的阶段,而且每个阶段中都有分配给进程的特定阶段的内存。例如,很多网络服务器进程都会分配很多针对每个连接的内存——内存的最大生存期限为当前连接的存在期。 Apache 使用了池式内存(pooled memory),将其连接拆分为各个阶段,每个阶段都有自己 的内存池。在结束每个阶段时,会一次释放所有内存。

    在池式内存管理中,每次内存分配都会指定内存池,从中分配内存。每个内存池都有不同的生存期限。在 Apache 中,有一个持续时间为服务器存在期的内存池,还有一个持续时 间为连接的存在期的内存池,以及一个持续时间为请求的存在期的池,另外还有其他一些内 存池。因此,如果我的一系列函数不会生成比连接持续时间更长的数据,那么我就可以完全 从连接池中分配内存,并知道在连接结束时,这些内存会被自动释放。另外,有一些实现允 许注册清除函数(cleanup functions),在清除内存池之前,恰好可以调用它,来完成在内存 被清理前需要完成的其他所有任务(类似于面向对象中的析构函数)。

    使用池式内存分配的优点如下所示:

  • 应用程序可以简单地管理内存。
  • 内存分配和回收更快,因为每次都是在一个池中完成的。分配可以在 O(1)时间内完成,释放内存池所需时间也差不多(实际上是 O(n)时间,不过在大部分情况下会除以一个大的因数,使其变成 O(1))。
  • 可以预先分配错误处理池(Error-handling pools),以便程序在常规内存被耗尽时仍可以恢复。
  • 有非常易于使用的标准实现。

   池式内存的缺点是:

  • 内存池只适用于操作可以分阶段的程序。
  • 内存池通常不能与第三方库很好地合作。
  • 如果程序的结构发生变化,则不得不修改内存池,这可能会导致内存管理系统的重新设计。
  • 您必须记住需要从哪个池进行分配。另外,如果在这里出错,就很难捕获该内存池。

4.3. 引用计数

    在引用计数中,所有共享的数据结构都有一个域来包含当前活动“引用”结构的次数。 当向一个程序传递一个指向某个数据结构指针时,该程序会将引用计数增加 1。实质上,是在告诉数据结构,它正在被存储在多少个位置上。然后,当进程完成对它的使用后,该程序就会将引用计数减少 1。结束这个动作之后,它还会检查计数是否已经减到零。如果是,那么它将释放内存。

    在 Java,Perl 等高级语言中,进行内存管理时使用引用计数非常广泛。在这些语言中, 引用计数由语言自动地处理,所以您根本不必担心它,除非要编写扩展模块。由于所有内容都必须进行引用计数,所以这会对速度产生一些影响,但它极大地提高了编程的安全性和方便性。

    以下是引用计数的好处:

  • 实现简单。
  • 易于使用。
  • 由于引用是数据结构的一部分,所以它有一个好的缓存位置。

    它也有其不足之处:

  • 要求您永远不要忘记调用引用计数函数。
  • 无法释放作为循环数据结构的一部分的结构。
  • 减缓几乎每一个指针的分配。
  • 尽管所使用的对象采用了引用计数,但是当使用异常处理(比如 try 或 setjmp()/longjmp())时,您必须采取其他方法。
  • 需要额外的内存来处理引用。
  • 引用计数占用了结构中的第一个位置,在大部分机器中最快可以访问到的就是这个位置。
  • 在多线程环境中更慢也更难以使用。

4.4. 垃圾收集

    垃圾收集(Garbage collection)是全自动地检测并移除不再使用的数据对象。垃圾收集器通常会在当可用内存减少到少于一个具体的阈值时运行。通常,它们以程序所知的可用的 一组“基本”数据——栈数据、全局变量、寄存器——作为出发点。然后它们尝试去追踪通 过这些数据连接到每一块数据。收集器找到的都是有用的数据;它没有找到的就是垃圾,可 以被销毁并重新使用这些无用的数据。为了有效地管理内存,很多类型的垃圾收集器都需要 知道数据结构内部指针的规划,所以,为了正确运行垃圾收集器,它们必须是语言本身的一 部分。

    垃圾收集的一些优点:

  • 永远不必担心内存的双重释放或者对象的生命周期。
  • 使用某些收集器,您可以使用与常规分配相同的 API。

    其缺点包括:

  • 使用大部分收集器时,您都无法干涉何时释放内存。
  • 在多数情况下,垃圾收集比其他形式的内存管理更慢。
  • 垃圾收集错误引发的缺陷难于调试。
  • 如果您忘记将不再使用的指针设置为 null,那么仍然会有内存泄漏。

5. 常见的C内存管理程序

  • Doug Lea Malloc:

    Doug Lea Malloc 实际上是完整的一组分配程序,其中包括 Doug Lea的原始分配程序、GNU libc 分配程序和 ptmalloc。Doug Lea 的分配程序加入了索引, 这使得搜索速度更快,并且可以将多个没有被使用的块组合为一个大的块。它还支 持缓存,以便更快地再次使用最近释放的内存。ptmalloc 是 Doug Lea Malloc 的一个 扩展版本,支持多线程。

  • BSD Malloc:

    BSD Malloc 是随 4.2 BSD 发行的实现,包含在 FreeBSD 之中,这个分配 程序可以从预先确实大小的对象构成的池中分配对象。它有一些用于对象大小的 size 类,这些对象的大小为 2 的若干次幂减去某一常数。所以,如果您请求给定大 小的一个对象,它就简单地分配一个与之匹配的 size 类。这样就提供了一个快速的 实现,但是可能会浪费内存。

  • Hoard:

    编写 Hoard 的目标是使内存分配在多线程环境中进行得非常快。因此,它的 构造以锁的使用为中心,从而使所有进程不必等待分配内存。它可以显著地加快那 些进行很多分配和回收的多线程进程的速度。

  • TCMalloc:

    tcmalloc是Google开发的内存分配器,在Golang、Chrome中都有使用该分配器进行内存分配。有效的优化了ptmalloc中存在的问题。当然为此也付出了一些代价,按下不表,先看tcmalloc的具体实现。

  • Jemalloc:

    jemalloc是facebook推出的,目前在firefox、facebook服务器、android 5.0 等服务中大量使用。 jemalloc最大的优势还是其强大的多核/多线程分配能力. 以现代计算机硬件架构来说, 最大的瓶颈已经不再是内存容量或cpu速度, 而是多核/多线程下的lock contention(锁竞争). 因为无论CPU核心数量如何多, 通常情况下内存只有一份. 可以说, 如果内存足够大, CPU的核心数量越多, 程序线程数越多, jemalloc的分配速度越快。

参考:

    《Glibc 内存管理 Ptmalloc2 源代码分析》

猜你喜欢

转载自blog.csdn.net/MOU_IT/article/details/115273132