操作系统_内存管理(一)

目录

1. 内存管理

1.1 内存管理的概念

1.1.1 内存管理的基本原理和要求

1.1.1.1 程序的链接和装入

1.1.1.2 逻辑地址与物理地址

1.1.1.3 进程的内存映像

1.1.1.4 内存保护

1.1.1.5 内存共享

1.1.1.6 内存分配与回收

1.2 覆盖与交换

1.2.1 覆盖

1.2.2 交换

1.3 连续分配管理方式

1.3.1 单一连续分配

1.3.2 固定分区分配

1.3.3 动态分区分配

1.4 非连续分配方式

1.4.1 基本分页存储管理

1.4.1.1 分页存储的几个基本概念

1.4.1.1.1 页面和页面大小

1.4.1.1.2 地址结构

1.4.1.1.3 页表

1.4.1.2 基本地址变换机构

1.4.1.3 具有快表的地址变换机构

1.4.1.4 两级页表

1.5 基本分段存储管理

1.5.1 分段

1.5.2 段表

1.5.3 地址变换机构

1.5.4 段的共享与保护

1.6 段页式管理

1.7 小结


1. 内存管理

思考问题?

1. 为什么要进行内存管理?

2. 页式管理中每个页表项大小的下限如何决定?

3. 多级页表解决了什么问题?又会带来什么问题?

什么是内存?内存有何作用?

        像我们买手机的时候,我们通常都会选择版本,一般有 8+64、8+128、8+256 等等,随着我们科技水平的发展不断更新,这里 8GB 指的就是内存+ 的 64GB、128GB、256GB 就是外存举个简单的例子理解:内存是指我们想要运行一个进程,或者说是一个APP,必须把该APP放进内存才能运行,外存上存放的是我们已有的进程,但是没有被执行,也可以简单的说是我们手机桌面上存放的图标; 有这样一个明显的现象:打个比方,8GB 内存可以使我们运行 5 个软件,这 5 个软件是被调入内存中运行的;倘若我们想要运行第 6 个软件,操作系统就会通过某种置换算法将其中一个软件换出内存,由第 6 个软件换入内存;我们玩手机时后台运行多个软件时,有些软件再次被启动时可能会出现转圈等待或者重新启动的现象,这就是内存进程的换入/换出过程;

        内存可存放数据。程序执行前需要先放到内存中才能被 CPU 处理 —— 我们的未运行的程序都是放在外存的,但是外存的读写速度比较慢,而 CPU 运行又比较快,这样,如果 CPU 去运行外存上的程序,那么 CPU 的速度会被外存所拖累;所以内存缓和了 CPU 与硬盘之间的矛盾; 


那么在多道程序运行的环境下,系统中会有多个进程并发执行,通俗点说会有多个程序的数据同时放到内存中。那么,如何区分每个程序的数据是放在什么地方的呢?

        这样就需要对内存进行编址,定义连续的地址;

        就比如说我们住酒店的时候,茫茫多的房间,我们就是通过房间号来找到我们对应的房间的; 

几个常用的数量单位:

        一台手机/电脑有 4GB 内存:

        这里的 G 表示内存单位,常用的单位还有 1K(千) = 2^{10} ;1 M(兆,百万) = 2^{20} ;1 G(十亿,千兆) = 2^{30} ;

        这里的 B 表示存储单位,大写的 B 表示 Byte 字节,小写的 b 表示比特位 bit ;也就是 1B = 8 b;

        也就是说 4GB 的内存可以存放 4 * 2^{30} 个地址空间,如果是按字节编址的话,也就是有 4 * 2^{30} = 2^{32} 个地址空间;地址需要 32 个二进制位来表示(0 ~ 2^{32} - 1);


指令的工作原理:

接下来我们思考,如果我们这个进程不是从地址 #0 开始存放的,会影响指令的正常执行吗?

那么非常重要的就是:如何把逻辑地址转换成物理地址?

        绝对装入:在编译时,如果知道程序将放到内存中的哪个位置,编译程序将产生绝对地址的目标代码。装入程序按照装入模块中的地址,将程序和数据装入内存。

        比如说上述的例子,如果编译时就知道模块要从地址 100 的地方开始存放……

        那么编译时就将指令 0 和指令 1 改为在地址为 179 的存储单元上进行操作。

        静态重定位(可重定位装入):编译、链接后的装入模块的地址都是从 0 开始的,指令中使用的地址、数据存放的地址都是相对于起始地址而言的逻辑地址。可根据内存的当前情况,将装入模块装到内存的适当位置。装入时对地址进行 “重定位”,将裸机地址变换为物理地址(地址变换是在装入时一次完成的)。

        比如说上述的例子,编译、链接后的程序都是在地址为 79 的存储单元上进行操作,但是在装入时对地址进行重定位操作,装入的起始物理地址为 100 ,则所有地址相关的参数都 +100;指令 0 就会变为往地址为 179 的存储单元中写入 10 ;静态重定位的特点是在一个作业装入内存时,必须分配其要求的全部内存空间,如果没有足够的内存,就不能装入该作业。而且作业一旦进入内存中,在运行期间是不能再移动的;

        动态重定位(动态运行时装入):编译、链接后的装入模块的地址都是从 0 开始的。装入程序把装入模块装入内存后,并不会立即把逻辑地址转换为物理地址,而是把地址推迟到程序真正要执行时才进行。因此装入内存后所有的地址依然是逻辑地址。这种方式需要一个重定位寄存器的支持。

        重定位寄存器中记录着:存放装入模块存放的起始地址

        比如说上述的例子:重定位寄存器记录着装入模块的起始地址为 100 ,那么 CPU 在运行时会把指令中的地址加上重定位寄存器中的地址,CPU 将会去读取加和之后得到的地址;


从写程序到程序运行的过程:

链接的三种方式:

        静态链接:在程序运行之前,先将各自目标函数及它们所需的库函数链接成一个完整的可执行文件,之后不再拆开;

        就是上述例子中的过程;

        装入时动态链接:将各目标模块装入内存时,边装入边链接的链接方式。

        也就是程序运行之前,先不把目标模块进行链接,而是目标模块进入内存后,再对目标模块进行链接,一边装入内存一边链接;

        运行时动态链接:在程序执行中需要该目标模块时,才对它进行链接。其优点是便于修改和更新,便于实现对目标模块的共享。

        就是说目标模块不是一起进入内存的,有可能是 main 文件先进入内存,然后 main 文件运行过程中有需要 a 文件,然后再将 a 文件调入至内存,再调入 a 文件之后,再将 a 文件和 main 文件链接到一起;

1.1 内存管理的概念

1.1.1 内存管理的基本原理和要求

        内存管理(Memory Management)是操作系统设计中最重要和最复杂的内容之一。虽然计算机硬件技术一直在飞速发展,内存容量也在不断增大,但仍然不可能将所有用户进程和系统所需要的全部程序和数据放入主存,因此操作系统必须对内存空间进行合理的划分和有效的动态分配。操作系统对内存的划分和动态分配,就是内存管理的概念

        有效的内存管理在多道程序设计中非常重要,它不仅可以方便用户使用存储器、提高内存利用率,还可以通过虚拟技术从逻辑上扩充存储器。

内存管理的主要功能有:

  • 内存空间的分配与回收。由操作系统完成主存储器空间的分配和管理,使程序员摆脱存储分配的麻烦,提高编程效率。
  • 地址转换。在多道程序环境下,程序中的逻辑地址与内存中的物理地址不可能一致,因此存储管理必须提供地址变换功能,把逻辑地址转换成相应的物理地址。
  • 内存空间的扩充。利用虚拟存储技术或自动覆盖技术,从逻辑上扩充内存。
  • 内存共享。指允许多个进程访问内存的同一部分。例如,多个合作进程可能需要访问同一块数据,因此必须支持对内存共享区域进行受控访问。
  • 存储保护。保证各道作业在各自的存储空间内运行,互不干扰。

        在进行具体的内存管理之前,需要了解进程运行的基本原理和要求。

1.1.1.1 程序的链接和装入

        创建进程首先要将程序和数据装入内存。将用户源程序变为可在内存中执行的程序,通常需要以下几个步骤:

  •         编译。由编译程序把用户源代码编译成若干目标模块。
  •         链接。由链接程序将编译后形成的一组目标模块及它们所需的库函数链接在一起,形成一个完整的装入模块。
  •         装入。由装入程序将装入模块装入内存运行。

程序的链接有以下三种方式:

1. 静态链接(在编译之前)

        在程序运行之前,先将各目标模块及它们所需的库函数链接成一个完整的装配模块,以后不再拆开。将几个目标模块装配成一个装入模块时,需要解决两个问题:

        ① 修改相对地址,编译后的所有目标模块都是从 0 开始的相对地址,当链接成一个装入模块时要修改相对地址。

        ② 变换外部调用符号,将每个模块中所用的外部调用符号也都变换为相对地址。
2. 装入时动态链接(在编译之后)
        将用户源程序编译后所得到的一组目标模块,在装入内存时,采用边装入边链接的方式。其优点是便于修改和更新,便于实现对目标模块的共享。
3. 运行时动态链接(在内存上运行时)

        对某些目标模块的链接,是在程序执行中需要该目标模块时才进行的。凡在执行过程中未被用到的目标模块,都不会被调入内存和被链接到装入模块上。其优点是能加快程序的装入过程,还可节省大量的内存空间。 

内存的装入模块在装入内存时,同样有以下三种方式:
1. 绝对装入
        绝对装入方式只适用于单道程序环境。在编译时,若知道程序将驻留在内存的某个位置,则编译程序将产生绝对地址的目标代码。绝对装入程序按照装入模块中的地址,将程序和数据装入内存。由于程序中的逻辑地址与实际内存地址完全相同,因此不需对程序和数据的地址进行修改。

        另外,程序中所用的绝对地址,可在编译或汇编时给出,也可由程序员直接赋予。而通常情况下在程序中采用的是符号地址,编译或汇编时再转换成绝对地址。
2. 可重定位装入
        在多道程序环境下,多个目标模块的起始地址通常都是从 0 开始的,程序中的其他地址都是相对于起始地址的,此时应采用可重定位装入方式。根据内存的当前情况,将装入模块装入内存的适当位置。在装入时对目标程序中指令和数据地址的修改过程称为重定位又因为地址变换通常是在进程装入时一次完成的,故称为静态重定位

        当一个作业装入内存时,必须给它分配要求的全部内存空间,若没有足够的内存,则无法装入。此外,作业一旦进入内存,整个运行期间就不能在内存中移动,也不能再申请内存空间。


3. 动态运行时装入
        也称为动态重定位程序在内存中若发生移动,则需要采用动态的装入方式。装入程序把装入模块装入内存后,并不立即把装入模块中的相对地址转换成绝对地址,而是把这种地址转换推迟到程序真正要执行时才进行。因此,装入内存后的所有地址均为相对地址。这种方式需要一个重定位寄存器的支持。

        动态重定位的优点

                可以将程序分配到不连续的存储区;

                在程序运行之前可以只装入部分代码即可投入运行,然后在程序运行期间,根据需要动态申请分配内存;

                便于程序段的共享。

