堆
概念与分配策略
所谓“堆”,即动态存储区,与栈不同,堆是在程序运行时被分配的。C语言中的malloc,C++中的new完成的都是堆上的操作。堆不会自动释放所以需要free和delete。
还记得经典面试题吗:比较一下malloc和new的不同。这个问题其实不难,首先明确:malloc是函数,而new是关键字。然后new作为C++中动态对象创建的基石,除了完成堆空间的分配操作以外还要完成一些初始化操作,及new的过程中会调用对象的构造函数去初始化,而malloc不会。最后要明确的是malloc分配的内存只能用free来释放,而new分配的地址只能用delete来释放,如果new分配的是数组,则需要delete[ ]来释放,否则会出现未定义行为。
无论是malloc还是new返回的都是一个指针,即堆地址。堆与栈不同它不是顺序分配的,而是离散分配的,它的空闲内存可能不是连续的,而是断断续续的,通常通过链表来连接每个空闲存储区(实际数据结构要更复杂和多样化)。关于动态内存的分配策略其实大家的“操作系统”课上都有学过,只不过通常大家很难直接和malloc/new联系起来。
随便翻开一本操作系统的课本,找到存储器管理的章节都能找到动态内存的分配策略(简单描述之):
- 首次适应算法:在空闲区链首开始向后寻找,找到第一块能满足所需大小的空闲块
- 循环首次适应算法:从上一次找的空闲区开始向后寻找。直到链尾都不满足,则返回链首寻找空闲块
- 最佳适应算法:每次分配内存时,都选择能满足要求,并且又是最小的空闲块。优点是:每次第一次找到的满足要求的空闲块,必然是最佳的
- 最坏适应算法:每次总是选择一个最大的空闲块分配给进程使用。优点是:产生内存碎片的几率较小
- 快速适应算法:将空闲区依据其容量大小进行分类,每一类同容量的空闲块都有自己的链表。同时在内存中设立一张管理索引表,每个表项为一种空闲块类型,并记录其链首指针。其优点是:查找效率高,不会产生内存碎片。缺点是实现复杂,系统开销大。
实际上真实的堆内存管理要比上述各类算法所描述的还要复杂。比如内存管理器(一般是glibc的ptmalloc)可能将零散的空闲内存移动到一起,形成更大的空闲内存块。基于各自不同的动态内存分配策略,很多其他的内存管理器应运而生,比如谷歌的 tcmalloc,BSD的jemalloc。
堆首地址
我们通过malloc分配的内存空间返回给我们一个这段内存的首地址,你有没有疑问,当我们free的时候,只是将该首地址传递给free函数。而free函数又是如何知道该释放该首地址以后多大的内存空间的呢?在初学的时候,我有这个疑惑,我认为如果是我来实习free函数的话,它需要两个参数:带释放堆内存的首地址及其偏移量(分配空间的大小)。
实际上关于这个问题,不同的内存管理器有各自的策略,但大致的思想就是将偏移量存储在内存中。比如一种简单实现是:在堆分配内存的时候,通常会比你需要的内存多分配一些,其中一部分用于字节对齐,另外一小部分用于存取偏移量。比如用前4个字节存储已分配内存的大小,然后将其后面的地址返回,首地址前的4个字节可以被称之为“首部”。这样free的时候,先搜索该首地址前的首部,取出这个值即为偏移量。(这只是一种最简单的概念性的方案,实际编译器的解决方案更为复杂,但无一例外都会分配额外的内存保存元数据)
知道这点后,我们可以理解这样一种C语法的限制,那就是free的时候,其参数只能是malloc等动态内存分配函数返回的(显然,不包括new)值。举个例子:
char *a = malloc(100); free(a+10);
比如你分配了100个字节,其首地址为a,如果你将a+10(首地址之后偏移10字节处)传递给free,那么free并不能为你释放掉剩余的90个字节,并且这是一个危险的行为。因为内存管理器会将a+10位置之前视作首部,并从中去取出该段地址的“偏移量”,然后进行释放。显然这样是达不到你想要的效果的。
当然,这只是举了一种例子来阐述free函数的参数为什么只需要一个首地址,实际上其真实的行为远比我这里描述的要复杂,可能有首部,还有尾部,其存储的内容除了偏移量以外可能还有其他信息,比如空闲链表的信息等。
虚拟地址和MMU
内存是分页的(页式存储)。物理内存、虚拟内存都是。并且这两者的页大小是严格相同的。不同之处当然是物理内存的页数比较多,虚拟内存的页数比较少。这两者之间是存在一定映射关系的。通过一道笔试题来揭露这种关系:
某虚拟存储器的用户编程空间共4个页面,每页1K,主存位16K,假定某时刻系统为用户的0,1,2,3页分别分配物理块号6,8,12,3中,则虚拟地址0C8Eh对应的物理地址为:
_____
这道题虚拟地址位CC8Eh,h表示16进制。为了便于计算可以把它转换位十进制:
0C8Eh = 3214。接着计算它所属的页号和页内偏移,页面大小是1K,即1024。页号=3214/1024 = 3。偏移=3214%1024=142
物理地址=物理页号*页面大小+页内偏移。因为根据题意,逻辑地址中的3号页对应物理地址中的页号也是3。所以实际这题物理地址和逻辑地址是一样的。
共享库基本概念
在进程的内存空间图示中,有一段标识是:共享库内存映射区。表示这段内存地址是给共享库使用的。何谓“共享库(shared libraries)”?共享库在Windows系统中叫做动态链接库(.dll),或者简称“动态库”。动态库就需要和静态库区分开来。你应该见过这两种库,知识你不知道罢了。
如果一个程序引用了静态库,那么在编译期静态库文件就会和该程序编译到一起。这样编译出来的可执行文件通常比较大,并且如果多个程序都使用了同一个静态库,那么每个程序编译后的文件都包含一份该静态库的拷贝;而如果一个程序引用了共享库,那么在编译期共享库文件不会和该程序编译到一起,而是在程序运行时动态地加载该共享库文件。如果多个程序都使用了同一个共享库,那么这些程序都是在运行时加载该共享库,系统中之后存在一份该库的拷贝,这就是为什么叫做共享库的原因。
因为共享库是运行时加载的,在加载后也必须有一个地址,图中的“共享内存映射区”就是用来给共享库分配地址的,它的地址增长方式同堆一样,从低到高。
关于共享库如何实现的,如何动态连接的。本文不详述,有兴趣的可以阅读《深入理解计算机系统》的“链接”一章。
链接共享库
另外需要注意的是,如果你要链接的共享库(动态库)不是标准库,并且不在标准库的路径下。那么在编译的时候通常会报错。此时需要给gcc加上-L选项加上共享库所在的路径,并用-l选项去连接对应的库,这里要明确的是如果你的库文件名叫libabc.so.1234那么连接选项l要写成 -labc(去掉前后缀),而当同一个库里面同时有静态库和共享库的时候,优先连接的是共享库。
此时只是解决了编译期间的麻烦,因为共享库实际是程序运行时链接的,即使你编译期间使用了-L选项也可能会找不到库(-L只解决编译期间的问题)。如果你有root的话,那么去/etc/ld.so.conf.d/目录下,新建一个conf文件,里面加入你的库路径。然后用ldconfig命令刷新共享库路径。
但如果你没有root权限的话,你可以使用gcc的-Wl,-rpath选项去指定路径(这是一个选项)。此时一个完整的编译命令类似:
gcc -L /home/jelly/lib/ \ -labc \ -Wl,-rpath=/home/xxx/lib/