【编译、链接、装载十四】堆与内存管理
- 一、堆与内存管理
- 1、什么是堆
- 二、Linux进程堆管理
- 三、Windows进程堆管理
- Q&A
一、堆与内存管理
相对于栈而言, 堆这片内存面临一个稍微复杂的行为模式: 在任意时刻, 程序可能发出请求, 要么申请一段内存, 要么释放一段已申请过的内存, 而且申请的大小从几个字节到数GB都是有可能的, 我们不能假设程序会一次申请多少堆空间, 因此, 堆的管理显得较为复杂。 下面让我们来了解一下堆的工作原理。
1、什么是堆
光有栈对于面向过程的程序设计还远远不够, 因为栈上的数据在函数返回的时候就会被释放掉, 所以无法将数据传递至函数外部。 而全局变量没有办法动态地产生, 只能在编译的时候定义, 有很多情况下缺乏表现力。 在这种情况下, 堆(Heap) 是唯一的选择。
堆是一块巨大的内存空间, 常常占据整个虚拟空间的绝大部分。 在这片空间里, 程序可以请求一块连续内存, 并自由地使用, 这块内存在程序主动放弃之前都会一直保持有效。 下面是一个申请堆空间最简单的例子。
int main()
{
char * p = (char*)malloc(1000);
/* use p as an array of size 1000*/
free(p);
}
在第3行用malloc申请了1000个字节的空间之后, 程序可以自由地使用这1000个字节, 直到程序用free函数释放它。
那么malloc到底是怎么实现的呢?
有一种做法是, 把进程的内存管理交给操作系统内核去做, 既然内核管理着进程的地址空间, 那么如果它提供一个系统调用, 可以让程序使用这个系统调用申请内存, 不就可以了吗? 当然这是一种理论上可行的做法,但实际上这样做的性能比较差,因为每次程序申请或者释放堆空间都需要进行系统调用。 我们知道系统调用的性能开销是很大的, 当程序对堆的操作比较频繁时, 这样做的结果是会严重影响程序的性能的。
比较好的做法就是程序向操作系统申请一块适当大小的堆空间, 然后由程序自己管理这块空间, 而具体来讲,管理着堆空间分配的往往是程序的运行库。
运行库相当于是向操作系统“批发”了一块较大的堆空间, 然后“零售”给程序用。 当全部“售完”或程序有大量的内存需求时, 再根据实际需求向操作系统“进货”。 当然运行库在向程序零售堆空间时, 必须管理它批发来的堆空间, 不能把同一块地址出售两次, 导致地址的冲突。 于是运行库需要一个算法来管理堆空间, 这个算法就是堆的分配算法。 不过在了解具体的分配算法之前, 我们先来看看运行库是怎么向操作系统批发内存的。
二、Linux进程堆管理
进程的地址空间中, 除了可执行文件、 共享库和栈之外, 剩余的未分配的空间都可以被用来作为堆空间。 Linux下的进程堆管理稍微有些复杂, 因为它提供了两种堆空间分配的方式, 即两个系统调用: 一个是 brk() 系统调用, 另外一个是 mmap()。
-
linux下的malloc是采用brk实现的,还是mmap实现的?
glibc的malloc函数是这样处理用户的空间请求的: 对于小于128KB的请求来说, 它会在现有的堆空间里面, 按照堆分配算法为它分配一块空间并返回; 对于大于128KB的请求来说, 它会使用 mmap() 函数为它分配一块匿名空间, 然后在这个匿名空间中为用户分配空间。 -
brk
brk() 的作用实际上就是设置进程数据段的结束地址, 即它可以扩大或者缩小数据段(Linux下数据段和BSS合并在一起统称数据段) 。 如果我们将数据段的结束地址向高地址移动, 那么扩大的那部分空间就可以被我们使用, 把这块空间拿来作为堆空间是最常见的做法之一 。
可以使用man 命令查看brk的用法。命令为:man brk
我的系统当时还出了问题,删除了/usr/bin下的man文件夹,重新安装后才解决
Glibc中还有一个函数叫sbrk, 它的功能与brk类似, 只不过参数和返回值略有不同。 sbrk以一个增量(Increment) 作为参数, 即需要增加(负数为减少) 的空间大小, 返回值是增加(或减少) 后数据段结束地址, 这个函数实际上是对brk系统调用的包装, 它是通过 brk() 实现的。
- mmap
mmap() 的作用和Windows系统下的VirtualAlloc很相似, 它的作用就是向操作系统申请一段虚拟地址空间, 当然这块虚拟地址空间可以映射到某个文件(这也是这个系统调用的最初的作用) , 当它不将地址空间映射到某个文件时, 我们又称这块空间0为匿名(Anonymous) 空间, 匿名空间就可以拿来作为堆空间。
man mmap
mmap的前两个参数分别用于指定需要申请的空间的起始地址和长度,如果起始地址设置为0, 那么Linux系统会自动挑选合适的起始地址。prot/flags这两个参数用于设置申请的空间的权限(可读、 可写、 可执行) 以及映射类型(文件映射、 匿名空间等) , 最后两个参数是用于文件映射时指定文件描述符和文件偏移的, 我们在这里并不关心它们。
三、Windows进程堆管理
- 每个线程的栈都是独立的, 每个线程默认的栈大小是1MB.
栈的位置则在0x00 030 000和EXE文件后面都有分布, 可能有读者奇怪为什么Windows需要这么多栈呢? 我们知道, 每个线程的栈都是独立的, 所以一个进程中有多少个线程, 就应该有多少个对应的栈, 对于Windows来说, 每个线程默认的栈大小是1MB, 在线程启动时, 系统会为它在进程地址空间中分配相应的空间作为栈, 线程栈的大小可以由创建线程时CreateThread的参数指定。
在分配完上面这些地址以后, Windows的进程地址空间已经是支离破碎了。 当程序向系统申请堆空间时, 只好从这些剩下的还没有被占用的地址上分配。 Windows系统提供了一个API叫做VirtualAlloc(), 用来向系统申请空间, 它与Linux下的mmap非常相似。 实际上VirtualAlloc()申请的空间不一定只用于堆, 它仅仅是向系统预留了一块虚拟地址, 应用程序可以按照需要随意使用。
在使用 VirtualAlloc() 函数申请空间时, 系统要求空间大小必须为页的整数倍, 即对于x86系统来说, 必须是4096字节的整数倍。 很明显, 这就是操作系统的“批发”内存的接口函数了, 4096字节起批, 而且只能是4096字节的整数倍, 多了少了都不行。
那么应用程序作为最终的“消费者”, 如果它直接向操作系统申请内存的话, 难免会造成大量的浪费,比如程序只需要4097个字节的空间, 它也必须申请8192字节。当然, 在Windows下我们也可以自己实现一个分配的算法, 首先通过VirtualAlloc向操作系统一次性批发大量空间, 比如10MB, 然后再根据需要分配给程序。 不过这么常用的分配算法已经被各种系统、 库实现了无数遍, 一般情况下我们没有必要再重复发明轮子, 自己再实现一个,用现成的就可以了。 在Windows中, 这个算法的实现位于堆管理器(Heap Manager) 。
堆管理器提供了一套与堆相关的API可以用来创建、 分配、 释放和销毁堆空间:
- HeapCreate: 创建一个堆。
- HeapAlloc: 在一个堆里分配内存。
- HeapFree: 释放已经分配的内存。
- HeapDestroy: 摧毁一个堆。
这四个API的作用很明显,
- HeapCreate就是创建一个堆空间, 它会向操作系统批发一块内存空间(它也是通过VirtualAlloc()实现的) ,
- 而HeapAlloc就是在堆空间里面分配一块小的空间并返回给用户, 如果堆空间不足的话, 它还会通过VirtualAlloc向操作系统批发更多的内存直到操作系统也没有空间可以分配为止。
- HeapFree和HeapDestroy的作用就更不言而喻了
Q&A
-
Q: 我可以重复释放两次堆里的同一片内存吗?
A: 不能。 几乎所有的堆实现里, 都会在重复释放同一片堆里的内存时产生错误。 glibc甚至能检测出这样的错误, 并给出确切的错误信息。 -
Q: 我在有些书里看到说堆总是向上增长, 是这样的吗?
A: 不是, 有些较老的书籍针对当时的系统曾做出过这样的断言, 这在当时可能是正确的。 因为当时的系统多是类unix系统, 它们使用类似于brk的方法来分配堆空间, 而brk的增长方向是向上的。 但随着Windows的出现, 这个规律被打破了。 在Windows里, 大部分堆使用HeapCreate产生, 而HeapCreate系列函数却完全不遵照向上增长这个规律。 -
Q: 调用malloc会不会最后调用到系统调用或者API?
A: 这个取决于当前进程向操作系统批发的那些空间还够不够用, 如果够用了, 那么它可以直接在仓库里取出来卖给用户; 如果不够用了, 它就只能通过系统调用或者API向操作系统再进一批货了。 -
Q: malloc申请的内存, 进程结束以后还会不会存在?
A: 这是一个很常见的问题, 答案是很明确的: 不会存在。 因为当进程结束以后, 所有与进程相关的资源, 包括进程的地址空间、 物理内存、打开的文件、 网络链接等都被操作系统关闭或者收回, 所以无论malloc申请了多少内存, 进程结束以后都不存在了。 -
Q: malloc申请的空间是不是连续的?
A: 在分析这个问题之前, 我们首先要分清楚“空间”这个词所指的意思。 如果“空间”是指虚拟空间的话, 那么答案是连续的, 即每一次malloc分配后返回的空间都可以看做是一块连续的地址; 如果空间是指“物理空间”的话, 则答案是不一定连续, 因为一块连续的虚拟地址空间有可能是若干个不连续的物理页拼凑而成的。
参考
1、《程序员的自我修养链接装载与库》