1.1.1.2 逻辑地址与物理地址

        编译后,每个目标模块都从 0 号单元开始编址,则称为该目标模块的相对地址(或逻辑地址)当链接程序将各个模块链接成一个完整的可执行目标程序时,链接程序顺序依次按各个模块的相对地址构成统一的从 0 号单元开始编址的逻辑地址空间(或虚拟地址空间),对于 32 位系统,逻辑地址空间的范围为 0 ~ 2^{32} - 1。进程在运行时,看到和使用的地址都是逻辑地址。用户程序和程序员只需要知道逻辑地址,而内存管理的具体机制则是完全透明的。不同进程可以有相同的逻辑地址,因为这些相同的逻辑地址可以映射到主存的不同位置

        物理地址空间是指内存中物理单元的集合,它是地址转换的最终地址,进程在运行时执行指令和访问数据,最后都要通过物理地址从主存中存取当装入程序将可执行代码装入内存时,必须通过地址转换将逻辑地址转换成物理地址,这个过程称为地址重定位

        操作系统通过内存管理部件(MMU)将进程使用的逻辑地址转换为物理地址。进程使用虚拟内存空间中的地址,操作系统在相关硬件的协助下,将它 “转换” 成真正的物理地址。逻辑地址通过页表映射到物理地址,页表由操作系统维护并被处理器引用

1.1.1.3 进程的内存映像

        不同于存放在硬盘上的可执行程序文件(.exe),当一个程序调入内存运行时,就构成了进程的内存映射。一个进程的内存映像一般有几个要素:

  •         代码段即程序的二进制代码,代码段是只读的,可以被多个进程共享。
  •         数据段即程序运行时加工处理的对象,包括全局变量和静态变量
  •         进程控制块(PCB)存放在系统区。操作系统通过 PCB 来控制和管理进程
  •         堆用来存放动态分配的变量。通过调用 malloc 函数动态地向高地址分配空间。
  •         栈用来实现函数调用。从用户空间的最大地址往低地址方向增长。

        代码段和数据段在程序调入内存时就指定了大小,而堆和栈不一样。当调用像 malloc 和 free 这样的 C 标准库函数时,堆可以在运行时动态地扩展和收缩。用户栈和程序运行期间也可以动态地扩展和收缩,每次调用一个函数,栈就会增长;从一个函数返回时,栈就会收缩。

        下图是一个进程在内存中的映像。其中,共享库用来存放进程用到的共享函数库代码,如 printf() 函数等。在只读代码段中,.init 是程序初始化时调用的 _init 函数;.text 是用户程序的机器代码;.rodata 是只读数据。在读/写数据段中,.data 是已初始化的全局变量和静态变量;.bss 是未初始化及所有初始化为 0 的全局变量的静态变量。

1.1.1.4 内存保护

        确保每个进程都有一个单独的内存空间内存分配前,需要保护操作系统不受用户进程的影响,同时保护用户进程不受其他用户进程的影响。内存保护可以采取两种方法:

        1. 在 CPU 中设置一对上、下限寄存器,存放用户作业在主存中的下限和上限地址,每当 CPU 要访问一个地址时,分别和两个寄存器的值相比,判断有无越界。

        2. 采用重定位寄存器(又称基地址寄存器)界地址寄存器(又称限长寄存器)来实现这种保护。重定位寄存器含最小的物理地址值,界地址寄存器含逻辑地址的最大值。内存管理机构动态地将逻辑地址与界地址寄存器进行比较,若未发生地址越界,则加上重定位寄存器的值后映射成物理地址,在送交内存单元:

        实现内存保护需要重定位寄存器界地址寄存器,因此要注意两者之间的区别。重定位寄存器是用来 “加” 的,逻辑地址加上重定位寄存器中的值就能得到物理地址;界地址寄存器是用来 “比” 的,通过比较界地址寄存器中的值与逻辑地址的值来判断是否越界。

        加载重定位寄存器和界地址寄存器时必须使用特权指令,只有操作系统内核才可以加载这两个寄存器。这种方案允许操作系统内核修改这两个寄存器的值,而不允许用户程序修改。

1.1.1.5 内存共享

        并不是所有的进程内存空间都适合共享,只有那些只读的区域才可以共享可重入代码又称纯代码,是一种允许多个进程同时访问但不允许被任何进程修改的代码。但在实际执行时,也可以为每个进程配以局部数据区,把在执行中可能改变的部分复制到该数据区,这样,程序在执行时只需对该私有数据区的内存进行修改,并不去改变共享的代码。

举个例子来看内存共享的实现方式:
        考虑一个可以同时容纳 40 个用户的多用户系统,它们同时执行一个文本编辑程序,若该程序有 160KB 代码区和 40KB 数据区,则共需要 160*40+40*40=8000KB 的内存空间来支持 40 个用户。如果 160KB 代码是可分享的纯代码,则不论是在分页系统还是在分段系统中,整个系统只需保留一份副本即可,此时所需的内存空间仅为 40*40+160=1760KB。对于分页系统,假设页面大小为 4KB,则代码区占用 40 个页面、数据区占用 10 个页面。

        为了实现代码共享,应在每个进程的页表中都建立 40 个页表项,它们都指向共享代码区的物理页号。此外,每个进程还要为自己的数据区建立 10 个页表项,指向私有数据区的物理页号。

        对于分段系统,由于是以段为分配单位的,不管该段有多大,都只需为该段设置一个段表项(指向共享代码段始址,以及段长 160KB)。

1.1.1.6 内存分配与回收

        存储管理方式随着操作系统的发展而发展。在操作系统由单道向多道发展时,存储管理方式便由单一连续分配发展为固定分区分配。为了能更好的适应不同大小的程序要求,又从固定分区分配发展到动态分区分配。为了更好的提高内存利用率,进而从连续分配方式发展到离散分配方式——页式存储管理。引入分段存储管理的目的,主要是为了满足用户在编程和使用方面的要求,其中某些要求是其他几种存储管理方式难以满足的。

1.2 覆盖与交换

        覆盖与交换技术是在多道程序环境用来扩充内存的两种方法。

1.2.1 覆盖

        早期的计算机系统中,主存容量很小,虽然主存中仅存放一道用户程序,但存储空间放不下用户进程的现象也经常发生,这一矛盾可以用覆盖技术来解决。

        覆盖的基本思想如下由于程序运行时并非任何时候都要访问程序及数据的各个部分(尤其是大程序),因此可把用户空间分成一个固定区和若干覆盖区。将经常活跃的部分放在固定区,其余部分按调用关系分段。首先将那些即将要访问的段放入覆盖区,其他段放在外存中,在需要调用前,系统再将其调入覆盖区,替换覆盖区中原有的段。

        覆盖技术的特点是:打破了必须将一个进程的全部信息装入内存后才能运行的限制,但当同时运行程序的代码量大于主存时仍不能运行,此外,内存中能够更新的地方只有覆盖区的段,不在覆盖区中的段会常驻内存。覆盖技术对用户和程序员不透明。

1.2.2 交换 

        交换(对换)的基本思想是:把处于等待状态(或在 CPU 调度原则下被剥夺运行权利)的程序从内存移到辅存,把内存空间腾出来,这一过程又称换出;把准备好竞争 CPU 运行的程序从辅存移到内存,这一过程又称换入

        比如说:有一个 CPU 采用时间片轮转调度算法的多道程序环境。时间片到,内存管理器将刚刚执行过的进程换出,将另一个进程换入刚刚释放的内存空间。同时,CPU 调度器可以将时间片分配给其他已在内存中的进程。每个进程用完时间片都与另一进程交换。在理想情况下,内存管理器的交换过程速度足够快,总有进程在内存中可以执行。

        有关交换,注意以下几个问题:

  •         交换需要备份存储,通常是磁盘。它必须足够大,并提供对这些内存映像的直接访问。
  •         为了有效使用 CPU,需要使每个进程的执行时间比交换时间长。
  •         若换出进程,则必须确保该进程完全处于空闲状态。
  •         交换空间通常作为磁盘的一整块,且独立于文件系统,因此使用起来可能很快。
  •         交换通常在由许多进程运行且内存空间吃紧时开始启动,而在系统负荷降低时就暂停。
  •         普通的交换使用不多,但交换策略的某些变体在许多系统中仍发挥作用。

        交换技术主要在不同进程(或作业)之间进行,而覆盖则用于同一程序或进程中。

1.3 连续分配管理方式

        连续分配管理方式是指为一个用户程序分配一个连续的内存空间

        比如说, 某用户需要 100MB 的内存空间,连续分配方式就在内存空间中为用户分配一块连续的 100MB 空间。连续分配方式主要包括单一连续分配固定分区分配动态分区分配

1.3.1 单一连续分配

        内存在此方式下分为系统区用户区系统区仅供操作系统使用,通常在低地址部分;在用户区内存中,仅有一道用户程序,即整个内存的用户空间由该程序独占

        这种方式的优点是简单、无外部碎片,无须进行内存保护,因为内存中永远只有一道程序。缺点是只能用于单用户、单任务的操作系统中,有内部碎片,存储器的利用率极低。

        但是,如果内存中用户区的程序比较小,用户区中还有很大一部分处于空闲状态,此时页无法写入其他程序,内存利用率低;分配给某进程的内存区域中,如果有些部分没有用上,就是 “内部碎片”;

1.3.2 固定分区分配

        固定分区分配最简单的一种多道程序存储管理方式,它将用户内存空间划分为若干固定大小的区域,每个分区只装入一道作业。当有空闲分区时,便可再从外存的后备作业队列中选择适当大小的作业装入该分区,如此循环。在划分分区时有两种不同的方法:

  •         分区大小相等。程序太小会造成浪费,程序太大又无法装入,缺乏灵活性。
  •         分区大小不等。划分为多个较小的分区、适量的中等分区和少量大分区。

        为便于内存分配,通常将分区按大小排队,并为之建立一张分区说明表,其中各表项包括每个分区的始址、大小及状态;如下图所示:

        当有用户程序要装入时,便检索该表,以找到合适的分区给与分配并将其状态置为 “已分配” ;未找到合适分区时,则拒绝为该程序分配内存。

        这种方式存在两个问题:一是程序可能太大而放不进任何一个分区,这时就需要使用覆盖技术来使用内存空间;二是当程序小于固定分区大小时,也要占用一个完整的内存分区,这样分区内存就存在空间浪费,这种现象称为内部碎片固定分区是可用于多道程序设计的最简单的存储分配,无外部碎片,但不能实现多进程共享一个主存区,所以存储空间利用率低。

        在固定分区分配中,为了便于分配,建立一张分区使用表,通常按分区大小排队各表项包括每个分区的起始地址、大小及状态(是否已分配)。分配内存时,检索分区使用表,找到一个能满足要求且尚未分配的分区分配给装入程序,并将对应表项的状态置为 “已分配” ;若找不到这样的分区,则拒绝分配。回收内存时,只需将对应表项的状态置为 “未分配” 即可。

1.3.3 动态分区分配

        又称可变分区分配,它是在进程装入内存时,根据进程的实际需要,动态地为之分配内存,并使分区的大小正好适合进程的需要。因此,系统中分区的大小和数目是可变的。

        如下图所示:系统中有 64MB 内存空间,其中低 8MB 固定分配给操作系统,其余为用户可用内存。开始时装入前三个进程,它们分别分配到所需的空间后,内存仅剩 4MB,进程 4 无法装入。

        在某个时刻,内存中没有一个就绪进程,CPU 出现空闲,操作系统就换出进程 2,换入进程 4。由于进程 4 比进程 2 小,这样在主存中就产生了一个 6MB 的内存块。之后 CPU 又出现空闲,需要换入进程 2 ,而主存无法容纳进程 2 ,操作系统就换出进程 1,换入进程 2。

        动态分区在开始时是很好的,但随着时间的推移,内存中会产生越来越多小的内存块,内存的利用率也随之下降。这些小的内存块称为外部碎片,它存在于所有分区的外部,这与固定分区中的内部碎片正好相对。克服外部碎片可以通过紧凑技术来解决,即操作系统不时地对进程进行移动和整理。但这需要动态重定位寄存器的支持,且相对费时。紧凑的过程实际上类似于 Windows 系统中的磁盘碎片整理程序,只不过后者是对外存空间的紧凑。

        在进程装入或换入内存时,若内存中有多个足够大的空闲块,则操作系统必须确定分配哪个内存块给进程使用,这就是动态分区的分配策略。考虑以下几种算法:

        1. 首次适应(First Fit)算法。空闲分区以地址递增的次序链接。分配内存时,从链首开始顺序查找,找到大小能满足要求的第一个空闲分区分配给作业。

        首次适应算法最简单,通常也是最好和最快的。不过,首次适应算法会使得内存的低地址部分出现很多小的空闲分区,而每次分配查找时都要经过这些分区,因此增加了开销。

        2. 邻近适应(Next Fit)算法。又称循环首次适应算法,由首次适应算法演变而成。不同之处是,分配内存时从上次查找结束的位置开始继续查找。

        邻近适应算法试图解决这个问题。但它常常导致在内存空间的尾部(因为在一遍扫描中,内存前面部分使用后再释放时,不会参与分配)分裂成小碎片。通常比首次适应算法要差。

        3. 最佳适应(Best Fit)算法。空闲分区按容量递增的次序形成空闲分区链,找到第一个能满足要求且最小的空闲分区分配给作业,避免 “大材小用” 。

        最佳适应算法虽然称为 “最佳”,但是性能通常很差,因为每次最佳的分配会留下很小的难以利用的内存块,会产生最多的外部碎片。

        4. 最坏适应(Worst Fit)算法。空闲分区以容量递减的次序链接,找到第一个能满足要求的,即最大的分区,从中分割一部分存储空间给作业。

        最坏适应算法与最佳适应算法相反,它选择最大的可用块,这看起来最不容易产生碎片,但是却把最大的连续内存划分开,会很快导致没有可用的大内存块,因此性能也非常差。

        在动态分区分配中,与固定分区分配类似,设置一张空闲分区链(表),并按始址排序。分配内存时,检索空闲分区链,找到所需的分区,若其大小大于请求大小,便从该分区中按请求大小分割一块空间分配给装入进程(若剩余部分小到不足以划分,则无须分割),余下部分仍留在空闲分区链中。

        回收内存时,系统根据回收内存的始址,从空闲分区链中找到相应的插入点,此时可能出现四种情况:① 回收区与插入点的前一空闲分区相邻,将这两个分区合并,并修改前一分区表项的大小为两者之和;② 回收区与插入点的后一空闲分区相邻,将这两个分区合并,并修改后一分区表项的始址和大小;③ 回收区同时与插入点的前、后两个分区相邻,此时将这三个分区合并,修改前一分区表项的大小为三者之和,取消后一分区表项;④ 回收区没有相邻的空闲分区,此时应为回收区新建一个表项,填写始址和大小,并插入空闲分区链。

        以上三种内存分区管理方法有一个共同特点,即用户程序在主存中都是连续存放的。

        在连续分配方式中,我们发现,即使内存有超过 1GB 的空闲空间,但若没有连续的 1GB 空间,则需要 1GB 空间的作业仍然是无法运行的;但若采用非连续分配方式,则作业所要求的 1GB 内存空间可以分散地分配在内存的各个区域,当然,这也需要额外的空间去存储它们(分散区域)的索引,使得非连续分配方式的存储密度低于连续分配方式。

内部碎片:分配给某进程的内存区域中,有些部分没有用上。

外部碎片:内存中某些空闲分区太小而难以利用。

1.4 非连续分配方式

        非连续分配方式根据分区的大小是否固定,分为分页存储管理分段存储管理。在分页存储管理中,又根据运行作业时是否要把作业的所有页面都装入内存才能运行,分为基本分页存储管理请求分页存储管理

1.4.1 基本分页存储管理

        固定分区会产生内部碎片,动态分区会产生外部碎片,这两种技术对内存的利用率都比较低。我们希望内存的使用能尽量避免碎片的产生,这就引入了分页的思想把主存空间划分为大小相等且固定的块,块相对较小,作为主存的基本单位。每个进程也以块为单位进行划分,进程在执行时,以块为单位逐个申请主存中的块空间

        分页的方法从形式上看,像分区相等的固定分区技术,分页管理不会产生外部碎片。但它又有本质的不同点∶块的大小相对分区要小很多,而进程也按照块进行划分,进程运行时按块申请主存可用空间并执行。这样,进程只会在为最后一个不完整的块申请一个主存块空间时,才产生主存碎片,所以尽管会产生内部碎片,但这种碎片相对于进程来说也是很小的,每个进程平均只产生半个块大小的内部碎片(也称页内碎片)。

1.4.1.1 分页存储的几个基本概念
1.4.1.1.1 页面和页面大小

        进程中的块称为页或页面(Page)内存中的块称为页框或页帧(Page Frame)外存也以同样的单位进行划分,直接称为块或盘块(Block)。进程在执行时需要申请主存空间,即要为每个页面分配主存中的可用页框,这就产生了页和页框的一一对应。

        将进程的逻辑地址空间也分为与页框大小相等的一个个部分, 每个部分称为一个 “页” “页面”。每个页面也有一个编号,即 “页号” ,页号也是从 0 开始的

        这里要明白:进程中的页面是和内存块一一对应的,所以页面大小和内存块大小设置的是一样的;

        为方便地址转换,页面大小应是 2 的整数幂。同时页面大小应该适中,页面太小会使进程的页面数过多,这样页表就会过长,占用大量内存,而且也会增加硬件地址转换的开销,降低页面换入/换出的效率;页面过大又会使页内碎片增多,降低内存的利用率。

1.4.1.1.2 地址结构

        分页存储管理的逻辑地址如下所示:

        地址结构包含两部分:前一部分为页号 P,后一部分为页内偏移量 W。地址长度为 32 位,其中 0~11 位为页内地址,即每页大小为 4KB;12~31 位为页号,即最多允许 2^{20} 页。

如果有 k 位表示 “页内偏移量”,则说明该系统中一个页面的大小是 2^{k} 个内存单元;

如果有 M 位表示 “页号”,则说明在该系统中,一个进程最多允许有 2^{M} 个页面;

        注意,地址结构决定了虚拟内存的寻址空间有多大。

问题 :如何实现地址变换?
        首先内存中的内存块和进程中的页面大小是一一对应的;

        虽然进程中的各个页面在内存中是离散存放的,但是页面内部是连续存放的;

        如果要访问逻辑地址 A,则

        ① 确定逻辑地址 A 对应的页号 P

        ② 找到 P 号页面在内存中的起始地址(需要查页表);

        ③ 确定逻辑地址 A 的 “页内偏移量” W

        逻辑地址 A 对应的物理地址 = P 号页面在内存中的起始地址 + 页内偏移量 W ;

问题:如何确定一个逻辑地址对应的页号和页内偏移量?

        在某计算机系统中,页面大小是 50B。某进程逻辑地址空间大小为 200B,则逻辑地址 110 对应的页号、页内偏移量是多少?

        首先进程的大小为 200B,页面大小为 50B,显然,进程可以被分为 4 个大小相等的页面;

        如何计算:

        页号 = 逻辑地址 / 页面长度(取除法的整数部分)

        业内偏移量 = 逻辑地址 % 页面长度(取除法的余数部分)

        页号 = 110 / 50 = 2

        页内偏移量 = 110 % 50 = 10

1.4.1.1.3 页表

        为了便于在内存中找到进程的每个页面所对应的物理块,系统为每个进程建立一张页表,它记录页面在内存中对应的物理块号,页表一般存放在内存中

        在配置页表后,内存执行时,通过查找该表,即可找到每页在内存中的物理块号。可见,页表的作用是实现从页号到物理块号的地址映射。

        页表是由页表项组成的,页表项和地址都由两部分构成,而且第一部分都是页号,但页表项的第二部分是物理内存中的块号,而地址的第二部分是页内偏移;页表项的第二部分与地址的第二部分共同组成物理地址。

问题 1 :每个页表项占用多少个字节?

        假设某系统物理内存大小为 4GB,页面大小为 4KB,则每个页表项至少应该为多少字节?
        看上图 3.8 ,页表项是页表中存储的每一项,页表项由页号和块号组成;

        上面我们说过进程中的页面和内存中的内存块是一一对应的,所以页面大小等于内存块大小,也就等于 4KB,1KB = 2^{10} B,4 * 2^{10} = 2^{12} B;

        也就是说内存块大小为 2^{12} ,1 GB = 2^{30} B,4GB = 2^{32} B,所以 4GB 大小的内存会被分为 2^{32} / 2^{12} = 2^{20} 个内存块;

        也就是说内存中内存块号应该从 0 ~ 2^{20} -1 开始排序;

        那么也就意味着页表中的块号至少要用 20bit 来表示;

        又因为计算机是以字节来定义的,1B = 8bit,所以3 * 8 = 24 bit ,所以至少要用 3 B 来表示页表中的块号;

        
        页号是不需要占用内存空间的,因为页号在内存中是连续存放的,所以页号是可以隐藏的;

        这里这么理解,每个页表项占用 3 个字节,且都是连续存放的,假设页表项是从地址 x 开始存放的,那么要找到页号 i 对应的地址,只需要 x + i * 3 即可解出页号 i 对应的起始地址;

1.4.1.2 基本地址变换机构

        地址变换机构的任务是将逻辑地址转换为内存中的物理地址。地址变换是借助于页表实现的。

        在系统中通常设置一个页表寄存器(PTR),存放页表在内存的起始地址 F 和页表长度 M。平时,进程未执行时,页表的始址和页表长度存放在本进程的 PCB 中,当进程被调度执行时,才将页表始址和页表长度装入页表寄存器中。设页面大小为 L,逻辑地址 A 到物理地址 E 的变换过程如下(假设逻辑地址、页号、每页的长度都是十进制数):

        ① 计算页号 P(P=A/L)和页内偏移量 W(W=A%L)。

        ② 比较页号 P 和页表长度 M,若 P \geq M,则产生越界中断,否则继续执行。

        ③ 页表中页号 P 对应的页表项地址 = 页表始址 F + 页号 P * 页表项长度,取出该页表项内容 b ,即为物理块号。注意区分页表长度和页表项长度。页表长度是指一共有多少页,页表项长度是指页地址占多大的存储空间。

        ④ 计算 E = b * L + W ,用得到的物理地址 E 去访问内存。

        以上整个地址变换过程均是硬件自动完成的。例如,若页面大小 L 为 1KB,页号 2 对应的物理块为 b = 8,计算逻辑地址 A = 2500 的物理地址 E 的过程如下:P =  2500/1K = 2,W = 2500 % 1KB = 452,查找得到页号 2 对应的物理块的块号为 8 , E = 8*1024+452=8644;

页表项的大小不是随意规定的,而是有所约束的?


        页表项的作用是找到该页在内存中的位置。以 32 位逻辑地址空间、字节编址单位、一页 4KB 为例,地址空间内一共有 2^{32}B/4KB=1M页,因此需要 log_{2}1 M = 20 位才能保证表示范围能容纳所有页面,又因为以字节作为编址单位,即页表项大小 \geq 20/8(向上取整) = 3B。所以在这个条件下,为了保证页表项能够指向所有页面,页表项的大小应该大于等于 3B,当然,也可以选择更大的页表项让一个页面能够正好容下整数个页表项,进而方便存储(如取成 4B,这样一页正好可以装下 1K 个页表项),或增加一些其他信息。

存在的两个主要问题:

        ① 每次访存操作都需要进行逻辑地址到物理地址的转换,地址转换过程必须足够快,否则访存速度会降低

        ② 每个进程引入页表,用于存储映射机制,页表不能太大,否则内存利用率会降低

1.4.1.3 具有快表的地址变换机构

        若页表全部放在内存中,则存取一个数据或一条指令至少要访问两次内存:第一次是访问页表,确定所存取的数据或指令的物理地址;第二次是根据该地址存取数据或指令。显然,这种方法比通常执行指令的速度慢了一半。

        为此,在地址变换机构中增设一个具有并行查找能力的高速缓冲存储器——快表(是一种访问速度比内存快很多的高速缓存),又称相联存储器(TLB);TLB不是内存是用来存放当前访问的若干页表项,以加速地址变换过程。与此对应,主存中的页表常称为慢表

这里解释一下什么是高速缓存?

        我们计算机在设计的时候通常都会设计存储设备,一般都是使用的硬盘,但是硬盘的读写速度不快,这里的不快是相对于 CPU 处理数据的能力而言的,这样这会造成 CPU 处理数据的速度和从硬盘读写数据的速度产生较大的差异;

        为了缓和 CPU 处理数据的速度和硬盘读写数据速度的较大差异,我们通常都是先将数据从硬盘加载到内存(RAM)中,然后再由 CPU 从内存中读写数据,内存读写数据的速度是硬盘读写数据的好几十倍;

        虽然内存读写数据的速度是硬盘读写数据的速度的好几十倍,但是相比于 CPU 处理数据的速度还是有很大的差异,因此又引入了高速缓存(Cache)高速缓存并不是内存

        明确一点就是:引入高速缓存的目的就是协调 CPU 处理数据和读写速度差异的问题;

        在具有快表的分页机制中,地址的变换过程如下:

        ① CPU 给出逻辑地址后,由硬件进行地址转换,将页号送入高速缓存寄存器,并将此页号与快表中的所有页号进行比较。

        ② 若找到匹配的页号,说明所要访问的页表项在快表中,则直接从中取出该页对应的页框号,与页内偏移量拼接形成物理地址。这样,存取数据仅一次访存便可实现。

        ③ 若未找到匹配的页号,则需要访问主存中的页表,读出页表项后,应同时将其存入快表(快表虽然访问速度快,但是造价也很贵,所以快表中存储的信息并不多,因此需要不断的更新),以便后面可能的再次访问。若快表已满,则须按特定的算法淘汰一个旧页表项。

        注意:有些处理机设计为快表和慢表同时查找,若在快表中查找成功则终止慢表的查找。

        由于查询快表的速度比查询页表的速度快很多,因此只要快表命中,就可以节省很多时间。

        因为局部性原理,一般来说快表的命中率可以达到 90% 以上。

        例:某系统使用基本分页存储管理,并采用了具有快表的地址变换机构。访问一次快表耗时 1us,访问一次内存耗时 100us。若快表的命中率为 90%,那么访问一个逻辑地址的平均耗时是多少?

        (1+100)*0.9+(1+100+100)*0.1=111us

        上述式子的意思是:若快表中存有该地址,则快表访问需要 1us,得到地址后,需要访问内存中的物理地址,需要 100us,命中率 90%

                                        如果快表中没有该地址,那么就需要在慢表中找到该地址,然后再访问内存中的物理地址,总共需要 1+100+100us,命中率 10%

这里介绍一下著名的局部性原理:

时间局部性:如果执行了程序中的某条指令,那么不久之后这条指令很有可能会再次被访问;如果某个数据被访问过,不久之后该数据很有可能再次被访问。(因为程序中存在大量的循环)

int i = 0;
int a[100];
while(i<100)
{
    a[i] = i;
    i++;
}
while循环肯定会不止一次的访问;
假设上述的程序存放在页面 1 ;
程序中定义的变量存放在页面 2 ;
那么上述程序一定会不断的在内存中循环页面 1 和页面 2;

空间局部性:一旦程序访问了某个存储空间,在不久之后,其附近的存储单元也很有可能被访问。(因为很多数据在内存中都是连续存放的)

1.4.1.4 两级页表

单级页表存在的问题?

        1. 页表必须连续存放,因此当页表很大时,需要占用很多个连续的页框。

        2. 没有必要让整个页表常驻内存,因为进程在一段时间内可能只需要访问某几个特定的页面。

        由于引入了分页管理,进程在执行时不需要将所有页调入内存页框,而只需将保存有映射关系的页表调入内存。但是,我们仍然需要考虑页表的大小

        以 32 位逻辑地址空间、页面大小 4KB、页表项大小 4B 为例,若要实现进程对全部逻辑地址空间的映射,则每个进程需要 2^{20 } 即约 100 万个页表项。也就是说,每个进程仅页表这一项就需要 4MB 主存空间,而且还要求是连续的,显然这是不切实际的。即便不考虑对全部逻辑地址空间进行映射的情况,一个逻辑地址空间稍大的进程,其页表大小也可能是过大的。以一个 40MB 的进程为例,页表项共 40KB(40MB/4KB×4B),若将所有页表项内容保存在内存中,则需要 10 个内存页框来保存整个页表。整个进程大小约为 1 万个页面,而实际执行时只需要几十个页面进入内存页框就可运行,但若要求 10 个页面大小的页表必须全部进入内存,则相对实际执行时的几十个进程页面的大小来说,肯定降低了内存利用 率;从另一方面来说,这 10 页的页表项也并不需要同时保存在内存中,因为在大多数情况下,映射所需要的页表项都在页表的同一个页面中。

        为了压缩页表,我们进一步延伸页表映射的思想,就可得到二级分页,即使用层次结构的页表:

        将页表的10页空间也进行地址映射,建立上一级页表,用于存储页表的映射关系。这里对页表的 10 个页面进行映射只需要 10 个页表项,所以上一级页表只需要 1 页就已足够(可以存储 2^{10} = 1024 个页表项)。在进程执行时,只需要将这一页的上一级页表调入内存即可,进程的页表和进程本身的页面可在后面的执行中再调入内存。根据上面提到的条件(32 位逻辑地址空间、页面大小4KB、页表项大小 4B ,以字节为编址单位),我们来构造一个适合的页表结构。页面大小为4KB , 页内偏移地址为 log_{2}4K=12 位,页号部分为 20 位,若不采用分级页表,则仅页表就要占用 220x4B/4KB = 1024 页,这大大超过了许多进程自身需要的页面,对于内存来说是非常浪费资源的,而且查询页表工作也会变得十分不便、试想若把这些页表放在连续的空间内,查询对应页的物理页号时可以通过页表首页地址+页号x4B的形式得到,而这种方法查询起来虽然相对方便,但连续的1024 页对于内存的要求实在太高,并且上面也说到了其中大多数页面都是不会用到的,所以这种方法并不具有可行性。若不把这些页表放在连续的空间里,则需要一张索引表来告诉我们第几张页表该上哪里去找,这能解决页表的查询问题,且不用把所有的页表都调入内存,只在需要它时才调入(下节介绍的虚拟存储器思想),因此能解决占用内存空间过大的问题。读者也许发现这个方案就和当初引进页表机制的方式一模一样,实际上就是构造一个页表的页表,也就是二级页表。为查询方便,顶级页表最多只能有1个页面(一定要记住这个规定),因此顶级页表总共可以容纳 4KB/4B = 1K个页表项,它占用的地址位数为 log_{2}1K = 10位,而之前已经计算出页内偏移地址占用了 12 位,因此一个 32 位的逻辑地址空间就剩下了 10 位,正好使得二级页表的大小在一页之内,这样就得到了逻辑地址空间的格式。

        二级页表实际上是在原有页表结构上再加上一层页表。

        建立多级页表的目的在于建立索引,以便不用浪费主存空间去存储无用的页表项,也不用盲目地顺序式查找页表项。

1.5 基本分段存储管理

        分页管理方式是从计算机的角度考虑的,目的是提高内存的利用率,提升计算机的性能。分页通过硬件机制实现,对用户完全透明。

        分段管理方式的提出则考虑了用户和程序员,以满足方便编程、信息保护和共享、动态增长及动态链接等多方面的需要。

1.5.1 分段

        段式管理方式按照用户进程中的自然段划分逻辑空间。例如,用户程序由主程序段、两个子程序段、栈段和数据段组成,于是可以把这个用户进程划分为 5 段,每段从 0 开始编址,并分配一段连续的地址空间(段内要求连续,段间不要求连续,因此整个作业的地址空间是二维的),其逻辑地址由段号 S 与段内偏移量 W 两部分组成

        段号为 16 位,段内偏移量为 16 位,因此一个作业最多有 2^{16} = 65536 段,最大段长为 64KB。

        在页式系统中,逻辑地址的页号和页内偏移量对用户是透明的,但在段式系统中,段号和段内偏移量必须由用户显示提供,在高级程序设计语言中,这个工作由编译程序完成。

1.5.2 段表

        每个进程都有一张逻辑空间与内存空间映射的段表,其中每个段表项对应进程的一段端表项记录该段在内存中的始址和长度

        配置段表后,执行中的进程可通过查找段表,找到每段所对应的内存区。可见,段表用于实现从逻辑段到物理内存区的映射

1.5.3 地址变换机构

        分段系统的地址变换机构如下图所示。为了实现进程从逻辑地址到物理地址的变换功能,在系统中设置了段表寄存器,用于存放段表始址 F 和段表长度 M。从逻辑地址 A 到物理地址 E 之间的地址变换过程如下:

        ① 从逻辑地址 A 中取出前几位为段号 S,后几位为段内偏移量 W。

        ② 比较段号 S 和段表长度 M,若 S \geq M,则产生越界中断,否则继续执行。

        ③ 段表中段号 S 对应的段表项地址 = 段表始址 F + 段号 S * 段表项长度,取出该段表项的前几位得到段长 C。若段内偏移量 \geq C,则产生越界中断,否则继续执行。

        ④ 取出段表项中该段的始址 b,计算 E = b + W,用得到的物理地址 E 去访问内存。

1.5.4 段的共享与保护

        在分段系统中,段的共享是通过两个作业的段表中相应表项指向被共享的段的同一个物理副本来实现的。当一个作业正从共享段中读取数据时,必须防止另一个作业修改此共享段中的数据。不能修改的代码称为纯代码可重入代码(它不属于临界资源),这样的代码和不能修改的数据可以共享,而可修改的代码和数据不能共享

        与分页管理类似,分段管理的保护方法主要有两种∶一种是存取控制保护,另一种是地址越界保护。地址越界保护将段表寄存器中的段表长度与逻辑地址中的段号比较,若段号大于段表长度,则产生越界中断;再将段表项中的段长和逻辑地址中的段内偏移进行比较,若段内偏移大于段长,也会产生越界中断。分页管理只需要判断页号是否越界,页内偏移是不可能越界的。

        与分页管理不同,段式管理不能通过给出一个整数便确定对应的物理地址,因为每段的长度是不固定的,无法通过整数除法得出段号,无法通过求余得出段内偏移,所以段号和段内偏移一定要显示给出(段号,段内偏移),因此分段管理的地址空间是二维的。

1.6 段页式管理

        分页存储管理能有效地提高内存利用率,而分段存储管理能反映程序的逻辑结构并有利于段的共享和保护。将这两种存储管理方法结合起来,便形成了段页式存储管理方式。

        在段页式系统中,作业的地址空间首先被分成若干逻辑段,每段都有自己的段号,然后将每段分成大小固定的页。对内存空间的管理仍然和分页存储管理一样,将其分成若干和页面大小相同的存储块,对内存的分配以存储块为单位。

        在段页式系统中,作业的逻辑地址分成三部分段号、页号和页内偏移量

        为了实现地址变换,系统为每个进程建立一张段表,每个分段有一张页表。段表表项中至少包括段号、页表长度和页表始址,页表表项中至少包括页号和块号。此外,系统中还应有一个段表寄存器,指出作业的段表始址和段表长度(段表寄存器和页表寄存器的作用都有两个,一是在段表或页表中寻址,二是判断是否越界)。

        注意:在一个进程中,段表只有一个,而页表可能有多个

         在进行地址变换时,首先通过段表查到页表始址,然后通过页表找到页帧号,最后形成物理地址。如下图所示,进行一次访问实际需要三次访问主存,这里同样可以使用快表来加快查找速度,其关键字由段号、页号组成,值是对应的页帧号和保护码。

1.7 小结

思考问题?

1. 为什么要进行内存管理?

        在单道系统阶段,一个系统在一个时间段内只执行一个程序,内存的分配极其简单,即仅分配给当前运行的进程。引入多道程序后,进程之间共享的不仅仅是处理机,还有主存储器。然而,共享主存会形成一些特殊的挑战。若不对内存进行管理,则容易导致内存数据的混乱,以至于影响进程的并发执行。因此,为了更好地支持多道程序并发执行,必须进行内存管理。

2. 页式管理中每个页表项大小的下限如何决定?

        页表项的作用是找到该页在内存中的位置。以 32 位逻辑地址空间、字节编址单位、一页 4KB 为例,地址空间内共含有2^{32}B/4KB= 1M页,需要 log_{2}1M = 20位才能保证表示范围能容纳所有页面,又因为以字节作为编址单位,即页表项的大小 ≥|20/8|=3B。当然,也可选择更大的页表项大小,让一个页面能够正好容下整数个页表项,以方便存储(例如取成4B,一页正好可以装下1K个页表项),或增加一些其他信息。

3. 多级页表解决了什么问题?又会带来什么问题?

        多级页表解决了当逻辑地址空间过大时,页表的长度会大大增加的问题。而采用多级页表时,一次访盘需要多次访问内存甚至磁盘,会大大增加一次访存的时间。

  • 2
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
文将对 Linux™ 程序员可以使用的内存管理技术进行概述,虽然关注的重点是 C 语言,但同样也适用于其他语言。文中将为您提供如何管理内存的细节,然后将进一步展示如何手工管理内存,如何使用引用计数或者内存池来半手工地管理内存,以及如何使用垃圾收集自动管理内存。 为什么必须管理内存 内存管理是计算机编程最为基本的领域之一。在很多脚本语言中,您不必担心内存是如何管理的,这并不能使得内存管理的重要性有一点点降低。对实际编程来说,理解您的内存管理器的能力与局限性至关重要。在大部分系统语言中,比如 C 和 C++,您必须进行内存管理。本文将介绍手工的、半手工的以及自动的内存管理实践的基本概念。 追溯到在 Apple II 上进行汇编语言编程的时代,那时内存管理还不是个大问题。您实际上在运行整个系统。系统有多少内存,您就有多少内存。您甚至不必费心思去弄明白它有多少内存,因为每一台机器的内存数量都相同。所以,如果内存需要非常固定,那么您只需要选择一个内存范围并使用它即可。 不过,即使是在这样一个简单的计算机中,您也会有问题,尤其是当您不知道程序的每个部分将需要多少内存时。如果您的空间有限,而内存需求是变化的,那么您需要一些方法来满足这些需求: 确定您是否有足够的内存来处理数据。 从可用的内存中获取一部分内存。 向可用内存池(pool)中返回部分内存,以使其可以由程序的其他部分或者其他程序使用。 实现这些需求的程序库称为 分配程序(allocators),因为它们负责分配和回收内存。程序的动态性越强,内存管理就越重要,您的内存分配程序的选择也就更重要。让我们来了解可用于内存管理的不同方法,它们的好处与不足,以及它们最适用的情形。 回页首 C 风格的内存分配程序 C 编程语言提供了两个函数来满足我们的三个需求: malloc:该函数分配给定的字节数,并返回一个指向它们的指针。如果没有足够的可用内存,那么它返回一个空指针。 free:该函数获得指向由 malloc 分配的内存片段的指针,并将其释放,以便以后的程序或操作系统使用(实际上,一些 malloc 实现只能将内存归还给程序,而无法将内存归还给操作系统)。 物理内存和虚拟内存 要理解内存在程序中是如何分配的,首先需要理解如何将内存从操作系统分配给程序。计算机上的每一个进程都认为自己可以访问所有的物理内存。显然,由于同时在运行多个程序,所以每个进程不可能拥有全部内存。实际上,这些进程使用的是 虚拟内存。 只是作为一个例子,让我们假定您的程序正在访问地址为 629 的内存。不过,虚拟内存系统不需要将其存储在位置为 629 的 RAM 中。实际上,它甚至可以不在 RAM 中 —— 如果物理 RAM 已经满了,它甚至可能已经被转移到硬盘上!由于这类地址不必反映内存所在的物理位置,所以它们被称为虚拟内存。操作系统维持着一个虚拟地址到物理地址的转换的表,以便计算机硬件可以正确地响应地址请求。并且,如果地址在硬盘上而不是在 RAM 中,那么操作系统将暂时停止您的进程,将其他内存转存到硬盘中,从硬盘上加载被请求的内存,然后再重新启动您的进程。这样,每个进程都获得了自己可以使用的地址空间,可以访问比您物理上安装的内存更多的内存。 在 32-位 x86 系统上,每一个进程可以访问 4 GB 内存。现在,大部分人的系统上并没有 4 GB 内存,即使您将 swap 也算上, 每个进程所使用的内存也肯定少于 4 GB。因此,当加载一个进程时,它会得到一个取决于某个称为 系统中断点(system break)的特定地址的初始内存分配。该地址之后是未被映射的内存 —— 用于在 RAM 或者硬盘中没有分配相应物理位置的内存。因此,如果一个进程运行超出了它初始分配的内存,那么它必须请求操作系统“映射进来(map in)”更多的内存。(映射是一个表示一一对应关系的数学术语 —— 当内存的虚拟地址有一个对应的物理地址来存储内存内容时,该内存将被映射。) 基于 UNIX 的系统有两个可映射到附加内存中的基本系统调用: brk: brk() 是一个非常简单的系统调用。还记得系统中断点吗?该位置是进程映射的内存边界。 brk() 只是简单地将这个位置向前或者向后移动,就可以向进程添加内存或者从进程取走内存。 mmap: mmap(),或者说是“内存映像”,类似于 brk(),但是更为灵活。首先,它可以映射任何位置的内存,而不单单只局限于进程。其次,它不仅可以将虚拟地址映射到物理的 RAM 或者 swap,它还可以将它们映射到文件和文件位置,这样,读写内存将对文件中的数据进行读写。不过,在这里,我们只关心 mmap 向进程添加被映射的内存的能力。 munmap() 所做的事情与 mmap() 相反。 如您所见, brk() 或者 mmap() 都可以用来向我们的进程添加额外的虚拟内存。在我们的例子中将使用 brk(),因为它更简单,更通用。 实现一个简单的分配程序 如果您曾经编写过很多 C 程序,那么您可能曾多次使用过 malloc() 和 free()。不过,您可能没有用一些时间去思考它们在您的操作系统中是如何实现的。本节将向您展示 malloc 和 free 的一个最简化实现的代码,来帮助说明管理内存时都涉及到了哪些事情。 要试着运行这些示例,需要先 复制本代码清单,并将其粘贴到一个名为 malloc.c 的文件中。接下来,我将一次一个部分地对该清单进行解释。 在大部分操作系统中,内存分配由以下两个简单的函数来处理: void *malloc(long numbytes):该函数负责分配 numbytes 大小的内存,并返回指向第一个字节的指针。 void free(void *firstbyte):如果给定一个由先前的 malloc 返回的指针,那么该函数会将分配的空间归还给进程的“空闲空间”。 malloc_init 将是初始化内存分配程序的函数。它要完成以下三件事:将分配程序标识为已经初始化,找到系统中最后一个有效内存地址,然后建立起指向我们管理的内存的指针。这三个变量都是全局变量: 清单 1. 我们的简单分配程序的全局变量 int has_initialized = 0; void *managed_memory_start; void *last_valid_address; 如前所述,被映射的内存的边界(最后一个有效地址)常被称为系统中断点或者 当前中断点。在很多 UNIX® 系统中,为了指出当前系统中断点,必须使用 sbrk(0) 函数。 sbrk 根据参数中给出的字节数移动当前系统中断点,然后返回新的系统中断点。使用参数 0 只是返回当前中断点。这里是我们的 malloc 初始化代码,它将找到当前中断点并初始化我们的变量: 清单 2. 分配程序初始化函数 /* Include the sbrk function */ #include void malloc_init() { /* grab the last valid address from the OS */ last_valid_address = sbrk(0); /* we don't have any memory to manage yet, so *just set the beginning to be last_valid_address */ managed_memory_start = last_valid_address; /* Okay, we're initialized and ready to go */ has_initialized = 1; } 现在,为了完全地管理内存,我们需要能够追踪要分配和回收哪些内存。在对内存块进行了 free 调用之后,我们需要做的是诸如将它们标记为未被使用的等事情,并且,在调用 malloc 时,我们要能够定位未被使用的内存块。因此, malloc 返回的每块内存的起始处首先要有这个结构: 清单 3. 内存控制块结构定义 struct mem_control_block { int is_available; int size; }; 现在,您可能会认为当程序调用 malloc 时这会引发问题 —— 它们如何知道这个结构?答案是它们不必知道;在返回指针之前,我们会将其移动到这个结构之后,把它隐藏起来。这使得返回的指针指向没有用于任何其他用途的内存。那样,从调用程序的角度来看,它们所得到的全部是空闲的、开放的内存。然后,当通过 free() 将该指针传递回来时,我们只需要倒退几个内存字节就可以再次找到这个结构。 在讨论分配内存之前,我们将先讨论释放,因为它更简单。为了释放内存,我们必须要做的惟一一件事情就是,获得我们给出的指针,回退 sizeof(struct mem_control_block) 个字节,并将其标记为可用的。这里是对应的代码: 清单 4. 解除分配函数 void free(void *firstbyte) { struct mem_control_block *mcb; /* Backup from the given pointer to find the * mem_control_block */ mcb = firstbyte - sizeof(struct mem_control_block); /* Mark the block as being available */ mcb->is_available = 1; /* That's It! We're done. */ return; } 如您所见,在这个分配程序中,内存的释放使用了一个非常简单的机制,在固定时间内完成内存释放。分配内存稍微困难一些。以下是该算法的略述: 清单 5. 主分配程序的伪代码 1. If our allocator has not been initialized, initialize it. 2. Add sizeof(struct mem_control_block) to the size requested. 3. start at managed_memory_start. 4. Are we at last_valid address? 5. If we are: A. We didn't find any existing space that was large enough -- ask the operating system for more and return that. 6. Otherwise: A. Is the current space available (check is_available from the mem_control_block)? B. If it is: i) Is it large enough (check "size" from the mem_control_block)? ii) If so: a. Mark it as unavailable b. Move past mem_control_block and return the pointer iii) Otherwise: a. Move forward "size" bytes b. Go back go step 4 C. Otherwise: i) Move forward "size" bytes ii) Go back to step 4 我们主要使用连接的指针遍历内存来寻找开放的内存块。这里是代码: 清单 6. 主分配程序 void *malloc(long numbytes) { /* Holds where we are looking in memory */ void *current_location; /* This is the same as current_location, but cast to a * memory_control_block */ struct mem_control_block *current_location_mcb; /* This is the memory location we will return. It will * be set to 0 until we find something suitable */ void *memory_location; /* Initialize if we haven't already done so */ if(! has_initialized) { malloc_init(); } /* The memory we search for has to include the memory * control block, but the users of malloc don't need * to know this, so we'll just add it in for them. */ numbytes = numbytes + sizeof(struct mem_control_block); /* Set memory_location to 0 until we find a suitable * location */ memory_location = 0; /* Begin searching at the start of managed memory */ current_location = managed_memory_start; /* Keep going until we have searched all allocated space */ while(current_location != last_valid_address) { /* current_location and current_location_mcb point * to the same address. However, current_location_mcb * is of the correct type, so we can use it as a struct. * current_location is a void pointer so we can use it * to calculate addresses. */ current_location_mcb = (struct mem_control_block *)current_location; if(current_location_mcb->is_available) { if(current_location_mcb->size >= numbytes) { /* Woohoo! We've found an open, * appropriately-size location. */ /* It is no longer available */ current_location_mcb->is_available = 0; /* We own it */ memory_location = current_location; /* Leave the loop */ break; } } /* If we made it here, it's because the Current memory * block not suitable; move to the next one */ current_location = current_location + current_location_mcb->size; } /* If we still don't have a valid location, we'll * have to ask the operating system for more memory */ if(! memory_location) { /* Move the program break numbytes further */ sbrk(numbytes); /* The new memory will be where the last valid * address left off */ memory_location = last_valid_address; /* We'll move the last valid address forward * numbytes */ last_valid_address = last_valid_address + numbytes; /* We need to initialize the mem_control_block */ current_location_mcb = memory_location; current_location_mcb->is_available = 0; current_location_mcb->size = numbytes; } /* Now, no matter what (well, except for error conditions), * memory_location has the address of the memory, including * the mem_control_block */ /* Move the pointer past the mem_control_block */ memory_location = memory_location + sizeof(struct mem_control_block); /* Return the pointer */ return memory_location; } 这就是我们的内存管理器。现在,我们只需要构建它,并在程序中使用它即可。 运行下面的命令来构建 malloc 兼容的分配程序(实际上,我们忽略了 realloc() 等一些函数,不过, malloc() 和 free() 才是最主要的函数): 清单 7. 编译分配程序 gcc -shared -fpic malloc.c -o malloc.so 该程序将生成一个名为 malloc.so 的文件,它是一个包含有我们的代码的共享库。 在 UNIX 系统中,现在您可以用您的分配程序来取代系统的 malloc(),做法如下: 清单 8. 替换您的标准的 malloc LD_PRELOAD=/path/to/malloc.so export LD_PRELOAD LD_PRELOAD 环境变量使动态链接器在加载任何可执行程序之前,先加载给定的共享库的符号。它还为特定库中的符号赋予优先权。因此,从现在起,该会话中的任何应用程序都将使用我们的 malloc(),而不是只有系统的应用程序能够使用。有一些应用程序不使用 malloc(),不过它们是例外。其他使用 realloc() 等其他内存管理函数的应用程序,或者错误地假定 malloc() 内部行为的那些应用程序,很可能会崩溃。ash shell 似乎可以使用我们的新 malloc() 很好地工作。 如果您想确保 malloc() 正在被使用,那么您应该通过向函数的入口点添加 write() 调用来进行测试。 我们的内存管理器在很多方面都还存在欠缺,但它可以有效地展示内存管理需要做什么事情。它的某些缺点包括: 由于它对系统中断点(一个全局变量)进行操作,所以它不能与其他分配程序或者 mmap 一起使用。 当分配内存时,在最坏的情形下,它将不得不遍历 全部进程内存;其中可能包括位于硬盘上的很多内存,这意味着操作系统将不得不花时间去向硬盘移入数据和从硬盘中移出数据。 没有很好的内存不足处理方案( malloc 只假定内存分配是成功的)。 它没有实现很多其他的内存函数,比如 realloc()。 由于 sbrk() 可能会交回比我们请求的更多的内存,所以在堆(heap)的末端会遗漏一些内存。 虽然 is_available 标记只包含一位信息,但它要使用完整的 4-字节 的字。 分配程序不是线程安全的。 分配程序不能将空闲空间拼合为更大的内存块。 分配程序的过于简单的匹配算法会导致产生很多潜在的内存碎片。 我确信还有很多其他问题。这就是为什么它只是一个例子! 其他 malloc 实现 malloc() 的实现有很多,这些实现各有优点与缺点。在设计一个分配程序时,要面临许多需要折衷的选择,其中包括: 分配的速度。 回收的速度。 有线程的环境的行为。 内存将要被用光时的行为。 局部缓存。 簿记(Bookkeeping)内存开销。 虚拟内存环境中的行为。 小的或者大的对象。 实时保证。 每一个实现都有其自身的优缺点集合。在我们的简单的分配程序中,分配非常慢,而回收非常快。另外,由于它在使用虚拟内存系统方面较差,所以它最适于处理大的对象。 还有其他许多分配程序可以使用。其中包括: Doug Lea Malloc:Doug Lea Malloc 实际上是完整的一组分配程序,其中包括 Doug Lea 的原始分配程序,GNU libc 分配程序和 ptmalloc。 Doug Lea 的分配程序有着与我们的版本非常类似的基本结构,但是它加入了索引,这使得搜索速度更快,并且可以将多个没有被使用的块组合为一个大的块。它还支持缓存,以便更快地再次使用最近释放的内存。 ptmalloc 是 Doug Lea Malloc 的一个扩展版本,支持多线程。在本文后面的 参考资料部分中,有一篇描述 Doug Lea 的 Malloc 实现的文章。 BSD Malloc:BSD Malloc 是随 4.2 BSD 发行的实现,包含在 FreeBSD 之中,这个分配程序可以从预先确实大小的对象构成的池中分配对象。它有一些用于对象大小的 size 类,这些对象的大小为 2 的若干次幂减去某一常数。所以,如果您请求给定大小的一个对象,它就简单地分配一个与之匹配的 size 类。这样就提供了一个快速的实现,但是可能会浪费内存。在 参考资料部分中,有一篇描述该实现的文章。 Hoard:编写 Hoard 的目标是使内存分配在多线程环境中进行得非常快。因此,它的构造以锁的使用为中心,从而使所有进程不必等待分配内存。它可以显著地加快那些进行很多分配和回收的多线程进程的速度。在 参考资料部分中,有一篇描述该实现的文章。 众多可用的分配程序中最有名的就是上述这些分配程序。如果您的程序有特别的分配需求,那么您可能更愿意编写一个定制的能匹配您的程序内存分配方式的分配程序。不过,如果不熟悉分配程序的设计,那么定制分配程序通常会带来比它们解决的问题更多的问题。要获得关于该主题的适当的介绍,请参阅 Donald Knuth 撰写的 The Art of Computer Programming Volume 1: Fundamental Algorithms 中的第 2.5 节“Dynamic Storage Allocation”(请参阅 参考资料中的链接)。它有点过时,因为它没有考虑虚拟内存环境,不过大部分算法都是基于前面给出的函数。 在 C++ 中,通过重载 operator new(),您可以以每个类或者每个模板为单位实现自己的分配程序。在 Andrei Alexandrescu 撰写的 Modern C++ Design 的第 4 章(“Small Object Allocation”)中,描述了一个小对象分配程序(请参阅 参考资料中的链接)。 基于 malloc() 的内存管理的缺点 不只是我们的内存管理器有缺点,基于 malloc() 的内存管理器仍然也有很多缺点,不管您使用的是哪个分配程序。对于那些需要保持长期存储的程序使用 malloc() 来管理内存可能会非常令人失望。如果您有大量的不固定的内存引用,经常难以知道它们何时被释放。生存期局限于当前函数的内存非常容易管理,但是对于生存期超出该范围的内存来说,管理内存则困难得多。而且,关于内存管理是由进行调用的程序还是由被调用的函数来负责这一问题,很多 API 都不是很明确。 因为管理内存的问题,很多程序倾向于使用它们自己的内存管理规则。C++ 的异常处理使得这项任务更成问题。有时好像致力于管理内存分配和清理的代码比实际完成计算任务的代码还要多!因此,我们将研究内存管理的其他选择。 回页首 半自动内存管理策略 引用计数 引用计数是一种 半自动(semi-automated)的内存管理技术,这表示它需要一些编程支持,但是它不需要您确切知道某一对象何时不再被使用。引用计数机制为您完成内存管理任务。 在引用计数中,所有共享的数据结构都有一个域来包含当前活动“引用”结构的次数。当向一个程序传递一个指向某个数据结构指针时,该程序会将引用计数增加 1。实质上,您是在告诉数据结构,它正在被存储在多少个位置上。然后,当您的进程完成对它的使用后,该程序就会将引用计数减少 1。结束这个动作之后,它还会检查计数是否已经减到零。如果是,那么它将释放内存。 这样做的好处是,您不必追踪程序中某个给定的数据结构可能会遵循的每一条路径。每次对其局部的引用,都将导致计数的适当增加或减少。这样可以防止在使用数据结构时释放该结构。不过,当您使用某个采用引用计数的数据结构时,您必须记得运行引用计数函数。另外,内置函数和第三方的库不会知道或者可以使用您的引用计数机制。引用计数也难以处理发生循环引用的数据结构。 要实现引用计数,您只需要两个函数 —— 一个增加引用计数,一个减少引用计数并当计数减少到零时释放内存。 一个示例引用计数函数集可能看起来如下所示: 清单 9. 基本的引用计数函数 /* Structure Definitions*/ /* Base structure that holds a refcount */ struct refcountedstruct { int refcount; } /* All refcounted structures must mirror struct * refcountedstruct for their first variables */ /* Refcount maintenance functions */ /* Increase reference count */ void REF(void *data) { struct refcountedstruct *rstruct; rstruct = (struct refcountedstruct *) data; rstruct->refcount++; } /* Decrease reference count */ void UNREF(void *data) { struct refcountedstruct *rstruct; rstruct = (struct refcountedstruct *) data; rstruct->refcount--; /* Free the structure if there are no more users */ if(rstruct->refcount == 0) { free(rstruct); } } REF 和 UNREF 可能会更复杂,这取决于您想要做的事情。例如,您可能想要为多线程程序增加锁,那么您可能想扩展 refcountedstruct,使它同样包含一个指向某个在释放内存之前要调用的函数的指针(类似于面向对象语言中的析构函数 —— 如果您的结构中包含这些指针,那么这是 必需的)。 当使用 REF 和 UNREF 时,您需要遵守这些指针的分配规则: UNREF 分配前左端指针(left-hand-side pointer)指向的值。 REF 分配后左端指针(left-hand-side pointer)指向的值。 在传递使用引用计数的结构的函数中,函数需要遵循以下这些规则: 在函数的起始处 REF 每一个指针。 在函数的结束处 UNREF 第一个指针。 以下是一个使用引用计数的生动的代码示例: 清单 10. 使用引用计数的示例 /* EXAMPLES OF USAGE */ /* Data type to be refcounted */ struct mydata { int refcount; /* same as refcountedstruct */ int datafield1; /* Fields specific to this struct */ int datafield2; /* other declarations would go here as appropriate */ }; /* Use the functions in code */ void dosomething(struct mydata *data) { REF(data); /* Process data */ /* when we are through */ UNREF(data); } struct mydata *globalvar1; /* Note that in this one, we don't decrease the * refcount since we are maintaining the reference * past the end of the function call through the * global variable */ void storesomething(struct mydata *data) { REF(data); /* passed as a parameter */ globalvar1 = data; REF(data); /* ref because of Assignment */ UNREF(data); /* Function finished */ } 由于引用计数是如此简单,大部分程序员都自已去实现它,而不是使用库。不过,它们依赖于 malloc 和 free 等低层的分配程序来实际地分配和释放它们的内存。 在 Perl 等高级语言中,进行内存管理时使用引用计数非常广泛。在这些语言中,引用计数由语言自动地处理,所以您根本不必担心它,除非要编写扩展模块。由于所有内容都必须进行引用计数,所以这会对速度产生一些影响,但它极大地提高了编程的安全性和方便性。以下是引用计数的益处: 实现简单。 易于使用。 由于引用是数据结构的一部分,所以它有一个好的缓存位置。 不过,它也有其不足之处: 要求您永远不要忘记调用引用计数函数。 无法释放作为循环数据结构的一部分的结构。 减缓几乎每一个指针的分配。 尽管所使用的对象采用了引用计数,但是当使用异常处理(比如 try 或 setjmp()/ longjmp())时,您必须采取其他方法。 需要额外的内存来处理引用。 引用计数占用了结构中的第一个位置,在大部分机器中最快可以访问到的就是这个位置。 在多线程环境中更慢也更难以使用。 C++ 可以通过使用 智能指针(smart pointers)来容忍程序员所犯的一些错误,智能指针可以为您处理引用计数等指针处理细节。不过,如果不得不使用任何先前的不能处理智能指针的代码(比如对 C 库的联接),实际上,使用它们的后果通实比不使用它们更为困难和复杂。因此,它通常只是有益于纯 C++ 项目。如果您想使用智能指针,那么您实在应该去阅读 Alexandrescu 撰写的 Modern C++ Design 一书中的“Smart Pointers”那一章。 内存池 内存池是另一种半自动内存管理方法。内存池帮助某些程序进行自动内存管理,这些程序会经历一些特定的阶段,而且每个阶段中都有分配给进程的特定阶段的内存。例如,很多网络服务器进程都会分配很多针对每个连接的内存 —— 内存的最大生存期限为当前连接的存在期。Apache 使用了池式内存(pooled memory),将其连接拆分为各个阶段,每个阶段都有自己的内存池。在结束每个阶段时,会一次释放所有内存。 在池式内存管理中,每次内存分配都会指定内存池,从中分配内存。每个内存池都有不同的生存期限。在 Apache 中,有一个持续时间为服务器存在期的内存池,还有一个持续时间为连接的存在期的内存池,以及一个持续时间为请求的存在期的池,另外还有其他一些内存池。因此,如果我的一系列函数不会生成比连接持续时间更长的数据,那么我就可以完全从连接池中分配内存,并知道在连接结束时,这些内存会被自动释放。另外,有一些实现允许注册 清除函数(cleanup functions),在清除内存池之前,恰好可以调用它,来完成在内存被清理前需要完成的其他所有任务(类似于面向对象中的析构函数)。 要在自己的程序中使用池,您既可以使用 GNU libc 的 obstack 实现,也可以使用 Apache 的 Apache Portable Runtime。GNU obstack 的好处在于,基于 GNU 的 Linux 发行版本中默认会包括它们。Apache Portable Runtime 的好处在于它有很多其他工具,可以处理编写多平台服务器软件所有方面的事情。要深入了解 GNU obstack 和 Apache 的池式内存实现,请参阅 参考资料部分中指向这些实现的文档的链接。 下面的假想代码列表展示了如何使用 obstack: 清单 11. obstack 的示例代码 #include #include /* Example code listing for using obstacks */ /* Used for obstack macros (xmalloc is a malloc function that exits if memory is exhausted */ #define obstack_chunk_alloc xmalloc #define obstack_chunk_free free /* Pools */ /* Only permanent allocations should go in this pool */ struct obstack *global_pool; /* This pool is for per-connection data */ struct obstack *connection_pool; /* This pool is for per-request data */ struct obstack *request_pool; void allocation_failed() { exit(1); } int main() { /* Initialize Pools */ global_pool = (struct obstack *) xmalloc (sizeof (struct obstack)); obstack_init(global_pool); connection_pool = (struct obstack *) xmalloc (sizeof (struct obstack)); obstack_init(connection_pool); request_pool = (struct obstack *) xmalloc (sizeof (struct obstack)); obstack_init(request_pool); /* Set the error handling function */ obstack_alloc_failed_handler = &allocation_failed; /* Server main loop */ while(1) { wait_for_connection(); /* We are in a connection */ while(more_requests_available()) { /* Handle request */ handle_request(); /* Free all of the memory allocated * in the request pool */ obstack_free(request_pool, NULL); } /* We're finished with the connection, time * to free that pool */ obstack_free(connection_pool, NULL); } } int handle_request() { /* Be sure that all object allocations are allocated * from the request pool */ int bytes_i_need = 400; void *data1 = obstack_alloc(request_pool, bytes_i_need); /* Do stuff to process the request */ /* return */ return 0; } 基本上,在操作的每一个主要阶段结束之后,这个阶段的 obstack 会被释放。不过,要注意的是,如果一个过程需要分配持续时间比当前阶段更长的内存,那么它也可以使用更长期限的 obstack,比如连接或者全局内存。传递给 obstack_free() 的 NULL 指出它应该释放 obstack 的全部内容。可以用其他的值,但是它们通常不怎么实用。 使用池式内存分配的益处如下所示: 应用程序可以简单地管理内存。 内存分配和回收更快,因为每次都是在一个池中完成的。分配可以在 O(1) 时间内完成,释放内存池所需时间也差不多(实际上是 O(n) 时间,不过在大部分情况下会除以一个大的因数,使其变成 O(1))。 可以预先分配错误处理池(Error-handling pools),以便程序在常规内存被耗尽时仍可以恢复。 有非常易于使用的标准实现。 池式内存的缺点是: 内存池只适用于操作可以分阶段的程序。 内存池通常不能与第三方库很好地合作。 如果程序的结构发生变化,则不得不修改内存池,这可能会导致内存管理系统的重新设计。 您必须记住需要从哪个池进行分配。另外,如果在这里出错,就很难捕获该内存池。 回页首 垃圾收集 垃圾收集(Garbage collection)是全自动地检测并移除不再使用的数据对象。垃圾收集器通常会在当可用内存减少到少于一个具体的阈值时运行。通常,它们以程序所知的可用的一组“基本”数据 —— 栈数据、全局变量、寄存器 —— 作为出发点。然后它们尝试去追踪通过这些数据连接到每一块数据。收集器找到的都是有用的数据;它没有找到的就是垃圾,可以被销毁并重新使用这些无用的数据。为了有效地管理内存,很多类型的垃圾收集器都需要知道数据结构内部指针的规划,所以,为了正确运行垃圾收集器,它们必须是语言本身的一部分。 收集器的类型 复制(copying): 这些收集器将内存存储器分为两部分,只允许数据驻留在其中一部分上。它们定时地从“基本”的元素开始将数据从一部分复制到另一部分。内存新近被占用的部分现在成为活动的,另一部分上的所有内容都认为是垃圾。另外,当进行这项复制操作时,所有指针都必须被更新为指向每个内存条目的新位置。因此,为使用这种垃圾收集方法,垃圾收集器必须与编程语言集成在一起。 标记并清理(Mark and sweep):每一块数据都被加上一个标签。不定期的,所有标签都被设置为 0,收集器从“基本”的元素开始遍历数据。当它遇到内存时,就将标签标记为 1。最后没有被标记为 1 的所有内容都认为是垃圾,以后分配内存时会重新使用它们。 增量的(Incremental):增量垃圾收集器不需要遍历全部数据对象。因为在收集期间的突然等待,也因为与访问所有当前数据相关的缓存问题(所有内容都不得不被页入(page-in)),遍历所有内存会引发问题。增量收集器避免了这些问题。 保守的(Conservative):保守的垃圾收集器在管理内存时不需要知道与数据结构相关的任何信息。它们只查看所有数据类型,并假定它们 可以全部都是指针。所以,如果一个字节序列可以是一个指向一块被分配的内存的指针,那么收集器就将其标记为正在被引用。有时没有被引用的内存会被收集,这样会引发问题,例如,如果一个整数域中包含一个值,该值是已分配内存的地址。不过,这种情况极少发生,而且它只会浪费少量内存。保守的收集器的优势是,它们可以与任何编程语言相集成。 Hans Boehm 的保守垃圾收集器是可用的最流行的垃圾收集器之一,因为它是免费的,而且既是保守的又是增量的,可以使用 --enable-redirect-malloc 选项来构建它,并且可以将它用作系统分配程序的简易替代者(drop-in replacement)(用 malloc/ free 代替它自己的 API)。实际上,如果这样做,您就可以使用与我们在示例分配程序中所使用的相同的 LD_PRELOAD 技巧,在系统上的几乎任何程序中启用垃圾收集。如果您怀疑某个程序正在泄漏内存,那么您可以使用这个垃圾收集器来控制进程。在早期,当 Mozilla 严重地泄漏内存时,很多人在其中使用了这项技术。这种垃圾收集器既可以在 Windows® 下运行,也可以在 UNIX 下运行。 垃圾收集的一些优点: 您永远不必担心内存的双重释放或者对象的生命周期。 使用某些收集器,您可以使用与常规分配相同的 API。 其缺点包括: 使用大部分收集器时,您都无法干涉何时释放内存。 在多数情况下,垃圾收集比其他形式的内存管理更慢。 垃圾收集错误引发的缺陷难于调试。 如果您忘记将不再使用的指针设置为 null,那么仍然会有内存泄漏。 回页首 结束语 一切都需要折衷:性能、易用、易于实现、支持线程的能力等,这里只列出了其中的一些。为了满足项目的要求,有很多内存管理模式可以供您使用。每种模式都有大量的实现,各有其优缺点。对很多项目来说,使用编程环境默认的技术就足够了,不过,当您的项目有特殊的需要时,了解可用的选择将会有帮助。下表对比了本文中涉及的内存管理策略。 表 1. 内存分配策略的对比 策略 分配速度 回收速度 局部缓存 易用性 通用性 实时可用 SMP 线程友好 定制分配程序 取决于实现 取决于实现 取决于实现 很难 无 取决于实现 取决于实现 简单分配程序 内存使用少时较快 很快 差 容易 高 否 否 GNU malloc 中 快 中 容易 高 否 中 Hoard 中 中 中 容易 高 否 是 引用计数 N/A N/A 非常好 中 中 是(取决于 malloc 实现) 取决于实现 池 中 非常快 极好 中 中 是(取决于 malloc 实现) 取决于实现 垃圾收集 中(进行收集时慢) 中 差 中 中 否 几乎不 增量垃圾收集 中 中 中 中 中 否 几乎不 增量保守垃圾收集 中 中 中 容易 高 否 几乎不 参考资料 您可以参阅本文在 developerWorks 全球站点上的 英文原文。 Web 上的文档 GNU C Library 手册的 obstacks 部分 提供了 obstacks 编程接口。 Apache Portable Runtime 文档 描述了它们的池式分配程序的接口。 基本的分配程序 Doug Lea 的 Malloc 是最流行的内存分配程序之一。 BSD Malloc 用于大部分基于 BSD 的系统中。 ptmalloc 起源于 Doug Lea 的 malloc,用于 GLIBC 之中。 Hoard 是一个为多线程应用程序优化的 malloc 实现。 GNU Memory-Mapped Malloc(GDB 的组成部分) 是一个基于 mmap() 的 malloc 实现。 池式分配程序 GNU Obstacks(GNU Libc 的组成部分)是安装最多的池式分配程序,因为在每一个基于 glibc 的系统中都有它。 Apache 的池式分配程序(Apache Portable Runtime 中) 是应用最为广泛的池式分配程序。 Squid 有其自己的池式分配程序。 NetBSD 也有其自己的池式分配程序。 talloc 是一个池式分配程序,是 Samba 的组成部分。 智能指针和定制分配程序 Loki C++ Library 有很多为 C++ 实现的通用模式,包括智能指针和一个定制的小对象分配程序。 垃圾收集器 Hahns Boehm Conservative Garbage Collector 是最流行的开源垃圾收集器,它可以用于常规的 C/C++ 程序。 关于现代操作系统中的虚拟内存的文章 Marshall Kirk McKusick 和 Michael J. Karels 合著的 A New Virtual Memory Implementation for Berkeley UNIX 讨论了 BSD 的 VM 系统。 Mel Gorman's Linux VM Documentation 讨论了 Linux VM 系统。 关于 malloc 的文章 Poul-Henning Kamp 撰写的 Malloc in Modern Virtual Memory Environments 讨论的是 malloc 以及它如何与 BSD 虚拟内存交互。 Berger、McKinley、Blumofe 和 Wilson 合著的 Hoard -- a Scalable Memory Allocator for Multithreaded Environments 讨论了 Hoard 分配程序的实现。 Marshall Kirk McKusick 和 Michael J. Karels 合著的 Design of a General Purpose Memory Allocator for the 4.3BSD UNIX Kernel 讨论了内核级的分配程序。 Doug Lea 撰写的 A Memory Allocator 给出了一个关于设计和实现分配程序的概述,其中包括设计选择与折衷。 Emery D. Berger 撰写的 Memory Management for High-Performance Applications 讨论的是定制内存管理以及它如何影响高性能应用程序。 关于定制分配程序的文章 Doug Lea 撰写的 Some Storage Management Techniques for Container Classes 描述的是为 C++ 类编写定制分配程序。 Berger、Zorn 和 McKinley 合著的 Composing High-Performance Memory Allocators 讨论了如何编写定制分配程序来加快具体工作的速度。 Berger、Zorn 和 McKinley 合著的 Reconsidering Custom Memory Allocation 再次提及了定制分配的主题,看是否真正值得为其费心。 关于垃圾收集的文章 Paul R. Wilson 撰写的 Uniprocessor Garbage Collection Techniques 给出了垃圾收集的一个基本概述。 Benjamin Zorn 撰写的 The Measured Cost of Garbage Collection 给出了关于垃圾收集和性能的硬数据(hard data)。 Hans-Juergen Boehm 撰写的 Memory Allocation Myths and Half-Truths 给出了关于垃圾收集的神话(myths)。 Hans-Juergen Boehm 撰写的 Space Efficient Conservative Garbage Collection 是一篇描述他的用于 C/C++ 的垃圾收集器的文章。 Web 上的通用参考资料 内存管理参考 中有很多关于内存管理参考资料和技术文章的链接。 关于内存管理和内存层级的 OOPS Group Papers 是非常好的一组关于此主题的技术文章。 C++ 中的内存管理讨论的是为 C++ 编写定制的分配程序。 Programming Alternatives: Memory Management 讨论了程序员进行内存管理时的一些选择。 垃圾收集 FAQ 讨论了关于垃圾收集您需要了解的所有内容。 Richard Jones 的 Garbage Collection Bibliography 有指向任何您想要的关于垃圾收集的文章的链接。 书籍 Michael Daconta 撰写的 C++ Pointers and Dynamic Memory Management 介绍了关于内存管理的很多技术。 Frantisek Franek 撰写的 Memory as a Programming Concept in C and C++ 讨论了有效使用内存的技术与工具,并给出了在计算机编程中应当引起注意的内存相关错误的角色。 Richard Jones 和 Rafael Lins 合著的 Garbage Collection: Algorithms for Automatic Dynamic Memory Management 描述了当前使用的最常见的垃圾收集算法。 在 Donald Knuth 撰写的 The Art of Computer Programming 第 1 卷 Fundamental Algorithms 的第 2.5 节“Dynamic Storage Allocation”中,描述了实现基本的分配程序的一些技术。 在 Donald Knuth 撰写的 The Art of Computer Programming 第 1 卷 Fundamental Algorithms 的第 2.3.5 节“Lists and Garbage Collection”中,讨论了用于列表的垃圾收集算法。 Andrei Alexandrescu 撰写的 Modern C++ Design 第 4 章“Small Object Allocation”描述了一个比 C++ 标准分配程序效率高得多的一个高速小对象分配程序。 Andrei Alexandrescu 撰写的 Modern C++ Design 第 7 章“Smart Pointers”描述了在 C++ 中智能指针的实现。 Jonathan 撰写的 Programming from the Ground Up 第 8 章“Intermediate Memory Topics”中有本文使用的简单分配程序的一个汇编语言版本。 来自 developerWorks 自我管理数据缓冲区内存 (developerWorks,2004 年 1 月)略述了一个用于管理内存的自管理的抽象数据缓存器的伪 C (pseudo-C)实现。 A framework for the user defined malloc replacement feature (developerWorks,2002 年 2 月)展示了如何利用 AIX 中的一个工具,使用自己设计的内存子系统取代原有的内存子系统。 掌握 Linux 调试技术 (developerWorks,2002 年 8 月)描述了可以使用调试方法的 4 种不同情形:段错误、内存溢出、内存泄漏和挂起。 在 处理 Java 程序中的内存漏洞 (developerWorks,2001 年 2 月)中,了解导致 Java 内存泄漏的原因,以及何时需要考虑它们。 在 developerWorks Linux 专区中,可以找到更多为 Linux 开发人员准备的参考资料。 从 developerWorks 的 Speed-start your Linux app 专区中,可以下载运行于 Linux 之上的 IBM 中间件产品的免费测试版本,其中包括 WebSphere® Studio Application Developer、WebSphere Application Server、DB2® Universal Database、Tivoli® Access Manager 和 Tivoli Directory Server,查找 how-to 文章和技术支持。 通过参与 developerWorks blogs 加入到 developerWorks 社区。 可以在 Developer Bookstore Linux 专栏中定购 打折出售的 Linux 书籍。 关于作者 Jonathan Bartlett 是 Programming from the Ground Up 一书的作者,这本书介绍的是 Linux 汇编语言编程。Jonathan Bartlett 是 New Media Worx 的总开发师,负责为客户开发 Web、视频、kiosk 和桌面应用程序。您可以通过 johnnyb@eskimo.com 与 Jonathan 联系。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值