内存管理 订阅
内存管理是指软件运行时对计算机内存资源的分配和使用的技术。其最主要的目的是如何高效,快速的分配,并且在适当的时候释放和回收内存资源。一个执行中的程式,譬如网页浏览器在个人电脑或是图灵机(Turing machine)里面,为一个行程将资料转换于真实世界及电脑内存之间,然后将资料存于电脑内存内部(在计算机科学,一个程式是一群指令的集合,一个行程是电脑在执行中的程式)。一个程式结构由以下两部分而成:“本文区段”,也就是指令存放,提供CPU使用及执行; “资料区段”,储存程式内部本身设定的资料,例如常数字串。 展开全文
内存管理是指软件运行时对计算机内存资源的分配和使用的技术。其最主要的目的是如何高效,快速的分配,并且在适当的时候释放和回收内存资源。一个执行中的程式,譬如网页浏览器在个人电脑或是图灵机(Turing machine)里面,为一个行程将资料转换于真实世界及电脑内存之间,然后将资料存于电脑内存内部(在计算机科学,一个程式是一群指令的集合,一个行程是电脑在执行中的程式)。一个程式结构由以下两部分而成:“本文区段”,也就是指令存放,提供CPU使用及执行; “资料区段”,储存程式内部本身设定的资料,例如常数字串。
信息
概    念
是指软件运行时对计
作用 
高效,快
媒    介
磁带或是磁盘
中文名
内存管理
学    科
计算机
外文名
memory management
内存管理技术简介
内存可以通过许多媒介实现,例如磁带或是磁盘,或是小阵列容量的微芯片。 从1950年代开始,计算机变的更复杂,它内部由许多种类的内存组成。内存管理的任务也变的更加复杂,甚至必须在一台机器同时执行多个进程。虚拟内存是内存管理技术的一个极其实用的创新。它是一段程序(由操作系统调度),持续监控着所有物理内存中的代码段、数据段,并保证他们在运行中的效率以及可靠性,对于每个用户层(user-level)的进程分配一段虚拟内存空间。当进程建立时,不需要在物理内存件之间搬移数据,数据储存于磁盘内的虚拟内存空间,也不需要为该进程去配置主内存空间,只有当该进程被被调用的时候才会被加载到主内存。可以想像一个很大的程序,当他执行时被操作系统调用,其运行需要的内存数据都被存到磁盘内的虚拟内存,只有需要用到的部分才被加载到主内存内部运行。 [1] 
收起全文
精华内容
下载资源
问答
  • 内存管理

    万次阅读 2015-03-12 17:09:40
    内存管理是C++最令人切齿痛恨的问题,也是C++最有争议的问题,C++高手从中获得了更好的性能,更大的自由,C++菜鸟的收获则是一遍一遍的检查代码和对C++的痛恨,但内存管理在C++中无处不在,内存泄漏几乎在每个C++...

    内存管理是C++最令人切齿痛恨的问题,也是C++最有争议的问题,C++高手从中获得了更好的性能,更大的自由,C++菜鸟的收获则是一遍一遍的检查代码和对C++的痛恨,但内存管理在C++中无处不在,内存泄漏几乎在每个C++程序中都会发生,因此要想成为C++高手,内存管理一关是必须要过的,除非放弃C++,转到Java或者.NET,他们的内存管理基本是自动的,当然你也放弃了自由和对内存的支配权,还放弃了C++超绝的性能。本期专题将从内存管理、内存泄漏、内存回收这三个方面来探讨C++内存管理问题。

    内存管理

    伟大的Bill Gates 曾经失言:

      640K ought to be enough for everybody  Bill Gates 1981

    程序员们经常编写内存管理程序,往往提心吊胆。如果不想触雷,唯一的解决办法就是发现所有潜伏的地雷并且排除它们,躲是躲不了的。本文的内容比一般教科书的要深入得多,读者需细心阅读,做到真正地通晓内存管理。

    1.1 C++内存管理详解

    1.1.1 内存分配方式

    1.1.1.1 分配方式简介

      在C++中,内存分成5个区,他们分别是堆、栈、自由存储区、全局/静态存储区和常量存储区。

      栈,在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限。

      堆,就是那些由new分配的内存块,他们的释放编译器不去管,由我们的应用程序去控制,一般一个new就要对应一个delete。如果程序员没有释放掉,那么在程序结束后,操作系统会自动回收。

      自由存储区,就是那些由malloc等分配的内存块,他和堆是十分相似的,不过它是用free来结束自己的生命的。

      全局/静态存储区,全局变量和静态变量被分配到同一块内存中,在以前的C语言中,全局变量又分为初始化的和未初始化的,在C++里面没有这个区分了,他们共同占用同一块内存区。

      常量存储区,这是一块比较特殊的存储区,他们里面存放的是常量,不允许修改。

    1.1.1.2 明确区分堆与栈

      在bbs上,堆与栈的区分问题,似乎是一个永恒的话题,由此可见,初学者对此往往是混淆不清的,所以我决定拿他第一个开刀。

      首先,我们举一个例子:

    void f() { int* p=new int[5]; }

      这条短短的一句话就包含了堆与栈,看到new,我们首先就应该想到,我们分配了一块堆内存,那么指针p呢?他分配的是一块栈内存,所以这句话的意思就是:在栈内存中存放了一个指向一块堆内存的指针p。在程序会先确定在堆中分配内存的大小,然后调用operator new分配内存,然后返回这块内存的首地址,放入栈中,他在VC6下的汇编代码如下:

    00401028 push 14h

    0040102A call operator new (00401060)

    0040102F add esp,4

    00401032 mov dword ptr [ebp-8],eax

    00401035 mov eax,dword ptr [ebp-8]

    00401038 mov dword ptr [ebp-4],eax

      这里,我们为了简单并没有释放内存,那么该怎么去释放呢?是delete p么?澳,错了,应该是delete []p,这是为了告诉编译器:我删除的是一个数组,VC6就会根据相应的Cookie信息去进行释放内存的工作。

    1.1.1.3 堆和栈究竟有什么区别?

      好了,我们回到我们的主题:堆和栈究竟有什么区别?

      主要的区别由以下几点:

      1、管理方式不同;

      2、空间大小不同;

      3、能否产生碎片不同;

      4、生长方向不同;

      5、分配方式不同;

      6、分配效率不同;

      管理方式:对于栈来讲,是由编译器自动管理,无需我们手工控制;对于堆来说,释放工作由程序员控制,容易产生memory leak

      空间大小:一般来讲在32位系统下,堆内存可以达到4G的空间,从这个角度来看堆内存几乎是没有什么限制的。但是对于栈来讲,一般都是有一定的空间大小的,例如,在VC6下面,默认的栈空间大小是1M(好像是,记不清楚了)。当然,我们可以修改:

      打开工程,依次操作菜单如下:Project->Setting->Link,在Category 中选中Output,然后在Reserve中设定堆栈的最大值和commit

      注意:reserve最小值为4Bytecommit是保留在虚拟内存的页文件里面,它设置的较大会使栈开辟较大的值,可能增加内存的开销和启动时间。

      碎片问题:对于堆来讲,频繁的new/delete势必会造成内存空间的不连续,从而造成大量的碎片,使程序效率降低。对于栈来讲,则不会存在这个问题,因为栈是先进后出的队列,他们是如此的一一对应,以至于永远都不可能有一个内存块从栈中间弹出,在他弹出之前,在他上面的后进的栈内容已经被弹出,详细的可以参考数据结构,这里我们就不再一一讨论了。

      生长方向:对于堆来讲,生长方向是向上的,也就是向着内存地址增加的方向;对于栈来讲,它的生长方向是向下的,是向着内存地址减小的方向增长。

      分配方式:堆都是动态分配的,没有静态分配的堆。栈有2种分配方式:静态分配和动态分配。静态分配是编译器完成的,比如局部变量的分配。动态分配由alloca函数进行分配,但是栈的动态分配和堆是不同的,他的动态分配是由编译器进行释放,无需我们手工实现。

      分配效率:栈是机器系统提供的数据结构,计算机会在底层对栈提供支持:分配专门的寄存器存放栈的地址,压栈出栈都有专门的指令执行,这就决定了栈的效率比较高。堆则是C/C++函数库提供的,它的机制是很复杂的,例如为了分配一块内存,库函数会按照一定的算法(具体的算法可以参考数据结构/操作系统)在堆内存中搜索可用的足够大小的空间,如果没有足够大小的空间(可能是由于内存碎片太多),就有可能调用系统功能去增加程序数据段的内存空间,这样就有机会分到足够大小的内存,然后进行返回。显然,堆的效率比栈要低得多。

      从这里我们可以看到,堆和栈相比,由于大量new/delete的使用,容易造成大量的内存碎片;由于没有专门的系统支持,效率很低;由于可能引发用户态和核心态的切换,内存的申请,代价变得更加昂贵。所以栈在程序中是应用最广泛的,就算是函数的调用也利用栈去完成,函数调用过程中的参数,返回地址,EBP和局部变量都采用栈的方式存放。所以,我们推荐大家尽量用栈,而不是用堆。

      虽然栈有如此众多的好处,但是由于和堆相比不是那么灵活,有时候分配大量的内存空间,还是用堆好一些。

    无论是堆还是栈,都要防止越界现象的发生(除非你是故意使其越界),因为越界的结果要么是程序崩溃,要么是摧毁程序的堆、栈结构,产生以想不到的结果,就算是在你的程序运行过程中,没有发生上面的问题,你还是要小心,说不定什么时候就崩掉,那时候debug可是相当困难的:)

    1.1.2 控制C++的内存分配

      在嵌入式系统中使用C++的一个常见问题是内存分配,即对new  delete 操作符的失控。

      具有讽刺意味的是,问题的根源却是C++对内存的管理非常的容易而且安全。具体地说,当一个对象被消除时,它的析构函数能够安全的释放所分配的内存。

      这当然是个好事情,但是这种使用的简单性使得程序员们过度使用new  delete,而不注意在嵌入式C++环境中的因果关系。并且,在嵌入式系统中,由于内存的限制,频繁的动态分配不定大小的内存会引起很大的问题以及堆破碎的风险。

      作为忠告,保守的使用内存分配是嵌入式环境中的第一原则。

      但当你必须要使用new delete时,你不得不控制C++中的内存分配。你需要用一个全局的new delete来代替系统的内存分配符,并且一个类一个类的重载new delete

      一个防止堆破碎的通用方法是从不同固定大小的内存持中分配不同类型的对象。对每个类重载new delete就提供了这样的控制。

    1.1.2.1 重载全局的newdelete操作符

      可以很容易地重载new  delete 操作符,如下所示:

    void * operator new(size_t size)

    {

    void *p = malloc(size);

    return (p);

    }

    void operator delete(void *p);

    {

    free(p);

      这段代码可以代替默认的操作符来满足内存分配的请求。出于解释C++的目的,我们也可以直接调用malloc() free()

      也可以对单个类的new  delete 操作符重载。这是你能灵活的控制对象的内存分配。

    class TestClass {

    public:

    void * operator new(size_t size);

    void operator delete(void *p);

    // .. other members here ...

    };

    void *TestClass::operator new(size_t size)

    {

    void *p = malloc(size); // Replace this with alternative allocator

    return (p);

    }

    void TestClass::operator delete(void *p)

    {

    free(p); // Replace this with alternative de-allocator

    }

      所有TestClass 对象的内存分配都采用这段代码。更进一步,任何从TestClass 继承的类也都采用这一方式,除非它自己也重载了new  delete 操作符。通过重载new  delete 操作符的方法,你可以自由地采用不同的分配策略,从不同的内存池中分配不同的类对象。

    1.1.2.2 为单个的类重载 new[ ]delete[ ]

      必须小心对象数组的分配。你可能希望调用到被你重载过的new  delete 操作符,但并不如此。内存的请求被定向到全局的new[ ]delete[ ] 操作符,而这些内存来自于系统堆。

      C++将对象数组的内存分配作为一个单独的操作,而不同于单个对象的内存分配。为了改变这种方式,你同样需要重载new[ ]  delete[ ]操作符。

    class TestClass {

    public:

    void * operator new[ ](size_t size);

    void operator delete[ ](void *p);

    // .. other members here ..

    };

    void *TestClass::operator new[ ](size_t size)

    {

    void *p = malloc(size);

    return (p);

    }

    void TestClass::operator delete[ ](void *p)

    {

    free(p);

    }

    int main(void)

    {

    TestClass *p = new TestClass[10];

    // ... etc ...

    delete[ ] p;

    但是注意:对于多数C++的实现,new[]操作符中的个数参数是数组的大小加上额外的存储对象数目的一些字节。在你的内存分配机制重要考虑的这一点。你应该尽量避免分配对象数组,从而使你的内存分配策略简单。

    1.1.3 常见的内存错误及其对策

    发生内存错误是件非常麻烦的事情。编译器不能自动发现这些错误,通常是在程序运行时才能捕捉到。而这些错误大多没有明显的症状,时隐时现,增加了改错的难度。有时用户怒气冲冲地把你找来,程序却没有发生任何问题,你一走,错误又发作了。 常见的内存错误及其对策如下:

      内存分配未成功,却使用了它。

      编程新手常犯这种错误,因为他们没有意识到内存分配会不成功。常用解决办法是,在使用内存之前检查指针是否为NULL。如果指针p是函数的参数,那么在函数的入口处用assert(p!=NULL)进行

      检查。如果是用mallocnew来申请内存,应该用if(p==NULL) if(p!=NULL)进行防错处理。

      内存分配虽然成功,但是尚未初始化就引用它。

      犯这种错误主要有两个起因:一是没有初始化的观念;二是误以为内存的缺省初值全为零,导致引用初值错误(例如数组)。 内存的缺省初值究竟是什么并没有统一的标准,尽管有些时候为零值,我们宁可信其无不可信其有。所以无论用何种方式创建数组,都别忘了赋初值,即便是赋零值也不可省略,不要嫌麻烦。

      内存分配成功并且已经初始化,但操作越过了内存的边界。

      例如在使用数组时经常发生下标“多1”或者“少1”的操作。特别是在for循环语句中,循环次数很容易搞错,导致数组操作越界。

      忘记了释放内存,造成内存泄露。

      含有这种错误的函数每被调用一次就丢失一块内存。刚开始时系统的内存充足,你看不到错误。终有一次程序突然死掉,系统出现提示:内存耗尽。

      动态内存的申请与释放必须配对,程序中mallocfree的使用次数一定要相同,否则肯定有错误(new/delete同理)。

      释放了内存却继续使用它。

     

      有三种情况:

      (1)程序中的对象调用关系过于复杂,实在难以搞清楚某个对象究竟是否已经释放了内存,此时应该重新设计数据结构,从根本上解决对象管理的混乱局面。

      (2)函数的return语句写错了,注意不要返回指向“栈内存”的“指针”或者“引用”,因为该内存在函数体结束时被自动销毁。

      (3)使用freedelete释放了内存后,没有将指针设置为NULL。导致产生“野指针”。

      【规则1】用mallocnew申请内存之后,应该立即检查指针值是否为NULL。防止使用指针值为NULL的内存。

      【规则2】不要忘记为数组和动态内存赋初值。防止将未被初始化的内存作为右值使用。

      【规则3】避免数组或指针的下标越界,特别要当心发生“多1”或者“少1”操作。

      【规则4】动态内存的申请与释放必须配对,防止内存泄漏。

      【规则5】用freedelete释放了内存之后,立即将指针设置为NULL,防止产生“野指针”。

    1.1.4 指针与数组的对比

      C++/C程序中,指针和数组在不少地方可以相互替换着用,让人产生一种错觉,以为两者是等价的。

      数组要么在静态存储区被创建(如全局数组),要么在栈上被创建。数组名对应着(而不是指向)一块内存,其地址与容量在生命期内保持不变,只有数组的内容可以改变。

      指针可以随时指向任意类型的内存块,它的特征是“可变”,所以我们常用指针来操作动态内存。指针远比数组灵活,但也更危险。

      下面以字符串为例比较指针与数组的特性。

    1.1.4.1 修改内容

    下面示例中,字符数组a的容量是6个字符,其内容为helloa的内容可以改变,如a[0]= X’。指针p指向常量字符串“world”(位于静态存储区,内容为world),常量字符串的内容是不可以被修改的。从语法上看,编译器并不觉得语句p[0]= X’有什么不妥,但是该语句企图修改常量字符串的内容而导致运行错误。

    char a[] = “hello”;

    a[0] = ‘X’;

    cout << a << endl;

    char *p = world; // 注意p指向常量字符串

    p[0] = X; // 编译器不能发现该错误

    cout << p << endl;

    1.1.4.2 内容复制与比较

      不能对数组名进行直接复制与比较。若想把数组a的内容复制给数组b,不能用语句 b = a ,否则将产生编译错误。应该用标准库函数strcpy进行复制。同理,比较ba的内容是否相同,不能用if(b==a) 来判断,应该用标准库函数strcmp进行比较。

    语句p = a 并不能把a的内容复制指针p,而是把a的地址赋给了p。要想复制a的内容,可以先用库函数mallocp申请一块容量为strlen(a)+1个字符的内存,再用strcpy进行字符串复制。同理,语句if(p==a) 比较的不是内容而是地址,应该用库函数strcmp来比较。

    // 数组…

    char a[] = "hello";

    char b[10];

    strcpy(b, a); // 不能用 b = a;

    if(strcmp(b, a) == 0) // 不能用 if (b == a)

    // 指针…

    int len = strlen(a);

    char *p = (char *)malloc(sizeof(char)*(len+1));

    strcpy(p,a); // 不要用 p = a;

    if(strcmp(p, a) == 0) // 不要用 if (p == a)

    1.1.4.3 计算内存容量

    用运算符sizeof可以计算出数组的容量(字节数)。如下示例中,sizeof(a)的值是12(注意别忘了’’)。指针p指向a,但是sizeof(p)的值却是4。这是因为sizeof(p)得到的是一个指针变量的字节数,相当于sizeof(char*),而不是p所指的内存容量。C++/C语言没有办法知道指针所指的内存容量,除非在申请内存时记住它。

    char a[] = "hello world";

    char *p = a;

    cout<< sizeof(a) << endl; // 12字节

    cout<< sizeof(p) << endl; // 4字节

      

    注意当数组作为函数的参数进行传递时,该数组自动退化为同类型的指针。如下示例中,不论数组a的容量是多少,sizeof(a)始终等于sizeof(char *)

    void Func(char a[100])

    {

     cout<< sizeof(a) << endl; // 4字节而不是100字节

    }

    1.1.5 指针参数是如何传递内存的?

    如果函数的参数是一个指针,不要指望用该指针去申请动态内存。如下示例中,Test函数的语句GetMemory(str, 200)并没有使str获得期望的内存,str依旧是NULL,为什么?

    void GetMemory(char *p, int num)

    {

     p = (char *)malloc(sizeof(char) * num);

    }

    void Test(void)

    {

     char *str = NULL;

     GetMemory(str, 100); // str 仍然为 NULL

     strcpy(str, "hello"); // 运行错误

    }

    毛病出在函数GetMemory中。编译器总是要为函数的每个参数制作临时副本,指针参数p的副本是 _p,编译器使 _p = p。如果函数体内的程序修改了_p的内容,就导致参数p的内容作相应的修改。这就是指针可以用作输出参数的原因。在本例中,_p申请了新的内存,只是把_p所指的内存地址改变了,但是p丝毫未变。所以函数GetMemory并不能输出任何东西。事实上,每执行一次GetMemory就会泄露一块内存,因为没有用free释放内存。

    如果非得要用指针参数去申请内存,那么应该改用“指向指针的指针”,见示例:

    void GetMemory2(char **p, int num)

    {

     *p = (char *)malloc(sizeof(char) * num);

    }

    void Test2(void)

    {

     char *str = NULL;

     GetMemory2(&str, 100); // 注意参数是 &str,而不是str

     strcpy(str, "hello");

     cout<< str << endl;

     free(str);

    }

    由于“指向指针的指针”这个概念不容易理解,我们可以用函数返回值来传递动态内存。这种方法更加简单,见示例:

    char *GetMemory3(int num)

    {

     char *p = (char *)malloc(sizeof(char) * num);

     return p;

    }

    void Test3(void)

    {

     char *str = NULL;

     str = GetMemory3(100);

     strcpy(str, "hello");

     cout<< str << endl;

     free(str);

    }

    用函数返回值来传递动态内存这种方法虽然好用,但是常常有人把return语句用错了。这里强调不要用return语句返回指向“栈内存”的指针,因为该内存在函数结束时自动消亡,见示例:

    char *GetString(void)

    {

     char p[] = "hello world";

     return p; // 编译器将提出警告

    }

    void Test4(void)

    {

     char *str = NULL;

     str = GetString(); // str 的内容是垃圾

     cout<< str << endl;

    }

    用调试器逐步跟踪Test4,发现执行str = GetString语句后str不再是NULL指针,但是str的内容不是“hello world”而是垃圾。

    如果把上述示例改写成如下示例,会怎么样?

    char *GetString2(void)

    {

     char *p = "hello world";

     return p;

    }

    void Test5(void)

    {

     char *str = NULL;

     str = GetString2();

     cout<< str << endl;

    }

    函数Test5运行虽然不会出错,但是函数GetString2的设计概念却是错误的。因为GetString2内的“hello world”是常量字符串,位于静态存储区,它在程序生命期内恒定不变。无论什么时候调用GetString2,它返回的始终是同一个“只读”的内存块。

    1.1.6 杜绝“野指针”

      “野指针”不是NULL指针,是指向“垃圾”内存的指针。人们一般不会错用NULL指针,因为用if语句很容易判断。但是“野指针”是很危险的,if语句对它不起作用。 “野指针”的成因主要有两种:

    1)指针变量没有被初始化。任何指针变量刚被创建时不会自动成为NULL指针,它的缺省值是随机的,它会乱指一气。所以,指针变量在创建的同时应当被初始化,要么将指针设置为NULL,要么让它指向合法的内存。例如

    char *p = NULL;

    char *str = (char *) malloc(100);

    2)指针pfree或者delete之后,没有置为NULL,让人误以为p是个合法的指针。

    3)指针操作超越了变量的作用域范围。这种情况让人防不胜防,示例程序如下:

    class A

    {

     public:

      void Func(void){ cout << Func of class A << endl; }

    };

    void Test(void)

    {

     A *p;

     {

      A a;

      p = &a; // 注意 a 的生命期

     }

     p->Func(); // p是“野指针”

    }

    函数Test在执行语句p->Func()时,对象a已经消失,而p是指向a的,所以p就成了“野指针”。但奇怪的是我运行这个程序时居然没有出错,这可能与编译器有关。

    1.1.7 有了malloc/free为什么还要new/delete

      mallocfreeC++/C语言的标准库函数,new/deleteC++的运算符。它们都可用于申请动态内存和释放内存。

      对于非内部数据类型的对象而言,光用maloc/free无法满足动态对象的要求。对象在创建的同时要自动执行构造函数,对象在消亡之前要自动执行析构函数。由于malloc/free是库函数而不是运算符,不在编译器控制权限之内,不能够把执行构造函数和析构函数的任务强加于malloc/free

    因此C++语言需要一个能完成动态内存分配和初始化工作的运算符new,以及一个能完成清理与释放内存工作的运算符delete。注意new/delete不是库函数。我们先看一看malloc/freenew/delete如何实现对象的动态内存管理,见示例:

    class Obj

    {

     public :

      Obj(void){ cout << Initialization << endl; }

      ~Obj(void){ cout << Destroy << endl; }

      void Initialize(void){ cout << Initialization << endl; }

      void Destroy(void){ cout << Destroy << endl; }

    };

    void UseMallocFree(void)

    {

     Obj *a = (obj *)malloc(sizeof(obj)); // 申请动态内存

     a->Initialize(); // 初始化

     //

     a->Destroy(); // 清除工作

     free(a); // 释放内存

    }

    void UseNewDelete(void)

    {

     Obj *a = new Obj; // 申请动态内存并且初始化

     //

     delete a; // 清除并且释放内存

    }

      类Obj的函数Initialize模拟了构造函数的功能,函数Destroy模拟了析构函数的功能。函数UseMallocFree中,由于malloc/free不能执行构造函数与析构函数,必须调用成员函数InitializeDestroy来完成初始化与清除工作。函数UseNewDelete则简单得多。

      所以我们不要企图用malloc/free来完成动态对象的内存管理,应该用new/delete。由于内部数据类型的“对象”没有构造与析构的过程,对它们而言malloc/freenew/delete是等价的。

      既然new/delete的功能完全覆盖了malloc/free,为什么C++不把malloc/free淘汰出局呢?这是因为C++程序经常要调用C函数,而C程序只能用malloc/free管理动态内存。

    如果用free释放“new创建的动态对象”,那么该对象因无法执行析构函数而可能导致程序出错。如果用delete释放“malloc申请的动态内存”,结果也会导致程序出错,但是该程序的可读性很差。所以new/delete必须配对使用,malloc/free也一样。

    1.1.8 内存耗尽怎么办?

      如果在申请动态内存时找不到足够大的内存块,mallocnew将返回NULL指针,宣告内存申请失败。通常有三种方式处理“内存耗尽”问题。

      (1)判断指针是否为NULL,如果是则马上用return语句终止本函数。例如:

    void Func(void)

    {

     A *a = new A;

     if(a == NULL)

     {

      return;

     }

     …

    }

     (2)判断指针是否为NULL,如果是则马上用exit(1)终止整个程序的运行。例如:

    void Func(void)

    {

     A *a = new A;

     if(a == NULL)

     {

      cout << Memory Exhausted << endl;

      exit(1);

     }

     …

    }

      (3)为newmalloc设置异常处理函数。例如Visual C++可以用_set_new_hander函数为new设置用户自己定义的异常处理函数,也可以让malloc享用与new相同的异常处理函数。详细内容请参考C++使用手册。

      上述(1)(2)方式使用最普遍。如果一个函数内有多处需要申请动态内存,那么方式(1)就显得力不从心(释放内存很麻烦),应该用方式(2)来处理。

      很多人不忍心用exit(1),问:“不编写出错处理程序,让操作系统自己解决行不行?”

      不行。如果发生“内存耗尽”这样的事情,一般说来应用程序已经无药可救。如果不用exit(1) 把坏程序杀死,它可能会害死操作系统。道理如同:如果不把歹徒击毙,歹徒在老死之前会犯下更多的罪。

      有一个很重要的现象要告诉大家。对于32位以上的应用程序而言,无论怎样使用mallocnew,几乎不可能导致“内存耗尽”。我在Windows 98下用Visual C++编写了测试程序,见示例7。这个程序会无休止地运行下去,根本不会终止。因为32位操作系统支持“虚存”,内存用完了,自动用硬盘空间顶替。我只听到硬盘嘎吱嘎吱地响,Window 98已经累得对键盘、鼠标毫无反应。

      我可以得出这么一个结论:对于32位以上的应用程序,“内存耗尽”错误处理程序毫无用处。这下可把UnixWindows程序员们乐坏了:反正错误处理程序不起作用,我就不写了,省了很多麻烦。

    我不想误导读者,必须强调:不加错误处理将导致程序的质量很差,千万不可因小失大。

    void main(void)

    {

     float *p = NULL;

     while(TRUE)

     {

      p = new float[1000000];

      cout << eat memory << endl;

      if(p==NULL)

       exit(1);

     }

    }

    1.1.9 malloc/free的使用要点

    函数malloc的原型如下:

    void * malloc(size_t size);

    malloc申请一块长度为length的整数类型的内存,程序如下:

    int *p = (int *) malloc(sizeof(int) * length);

    我们应当把注意力集中在两个要素上:“类型转换”和“sizeof”。

    * malloc返回值的类型是void *,所以在调用malloc时要显式地进行类型转换,将void * 转换成所需要的指针类型。

    * malloc函数本身并不识别要申请的内存是什么类型,它只关心内存的总字节数。我们通常记不住int, float等数据类型的变量的确切字节数。例如int变量在16位系统下是2个字节,在32位下是4个字节;而float变量在16位系统下是4个字节,在32位下也是4个字节。最好用以下程序作一次测试:

    cout << sizeof(char) << endl;

    cout << sizeof(int) << endl;

    cout << sizeof(unsigned int) << endl;

    cout << sizeof(long) << endl;

    cout << sizeof(unsigned long) << endl;

    cout << sizeof(float) << endl;

    cout << sizeof(double) << endl;

    cout << sizeof(void *) << endl;

    malloc的“()”中使用sizeof运算符是良好的风格,但要当心有时我们会昏了头,写出 p = malloc(sizeof(p))这样的程序来。

    函数free的原型如下:

    void free( void * memblock );

    为什么free函数不象malloc函数那样复杂呢?这是因为指针p的类型以及它所指的内存的容量事先都是知道的,语句free(p)能正确地释放内存。如果pNULL指针,那么freep无论操作多少次都不会出问题。如果p不是NULL指针,那么freep连续操作两次就会导致程序运行错误。

    1.1.10 new/delete的使用要点

    运算符new使用起来要比函数malloc简单得多,例如:

    int *p1 = (int *)malloc(sizeof(int) * length);

    int *p2 = new int[length];

    这是因为new内置了sizeof、类型转换和类型安全检查功能。对于非内部数据类型的对象而言,new在创建动态对象的同时完成了初始化工作。如果对象有多个构造函数,那么new的语句也可以有多种形式。例如

    class Obj

    {

     public :

      Obj(void); // 无参数的构造函数

      Obj(int x); // 带一个参数的构造函数

      …

    }

    void Test(void)

    {

     Obj *a = new Obj;

     Obj *b = new Obj(1); // 初值为1

     …

     delete a;

     delete b;

    }

    如果用new创建对象数组,那么只能使用对象的无参数构造函数。例如:

    Obj *objects = new Obj[100]; // 创建100个动态对象

    不能写成:

    Obj *objects = new Obj[100](1);// 创建100个动态对象的同时赋初值1

    在用delete释放对象数组时,留意不要丢了符号‘[]’。例如:

    delete []objects; // 正确的用法

    delete objects; // 错误的用法

    后者有可能引起程序崩溃和内存泄漏。

    1.2 C++中的健壮指针和资源管理

      我最喜欢的对资源的定义是:"任何在你的程序中获得并在此后释放的东西?quot;内存是一个相当明显的资源的例子。它需要用new来获得,用delete来释放。同时也有许多其它类型的资源文件句柄、重要的片断、Windows中的GDI资源,等等。将资源的概念推广到程序中创建、释放的所有对象也是十分方便的,无论对象是在堆中分配的还是在栈中或者是在全局作用于内生命的。

      对于给定的资源的拥有着,是负责释放资源的一个对象或者是一段代码。所有权分立为两种级别——自动的和显式的(automatic and explicit),如果一个对象的释放是由语言本身的机制来保证的,这个对象的就是被自动地所有。例如,一个嵌入在其他对象中的对象,他的清除需要其他对象来在清除的时候保证。外面的对象被看作嵌入类的所有者。   类似地,每个在栈上创建的对象(作为自动变量)的释放(破坏)是在控制流离开了对象被定义的作用域的时候保证的。这种情况下,作用于被看作是对象的所有者。注意所有的自动所有权都是和语言的其他机制相容的,包括异常。无论是如何退出作用域的——正常流程控制退出、一个break语句、一个return、一个goto、或者是一个throw——自动资源都可以被清除。

      到目前为止,一切都很好!问题是在引入指针、句柄和抽象的时候产生的。如果通过一个指针访问一个对象的话,比如对象在堆中分配,C++不自动地关注它的释放。程序员必须明确的用适当的程序方法来释放这些资源。比如说,如果一个对象是通过调用new来创建的,它需要用delete来回收。一个文件是用CreateFile(Win32 API)打开的,它需要用CloseHandle来关闭。用EnterCritialSection进入的临界区(Critical Section)需要LeaveCriticalSection退出,等等。一个""指针,文件句柄,或者临界区状态没有所有者来确保它们的最终释放。基本的资源管理的前提就是确保每个资源都有他们的所有者。

    1.2.1 第一条规则(RAII

      一个指针,一个句柄,一个临界区状态只有在我们将它们封装入对象的时候才会拥有所有者。这就是我们的第一规则:在构造函数中分配资源,在析构函数中释放资源。

      当你按照规则将所有资源封装的时候,你可以保证你的程序中没有任何的资源泄露。这点在当封装对象(Encapsulating Object)在栈中建立或者嵌入在其他的对象中的时候非常明显。但是对那些动态申请的对象呢?不要急!任何动态申请的东西都被看作一种资源,并且要按照上面提到的方法进行封装。这一对象封装对象的链不得不在某个地方终止。它最终终止在最高级的所有者,自动的或者是静态的。这些分别是对离开作用域或者程序时释放资源的保证。

      下面是资源封装的一个经典例子。在一个多线程的应用程序中,线程之间共享对象的问题是通过用这样一个对象联系临界区来解决的。每一个需要访问共享资源的客户需要获得临界区。例如,这可能是Win32下临界区的实现方法。

    class CritSect

    {

     friend class Lock;

     public:

      CritSect () { InitializeCriticalSection (&_critSection); }

      ~CritSect () { DeleteCriticalSection (&_critSection); }

     private:

      void Acquire ()

      {

       EnterCriticalSection (&_critSection);

      }

      void Release ()

      {

       LeaveCriticalSection (&_critSection);

      }

     private:

      CRITICAL_SECTION _critSection;

    };

      这里聪明的部分是我们确保每一个进入临界区的客户最后都可以离开。"进入"临界区的状态是一种资源,并应当被封装。封装器通常被称作一个锁(lock)。

    class Lock

    {

     public:

      Lock (CritSect& critSect) : _critSect (critSect)

      {

       _critSect.Acquire ();

      }

      ~Lock ()

      {

       _critSect.Release ();

      }

     private

      CritSect & _critSect;

    };

      锁一般的用法如下:

    void Shared::Act () throw (char *)

    {

     Lock lock (_critSect);

     // perform action —— may throw

     // automatic destructor of lock

    }

      注意无论发生什么,临界区都会借助于语言的机制保证释放。

      还有一件需要记住的事情——每一种资源都需要被分别封装。这是因为资源分配是一个非常容易出错的操作,是要资源是有限提供的。我们会假设一个失败的资源分配会导致一个异常——事实上,这会经常的发生。所以如果你想试图用一个石头打两只鸟的话,或者在一个构造函数中申请两种形式的资源,你可能就会陷入麻烦。只要想想在一种资源分配成功但另一种失败抛出异常时会发生什么。因为构造函数还没有全部完成,析构函数不可能被调用,第一种资源就会发生泄露。

    这种情况可以非常简单的避免。无论何时你有一个需要两种以上资源的类时,写两个小的封装器将它们嵌入你的类中。每一个嵌入的构造都可以保证删除,即使包装类没有构造完成。

    1.2.2 Smart Pointers

      我们至今还没有讨论最常见类型的资源——用操作符new分配,此后用指针访问的一个对象。我们需要为每个对象分别定义一个封装类吗?(事实上,C++标准模板库已经有了一个模板类,叫做auto_ptr,其作用就是提供这种封装。我们一会儿在回到auto_ptr。)让我们从一个极其简单、呆板但安全的东西开始。看下面的Smart Pointer模板类,它十分坚固,甚至无法实现。

    template <class T>

    class SmartPointer

    {

     public:

      ~SmartPointer () { delete _p; }

      T * operator->() { return _p; }

      T const * operator->() const { return _p; }

     protected:

      SmartPointer (): _p (0) {}

      explicit SmartPointer (T* p): _p (p) {}

      T * _p;

    };

      为什么要把SmartPointer的构造函数设计为protected呢?如果我需要遵守第一条规则,那么我就必须这样做。资源——在这里是class T的一个对象——必须在封装器的构造函数中分配。但是我不能只简单的调用new T,因为我不知道T的构造函数的参数。因为,在原则上,每一个T都有一个不同的构造函数;我需要为他定义个另外一个封装器。模板的用处会很大,为每一个新的类,我可以通过继承SmartPointer定义一个新的封装器,并且提供一个特定的构造函数。

    class SmartItem: public SmartPointer<Item>

    {

     public:

      explicit SmartItem (int i)

      : SmartPointer<Item> (new Item (i)) {}

    };

      为每一个类提供一个Smart Pointer真的值得吗?说实话——不!他很有教学的价值,但是一旦你学会如何遵循第一规则的话,你就可以放松规则并使用一些高级的技术。这一技术是让SmartPointer的构造函数成为public,但是只是是用它来做资源转换(Resource Transfer)我的意思是用new操作符的结果直接作为SmartPointer的构造函数的参数,像这样:

    SmartPointer<Item> item (new Item (i));

      这个方法明显更需要自控性,不只是你,而且包括你的程序小组的每个成员。他们都必须发誓出了作资源转换外不把构造函数用在人以其他用途。幸运的是,这条规矩很容易得以加强。只需要在源文件中查找所有的new即可。

    1.2.3 Resource Transfer

      到目前为止,我们所讨论的一直是生命周期在一个单独的作用域内的资源。现在我们要解决一个困难的问题——如何在不同的作用域间安全的传递资源。这一问题在当你处理容器的时候会变得十分明显。你可以动态的创建一串对象,将它们存放至一个容器中,然后将它们取出,并且在最终安排它们。为了能够让这安全的工作——没有泄露——对象需要改变其所有者。

      这个问题的一个非常显而易见的解决方法是使用Smart Pointer,无论是在加入容器前还是还找到它们以后。这是他如何运作的,你加入Release方法到Smart Pointer中:

    template <class T>

    T * SmartPointer<T>::Release ()

    {

    T * pTmp = _p;

    _p = 0;

    return pTmp;

    }

      注意在Release调用以后,Smart Pointer就不再是对象的所有者了——它内部的指针指向空。现在,调用了Release都必须是一个负责的人并且迅速隐藏返回的指针到新的所有者对象中。在我们的例子中,容器调用了Release,比如这个Stack的例子:

    void Stack::Push (SmartPointer <Item> & item) throw (char *)

    {

    if (_top == maxStack)

    throw "Stack overflow";

    _arr [_top++] = item.Release ();

    };

      同样的,你也可以再你的代码中用加强Release的可靠性。

    相应的Pop方法要做些什么呢?他应该释放了资源并祈祷调用它的是一个负责的人而且立即作一个资源传递它到一个Smart Pointer?这听起来并不好。

    1.2.4 Strong Pointers

      资源管理在内容索引(Windows NT Server上的一部分,现在是Windows 2000)上工作,并且,我对这十分满意。然后我开始想……这一方法是在这样一个完整的系统中形成的,如果可以把它内建入语言的本身岂不是一件非常好?我提出了强指针(Strong Pointer)和弱指针(Weak Pointer)。一个Strong Pointer会在许多地方和我们这个SmartPointer相似--它在超出它的作用域后会清除他所指向的对象。资源传递会以强指针赋值的形式进行。也可以有Weak Pointer存在,它们用来访问对象而不需要所有对象--比如可赋值的引用。

      任何指针都必须声明为Strong或者Weak,并且语言应该来关注类型转换的规定。例如,你不可以将Weak Pointer传递到一个需要Strong Pointer的地方,但是相反却可以。Push方法可以接受一个Strong Pointer并且将它转移到Stack中的Strong Pointer的序列中。Pop方法将会返回一个Strong Pointer。把Strong Pointer的引入语言将会使垃圾回收成为历史。

      这里还有一个小问题--修改C++标准几乎和竞选美国总统一样容易。当我将我的注意告诉给Bjarne Stroutrup的时候,他看我的眼神好像是我刚刚要向他借一千美元一样。

    然后我突然想到一个念头。我可以自己实现Strong Pointers。毕竟,它们都很想Smart Pointers。给它们一个拷贝构造函数并重载赋值操作符并不是一个大问题。事实上,这正是标准库中的auto_ptr有的。重要的是对这些操作给出一个资源转移的语法,但是这也不是很难。

    template <class T>

    SmartPointer<T>::SmartPointer (SmartPointer<T> & ptr)

    {

    _p = ptr.Release ();

    }

    template <class T>

    void SmartPointer<T>::operator = (SmartPointer<T> & ptr)

    {

    if (_p != ptr._p)

    {

    delete _p;

    _p = ptr.Release ();

    }

    }

      使这整个想法迅速成功的原因之一是我可以以值方式传递这种封装指针!我有了我的蛋糕,并且也可以吃了。看这个Stack的新的实现:

    class Stack

    {

    enum { maxStack = 3 };

    public:

    Stack ()

    : _top (0)

    {}

    void Push (SmartPointer<Item> & item) throw (char *)

    {

    if (_top >= maxStack)

    throw "Stack overflow";

    _arr [_top++] = item;

    }

    SmartPointer<Item> Pop ()

    {

    if (_top == 0)

    return SmartPointer<Item> ();

    return _arr [--_top];

    }

    private

    int _top;

    SmartPointer<Item> _arr [maxStack];

    };

      Pop方法强制客户将其返回值赋给一个Strong Pointer,SmartPointer<Item>。任何试图将他对一个普通指针的赋值都会产生一个编译期错误,因为类型不匹配。此外,因为Pop以值方式返回一个Strong Pointer(Pop的声明时SmartPointer<Item>后面没有&符号),编译器在return时自动进行了一个资源转换。他调用了operator =来从数组中提取一个Item,拷贝构造函数将他传递给调用者。调用者最后拥有了指向Pop赋值的Strong Pointer指向的一个Item

    我马上意识到我已经在某些东西之上了。我开始用了新的方法重写原来的代码。

    1.2.5 Parser

    我过去有一个老的算术操作分析器,是用老的资源管理的技术写的。分析器的作用是在分析树中生成节点,节点是动态分配的。例如分析器的Expression方法生成一个表达式节点。我没有时间用Strong Pointer去重写这个分析器。我令ExpressionTermFactor方法以传值的方式将Strong Pointer返回到Node中。看下面的Expression方法的实现:

    SmartPointer<Node> Parser::Expression()

    {

    // Parse a term

    SmartPointer<Node> pNode = Term ();

    EToken token = _scanner.Token();

    if ( token == tPlus || token == tMinus )

    {

    // Expr := Term { ('+' | '-') Term }

    SmartPointer<MultiNode> pMultiNode = new SumNode (pNode);

    do

    {

    _scanner.Accept();

    SmartPointer<Node> pRight = Term ();

    pMultiNode->AddChild (pRight, (token == tPlus));

    token = _scanner.Token();

    } while (token == tPlus || token == tMinus);

    pNode = up_cast<Node, MultiNode> (pMultiNode);

    }

    // otherwise Expr := Term

    return pNode; // by value!

    }

      最开始,Term方法被调用。他传值返回一个指向NodeStrong Pointer并且立刻把它保存到我们自己的Strong Pointer,pNode中。如果下一个符号不是加号或者减号,我们就简单的把这个SmartPointer以值返回,这样就释放了Node的所有权。另外一方面,如果下一个符号是加号或者减号,我们创建一个新的SumMode并且立刻(直接传递)将它储存到MultiNode的一个Strong Pointer中。这里,SumNode是从MultiMode中继承而来的,而MulitNode是从Node继承而来的。原来的Node的所有权转给了SumNode

      只要是他们在被加号和减号分开的时候,我们就不断的创建terms,我们将这些term转移到我们的MultiNode中,同时MultiNode得到了所有权。最后,我们将指向MultiNodeStrong Pointer向上映射为指向ModeStrong Pointer,并且将他返回调用着。

      我们需要对Strong Pointers进行显式的向上映射,即使指针是被隐式的封装。例如,一个MultiNode是一个Node,但是相同的is-a关系在SmartPointer<MultiNode>SmartPointer<Node>之间并不存在,因为它们是分离的类(模板实例)并不存在继承关系。up-cast模板是像下面这样定义的:

    template<class To, class From>

    inline SmartPointer<To> up_cast (SmartPointer<From> & from)

    {

    return SmartPointer<To> (from.Release ());

    }

      如果你的编译器支持新加入标准的成员模板(member template)的话,你可以为SmartPointer<T>定义一个新的构造函数用来从接受一个class U

    template <class T>

    template <class U> SmartPointer<T>::SmartPointer (SPrt<U> & uptr)

    : _p (uptr.Release ())

    {}

      这里的这个花招是模板在U不是T的子类的时候就不会编译成功(换句话说,只在U is-a T的时候才会编译)。这是因为uptr的缘故。Release()方法返回一个指向U的指针,并被赋值为_p,一个指向T的指针。所以如果U不是一个T的话,赋值会导致一个编译时刻错误。

    std::auto_ptr

    后来我意识到在STL中的auto_ptr模板,就是我的Strong Pointer。在那时候还有许多的实现差异(auto_ptrRelease方法并不将内部的指针清零--你的编译器的库很可能用的就是这种陈旧的实现),但是最后在标准被广泛接受之前都被解决了。

    1.2.6 Transfer Semantics

      目前为止,我们一直在讨论在C++程序中资源管理的方法。宗旨是将资源封装到一些轻量级的类中,并由类负责它们的释放。特别的是,所有用new操作符分配的资源都会被储存并传递进Strong Pointer(标准库中的auto_ptr)的内部。

      这里的关键词是传递(passing)。一个容器可以通过传值返回一个Strong Pointer来安全的释放资源。容器的客户只能够通过提供一个相应的Strong Pointer来保存这个资源。任何一个将结果赋给一个""指针的做法都立即会被编译器发现。

    auto_ptr<Item> item = stack.Pop (); // ok

    Item * p = stack.Pop (); // Error! Type mismatch.

      以传值方式被传递的对象有value semantics 或者称为 copy semanticsStrong Pointers是以值方式传递的--但是我们能说它们有copy semantics吗?不是这样的!它们所指向的对象肯定没有被拷贝过。事实上,传递过后,源auto_ptr不在访问原有的对象,并且目标auto_ptr成为了对象的唯一拥有者(但是往往auto_ptr的旧的实现即使在释放后仍然保持着对对象的所有权)。自然而然的我们可以将这种新的行为称作Transfer Semantics

      拷贝构造函数(copy construcor)和赋值操作符定义了auto_ptrTransfer Semantics,它们用了非constauto_ptr引用作为它们的参数。

    auto_ptr (auto_ptr<T> & ptr);

    auto_ptr & operator = (auto_ptr<T> & ptr);

      这是因为它们确实改变了他们的源--剥夺了对资源的所有权。

    通过定义相应的拷贝构造函数和重载赋值操作符,你可以将Transfer Semantics加入到许多对象中。例如,许多Windows中的资源,比如动态建立的菜单或者位图,可以用有Transfer Semantics的类来封装。

    1.2.7 Strong Vectors

      标准库只在auto_ptr中支持资源管理。甚至连最简单的容器也不支持ownership semantics。你可能想将auto_ptr和标准容器组合到一起可能会管用,但是并不是这样的。例如,你可能会这样做,但是会发现你不能够用标准的方法来进行索引。

    vector< auto_ptr<Item> > autoVector;

      这种建造不会编译成功;

    Item * item = autoVector [0];

      另一方面,这会导致一个从autoVectauto_ptr的所有权转换:

    auto_ptr<Item> item = autoVector [0];

      我们没有选择,只能够构造我们自己的Strong Vector。最小的接口应该如下:

    template <class T>

    class auto_vector

    {

    public:

    explicit auto_vector (size_t capacity = 0);

    T const * operator [] (size_t i) const;

    T * operator [] (size_t i);

    void assign (size_t i, auto_ptr<T> & p);

    void assign_direct (size_t i, T * p);

    void push_back (auto_ptr<T> & p);

    auto_ptr<T> pop_back ();

    };

      你也许会发现一个非常防御性的设计态度。我决定不提供一个对vector的左值索引的访问,取而代之,如果你想设定(set)一个值的话,你必须用assign或者assign_direct方法。我的观点是,资源管理不应该被忽视,同时,也不应该在所有的地方滥用。在我的经验里,一个strong vector经常被许多push_back方法充斥着。

      Strong vector最好用一个动态的Strong Pointers的数组来实现:

    template <class T>

    class auto_vector

    {

    private

    void grow (size_t reqCapacity);

    auto_ptr<T> *_arr;

    size_t _capacity;

    size_t _end;

    };

      grow方法申请了一个很大的auto_ptr<T>的数组,将所有的东西从老的书组类转移出来,在其中交换,并且删除原来的数组。

      auto_vector的其他实现都是十分直接的,因为所有资源管理的复杂度都在auto_ptr中。例如,assign方法简单的利用了重载的赋值操作符来删除原有的对象并转移资源到新的对象:

    void assign (size_t i, auto_ptr<T> & p)

    {

    _arr [i] = p;

    }

      我已经讨论了push_backpop_back方法。push_back方法传值返回一个auto_ptr,因为它将所有权从auto_vector转换到auto_ptr中。

      对auto_vector的索引访问是借助auto_ptrget方法来实现的,get简单的返回一个内部指针。

    T * operator [] (size_t i)

    {

    return _arr [i].get ();

    }

      没有容器可以没有iterator。我们需要一个iteratorauto_vector看起来更像一个普通的指针向量。特别是,当我们废弃iterator的时候,我们需要的是一个指针而不是auto_ptr。我们不希望一个auto_vectoriterator在无意中进行资源转换。

    template<class T>

    class auto_iterator: public

    iterator<random_access_iterator_tag, T *>

    {

    public:

    auto_iterator () : _pp (0) {}

    auto_iterator (auto_ptr<T> * pp) : _pp (pp) {}

    bool operator != (auto_iterator<T> const & it) const

    { return it._pp != _pp; }

    auto_iterator const & operator++ (int) { return _pp++; }

    auto_iterator operator++ () { return ++_pp; }

    T * operator * () { return _pp->get (); }

    private

    auto_ptr<T> * _pp;

    };

    我们给auto_vect提供了标准的beginend方法来找回iterator

    class auto_vector

    {

    public:

    typedef auto_iterator<T> iterator;

    iterator begin () { return _arr; }

    iterator end () { return _arr + _end; }

    }; 

      你也许会问我们是否要利用资源管理重新实现每一个标准的容器?幸运的是,不;事实是strong vector解决了大部分所有权的需求。当你把你的对象都安全的放置到一个strong vector中,你可以用所有其它的容器来重新安排(weakpointer

    设想,例如,你需要对一些动态分配的对象排序的时候。你将它们的指针保存到一个strong vector中。然后你用一个标准的vector来保存从strong vector中获得的weak指针。你可以用标准的算法对这个vector进行排序。这种中介vector叫做permutation vector。相似的,你也可以用标准的maps, priority queues, heaps, hash tables等等。

    1.2.8 Code Inspection

      如果你严格遵照资源管理的条款,你就不会再资源泄露或者两次删除的地方遇到麻烦。你也降低了访问野指针的几率。同样的,遵循原有的规则,用delete删除用new申请的德指针,不要两次删除一个指针。你也不会遇到麻烦。但是,那个是更好的注意呢?

      这两个方法有一个很大的不同点。就是和寻找传统方法的bug相比,找到违反资源管理的规定要容易的多。后者仅需要一个代码检测或者一个运行测试,而前者则在代码中隐藏得很深,并需要很深的检查。

      设想你要做一段传统的代码的内存泄露检查。第一件事,你要做的就是grep所有在代码中出现的new,你需要找出被分配空间地指针都作了什么。你需要确定导致删除这个指针的所有的执行路径。你需要检查break语句,过程返回,异常。原有的指针可能赋给另一个指针,你对这个指针也要做相同的事。

      相比之下,对于一段用资源管理技术实现的代码。你也用grep检查所有的new,但是这次你只需要检查邻近的调用:

      ● 这是一个直接的Strong Pointer转换,还是我们在一个构造函数的函数体中?

      ● 调用的返回知是否立即保存到对象中,构造函数中是否有可以产生异常的代码。?

      ● 如果这样的话析构函数中时候有delete?

      下一步,你需要用grep查找所有的release方法,并实施相同的检查。

      不同点是需要检查、理解单个执行路径和只需要做一些本地的检验。这难道不是提醒你非结构化的和结构化的程序设计的不同吗?原理上,你可以认为你可以应付goto,并且跟踪所有的可能分支。另一方面,你可以将你的怀疑本地化为一段代码。本地化在两种情况下都是关键所在。

      在资源管理中的错误模式也比较容易调试。最常见的bug是试图访问一个释放过的strong pointer。这将导致一个错误,并且很容易跟踪。

    1.2.9 共享的所有权

      为每一个程序中的资源都找出或者指定一个所有者是一件很容易的事情吗?答案是出乎意料的,是!如果你发现了一些问题,这可能说明你的设计上存在问题。还有另一种情况就是共享所有权是最好的甚至是唯一的选择。

      共享的责任分配给被共享的对象和它的客户(client)。一个共享资源必须为它的所有者保持一个引用计数。另一方面,所有者再释放资源的时候必须通报共享对象。最后一个释放资源的需要在最后负责free的工作。

      最简单的共享的实现是共享对象继承引用计数的类RefCounted

    class RefCounted

    {

    public:

    RefCounted () : _count (1) {}

    int GetRefCount () const { return _count; }

    void IncRefCount () { _count++; }

    int DecRefCount () { return --_count; }

    private

    int _count;

    };

      按照资源管理,一个引用计数是一种资源。如果你遵守它,你需要释放它。当你意识到这一事实的时候,剩下的就变得简单了。简单的遵循规则--再构造函数中获得引用计数,在析构函数中释放。甚至有一个RefCountedsmart pointer等价物:

    template <class T>

    class RefPtr

    {

    public:

    RefPtr (T * p) : _p (p) {}

    RefPtr (RefPtr<T> & p)

    {

    _p = p._p;

    _p->IncRefCount ();

    }

    ~RefPtr ()

    {

    if (_p->DecRefCount () == 0)

    delete _p;

    }

    private

    T * _p;

    };

      注意模板中的T不比成为RefCounted的后代,但是它必须有IncRefCountDecRefCount的方法。当然,一个便于使用的RefPtr需要有一个重载的指针访问操作符。在RefPtr中加入转换语义学(transfer semantics)是读者的工作。

    1.2.10 所有权网络

      链表是资源管理分析中的一个很有意思的例子。如果你选择表成为链(link)的所有者的话,你会陷入实现递归的所有权。每一个link都是它的继承者的所有者,并且,相应的,余下的链表的所有者。下面是用smart pointer实现的一个表单元:

    class Link

    {

    // ...

    private

    auto_ptr<Link> _next;

    };

      最好的方法是,将连接控制封装到一个弄构进行资源转换的类中。

      对于双链表呢?安全的做法是指明一个方向,如forward:

    class DoubleLink

    {

    // ...

    private

    DoubleLink *_prev;

    auto_ptr<DoubleLink> _next;

    };

      注意不要创建环形链表。

      这给我们带来了另外一个有趣的问题--资源管理可以处理环形的所有权吗?它可以,用一个mark-and-sweep的算法。这里是实现这种方法的一个例子:

    template<class T>

    class CyclPtr

    {

    public:

    CyclPtr (T * p)

    :_p (p), _isBeingDeleted (false)

    {}

    ~CyclPtr ()

    {

    _isBeingDeleted = true;

    if (!_p->IsBeingDeleted ())

    delete _p;

    }

    void Set (T * p)

    {

    _p = p;

    }

    bool IsBeingDeleted () const { return _isBeingDeleted; }

    private

    T * _p;

    bool _isBeingDeleted;

    };

      注意我们需要用class T来实现方法IsBeingDeleted,就像从CyclPtr继承。对特殊的所有权网络普通化是十分直接的。

      将原有代码转换为资源管理代码

    如果你是一个经验丰富的程序员,你一定会知道找资源的bug是一件浪费时间的痛苦的经历。我不必说服你和你的团队花费一点时间来熟悉资源管理是十分值得的。你可以立即开始用这个方法,无论你是在开始一个新项目或者是在一个项目的中期。转换不必立即全部完成。下面是步骤。

    (1)       首先,在你的工程中建立基本的Strong Pointer。然后通过查找代码中的new来开始封装裸指针。

    (2)       最先封装的是在过程中定义的临时指针。简单的将它们替换为auto_ptr并且删除相应的delete。如果一个指针在过程中没有被删除而是被返回,用auto_ptr替换并在返回前调用release方法。在你做第二次传递的时候,你需要处理对release的调用。注意,即使是在这点,你的代码也可能更加"精力充沛"--你会移出代码中潜在的资源泄漏问题。

    (3)       下面是指向资源的裸指针。确保它们被独立的封装到auto_ptr中,或者在构造函数中分配在析构函数中释放。如果你有传递所有权的行为的话,需要调用release方法。如果你有容器所有对象,用Strong Pointers重新实现它们。

    (4)       接下来,找到所有对release的方法调用并且尽力清除所有,如果一个release调用返回一个指针,将它修改传值返回一个auto_ptr

    (5)       重复着一过程,直到最后所有newrelease的调用都在构造函数或者资源转换的时候发生。这样,你在你的代码中处理了资源泄漏的问题。对其他资源进行相似的操作。

    (6)       你会发现资源管理清除了许多错误和异常处理带来的复杂性。不仅仅你的代码会变得精力充沛,它也会变得简单并容易维护。

    内存泄漏

    2.1 C++中动态内存分配引发问题的解决方案

    假设我们要开发一个String类,它可以方便地处理字符串数据。我们可以在类中声明一个数组,考虑到有时候字符串极长,我们可以把数组大小设为200,但一般的情况下又不需要这么多的空间,这样是浪费了内存。对了,我们可以使用new操作符,这样是十分灵活的,但在类中就会出现许多意想不到的问题,本文就是针对这一现象而写的。现在,我们先来开发一个String类,但它是一个不完善的类。的确,我们要刻意地使它出现各种各样的问题,这样才好对症下药。好了,我们开始吧!

    /* String.h */

    #ifndef STRING_H_

    #define STRING_H_

    class String

    {

    private:

    char * str; //存储数据

    int len; //字符串长度

    public:

    String(const char * s); //构造函数

    String(); // 默认构造函数

    ~String(); // 析构函数

    friend ostream & operator<<(ostream & os,const String& st);

    };

    #endif

    /*String.cpp*/

    #include iostream

    #include cstring

    #include "String.h"

    using namespace std;

    String::String(const char * s)

    {

    len = strlen(s);

    str = new char[len + 1];

    strcpy(str, s);

    }//拷贝数据

    String::String()

    {

    len =0;

    str = new char[len+1];

    str[0]='"0';

    }

    String::~String()

    {

    cout<<"这个字符串将被删除:"<<str<<'"n';//为了方便观察结果,特留此行代码。

    delete [] str;

    }

    ostream & operator<<(ostream & os, const String & st)

    {

    os << st.str;

    return os;

    }

    /*test_right.cpp*/

    #include iostream

    #include stdlib.h

    #include "String.h"

    using namespace std;

    int main()

    {

    String temp("天极网");

    cout<<temp<<'"n';

    system("PAUSE");

    return 0;

    }

      

      运行结果:

      天极网

      请按任意键继续. . .

      大家可以看到,以上程序十分正确,而且也是十分有用的。可是,我们不能被表面现象所迷惑!下面,请大家用test_String.cpp文件替换test_right.cpp文件进行编译,看看结果。有的编译器可能就是根本不能进行编译!

    test_String.cpp:

    #include iostream

    #include stdlib.h

    #include "String.h"

    using namespace std;

    void show_right(const String&);

    void show_String(const String);//注意,参数非引用,而是按值传递。

    int main()

    {

    String test1("第一个范例。");

    String test2("第二个范例。");

    String test3("第三个范例。");

    String test4("第四个范例。");

    cout<<"下面分别输入三个范例:"n";

    cout<<test1<<endl;

    cout<<test2<<endl;

    cout<<test3<<endl;

    String* String1=new String(test1);

    cout<<*String1<<endl;

    delete String1;

    cout<<test1<<endl; //Dev-cpp上没有任何反应。

    cout<<"使用正确的函数:"<<endl;

    show_right(test2);

    cout<<test2<<endl;

    cout<<"使用错误的函数:"<<endl;

    show_String(test2);

    cout<<test2<<endl; //这一段代码出现严重的错误!

    String String2(test3);

    cout<<"String2: "<<String2<<endl;

    String String3;

    String3=test4;

    cout<<"String3: "<<String3<<endl;

    cout<<"下面,程序结束,析构函数将被调用。"<<endl;

    return 0;

    }

    void show_right(const String& a)

    {

    cout<<a<<endl;

    }

    void show_String(const String a)

    {

    cout<<a<<endl;

    }

      运行结果:

      下面分别输入三个范例:

      第一个范例。

      第二个范例。

      第三个范例。

      第一个范例。

      这个字符串将被删除:第一个范例。

      使用正确的函数:

      

      第二个范例。

      第二个范例。

      使用错误的函数:

      第二个范例。

      这个字符串将被删除:第二个范例。

      这个字符串将被删除:?=

      ?=

      String2: 第三个范例。

      String3: 第四个范例。

      下面,程序结束,析构函数将被调用。

      这个字符串将被删除:第四个范例。

      这个字符串将被删除:第三个范例。

      这个字符串将被删除:?=

      这个字符串将被删除:x =

      这个字符串将被删除:?=

      这个字符串将被删除:

    现在,请大家自己试试运行结果,或许会更加惨不忍睹呢!下面,我为大家一一分析原因。

    首先,大家要知道,C++类有以下这些极为重要的函数:

    一:复制构造函数。

    二:赋值函数。

    我们先来讲复制构造函数。什么是复制构造函数呢?比如,我们可以写下这样的代码:String test1(test2);这是进行初始化。我们知道,初始化对象要用构造函数。可这儿呢?按理说,应该有声明为这样的构造函数:String(const String &);可是,我们并没有定义这个构造函数呀?答案是,C++提供了默认的复制构造函数,问题也就出在这儿。

    1):什么时候会调用复制构造函数呢?(以String类为例。)

      在我们提供这样的代码:String test1(test2)时,它会被调用;当函数的参数列表为按值传递,也就是没有用引用和指针作为类型时,如:void show_String(const String),它会被调用。其实,还有一些情况,但在这儿就不列举了。

    2):它是什么样的函数。

    它的作用就是把两个类进行复制。拿String类为例,C++提供的默认复制构造函数是这样的:

    String(const String& a)

    {

    str=a.str;

    len=a.len;

    }

    在平时,这样并不会有任何的问题出现,但我们用了new操作符,涉及到了动态内存分配,我们就不得不谈谈浅复制和深复制了。以上的函数就是实行的浅复制,它只是复制了指针,而并没有复制指针指向的数据,可谓一点儿用也没有。打个比方吧!就像一个朋友让你把一个程序通过网络发给他,而你大大咧咧地把快捷方式发给了他,有什么用处呢?我们来具体谈谈:

    假如,A对象中存储了这样的字符串:“C++”。它的地址为2000。现在,我们把A对象赋给B对象:String B=A。现在,AB对象的str指针均指向2000地址。看似可以使用,但如果B对象的析构函数被调用时,则地址2000处的字符串“C++”已经被从内存中抹去,而A对象仍然指向地址2000。这时,如果我们写下这样的代码:cout<<A<<endl;或是等待程序结束,A对象的析构函数被调用时,A对象的数据能否显示出来呢?只会是乱码。而且,程序还会这样做:连续对地址2000处使用两次delete操作符,这样的后果是十分严重的!

    本例中,有这样的代码:

    String* String1=new String(test1);

    cout<<*String1<<endl;

    delete String1;

      假设test1str指向的地址为2000,Stringstr指针同样指向地址2000,我们删除了2000处的数据,而test1对象呢?已经被破坏了。大家从运行结果上可以看到,我们使用cout<<test1时,一点反应也没有。而在test1的析构函数被调用时,显示是这样:“这个字符串将被删除:”。

    再看看这段代码:

    cout<<"使用错误的函数:"<<endl;

    show_String(test2);

    cout<<test2<<endl;//这一段代码出现严重的错误!

    show_String函数的参数列表void show_String(const String a)是按值传递的,所以,我们相当于执行了这样的代码:String a=test2;函数执行完毕,由于生存周期的缘故,对象a被析构函数删除,我们马上就可以看到错误的显示结果了:这个字符串将被删除:?=。当然,test2也被破坏了。解决的办法很简单,当然是手工定义一个复制构造函数喽!人力可以胜天!

    String::String(const String& a)
    {
    len=a.len;
    str=new char(len+1);
    strcpy(str,a.str);
    }

      我们执行的是深复制。这个函数的功能是这样的:假设对象A中的str指针指向地址2000,内容为“I am a C++ Boy!”。我们执行代码String B=A时,我们先开辟出一块内存,假设为3000。我们用strcpy函数将地址2000的内容拷贝到地址3000中,再将对象Bstr指针指向地址3000。这样,就互不干扰了。

    大家把这个函数加入程序中,问题就解决了大半,但还没有完全解决,问题在赋值函数上。我们的程序中有这样的段代码:

    String String3;

    String3=test4;

      经过我前面的讲解,大家应该也会对这段代码进行寻根摸底:凭什么可以这样做:String3=test4???原因是,C++为了用户的方便,提供的这样的一个操作符重载函数:operator=。所以,我们可以这样做。大家应该猜得到,它同样是执行了浅复制,出了同样的毛病。比如,执行了这段代码后,析构函数开始大展神威^_^。由于这些变量是后进先出的,所以最后的String3变量先被删除:这个字符串将被删除:第四个范例。很正常。最后,删除到test4的时候,问题来了:这个字符串将被删除:?=。原因我不用赘述了,只是这个赋值函数怎么写,还有一点儿学问呢!大家请看:

    平时,我们可以写这样的代码:x=y=z。(均为整型变量。)而在类对象中,我们同样要这样,因为这很方便。而对象A=B=C就是A.operator=(B.operator=(c))。而这个operator=函数的参数列表应该是:const String& a,所以,大家不难推出,要实现这样的功能,返回值也要是String&,这样才能实现ABC。我们先来写写看:

    String& String::operator=(const String& a)

    {

    delete [] str;//先删除自身的数据

    len=a.len;

    str=new char[len+1];

    strcpy(str,a.str);//此三行为进行拷贝

    return *this;//返回自身的引用

    }

    是不是这样就行了呢?我们假如写出了这种代码:A=A,那么大家看看,岂不是把A对象的数据给删除了吗?这样可谓引发一系列的错误。所以,我们还要检查是否为自身赋值。只比较两对象的数据是不行了,因为两个对象的数据很有可能相同。我们应该比较地址。以下是完好的赋值函数:

    String& String::operator=(const String& a)

    {

    if(this==&a)

    return *this;

    delete [] str;

    len=a.len;

    str=new char[len+1];

    strcpy(str,a.str);

    return *this;

    }

    把这些代码加入程序,问题就完全解决,下面是运行结果:

      下面分别输入三个范例:

      第一个范例

      第二个范例

      第三个范例

      第一个范例

      这个字符串将被删除:第一个范例。

      第一个范例

       使用正确的函数:

      第二个范例。

      第二个范例。

       使用错误的函数:

      第二个范例。

      这个字符串将被删除:第二个范例。

      第二个范例。

      String2: 第三个范例。

      String3: 第四个范例。

      下面,程序结束,析构函数将被调用。

      这个字符串将被删除:第四个范例。

      这个字符串将被删除:第三个范例。

      这个字符串将被删除:第四个范例。

      这个字符串将被删除:第三个范例。

      这个字符串将被删除:第二个范例。

      这个字符串将被删除:第一个范例。

    2.2 如何对付内存泄漏?

    写出那些不会导致任何内存泄漏的代码。很明显,当你的代码中到处充满了new 操作、delete操作和指针运算的话,你将会在某个地方搞晕了头,导致内存泄漏,指针引用错误,以及诸如此类的问题。这和你如何小心地对待内存分配工作其实完全没有关系:代码的复杂性最终总是会超过你能够付出的时间和努力。于是随后产生了一些成功的技巧,它们依赖于将内存分配(allocations)与重新分配(deallocation)工作隐藏在易于管理的类型之后。标准容器(standard containers)是一个优秀的例子。它们不是通过你而是自己为元素管理内存,从而避免了产生糟糕的结果。想象一下,没有stringvector的帮助,写出这个:

    #include<vector>

    #include<string>

    #include<iostream>

    #include<algorithm>

    using namespace std;

    int main() // small program messing around with strings

    {

     cout << "enter some whitespace-separated words:"n";

     vector<string> v;

     string s;

     while (cin>>s) v.push_back(s);

     sort(v.begin(),v.end());

     string cat;

     typedef vector<string>::const_iterator Iter;

     for (Iter p = v.begin(); p!=v.end(); ++p) cat += *p+"+";

     cout << cat << "n;

    }

      你有多少机会在第一次就得到正确的结果?你又怎么知道你没有导致内存泄漏呢?

      注意,没有出现显式的内存管理,宏,造型,溢出检查,显式的长度限制,以及指针。通过使用函数对象和标准算法(standard algorithm),我可以避免使用指针——例如使用迭代子(iterator),不过对于一个这么小的程序来说有点小题大作了。

      这些技巧并不完美,要系统化地使用它们也并不总是那么容易。但是,应用它们产生了惊人的差异,而且通过减少显式的内存分配与重新分配的次数,你甚至可以使余下的例子更加容易被跟踪。早在1981年,我就指出,通过将我必须显式地跟踪的对象的数量从几万个减少到几打,为了使程序正确运行而付出的努力从可怕的苦工,变成了应付一些可管理的对象,甚至更加简单了。

      如果你的程序还没有包含将显式内存管理减少到最小限度的库,那么要让你程序完成和正确运行的话,最快的途径也许就是先建立一个这样的库。

      模板和标准库实现了容器、资源句柄以及诸如此类的东西,更早的使用甚至在多年以前。异常的使用使之更加完善。

      如果你实在不能将内存分配/重新分配的操作隐藏到你需要的对象中时,你可以使用资源句柄(resource handle),以将内存泄漏的可能性降至最低。这里有个例子:我需要通过一个函数,在空闲内存中建立一个对象并返回它。这时候可能忘记释放这个对象。毕竟,我们不能说,仅仅关注当这个指针要被释放的时候,谁将负责去做。使用资源句柄,这里用了标准库中的auto_ptr,使需要为之负责的地方变得明确了。

    #include<memory>

    #include<iostream>

    using namespace std;

    struct S {

     S() { cout << "make an S"n"; }

     ~S() { cout << "destroy an S"n"; }

     S(const S&) { cout << "copy initialize an S"n"; }

     S& operator=(const S&) { cout << "copy assign an S"n"; }

    };

    S* f()

    {

     return new S; // 谁该负责释放这个S

    };

    auto_ptr<S> g()

    {

     return auto_ptr<S>(new S); // 显式传递负责释放这个S

    }

    int main()

    {

     cout << "start main"n";

     S* p = f();

     cout << "after f() before g()"n";

     // S* q = g(); // 将被编译器捕捉

     auto_ptr<S> q = g();

     cout << "exit main"n";

     // *p产生了内存泄漏

     // *q被自动释放

    }

      在更一般的意义上考虑资源,而不仅仅是内存。

    如果在你的环境中不能系统地应用这些技巧(例如,你必须使用别的地方的代码,或者你的程序的另一部分简直是原始人类(译注:原文是Neanderthals,尼安德特人,旧石器时代广泛分布在欧洲的猿人)写的,如此等等),那么注意使用一个内存泄漏检测器作为开发过程的一部分,或者插入一个垃圾收集器(garbage collector)。

    2.3浅谈C/C++内存泄漏及其检测工具

      对于一个c/c++程序员来说,内存泄漏是一个常见的也是令人头疼的问题。已经有许多技术被研究出来以应对这个问题,比如Smart PointerGarbage Collection等。Smart Pointer技术比较成熟,STL中已经包含支持Smart Pointerclass,但是它的使用似乎并不广泛,而且它也不能解决所有的问题;Garbage Collection技术在Java中已经比较成熟,但是在c/c++领域的发展并不顺畅,虽然很早就有人思考在C++中也加入GC的支持。现实世界就是这样的,作为一个c/c++程序员,内存泄漏是你心中永远的痛。不过好在现在有许多工具能够帮助我们验证内存泄漏的存在,找出发生问题的代码。

    2.3.1 内存泄漏的定义

    一般我们常说的内存泄漏是指堆内存的泄漏。堆内存是指程序从堆中分配的,大小任意的(内存块的大小可以在程序运行期决定),使用完后必须显示释放的内存。应用程序一般使用mallocreallocnew等函数从堆中分配到一块内存,使用完后,程序必须负责相应的调用freedelete释放该内存块,否则,这块内存就不能被再次使用,我们就说这块内存泄漏了。以下这段小程序演示了堆内存发生泄漏的情形:

    void MyFunction(int nSize)

    {

     char* p= new char[nSize];

     if( !GetStringFrom( p, nSize ) ){

      MessageBox(Error);

      return;

     }

     …//using the string pointed by p;

     delete p;

    }

      当函数GetStringFrom()返回零的时候,指针p指向的内存就不会被释放。这是一种常见的发生内存泄漏的情形。程序在入口处分配内存,在出口处释放内存,但是c函数可以在任何地方退出,所以一旦有某个出口处没有释放应该释放的内存,就会发生内存泄漏。

      广义的说,内存泄漏不仅仅包含堆内存的泄漏,还包含系统资源的泄漏(resource leak),比如核心态HANDLEGDI ObjectSOCKET Interface等,从根本上说这些由操作系统分配的对象也消耗内存,如果这些对象发生泄漏最终也会导致内存的泄漏。而且,某些对象消耗的是核心态内存,这些对象严重泄漏时会导致整个操作系统不稳定。所以相比之下,系统资源的泄漏比堆内存的泄漏更为严重。

    GDI Object的泄漏是一种常见的资源泄漏:

    void CMyView::OnPaint( CDC* pDC )

    {

     CBitmap bmp;

     CBitmap* pOldBmp;

     bmp.LoadBitmap(IDB_MYBMP);

     pOldBmp = pDC->SelectObject( &bmp );

     …

     if( Something() ){

      return;

     }

     pDC->SelectObject( pOldBmp );

     return;

    }

      当函数Something()返回非零的时候,程序在退出前没有把pOldBmp选回pDC中,这会导致pOldBmp指向的HBITMAP对象发生泄漏。这个程序如果长时间的运行,可能会导致整个系统花屏。这种问题在Win9x下比较容易暴露出来,因为Win9xGDI堆比Win2kNT的要小很多。

    2.3.2 内存泄漏的发生方式

      以发生的方式来分类,内存泄漏可以分为4类:

      1. 常发性内存泄漏。发生内存泄漏的代码会被多次执行到,每次被执行的时候都会导致一块内存泄漏。比如例二,如果Something()函数一直返回True,那么pOldBmp指向的HBITMAP对象总是发生泄漏。

      2. 偶发性内存泄漏。发生内存泄漏的代码只有在某些特定环境或操作过程下才会发生。比如例二,如果Something()函数只有在特定环境下才返回True,那么pOldBmp指向的HBITMAP对象并不总是发生泄漏。常发性和偶发性是相对的。对于特定的环境,偶发性的也许就变成了常发性的。所以测试环境和测试方法对检测内存泄漏至关重要。

    3. 一次性内存泄漏。发生内存泄漏的代码只会被执行一次,或者由于算法上的缺陷,导致总会有一块仅且一块内存发生泄漏。比如,在类的构造函数中分配内存,在析构函数中却没有释放该内存,但是因为这个类是一个Singleton,所以内存泄漏只会发生一次。另一个例子:

    char* g_lpszFileName = NULL;

    void SetFileName( const char* lpcszFileName )

    {

     if( g_lpszFileName ){

      free( g_lpszFileName );

     }

     g_lpszFileName = strdup( lpcszFileName );

    }

      如果程序在结束的时候没有释放g_lpszFileName指向的字符串,那么,即使多次调用SetFileName(),总会有一块内存,而且仅有一块内存发生泄漏。

    4. 隐式内存泄漏。程序在运行过程中不停的分配内存,但是直到结束的时候才释放内存。严格的说这里并没有发生内存泄漏,因为最终程序释放了所有申请的内存。但是对于一个服务器程序,需要运行几天,几周甚至几个月,不及时释放内存也可能导致最终耗尽系统的所有内存。所以,我们称这类内存泄漏为隐式内存泄漏。举一个例子:

    class Connection

    {

     public:

      Connection( SOCKET s);

      ~Connection();

      …

     private:

      SOCKET _socket;

      …

    };

    class ConnectionManager

    {

     public:

      ConnectionManager(){}

      ~ConnectionManager(){

       list::iterator it;

       for( it = _connlist.begin(); it != _connlist.end(); ++it ){

        delete *it;

       }

       _connlist.clear();

      }

      void OnClientConnected( SOCKET s ){

       Connection* p = new Connection(s);

       _connlist.push_back(p);

      }

      void OnClientDisconnected( Connection* pconn ){

       _connlist.remove( pconn );

       delete pconn;

      }

     private:

      list _connlist;

    };

      假设在ClientServer端断开后,Server并没有呼叫OnClientDisconnected()函数,那么代表那次连接的Connection对象就不会被及时的删除(在Server程序退出的时候,所有Connection对象会在ConnectionManager的析构函数里被删除)。当不断的有连接建立、断开时隐式内存泄漏就发生了。

    从用户使用程序的角度来看,内存泄漏本身不会产生什么危害,作为一般的用户,根本感觉不到内存泄漏的存在。真正有危害的是内存泄漏的堆积,这会最终消耗尽系统所有的内存。从这个角度来说,一次性内存泄漏并没有什么危害,因为它不会堆积,而隐式内存泄漏危害性则非常大,因为较之于常发性和偶发性内存泄漏它更难被检测到。

    2.3.3 检测内存泄漏

      检测内存泄漏的关键是要能截获住对分配内存和释放内存的函数的调用。截获住这两个函数,我们就能跟踪每一块内存的生命周期,比如,每当成功的分配一块内存后,就把它的指针加入一个全局的list中;每当释放一块内存,再把它的指针从list中删除。这样,当程序结束的时候,list中剩余的指针就是指向那些没有被释放的内存。这里只是简单的描述了检测内存泄漏的基本原理,详细的算法可以参见Steve Maguire<<Writing Solid Code>>

      如果要检测堆内存的泄漏,那么需要截获住malloc/realloc/freenew/delete就可以了(其实new/delete最终也是用malloc/free的,所以只要截获前面一组即可)。对于其他的泄漏,可以采用类似的方法,截获住相应的分配和释放函数。比如,要检测BSTR的泄漏,就需要截获SysAllocString/SysFreeString;要检测HMENU的泄漏,就需要截获CreateMenu/ DestroyMenu。(有的资源的分配函数有多个,释放函数只有一个,比如,SysAllocStringLen也可以用来分配BSTR,这时就需要截获多个分配函数)

      在Windows平台下,检测内存泄漏的工具常用的一般有三种,MS C-Runtime Library内建的检测功能;外挂式的检测工具,诸如,PurifyBoundsChecker等;利用Windows NT自带的Performance Monitor。这三种工具各有优缺点,MS C-Runtime Library虽然功能上较之外挂式的工具要弱,但是它是免费的;Performance Monitor虽然无法标示出发生问题的代码,但是它能检测出隐式的内存泄漏的存在,这是其他两类工具无能为力的地方。

      以下我们详细讨论这三种检测工具:

    2.3.3.1 VC下内存泄漏的检测方法

      用MFC开发的应用程序,在DEBUG版模式下编译后,都会自动加入内存泄漏的检测代码。在程序结束后,如果发生了内存泄漏,在Debug窗口中会显示出所有发生泄漏的内存块的信息,以下两行显示了一块被泄漏的内存块的信息:

    E:"TestMemLeak"TestDlg.cpp(70) : {59} normal block at 0x00881710, 200 bytes long.

    Data: <abcdefghijklmnop> 61 62 63 64 65 66 67 68 69 6A 6B 6C 6D 6E 6F 70

      第一行显示该内存块由TestDlg.cpp文件,第70行代码分配,地址在0x00881710,大小为200字节,{59}是指调用内存分配函数的Request Order,关于它的详细信息可以参见MSDN_CrtSetBreakAlloc()的帮助。第二行显示该内存块前16个字节的内容,尖括号内是以ASCII方式显示,接着的是以16进制方式显示。

      一般大家都误以为这些内存泄漏的检测功能是由MFC提供的,其实不然。MFC只是封装和利用了MS C-Runtime LibraryDebug Function。非MFC程序也可以利用MS C-Runtime LibraryDebug Function加入内存泄漏的检测功能。MS C-Runtime Library在实现malloc/freestrdup等函数时已经内建了内存泄漏的检测功能。

    注意观察一下由MFC Application Wizard生成的项目,在每一个cpp文件的头部都有这样一段宏定义:

    #ifdef _DEBUG

    #define new DEBUG_NEW

    #undef THIS_FILE

    static char THIS_FILE[] = __FILE__;

    #endif

    有了这样的定义,在编译DEBUG版时,出现在这个cpp文件中的所有new都被替换成DEBUG_NEW了。那么DEBUG_NEW是什么呢?DEBUG_NEW也是一个宏,以下摘自afx.h1632

    #define DEBUG_NEW new(THIS_FILE, __LINE__)

    所以如果有这样一行代码:

    char* p = new char[200];

    经过宏替换就变成了:

    char* p = new( THIS_FILE, __LINE__)char[200];

    根据C++的标准,对于以上的new的使用方法,编译器会去找这样定义的operator new

    void* operator new(size_t, LPCSTR, int)

    我们在afxmem.cpp 63行找到了一个这样的operator new 的实现

    void* AFX_CDECL operator new(size_t nSize, LPCSTR lpszFileName, int nLine)

    {

     return ::operator new(nSize, _NORMAL_BLOCK, lpszFileName, nLine);

    }

    void* __cdecl operator new(size_t nSize, int nType, LPCSTR lpszFileName, int nLine)

    {

     …

     pResult = _malloc_dbg(nSize, nType, lpszFileName, nLine);

     if (pResult != NULL)

      return pResult;

     …

    }

      第二个operator new函数比较长,为了简单期间,我只摘录了部分。很显然最后的内存分配还是通过_malloc_dbg函数实现的,这个函数属于MS C-Runtime Library Debug Function。这个函数不但要求传入内存的大小,另外还有文件名和行号两个参数。文件名和行号就是用来记录此次分配是由哪一段代码造成的。如果这块内存在程序结束之前没有被释放,那么这些信息就会输出到Debug窗口里。

      这里顺便提一下THIS_FILE__FILE__LINE____FILE____LINE__都是编译器定义的宏。当碰到__FILE__时,编译器会把__FILE__替换成一个字符串,这个字符串就是当前在编译的文件的路径名。当碰到__LINE__时,编译器会把__LINE__替换成一个数字,这个数字就是当前这行代码的行号。在DEBUG_NEW的定义中没有直接使用__FILE__,而是用了THIS_FILE,其目的是为了减小目标文件的大小。假设在某个cpp文件中有100处使用了new,如果直接使用__FILE__,那编译器会产生100个常量字符串,这100个字符串都是飧?/SPAN>cpp文件的路径名,显然十分冗余。如果使用THIS_FILE,编译器只会产生一个常量字符串,那100new的调用使用的都是指向常量字符串的指针。

      再次观察一下由MFC Application Wizard生成的项目,我们会发现在cpp文件中只对new做了映射,如果你在程序中直接使用malloc函数分配内存,调用malloc的文件名和行号是不会被记录下来的。如果这块内存发生了泄漏,MS C-Runtime Library仍然能检测到,但是当输出这块内存块的信息,不会包含分配它的的文件名和行号。

    要在非MFC程序中打开内存泄漏的检测功能非常容易,你只要在程序的入口处加入以下几行代码:

    int tmpFlag = _CrtSetDbgFlag( _CRTDBG_REPORT_FLAG );

    tmpFlag |= _CRTDBG_LEAK_CHECK_DF;

    _CrtSetDbgFlag( tmpFlag );

      这样,在程序结束的时候,也就是winmainmaindllmain函数返回之后,如果还有内存块没有释放,它们的信息会被打印到Debug窗口里。

    如果你试着创建了一个非MFC应用程序,而且在程序的入口处加入了以上代码,并且故意在程序中不释放某些内存块,你会在Debug窗口里看到以下的信息:

    {47} normal block at 0x00C91C90, 200 bytes long.

    Data: < > 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F

      内存泄漏的确检测到了,但是和上面MFC程序的例子相比,缺少了文件名和行号。对于一个比较大的程序,没有这些信息,解决问题将变得十分困难。

      为了能够知道泄漏的内存块是在哪里分配的,你需要实现类似MFC的映射功能,把newmaolloc等函数映射到_malloc_dbg函数上。这里我不再赘述,你可以参考MFC的源代码。

      由于Debug Function实现在MS C-RuntimeLibrary中,所以它只能检测到堆内存的泄漏,而且只限于mallocreallocstrdup等分配的内存,而那些系统资源,比如HANDLEGDI Object,或是不通过C-Runtime Library分配的内存,比如VARIANTBSTR的泄漏,它是无法检测到的,这是这种检测法的一个重大的局限性。另外,为了能记录内存块是在哪里分配的,源代码必须相应的配合,这在调试一些老的程序非常麻烦,毕竟修改源代码不是一件省心的事,这是这种检测法的另一个局限性。

    对于开发一个大型的程序,MS C-Runtime Library提供的检测功能是远远不够的。接下来我们就看看外挂式的检测工具。我用的比较多的是BoundsChecker,一则因为它的功能比较全面,更重要的是它的稳定性。这类工具如果不稳定,反而会忙里添乱。到底是出自鼎鼎大名的NuMega,我用下来基本上没有什么大问题。

    2.3.3.2 使用BoundsChecker检测内存泄漏

      BoundsChecker采用一种被称为 Code Injection的技术,来截获对分配内存和释放内存的函数的调用。简单地说,当你的程序开始运行时,BoundsCheckerDLL被自动载入进程的地址空间(这可以通过system-levelHook实现),然后它会修改进程中对内存分配和释放的函数调用,让这些调用首先转入它的代码,然后再执行原来的代码。BoundsChecker在做这些动作的时,无须修改被调试程序的源代码或工程配置文件,这使得使用它非常的简便、直接。

      这里我们以malloc函数为例,截获其他的函数方法与此类似。

      需要被截获的函数可能在DLL中,也可能在程序的代码里。比如,如果静态连结C-Runtime Library,那么malloc函数的代码会被连结到程序里。为了截获住对这类函数的调用,BoundsChecker会动态修改这些函数的指令。

    以下两段汇编代码,一段没有BoundsChecker介入,另一段则有BoundsChecker的介入:

    126: _CRTIMP void * __cdecl malloc (

    127: size_t nSize

    128: )

    129: {

    00403C10 push ebp

    00403C11 mov ebp,esp

    130: return _nh_malloc_dbg(nSize, _newmode, _NORMAL_BLOCK, NULL, 0);

    00403C13 push 0

    00403C15 push 0

    00403C17 push 1

    00403C19 mov eax,[__newmode (0042376c)]

    00403C1E push eax

    00403C1F mov ecx,dword ptr [nSize]

    00403C22 push ecx

    00403C23 call _nh_malloc_dbg (00403c80)

    00403C28 add esp,14h

    131: }

    以下这一段代码有BoundsChecker介入:

    126: _CRTIMP void * __cdecl malloc (

    127: size_t nSize

    128: )

    129: {

    00403C10 jmp 01F41EC8

    00403C15 push 0

    00403C17 push 1

    00403C19 mov eax,[__newmode (0042376c)]

    00403C1E push eax

    00403C1F mov ecx,dword ptr [nSize]

    00403C22 push ecx

    00403C23 call _nh_malloc_dbg (00403c80)

    00403C28 add esp,14h

    131: }

      当BoundsChecker介入后,函数malloc的前三条汇编指令被替换成一条jmp指令,原来的三条指令被搬到地址01F41EC8处了。当程序进入malloc后先jmp01F41EC8,执行原来的三条指令,然后就是BoundsChecker的天下了。大致上它会先记录函数的返回地址(函数的返回地址在stack上,所以很容易修改),然后把返回地址指向属于BoundsChecker的代码,接着跳到malloc函数原来的指令,也就是在00403c15的地方。当malloc函数结束的时候,由于返回地址被修改,它会返回到BoundsChecker的代码中,此时BoundsChecker会记录由malloc分配的内存的指针,然后再跳转到到原来的返回地址去。

      如果内存分配/释放函数在DLL中,BoundsChecker则采用另一种方法来截获对这些函数的调用。BoundsChecker通过修改程序的DLL Import Tabletable中的函数地址指向自己的地址,以达到截获的目的。

    截获住这些分配和释放函数,BoundsChecker就能记录被分配的内存或资源的生命周期。接下来的问题是如何与源代码相关,也就是说当BoundsChecker检测到内存泄漏,它如何报告这块内存块是哪段代码分配的。答案是调试信息(Debug Information)。当我们编译一个Debug版的程序时,编译器会把源代码和二进制代码之间的对应关系记录下来,放到一个单独的文件里(.pdb)或者直接连结进目标程序,通过直接读取调试信息就能得到分配某块内存的源代码在哪个文件,哪一行上。使用Code InjectionDebug Information,使BoundsChecker不但能记录呼叫分配函数的源代码的位置,而且还能记录分配时的Call Stack,以及Call Stack上的函数的源代码位置。这在使用像MFC这样的类库时非常有用,以下我用一个例子来说明:

    void ShowXItemMenu()

    {

     …

     CMenu menu;

     menu.CreatePopupMenu();

     //add menu items.

     menu.TrackPropupMenu();

     …

    }

    void ShowYItemMenu( )

    {

     …

     CMenu menu;

     menu.CreatePopupMenu();

     //add menu items.

     menu.TrackPropupMenu();

     menu.Detach();//this will cause HMENU leak

     …

    }

    BOOL CMenu::CreatePopupMenu()

    {

     …

     hMenu = CreatePopupMenu();

     …

    }

    当调用ShowYItemMenu()时,我们故意造成HMENU的泄漏。但是,对于BoundsChecker来说被泄漏的HMENU是在class CMenu::CreatePopupMenu()中分配的。假设的你的程序有许多地方使用了CMenuCreatePopupMenu()函数,如CMenu::CreatePopupMenu()造成的,你依然无法确认问题的根结到底在哪里,在ShowXItemMenu()中还是在ShowYItemMenu()中,或者还有其它的地方也使用了CreatePopupMenu()?有了Call Stack的信息,问题就容易了。BoundsChecker会如下报告泄漏的HMENU的信息:

    Function

    File

    Line

    CMenu::CreatePopupMenu

    E:"8168"vc98"mfc"mfc"include"afxwin1.inl

    1009

    ShowYItemMenu

    E:"testmemleak"mytest.cpp

    100

      这里省略了其他的函数调用

      如此,我们很容易找到发生问题的函数是ShowYItemMenu()。当使用MFC之类的类库编程时,大部分的API调用都被封装在类库的class里,有了Call Stack信息,我们就可以非常容易的追踪到真正发生泄漏的代码。

      记录Call Stack信息会使程序的运行变得非常慢,因此默认情况下BoundsChecker不会记录Call Stack信息。可以按照以下的步骤打开记录Call Stack信息的选项开关:

      1. 打开菜单:BoundsChecker|Setting

      2. Error Detection页中,在Error Detection SchemeList中选择Custom

      3. CategoryCombox中选择 Pointer and leak error check

      4. 钩上Report Call Stack复选框

      5. 点击Ok

      基于Code InjectionBoundsChecker还提供了API Parameter的校验功能,memory over run等功能。这些功能对于程序的开发都非常有益。由于这些内容不属于本文的主题,所以不在此详述了。

    尽管BoundsChecker的功能如此强大,但是面对隐式内存泄漏仍然显得苍白无力。所以接下来我们看看如何用Performance Monitor检测内存泄漏。

    2.3.3.3 使用Performance Monitor检测内存泄漏

      NT的内核在设计过程中已经加入了系统监视功能,比如CPU的使用率,内存的使用情况,I/O操作的频繁度等都作为一个个Counter,应用程序可以通过读取这些Counter了解整个系统的或者某个进程的运行状况。Performance Monitor就是这样一个应用程序。

      为了检测内存泄漏,我们一般可以监视Process对象的Handle CountVirutal Bytes Working Set三个CounterHandle Count记录了进程当前打开的HANDLE的个数,监视这个Counter有助于我们发现程序是否有Handle泄漏;Virtual Bytes记录了该进程当前在虚地址空间上使用的虚拟内存的大小,NT的内存分配采用了两步走的方法,首先,在虚地址空间上保留一段空间,这时操作系统并没有分配物理内存,只是保留了一段地址。然后,再提交这段空间,这时操作系统才会分配物理内存。所以,Virtual Bytes一般总大于程序的Working Set。监视Virutal Bytes可以帮助我们发现一些系统底层的问题; Working Set记录了操作系统为进程已提交的内存的总量,这个值和程序申请的内存总量存在密切的关系,如果程序存在内存的泄漏这个值会持续增加,但是Virtual Bytes却是跳跃式增加的。

      监视这些Counter可以让我们了解进程使用内存的情况,如果发生了泄漏,即使是隐式内存泄漏,这些Counter的值也会持续增加。但是,我们知道有问题却不知道哪里有问题,所以一般使用Performance Monitor来验证是否有内存泄漏,而使用BoundsChecker来找到和解决。

      当Performance Monitor显示有内存泄漏,而BoundsChecker却无法检测到,这时有两种可能:第一种,发生了偶发性内存泄漏。这时你要确保使用Performance Monitor和使用BoundsChecker时,程序的运行环境和操作方法是一致的。第二种,发生了隐式的内存泄漏。这时你要重新审查程序的设计,然后仔细研究Performance Monitor记录的Counter的值的变化图,分析其中的变化和程序运行逻辑的关系,找到一些可能的原因。这是一个痛苦的过程,充满了假设、猜想、验证、失败,但这也是一个积累经验的绝好机会。

    探讨C++内存回收

    3.1 C++内存对象大会战

      如果一个人自称为程序高手,却对内存一无所知,那么我可以告诉你,他一定在吹牛。用CC++写程序,需要更多地关注内存,这不仅仅是因为内存的分配是否合理直接影响着程序的效率和性能,更为主要的是,当我们操作内存的时候一不小心就会出现问题,而且很多时候,这些问题都是不易发觉的,比如内存泄漏,比如悬挂指针。笔者今天在这里并不是要讨论如何避免这些问题,而是想从另外一个角度来认识C++内存对象。

      我们知道,C++将内存划分为三个逻辑区域:堆、栈和静态存储区。既然如此,我称位于它们之中的对象分别为堆对象,栈对象以及静态对象。那么这些不同的内存对象有什么区别了?堆对象和栈对象各有什么优劣了?如何禁止创建堆对象或栈对象了?这些便是今天的主题。

    3.1.1 基本概念

      先来看看栈。栈,一般用于存放局部变量或对象,如我们在函数定义中用类似下面语句声明的对象:

    Type stack_object ; 

      stack_object便是一个栈对象,它的生命期是从定义点开始,当所在函数返回时,生命结束。

      另外,几乎所有的临时对象都是栈对象。比如,下面的函数定义:

    Type fun(Type object);

      这个函数至少产生两个临时对象,首先,参数是按值传递的,所以会调用拷贝构造函数生成一个临时对象object_copy1 ,在函数内部使用的不是使用的不是object,而是object_copy1,自然,object_copy1是一个栈对象,它在函数返回时被释放;还有这个函数是值返回的,在函数返回时,如果我们不考虑返回值优化(NRV),那么也会产生一个临时对象object_copy2,这个临时对象会在函数返回后一段时间内被释放。比如某个函数中有如下代码:

    Type tt ,result ; //生成两个栈对象

    tt = fun(tt); //函数返回时,生成的是一个临时对象object_copy2

      上面的第二个语句的执行情况是这样的,首先函数fun返回时生成一个临时对象object_copy2 ,然后再调用赋值运算符执行

    tt = object_copy2 ; //调用赋值运算符

      看到了吗?编译器在我们毫无知觉的情况下,为我们生成了这么多临时对象,而生成这些临时对象的时间和空间的开销可能是很大的,所以,你也许明白了,为什么对于“大”对象最好用const引用传递代替按值进行函数参数传递了。

      接下来,看看堆。堆,又叫自由存储区,它是在程序执行的过程中动态分配的,所以它最大的特性就是动态性。在C++中,所有堆对象的创建和销毁都要由程序员负责,所以,如果处理不好,就会发生内存问题。如果分配了堆对象,却忘记了释放,就会产生内存泄漏;而如果已释放了对象,却没有将相应的指针置为NULL,该指针就是所谓的“悬挂指针”,再度使用此指针时,就会出现非法访问,严重时就导致程序崩溃。

      那么,C++中是怎样分配堆对象的?唯一的方法就是用new(当然,用类malloc指令也可获得C式堆内存),只要使用new,就会在堆中分配一块内存,并且返回指向该堆对象的指针。

      再来看看静态存储区。所有的静态对象、全局对象都于静态存储区分配。关于全局对象,是在main()函数执行前就分配好了的。其实,在main()函数中的显示代码执行之前,会调用一个由编译器生成的_main()函数,而_main()函数会进行所有全局对象的的构造及初始化工作。而在main()函数结束之前,会调用由编译器生成的exit函数,来释放所有的全局对象。比如下面的代码:

    void mainvoid

    {

     … // 显式代码

    }

      实际上,被转化成这样:

    void mainvoid

    {

     _main(); //隐式代码,由编译器产生,用以构造所有全局对象

     …  // 显式代码

     … 

     exit() ; // 隐式代码,由编译器产生,用以释放所有全局对象

    }

      所以,知道了这个之后,便可以由此引出一些技巧,如,假设我们要在main()函数执行之前做某些准备工作,那么我们可以将这些准备工作写到一个自定义的全局对象的构造函数中,这样,在main()函数的显式代码执行之前,这个全局对象的构造函数会被调用,执行预期的动作,这样就达到了我们的目的。 刚才讲的是静态存储区中的全局对象,那么,局部静态对象了?局部静态对象通常也是在函数中定义的,就像栈对象一样,只不过,其前面多了个static关键字。局部静态对象的生命期是从其所在函数第一次被调用,更确切地说,是当第一次执行到该静态对象的声明代码时,产生该静态局部对象,直到整个程序结束时,才销毁该对象。

      还有一种静态对象,那就是它作为class的静态成员。考虑这种情况时,就牵涉了一些较复杂的问题。

      第一个问题是class的静态成员对象的生命期,class的静态成员对象随着第一个class object的产生而产生,在整个程序结束时消亡。也就是有这样的情况存在,在程序中我们定义了一个class,该类中有一个静态对象作为成员,但是在程序执行过程中,如果我们没有创建任何一个该class object,那么也就不会产生该class所包含的那个静态对象。还有,如果创建了多个class object,那么所有这些object都共享那个静态对象成员。

      第二个问题是,当出现下列情况时:

     class Base

    {

     public:

      static Type s_object ;

    }

    class Derived1 : public Base / / 公共继承

    {

     … // other data

    }

    class Derived2 : public Base / / 公共继承

    {

     … // other data

    }

    Base example ;

    Derivde1 example1 ;

    Derivde2 example2 ;

    example.s_object = …… ;

    example1.s_object = …… ;

    example2.s_object = …… ; 

      请注意上面标为黑体的三条语句,它们所访问的s_object是同一个对象吗?答案是肯定的,它们的确是指向同一个对象,这听起来不像是真的,是吗?但这是事实,你可以自己写段简单的代码验证一下。我要做的是来解释为什么会这样? 我们知道,当一个类比如Derived1,从另一个类比如Base继承时,那么,可以看作一个Derived1对象中含有一个Base型的对象,这就是一个subobject。一个Derived1对象的大致内存布局如下:

      

      让我们想想,当我们将一个Derived1型的对象传给一个接受非引用Base型参数的函数时会发生切割,那么是怎么切割的呢?相信现在你已经知道了,那就是仅仅取出了Derived1型的对象中的subobject,而忽略了所有Derived1自定义的其它数据成员,然后将这个subobject传递给函数(实际上,函数中使用的是这个subobject的拷贝)。

      所有继承Base类的派生类的对象都含有一个Base型的subobject(这是能用Base型指针指向一个Derived1对象的关键所在,自然也是多态的关键了),而所有的subobject和所有Base型的对象都共用同一个s_object对象,自然,从Base类派生的整个继承体系中的类的实例都会共用同一个s_object对象了。上面提到的exampleexample1example2的对象布局如下图所示:

    3.1.2 三种内存对象的比较

      栈对象的优势是在适当的时候自动生成,又在适当的时候自动销毁,不需要程序员操心;而且栈对象的创建速度一般较堆对象快,因为分配堆对象时,会调用operator new操作,operator new会采用某种内存空间搜索算法,而该搜索过程可能是很费时间的,产生栈对象则没有这么麻烦,它仅仅需要移动栈顶指针就可以了。但是要注意的是,通常栈空间容量比较小,一般是1MB2MB,所以体积比较大的对象不适合在栈中分配。特别要注意递归函数中最好不要使用栈对象,因为随着递归调用深度的增加,所需的栈空间也会线性增加,当所需栈空间不够时,便会导致栈溢出,这样就会产生运行时错误。

      堆对象,其产生时刻和销毁时刻都要程序员精确定义,也就是说,程序员对堆对象的生命具有完全的控制权。我们常常需要这样的对象,比如,我们需要创建一个对象,能够被多个函数所访问,但是又不想使其成为全局的,那么这个时候创建一个堆对象无疑是良好的选择,然后在各个函数之间传递这个堆对象的指针,便可以实现对该对象的共享。另外,相比于栈空间,堆的容量要大得多。实际上,当物理内存不够时,如果这时还需要生成新的堆对象,通常不会产生运行时错误,而是系统会使用虚拟内存来扩展实际的物理内存。

    接下来看看static对象。

      首先是全局对象。全局对象为类间通信和函数间通信提供了一种最简单的方式,虽然这种方式并不优雅。一般而言,在完全的面向对象语言中,是不存在全局对象的,比如C#,因为全局对象意味着不安全和高耦合,在程序中过多地使用全局对象将大大降低程序的健壮性、稳定性、可维护性和可复用性。C++也完全可以剔除全局对象,但是最终没有,我想原因之一是为了兼容C

      其次是类的静态成员,上面已经提到,基类及其派生类的所有对象都共享这个静态成员对象,所以当需要在这些class之间或这些class objects之间进行数据共享或通信时,这样的静态成员无疑是很好的选择。

      接着是静态局部对象,主要可用于保存该对象所在函数被屡次调用期间的中间状态,其中一个最显著的例子就是递归函数,我们都知道递归函数是自己调用自己的函数,如果在递归函数中定义一个nonstatic局部对象,那么当递归次数相当大时,所产生的开销也是巨大的。这是因为nonstatic局部对象是栈对象,每递归调用一次,就会产生一个这样的对象,每返回一次,就会释放这个对象,而且,这样的对象只局限于当前调用层,对于更深入的嵌套层和更浅露的外层,都是不可见的。每个层都有自己的局部对象和参数。

      在递归函数设计中,可以使用static对象替代nonstatic局部对象(即栈对象),这不仅可以减少每次递归调用和返回时产生和释放nonstatic对象的开销,而且static对象还可以保存递归调用的中间状态,并且可为各个调用层所访问。

    3.1.3 使用栈对象的意外收获

      前面已经介绍到,栈对象是在适当的时候创建,然后在适当的时候自动释放的,也就是栈对象有自动管理功能。那么栈对象会在什么会自动释放了?第一,在其生命期结束的时候;第二,在其所在的函数发生异常的时候。你也许说,这些都很正常啊,没什么大不了的。是的,没什么大不了的。但是只要我们再深入一点点,也许就有意外的收获了。

      栈对象,自动释放时,会调用它自己的析构函数。如果我们在栈对象中封装资源,而且在栈对象的析构函数中执行释放资源的动作,那么就会使资源泄漏的概率大大降低,因为栈对象可以自动的释放资源,即使在所在函数发生异常的时候。实际的过程是这样的:函数抛出异常时,会发生所谓的stack_unwinding(堆栈回滚),即堆栈会展开,由于是栈对象,自然存在于栈中,所以在堆栈回滚的过程中,栈对象的析构函数会被执行,从而释放其所封装的资源。除非,除非在析构函数执行的过程中再次抛出异常――而这种可能性是很小的,所以用栈对象封装资源是比较安全的。基于此认识,我们就可以创建一个自己的句柄或代理来封装资源了。智能指针(auto_ptr)中就使用了这种技术。在有这种需要的时候,我们就希望我们的资源封装类只能在栈中创建,也就是要限制在堆中创建该资源封装类的实例。

    3.1.4 禁止产生堆对象

      上面已经提到,你决定禁止产生某种类型的堆对象,这时你可以自己创建一个资源封装类,该类对象只能在栈中产生,这样就能在异常的情况下自动释放封装的资源。

      那么怎样禁止产生堆对象了?我们已经知道,产生堆对象的唯一方法是使用new操作,如果我们禁止使用new不就行了么。再进一步,new操作执行时会调用operator new,而operator new是可以重载的。方法有了,就是使new operator private,为了对称,最好将operator delete也重载为private。现在,你也许又有疑问了,难道创建栈对象不需要调用new吗?是的,不需要,因为创建栈对象不需要搜索内存,而是直接调整堆栈指针,将对象压栈,而operator new的主要任务是搜索合适的堆内存,为堆对象分配空间,这在上面已经提到过了。好,让我们看看下面的示例代码:

    #include <stdlib.h> //需要用到C式内存分配函数

    class Resource ; //代表需要被封装的资源类

    class NoHashObject

    {

     private:

      Resource* ptr ;//指向被封装的资源

      ... ... //其它数据成员

      void* operator new(size_t size) //非严格实现,仅作示意之用

      {

       return malloc(size) ;

      }

      void operator delete(void* pp) //非严格实现,仅作示意之用

      {

       free(pp) ;

      }

     public:

      NoHashObject()

      {

       //此处可以获得需要封装的资源,并让ptr指针指向该资源

       ptr = new Resource() ;

      }

      ~NoHashObject()

      {

       delete ptr ; //释放封装的资源

      }

    }; 

      NoHashObject现在就是一个禁止堆对象的类了,如果你写下如下代码:

    NoHashObject* fp = new NoHashObject() ; //编译期错误!

    delete fp ; 

    上面代码会产生编译期错误。好了,现在你已经知道了如何设计一个禁止堆对象的类了,你也许和我一样有这样的疑问,难道在类NoHashObject的定义不能改变的情况下,就一定不能产生该类型的堆对象了吗?不,还是有办法的,我称之为“暴力破解法”。C++是如此地强大,强大到你可以用它做你想做的任何事情。这里主要用到的是技巧是指针类型的强制转换。

    void main(void)

    {

     char* temp = new char[sizeof(NoHashObject)] ;

     //强制类型转换,现在ptr是一个指向NoHashObject对象的指针

     NoHashObject* obj_ptr = (NoHashObject*)temp ;

     temp = NULL ; //防止通过temp指针修改NoHashObject对象

     //再一次强制类型转换,让rp指针指向堆中NoHashObject对象的ptr成员

     Resource* rp = (Resource*)obj_ptr ;

     //初始化obj_ptr指向的NoHashObject对象的ptr成员

     rp = new Resource() ;

     //现在可以通过使用obj_ptr指针使用堆中的NoHashObject对象成员了

     ... ...

     delete rp ;//释放资源

     temp = (char*)obj_ptr ;

     obj_ptr = NULL ;//防止悬挂指针产生

     delete [] temp ;//释放NoHashObject对象所占的堆空间。

      上面的实现是麻烦的,而且这种实现方式几乎不会在实践中使用,但是我还是写出来路,因为理解它,对于我们理解C++内存对象是有好处的。对于上面的这么多强制类型转换,其最根本的是什么了?我们可以这样理解:

      某块内存中的数据是不变的,而类型就是我们戴上的眼镜,当我们戴上一种眼镜后,我们就会用对应的类型来解释内存中的数据,这样不同的解释就得到了不同的信息。

      所谓强制类型转换实际上就是换上另一副眼镜后再来看同样的那块内存数据。

      另外要提醒的是,不同的编译器对对象的成员数据的布局安排可能是不一样的,比如,大多数编译器将NoHashObjectptr指针成员安排在对象空间的头4个字节,这样才会保证下面这条语句的转换动作像我们预期的那样执行:

    Resource* rp = (Resource*)obj_ptr ; 

      但是,并不一定所有的编译器都是如此。

      既然我们可以禁止产生某种类型的堆对象,那么可以设计一个类,使之不能产生栈对象吗?当然可以。

    3.1.5 禁止产生栈对象

      前面已经提到了,创建栈对象时会移动栈顶指针以“挪出”适当大小的空间,然后在这个空间上直接调用对应的构造函数以形成一个栈对象,而当函数返回时,会调用其析构函数释放这个对象,然后再调整栈顶指针收回那块栈内存。在这个过程中是不需要operator new/delete操作的,所以将operator new/delete设置为private不能达到目的。当然从上面的叙述中,你也许已经想到了:将构造函数或析构函数设为私有的,这样系统就不能调用构造/析构函数了,当然就不能在栈中生成对象了。

      这样的确可以,而且我也打算采用这种方案。但是在此之前,有一点需要考虑清楚,那就是,如果我们将构造函数设置为私有,那么我们也就不能用new来直接产生堆对象了,因为new在为对象分配空间后也会调用它的构造函数啊。所以,我打算只将析构函数设置为private。再进一步,将析构函数设为private除了会限制栈对象生成外,还有其它影响吗?是的,这还会限制继承。

      如果一个类不打算作为基类,通常采用的方案就是将其析构函数声明为private

      为了限制栈对象,却不限制继承,我们可以将析构函数声明为protected,这样就两全其美了。如下代码所示:

    class NoStackObject

    {

     protected:

      ~NoStackObject() { }

     public:

      void destroy()

      {

       delete this ;//调用保护析构函数

      }

    }; 

      接着,可以像这样使用NoStackObject类:

    NoStackObject* hash_ptr = new NoStackObject() ;

    ... ... //hash_ptr指向的对象进行操作

    hash_ptr->destroy() ; 

      呵呵,是不是觉得有点怪怪的,我们用new创建一个对象,却不是用delete去删除它,而是要用destroy方法。很显然,用户是不习惯这种怪异的使用方式的。所以,我决定将构造函数也设为privateprotected。这又回到了上面曾试图避免的问题,即不用new,那么该用什么方式来生成一个对象了?我们可以用间接的办法完成,即让这个类提供一个static成员函数专门用于产生该类型的堆对象。(设计模式中的singleton模式就可以用这种方式实现。)让我们来看看:

    class NoStackObject

    {

     protected:

      NoStackObject() { }

      ~NoStackObject() { }

     public:

      static NoStackObject* creatInstance()

      {

       return new NoStackObject() ;//调用保护的构造函数

      }

      void destroy()

      {

       delete this ;//调用保护的析构函数

      }

    };

      现在可以这样使用NoStackObject类了:

    NoStackObject* hash_ptr = NoStackObject::creatInstance() ;

    ... ... //hash_ptr指向的对象进行操作

    hash_ptr->destroy() ;

    hash_ptr = NULL ; //防止使用悬挂指针 

    现在感觉是不是好多了,生成对象和释放对象的操作一致了。

    3.2 浅议C++ 中的垃圾回收方法

      许多 C 或者 C++ 程序员对垃圾回收嗤之以鼻,认为垃圾回收肯定比自己来管理动态内存要低效,而且在回收的时候一定会让程序停顿在那里,而如果自己控制内存管理的话,分配和释放时间都是稳定的,不会导致程序停顿。最后,很多 C/C++ 程序员坚信在C/C++ 中无法实现垃圾回收机制。这些错误的观点都是由于不了解垃圾回收的算法而臆想出来的。

      其实垃圾回收机制并不慢,甚至比动态内存分配更高效。因为我们可以只分配不释放,那么分配内存的时候只需要从堆上一直的获得新的内存,移动堆顶的指针就够了;而释放的过程被省略了,自然也加快了速度。现代的垃圾回收算法已经发展了很多,增量收集算法已经可以让垃圾回收过程分段进行,避免打断程序的运行了。而传统的动态内存管理的算法同样有在适当的时间收集内存碎片的工作要做,并不比垃圾回收更有优势。

      而垃圾回收的算法的基础通常基于扫描并标记当前可能被使用的所有内存块,从已经被分配的所有内存中把未标记的内存回收来做的。C/C++ 中无法实现垃圾回收的观点通常基于无法正确扫描出所有可能还会被使用的内存块,但是,看似不可能的事情实际上实现起来却并不复杂。首先,通过扫描内存的数据,指向堆上动态分配出来内存的指针是很容易被识别出来的,如果有识别错误,也只能是把一些不是指针的数据当成指针,而不会把指针当成非指针数据。这样,回收垃圾的过程只会漏回收掉而不会错误的把不应该回收的内存清理。其次,如果回溯所有内存块被引用的根,只可能存在于全局变量和当前的栈内,而全局变量(包括函数内的静态变量)都是集中存在于 bss 段或 data段中。

      垃圾回收的时候,只需要扫描 bss , data 段以及当前被使用着的栈空间,找到可能是动态内存指针的量,把引用到的内存递归扫描就可以得到当前正在使用的所有动态内存了。

      如果肯为你的工程实现一个不错的垃圾回收器,提高内存管理的速度,甚至减少总的内存消耗都是可能的。

    展开全文
  • 操作系统-内存管理

    万次阅读 多人点赞 2019-06-06 09:20:53
    文章目录一、内存管理1.1 内存的基础知识1.1.1 什么是内存,有何作用1.1.2 进程运行的原理-指令1.1.3 逻辑地址VS物理地址1.1.4 进程运行的基本原理(从写程序到程序运行)1.1.5 装入内存的三种方式1.1.5 链接的三种...

    文章目录

    一、内存管理

    1.1 内存的基础知识

    1.1.1 什么是内存,有何作用

    在这里插入图片描述

    1.1.2 进程运行的原理-指令

    在这里插入图片描述
    可见,我们写的代码要翻译成CPU能识别的指令。这些指令会告诉CPU应该去内存的哪个地址存/取数据,这个数据应该做什么样的处理。在这个例子中,指令中直接给出了变量x的实际存放地址(物理地址)。但实际在生成机器指令的时候并不知道该进程的数据会被放到什么位置。所以编译生成的指令中一般是使用逻辑地址(相对地址)

    1.1.3 逻辑地址VS物理地址

    Eg:编译时只需确定变量x存放的相对地址是100 ( 也就是说相对于进程在内存中的起始地址而言的地址)。CPU想要找到x在内存中的实际存放位置,只需要用进程的起始地址+100即可。
    相对地址又称逻辑地址,绝对地址又称物理地址。

    1.1.4 进程运行的基本原理(从写程序到程序运行)

    在这里插入图片描述
    编译:由编译程序将用户源代码编译成若千个目标模块(编译就是把高级语言翻译为机器语言)

    链接:由链接程序将编译后形成的一组目标模块,以及所需库函数链接在一起,形成- -个完整的装入模块装入(装载) :由装入程序将装入模块装入内存运行
    在这里插入图片描述
    在这里插入图片描述

    1.1.5 装入内存的三种方式

    1. 绝对装入:在编译时,如果知道程序将放到内存中的哪个位置,编译程序将产生绝对地址的目标代码。装入程序按照装入模块中的地址,将程序和数据装入内存。
      绝对装入由于逻辑与实际内存地址相同,故不需要对程序和数据的地址进行修改。绝对装入只适用于单道程序环境,绝对地址可由程序员在编译或者汇编时赋予。
      在这里插入图片描述
    2. 可重定位装入:在多道程序环境下,多个目标模块的起始地址通常都是从0开始,程序中的其他地址都是相对于起始地址的,此时采用可重定位装入方式,根据内存的目前情况,将装入模块装入到内存的适当位置。装入时对目标程序中指令和数据的修改称为重定位,地址变换通常是在装入时一次完成的。所以又称静态重定位。
      静态重定位的特点是在一个作业装入内存时,必须分配其要求的全部内存空间,如果没有足够的内存,就不能装入该作业,此外,作业一旦进入内存后,在整个运行期间不能在内存中移动。
      在这里插入图片描述
    3. 动态运行时装入:也称为动态重定位,程序在内存中如果发生移动,就需要采用动态的装入方式。编译、链接后的装入模块的地址都是从0开始的。装入程序在把装入模块装入内存后,并不立即把装入模块中的相对地址转换成绝对地址,而是把这种地址转换推迟到程序真正要执行时才进行。因此,装入内存后的所有地址均为相对地址。这种方式需要一个重定位寄存器的支持
      动态重定位的特点是可以将程序分配到不连续的存储区中,在程序运行之前可以只装入它的部分代码即可投入运行,然后在程序运行期间,根据需要动态申请分配的内存,便于程序段的共享,可以向用户提供一个比存储空间大得多的地址空间。
      在这里插入图片描述
      在这里插入图片描述

    1.1.5 链接的三种方式

    1. 静态链接:在程序运行之前,先将各目标模块及它们所需的库函数连接成-一个完整的可执行文件(装入模块),之后不再拆开。
      在这里插入图片描述
      2.装入时动态链接:将用户源程序编译后所得到的一组目标模块,将各目标模块装入内存时,边装入边链接的链接方式。
      在这里插入图片描述
      3.运行时动态链接:对某些目标模块的链接,是在程序执行中需要该目标模块时,才对它进行的链接,其优点是便于修改和更新,便于实现对目标模块的共享。
      在这里插入图片描述

    1.1.6 总结

    在这里插入图片描述

    1.2 内存管理的概念

    操作系统作为系统资源的管理者,当然也需要对内存进行管理,要管些什么呢?

    1. 操作系统负责内存空间的分配与回收。
    2. 操作系统需要提供某种技术从逻辑.上对内存空间进行扩充。
    3. 操作系统需要提供地址转换功能,负责程序的逻辑地址与物理地址的转换
    4. 操作系统需要提供内存保护功能。保证各进程在各自存储空间内运行,互不干扰

    1.2.1 内存空间的分配与回收

    内存空间的分配与回收:由操作系统完成主存储器空间的分配和管理,使程序员摆脱存储分配麻烦,提高编程效率。
    在这里插入图片描述

    1.2.2 内存空间的扩展

    内存空间的扩充:利用虚拟存储技术或者自动覆盖技术,从逻辑上扩充内存。

    游戏GTA的大小超过60GB,按理来说这个游戏程序运行之前需要把60GB数据全部放入内存。然而,实际我的电脑内存才4GB, 但为什么这个游戏可以顺利运行呢?
    –虚拟技术(操作系统的虚拟性)

    1.2.3 地址转换

    地址转换:在多道程序环境下,程序中的逻辑地址与内存中的物理地址不可能一致,因此存储管理器必须提供地址变换功能,把逻辑地址转换成相应的物理地址。

    为了使编程更方便,程序员写程序时应该只需要关注指令、数据的逻辑地址。而逻辑地址到物理地址的转换(这个过程称为地址重定位(三种装入方式))应该由操作系统负责,这样就保证了程序员写程序时不需要关注物理内存的实际情况。
    在这里插入图片描述

    1.2.4 内存保护

    存储保护:保证各道作业在各自的存储空间内运行,互不干扰。

    两种方式:

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

    2.通过采用重定位寄存器(或基址寄存器)和界地址寄存器(又称限长寄存器)来实现这种保护,重定位寄存器含最小的物理地址值,界地址寄存器含逻辑地址的最大值,每个逻辑地址值必须小于界地址寄存器,内存管理机构动态的将逻辑地址与界地址寄存器进行比较,如果未发生地址越界。则加上重定位寄存器的值后映射成物理地址,再送交内存单元。
    在这里插入图片描述
    在这里插入图片描述

    1.2.5 总结

    在这里插入图片描述

    1.3 内存的覆盖与交换

    1.3.1 覆盖技术

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

    覆盖技术的特点:是打破了必须将一个进程的全部信息装入内存后才能运行的限制,但当同时运行程序的代码量大于主存时仍不能运行,再而,大家要注意到,内存中能够更新的地方只有覆盖区的段,不在覆盖区的段会常驻内存。

    早期的计算机内存很小,比如IBM推出的第一台PC机最大只支持1MB大小的内存。因此经常会出现内存大小不够的情况。后来人们引入了覆盖技术,用来解决“程序大小超过物理内存总和”的问题
    覆盖技术的思想:将程序分为多个段(多个模块)。常用的段常驻内存,不常用的段在需要时调入内存。
    内存中分为一个“固定区”和若干个“覆盖区”
    需要常驻内存的段放在“固定区”中,调入后就不再调出(除非运行结束)
    不常用的段放在“覆盖区”,需要用到时调入内存,用不到时调出内存
    在这里插入图片描述
    必须由程序员声明覆盖结构,操作系统完成自动覆盖。缺点:对用户不透明,增加了用户编程负担。覆盖技术只用于早期的操作系统中,现在已成为历史。

    1.3.2 交换技术

    交换(对换)技术的设计思想:内存空间紧张时,系统将内存中某些进程暂时换出外存,把外存中某些已具备运行条件的进程换入内存(进程在内存与磁盘间动态调度)

    换入:把准备好竞争CPU运行的程序从辅存移到内存。
    换出:把处于等待状态(或CPU调度原则下被剥夺运行权力)的程序从内存移到辅存,把内存空间腾出来。
    在这里插入图片描述
    暂时换出外存等待的进程状态为挂起状态(挂起态,suspend),挂起态又可以进–步细分为就绪挂起阻塞挂起两种状态。
    在这里插入图片描述

    1. 应该在外存(磁盘)的什么位置保存被换出的进程?
      答:具有对换功能的操作系统中,通常把磁盘空间分为文件区对换区两部分。文件区主要用于存放文件,主要追求存储空间的利用率,因此对文件区空间的管理采用离散分配方式;对换区空间只占磁盘空间的小部分,被换出的进程数据就存放在对换区。由于对换的速度直接影响到系统的整体速度,因此对换区空间的管理主要追求换入换出速度,因此通常对换区采用连续分配方式(学过文件管理章节后即可理解)。总之,对换区的I/O速度比文件区的更快

    2. 什么时候应该交换?
      答:交换通常在许多进程运行且内存吃紧时进行,而系统负荷降低就暂停。例如:在发现许多进程运行时经常发生缺页,就说明内存紧张,此时可以换出一些进程;如果缺页率明显下降,就可以暂停换出。

    3. 应该换出哪些进程?
      答:可优先换出阻塞进程;可换出优先级低的进程;为了防止优先级低的进程在被调入内存后很快又被换出,有的系统还会考虑进程在内存的驻留时间…
      (注意: PCB 会常驻内存,不会被换出外存)

    注意:

    1. 交换需要备份存储,通常是快速磁盘,它必须足够大,并且提供对这些内存映像的直接访问。
    2. 为了有效使用CPU,需要每个进程的执行时间比交换时间长,而影响交换时间的主要是转移时间,转移时间与所交换的空间内存成正比。
    3. 如果换出进程,比如确保该进程的内存空间成正比。
    4. 交换空间通常作为磁盘的一整块,且独立于文件系统,因此使用就可能很快。
    5. 交换通常在有许多进程运行且内存空间吃紧时开始启动,而系统负荷降低就暂停。
    6. 普通交换使用不多,但交换的策略的某些变种在许多系统中(如UNIX系统)仍然发挥作用。

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

    1.3.3 总结

    在这里插入图片描述

    1.4 连续分配管理方式

    1.4.1 单一连续分配

    内存在此方式下分为系统区用户区
    系统区仅提供给操作系统使用,通常在低地址部分;
    用户区是为用户提供的,除系统区之外的内存空间,我们平常运行的软件都在用户区里分配空间。

    优点:无外部碎片,可以采用覆盖技术,不需要额外技术支持。
    缺点:只能用于单用户,单任务操作系统中,有内部碎片,存储器利用率极低。

    在这里插入图片描述

    1.4.2 固定分区分配

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

    1. 分区大小相等:用于利用一台计算机控制多个相同对象的场合,缺乏灵活性
    2. 分区大小不等:划分为含有多个较小的分区,适量的中等分区及少量的大分区。
      优点:1.没有外部碎片
      缺点:1.程序可能太大而放不进任何一个分区中,这时用户不得不使用覆盖技术来使用内存空间。
    3. 主存利用率低,当程序小于固定分区大小时,也占用一个完整的内存分区空间,这样分区内部有空间浪费,这种现象称为内部碎片。
    4. 不能多个进程共享一个主存区

    1.4.3 动态分区分配

    动态分区分配又称为可变分区分配,是一种动态划分内存的分区方法。这种分配方式不会预先划分内存分区,而是在进程装入内存时, 根据进程的大小动态地建立分区,并使分区的大小正好适合进程的需要。因此系统分区的大小和数目是可变的。(eg: 假设某计算机内存大小为64MB, 系统区8MB,用户区共56 M…)
    !在这里插入图片描述
    缺点:外部随便很多(通过紧凑消除,就是操作系统不时地对进程进行移动和整理。但是这需要动态重定位寄存器地支持,且相对费时。紧凑地过程实际上类似于Windows系统中地磁盘整理程序,只不过后者是对外存空间地紧凑)

    1.系统要用什么样的数据结构记录内存的使用情况?
    在这里插入图片描述
    2.当很多个空闲分区都能满足需求时,应该选择哪个分区进行分配?

    在这里插入图片描述
    3.如何进行分区的分配与回收操作? 假设系统采用的数据结构是“空闲分区表”如何分配?
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述
    动态分区分配又称为可变分区分配。这种分配方式不会预先划分内存分区,而是在进程装入内存时,根据进程的大小动态地建立分区,并使分区的大小正好适合进程的需要。因此系统分区的大小和数目是可变的。
    在这里插入图片描述

    1.4.4 总结

    在这里插入图片描述

    1.5 动态分区分配算法

    1.首次适应算法
    2.最佳适应算法
    3.最坏适应算法
    4.邻近适应

    1.5.1 首次适应算法

    算法思想:每次都从低地址开始查找,找到第–个能满足大小的空闲分区。

    如何实现:空闲分区以地址递增的次序排列。每次分配内存时顺序查找空闲分区链( 或空闲分[表),找到大小能满足要求的第-一个空闲分区。
    在这里插入图片描述

    1.5.2 最佳适应算法

    算法思想:由于动态分区分配是一种连续分配方式,为各进程分配的空间必须是连续的一整片区域。因此为了保证当“大进程”到来时能有连续的大片空间,可以尽可能多地留下大片的空闲区,即,优先使用更小的空闲区。

    如何实现:空闲分区按容量递增次序链接。每次分配内存时顺序查找空闲分区链(或空闲分区表),找到大小能满足要求的第-一个空闲分区。
    在这里插入图片描述

    1.5.3 最坏适应算法

    又称最大适应算法(Largest Fit)

    算法思想:为了解决最佳适应算法的问题—即留下太多难以利用的小碎片,可以在每次分配时优先使用最大的连续空闲区,这样分配后剩余的空闲区就不会太小,更方便使用。

    如何实现:空闲分区按容量递减次序链接。每次分配内存时顺序查找空闲分区链(或空闲分区表),找到大小能满足要求的第-一个空闲分区。
    在这里插入图片描述

    1.5.4 邻近适应算法

    算法思想:首次适应算法每次都从链头开始查找的。这可能会导致低地址部分出现很多小的空闲分区,而每次分配查找时,都要经过这些分区,因此也增加了查找的开销。如果每次都从上次查找结束的位置开始检索,就能解决上述问题。

    如何实现:空闲分区以地址递增的顺序排列(可排成-一个循环链表)。每次分配内存时从上次查找结束的位置开始查找空闲分区链(或空闲分区表),找到大小能满足要求的第一个空闲分区。
    在这里插入图片描述

    1.5.5 总结

    首次适应不仅最简单,通常也是最好最快,不过首次适应算法会使得内存低地址部分出现很多小的空闲分区,而每次查找都要经过这些分区,因此也增加了查找的开销。邻近算法试图解决这个问题,但实际上,它常常会导致在内存的末尾分配空间分裂成小的碎片,它通常比首次适应算法结果要差。

    最佳导致大量碎片,最坏导致没有大的空间。

    进过实验,首次适应比最佳适应要好,他们都比最坏好。

    算法 算法思想 分区排列顺序 优点 缺点
    首次适应 从头到尾找适合的分区 空闲分区以地址递增次序排列 综合看性能最好。算法开销小,回收分区后一.般不需要对空闲分区队列重新排序
    最佳适应 优先使用更小的分区,以保留更多大分区 空闲分区以容量递增次序排列 会有更多的大分区被保留下来,更能满足大进程需求 会产生很多太小的、难以利用的碎片;算法开销大,回收分区后可能需要对空闲分区队列重新排序
    最坏适应 优先使用更大的分区,以防止产生太小的不可用的碎片 空闲分区以容量递减次序排列 可以减少难以利用的小碎片 大分区容易被用完,不利于大进程;算法开销大(原因同上)
    邻近适应 由首次适应演变而来,每次从上次查找结束位置开始查找 空闲分区以地址递增次序排列(可排列成循环链表) 不用每次都从低地址的小分区开始检索。算法开销小(原因同首次适应算法) 会使高地址的大分区也被用完

    1.6 基本分页存储管理的基本概念

    在前面的几种存储管理方法中,为进程分配的空间是连续的,使用的地址都是物理地址。如果允许将一个进程分散到许多不连续的空间,就可以避免内存紧缩,减少碎片。基于这一思想,通过引入进程的逻辑地址,把进程地址空间与实际存储空间分离,增加存储管理的灵活性。

    1.6.1 连续分区分配方式的缺点

    在这里插入图片描述

    1.6.2 把"固定分区分配"改成"非连续分配版本"

    非连续分配管理

    需要额外地空间存储(分散区域)地索引,使得非连续分配地方式存储密度低于连续存储方式。
    根据分区大小是否固定分为分页存储管理方式和分段存储管理方式。

    根据分配时所采用的基本单位不同,可将离散分配的管理方式分为以下三种:
    页式存储管理、段式存储管理、段页式存储管理。其中段页式存储管理是前两种结合的产物。
    在这里插入图片描述
    根据分区大小是否固定分为分页存储管理方式和分段存储管理方式。

    1.6.3 分页存储管理的基本概念

    在这里插入图片描述
    1.分页存储管理方式(根据是否把所有页面都装入内存分为)

    1.1 基本分页存储管理
    1.2请求分页存储管理

    分页的方法形式上看,像分区相等的固定分区技术,分页管理不会产生外部碎片,但它又又本质不同点:块的大小相对分区要小很多,而且进程页按照块进行划分,进程运行时按块申请主存可用空间执行。每个进程平均只产生半个块大小的内部碎片(也称页内碎片)

    进程中的块称为页,内存中的块称为页框(页帧),外存页以同样的单位进行划分,直接称为块。
    页面过小页表占用空间大,页面过大,碎片大。

    页式管理只需要给出一个整数就能确定对应的物理地址,这是因为页面大小L是固定的,因此,页式管理中地址空间是一维空间。

    1.6.4 如何实现地址的转换

    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述
    如何计算:
    页号=逻辑地址/页面长度(取除法的整数部分)
    页内偏移量=逻辑地址%页面长度(取除法的余数部分)(为了方便计算页号、页内偏移量 重页面大小一般要为2的整数幂)

    假设用32个二进制位表示逻辑地址,页面大小为2^12 B = 4096B = 4KB

    0号页的逻辑地址空间应该是0~4095,用二进制表示应该是:
    0000 0000 0000 0000 0000 0000 0000 0000 ~ 0000 0000 0000 0000 0000 1111 1111 1111

    1号页的逻辑地址空间应该是4096~8191, 用二进制表示应该是:
    0000 0000 0000 0000 0001 0000 0000 0000 ~ 0000 0000 0000 0000 0001 1111 1111 1111

    2号页的逻辑地址空间应该是8192~12287, 用二进制表示应该是:
    0000 0000 0000 0000 0100 0000 0000 0000 ~ 0000 0000 0000 0000 0101 1111 1111 1111

    Eg:逻辑地址2,用二进制表示应该是0000 0000 0000 0000 0000 0000 0000 0010
    若0号页在内存中的起始地址为X,则逻辑地址2对应的物理地址应该是X+0000 0000 0100
    与另一种算法的结果对比:页号=逻辑地址/页面长度,页内偏移量=逻辑地址%页面长度

    Eg:逻辑地址4097,用-二进制表示应该是 0000 0000 0000 0000 0001 0000 0000 0001
    若1号页在内存中的起始地址为X,则逻辑地址4097 对应的物理地址应该是X+0000 0000 0001

    假设用32个二进制位表示逻辑地址,页面大小为2^10 B = 1024B = 1KB

    0号页的逻辑地址空间应该是0~1023,用二进制表示应该是:
    0000 0000 0000 0000 0000 0000 0000 0000 ~ 0000 0000 0000 0000 0000 0011 1111 1111

    1号页的逻辑地址空间应该是1024~2047, 用二进制表示应该是:
    0000 0000 0000 0000 0000 0100 0000 0000 ~ 0000 0000 0000 0000 0000 0111 1111 1111

    2号页的逻辑地址空间应该是2048~3021, 用二进制表示应该是:
    0000 0000 0000 0000 0000 1000 0000 0000 ~ 0000 0000 0000 0000 0000 1011 1100 1101

    结论:如果每个页面大小为2^K B,用二进制数表示逻辑地址,则末尾K位即为页内偏移量,其余部分就是页号
    因此,如果让每个页面的大小为2的整数幂,计算机就可以很方便地得出一个逻辑地址对应的页号和页内偏移量。

    1.6.4 逻辑地址结构

    在这里插入图片描述

    1.6.5 页表

    为了能知道进程的每个页面在内存中存放的位置,操作系统要为每个进程建立–张页表。
    在这里插入图片描述
    为什么每个页表项的长度是相同的,页号是“隐含”的?

    Eg:假设某系统物理内存大小为4GB,页面大小为4KB,则每个页表项至少应该为多少字节?

    4GB= 2^32 B,4KB= 2^12 B
    因此4GB的内存总共会被分为2^32/ 2^12= 220个内存块,因此内存块号的范围应该是0~220 -1,因此至少要20个二进制位才能表示这么多的内存块号,因此至少要3个字节才够
    (每个字节8个二进制位,3个字节共24个二进制位)
    在这里插入图片描述

    1.6.6 总结

    在这里插入图片描述

    1.7 基本地址变换结构

    1.7.1 基本地址变换机构

    基本地址变换机构可以借助进程的页表将逻辑地址转换为物理地址。

    通常会在系统中设置一个页表寄存器(PTR),存放页表在内存中的起始地址F页表长度M。进程未执行时,页表的始址和页表长度放在进程控制块(PCB) 中,当进程被调度时,操作系统内核会把它们放到页表寄存器中。

    注意:页面大小是2的整数幂
    设页面大小为L,逻辑地址A到物理地址E的变换过程如下:
    在这里插入图片描述
    在这里插入图片描述
    例:若页面大小L为1K字节,页号2对应的内存块号b=8,将逻辑地址A=2500转换为物理地址E。
    等价描述:某系统按字节寻址,逻辑地址结构中,页内偏移量占10位(说明一个页面的大小为2^10B = 1KB),页号2对应的内存块号 b=8,将逻辑地址A=2500转换为物理地址E。

    ①计算页号、页内偏移量
    页号P=A/L = 2500/1024 = 2; 页内偏移量W= A%L = 2500%1024 = 452

    ②根据题中条件可知,页号2没有越界,其存放的内存块号b=8

    ③物理地址E=b*L+W=8 * 1024+ 425 = 8644

    在分页存储管理(页式管理)的系统中,只要确定了每个页面的大小,逻辑地址结构就确定了。因此,页式管理中地址是-维的。即,只要给出一个逻辑地址,系统就可以自动地算出页号、页内偏移量两个部分,并不需要显式地告诉系统这个逻辑地址中,页内偏移量占多少位。

    1.7.2 对页表项大小的进一步探讨

    在这里插入图片描述
    在这里插入图片描述

    1.7.3 总结

    在这里插入图片描述

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

    1.8.1 局部性原理

    在这里插入图片描述

    1.8.2 什么是快表

    快表,又称联想寄存器(TLB) ,是一种访问速度比内存快很多的高速缓冲存储器,用来存放当前访问的若干页表项,以加速地址变换的过程。与此对应,内存中的页表常称为慢表
    在这里插入图片描述

    1.8.3 引入快表后,地址的转换过程

    ①CPU给出逻辑地址,由某个硬件算得页号、页内偏移量,将页号与快表中的所有页号进行比较。②如果找到匹配的页号,说明要访问的页表项在快表中有副本,则直接从中取出该页对应的内存块号,再将内存块号与页内偏移量拼接形成物理地址,最后,访问该物理地址对应的内存单元。因此,若快表命中,则访问某个逻辑地址仅需一次访存即可。
    ③如果没有找到匹配的页号,则需要访问内存中的页表,找到对应页表项,得到页面存放的内存块号,再将内存块号与页内偏移量拼接形成物理地址,最后,访问该物理地址对应的内存单元。因此,若快表未命中,则访问某个逻辑地址需要两次访存(注意:在找到页表项后,应同时将其存入快表,以便后面可能的再次访问。但若快表已满,则必须按照-定的算法对旧的页表项进行替换)

    由于查询快表的速度比查询页表的速度快很多,因此只要快表命中,就可以节省很多时间。
    因为局部性原理,–般来说快表的命中率可以达到90%以上。

    例:某系统使用基本分页存储管理,并采用了具有快表的地址变换机构。访问- -次快表耗时1us, 访问一次内存耗时100us。若快表的命中率为90%,那么访问一个逻辑地址的平均耗时是多少?
    (1+100) * 0.9 + (1+100+100) * 0.1 = 111 us
    有的系统支持快表和慢表同时查找,如果是这样,平均耗时应该是(1+100) * 0.9+ (100+100) *0.1=110.9 us
    若未采用快表机制,则访问一个逻辑地址需要100+100 = 200us
    显然,引入快表机制后,访问一个逻辑地址的速度快多了。

    1.8.4 总结

    地址变换过程 访问一个逻辑地址的访存次数
    基本地址变换机构 ①算页号、页内偏移量
    ②检查页号合法性
    ③查页表,找到页面存放的内存块号
    ④根据内存块号与页内偏移量得到物理地址
    ⑤访问目标内存单元
    两次访存
    具有快表的地址变换机构 ①算页号、页内偏移量
    ②检查页号合法性
    查快表。若命中,即可知道页面存放的内存块号,可直接进行⑤;若未命中则进行④
    ④查页表,找到页面存放的内存块号,并且将页表项复制到快表中
    ⑤根据内存块号与页内偏移量得到物理地址
    ⑥访问目标内存单元
    快表命中,只需一次访存
    快表未命中,需要两次访存

    1.9 两级页表

    1.9.1 单级页表的问题

    在这里插入图片描述
    根据局部性原理可知,很多时候,进程在一段时间内只需要访问某几个页面就可以正常运行了,因此没有必要让整个页表都常驻内存。

    1.9.2 如何解决单级页表的问题

    在这里插入图片描述
    在这里插入图片描述

    1.9.3 两级页表的原理、地址结构

    在这里插入图片描述
    在这里插入图片描述

    1.9.4 如何实现地址转换

    在这里插入图片描述

    1.9.5 需要注意的问题

    在这里插入图片描述

    1.9.6 总结

    在这里插入图片描述

    1.10 基本分段存储管理方式

    1.10.1 分段

    进程的地址空间:按照程序自身的逻辑关系划分为若千个段,每个段都有一个段名(在低级语言中,程序员使用段名来编程),每段从0开始编址

    内存分配规则:以段为单位进行分配,每个段在内存中占据连续空间,但各段之间可以不相邻
    在这里插入图片描述
    在这里插入图片描述

    1.10.2 段表

    问题:程序分多个段,各段离散地装入内存,为了保证程序能正常运行,就必须能从物理内存中找到各个逻辑段的存放位置。为此,需为每个进程建立- -张段映射表,简称“段表
    在这里插入图片描述

    1.10.3 地址转换

    在这里插入图片描述
    在这里插入图片描述

    1.10.3 分段、分页管理的对比

    1. 信息的物理单位。分页的主要目的是为了实现离散分配,提高内存利用率。分页仅仅是系统管理.上的需要,完全是系统行为,对用户是不可见的信息的逻辑单位。分段的主要目的是更好地满足用户需求。-一个段通常包含着一-组属于一个逻辑模块的信息。分段对用户是可见的,用户编程时需要显式地给出段名。
    2. 页的大小固定且由系统决定。段的长度却不固定,决定于用户编写的程序
    3. 分段的方法中,每次程序运行时总是把程序全部装入内存,而分页的方法则有所不同。分页的思想是程序运行时用到哪页就为哪页分配内存,没用到的页暂时保留在硬盘上。当用到这些页时再在物理地址空间中为这些页分配内存,然后建立虚拟地址空间中的页和刚分配的物理内存页间的映射。
    4. 分页的用户进程地址空间是一维的,程序员只需给出一个记忆符即可表示一个地址。分段的用户进程地址空间是二维的,程序员在标识一个地址时,既要给出段名,也要给出段内地址。
      在这里插入图片描述
    5. 分段比分页更容易实现信息的共享和保护。不能被修改的代码称为纯代码或可重入代码(不属于临界资源),这样的代码是可以共享的。可修改的代码是不能共享的
      在这里插入图片描述
      在这里插入图片描述
      访问一个逻辑地址需要几次访存?
      分页(单级页表) :第一次访存–查内存中的页表,第二次访存一-访问目标内存单元。总共两次访存
      分段:第一次访存–查内存中的段表,第二次访存–访问目标内存单元。总共两次访存
      与分页系统类似,分段系统中也可以引入快表机构,将近期访问过的段表项放到快表中,这样可以少一.次访问,加快地址变换速度。

    1.10.4 总结

    在这里插入图片描述

    1.11 段页式管理方式

    1.11.1 分页、分段的优缺点分析

    在这里插入图片描述

    1.11.2 分段+分页=段页式管理

    在这里插入图片描述
    将进程按逻辑模块分段,再将各段分页(如每个页面4KB )再将内存空间分为大小相同的内存块/页框/页帧/物理块进程前将各页面分别装入各内存块中

    1.11.3 段页式管理的逻辑地址结构

    在这里插入图片描述

    1.11.4 段表、页表

    在这里插入图片描述
    在这里插入图片描述

    1.11.4 总结

    在这里插入图片描述

    二 虚拟内存

    2.1 虚拟内存的基本概念

    2.1.1 传统存储管理方式的特征、缺点

    在这里插入图片描述
    一次性(可用虛拟存储技术解决问题):作业必须一次性全部装入内存后才能开始运行。这会造成两个问题:①作业很大时,不能全部装入内存,导致大作业无法运行;②当大量作业要求运行时,由于内存无法容纳所有作业,因此只有少量作业能运行,导致多道程序并发度下降
    驻留性:一旦作业被装入内存,就会- 直驻留在内存中,直至作业运行结束。事实上,在一个时间段内,只需要访问作业的一-小部分数据即可正常运行,这就导致了内存中会驻留大量的、暂时用不到的数据,浪费了宝贵的内存资源。

    2.1.2 局部性原理

    时间局部性:如果执行了程序中的某条指令,那么不久后这条指令很有可能再次执行;如果某个数据被访问过,不久之后该数据很可能再次被访问。(因为程序中存在大量的循环)
    空间局部性:一旦程序访问了某个存储单元,在不久之后,其附近的存储单元也很有可能被访问。(因为很多数据在内存中都是连续存放的,并且程序的指令也是顺序地在内存中存放的)
    在这里插入图片描述

    2.1.3 虚拟内存的定义和特征

    基于局部性原理,在程序装入时,可以将程序中很快会用到的部分装入内存,暂时用不到的部分留在外存,就可以让程序开始执行。
    在程序执行过程中,当所访问的信息不在内存时, 由操作系统负责将所需信息从外存调入内存,然后继续执行程序。
    若内存空间不够,由操作系统负责将内存中暂时用不到的信息换出到外存
    在操作系统的管理下,在用户看来似乎有-一个比实际内存大得多的内存,这就是虚拟内存(操作系统虚拟性的一个体现,实际的物理内存大小没有变,只是在逻辑.上进行了扩充)

    易混知识点:
    虚拟内存的最大容量是由计算机的地址结构(CPU寻址范围)确定的
    虚拟内存的实际容量= min (内存和外存容量之和,CPU寻址范围)

    如:某计算机地址结构为32位,按字节编址,内存大小为512MB,外存大小为2GB。
    则虚拟内存的最大容量为2^32 B = 4GB
    虚拟内存的实际容量= min (2^32 B, 512MB+2GB) = 2GB+512MB

    虚拟内存有以下三个主要特征

    多次性:无需在作业运行时一次性全部装入内存,二十运行被分成多次调入内存
    对换性:在作业运行时无需一直常驻内存,而是允许在作业运行过程中,讲作业换出、换入。
    虚拟性:从逻辑上扩充了内存的容量,使用户看到的内存容量,远大于实际的容量

    2.1.4 如何实现虚拟内存技术

    虚拟内存中,允许将一个作业分多次调入内存,采用连续分配方式时,会使相当一部分内存空间都处于暂时或“永久”的空闲状态,造成内存资源的严重浪费,而且也无法从逻辑上扩大内存容量,因此,虚拟内存的实现需要建立在离散分配的内存管理方式基础上。

    传统的非连续分配存储管理:

    1. 基本分页存储管理
    2. 基本分段存储管理
    3. 基本段页式存储管理

    虚拟内存的实现有以下三种方式:

    1.请求分页存储管理
    2.请求分段存储管理
    3.请求段页式存储管理

    请求分页系统建立在基本分页系统的基础上,为了支持虚拟存储器功能而增加了请求调页功能和页面置换功能。请求分页是目前最常用的一种虚拟存储器方法。

    主要区别:

    在程序执行过程中,当所访问的信息不在内存时,由操作系统负责将所需信息从外存调入内存(操作系统要提供请求调页(或请求调段)功能),然后继续执行程序。
    若内存空间不够,由操作系统负责将内存中暂时用不到的信息换出到外存(操作系统要提供页面置换(或段置换)的功能)。

    2.1.5 总结

    在这里插入图片描述

    2.2 请求分页管理方式

    2.2.1 页表机制

    与基本分页管理相比,请求分页管理中,为了实现“请求调页”,操作系统需要知道每个页面是否已经调入内存;如果还没调入,那么也需要知道该页面在外存中存放的位置。

    当内存空间不够时,要实现“页面置换” 操作系统需要通过某些指标来决定到底换出哪个页面;有的页面没有被修改过,就不用再浪费时间写回外存。有的页面修改过,就需要将外存中的旧数据覆盖,因此,操作系统也需要记录各个页面是否被修改的信息。
    在这里插入图片描述
    在基本分页系统基础上增加了四个字段:页号 | 物理块号 | 状态位P | 访问字段A | 修改位M | 外存地址

    (1) 状态位P:用于指示该页是否已调入内存,供程序訪问时參考。
    (2) 访问字段A:用于记录本页在一段时间内被訪问的次数,或记录本页近期已有多长时间未被訪问,供选择换出页面时參考。
    (3) 改动位M:表示该页在调入内存后是否被改动过。因为内存中的每一页都在外存上保留一份副本,因此,若未被改动,在置换该页时就不需再将该页写回到外存上,以降低系统的开销和启动磁盘的次数;若已被改动,则必须将该页重写到外存上,以保证外存中所保留的始终是最新副本 。简言之,M位供置换页面时參考。
    (4) 外存地址:用于指出该页在外存上的地址,一般是物理块号,供调入该页时參考。

    2.2.2 缺页中断机构

    在这里插入图片描述
    在这里插入图片描述
    缺页中断是因为当前执行的指令想要访问的目标页面未调入内存而产生的,因此缺页中断作为中断同样要经历,诸如保护CPU环境、分析中断原因、转入缺页中断处理程序、恢复CPU环境等几个步骤。但与一般的中断相比,它有以下两个明显的区别:

    1. 在指令执行期间产生和处理中断信号,而非一条指令执行完后,属于内中断
      一条指令在执行期间,可能产生多次缺页中断。(如:copyAtoB,即将逻辑地址A中的数据复制到逻辑地址B,而A、B属于不同的页面,则有可能产生两次中断)

    在这里插入图片描述

    2.2.3 地址变换机构

    请求分页存储管理与基本分页存储管理的主要区别:
    在程序执行过程中,当所访问的信息不在内存时,由操作系统负责将所需信息从外存调入内存(操作系统要提供请求调页功能,将缺失页面从外存调入内存),然后继续执行程序。

    若内存空间不够,由操作系统负责将内存中暂时用不到的信息换出到外存(操作系统要提供页面置换的功能,将暂时用不到的页面换出外存)。
    相对于分页系统主要增加了关于状态位P的操作。
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述

    2.2.4 总结

    在这里插入图片描述

    2.3 页面置换算法

    2.3.1 最佳置换法(OPT)

    最佳置换算法(OPT,Optimal) :每次选择淘汰的页面将是以后永不使用,或者在最长时间内不再被访问的页面,这样可以保证最低的缺页率。
    在这里插入图片描述
    最佳置换算法可以保证最低的缺页率,但实际上,只有在进程执行的过程中才能知道接下来会访问到的是哪个页面。操作系统无法提前预判页面访问序列。因此,最佳置换算法是无法实现的

    2.3.2 先进先出置换算法(FIFO)

    先进先出置换算法(FIFO) :每次选择淘汰的页面最早进入内存的页面
    实现方法:把调入内存的页面根据调入的先后顺序排成一个队列,需要换出页面时选择队头页面队列的最大长度取决于系统为进程分配了多少个内存块。
    在这里插入图片描述
    在这里插入图片描述
    Belady异常—当为进程分配的物理块数增大时,缺页次数不减反增的异常现象。

    只有FIFO算法会产生Belady异常,而LRU和OPT算法永远不会出现Belady异常。另外,FIFO算法虽然实现简单,但是该算法与进程实际运行时的规律不适应,因为先进入的页面也有可能最经常被访问。因此,算法性能差

    FIFO的性能较差,因为较早调入的页往往是经常被访问的页,这些页在FIFO算法下被反复调入和调出,并且有Belady现象。所谓Belady现象是指:采用FIFO算法时,如果对—个进程未分配它所要求的全部页面,有时就会出现分配的页面数增多但缺页率反而提高的异常现象。

    2.3.3 最近最久未使用置换算法(LRU)

    最近最久未使用置换算法(LRU,least recently used) :每次淘汰的页面最近最久未使用的页面
    实现方法:赋予每个页面对应的页表项中,用访问字段记录该页面自.上次被访问以来所经历的时间t(该算法的实现需要专门的硬件支持,虽然算法性能好,但是实现困难,开销大)。当需要淘汰一个页面时,选择现有页面中t值最大的,即最近最久未使用的页面。

    LRU性能较好,但需要寄存器和栈的硬件支持。LRU是堆栈类算法,理论上可以证明,堆栈类算法不可能出现Belady异常。
    在这里插入图片描述
    在手动做题时,若需要淘汰页面,可以逆向检查此时在内存中的几个页面号。在逆向扫描过程中最后一个出现的页号就是要淘汰的页面

    2.3.4 时钟置换算法(CLOCK)

    最佳置换算法性OPT能最好,但无法实现;先进先出置换算法实现简单,但算法性能差;最近最久未使用置换算法性能好,是最接近OPT算法性能的,但是实现起来需要专门的硬件支持,算法开销大。

    所以操作系统的设计者尝试了很多算法,试图用比较小的开销接近LRU的性能,这类算法都是CLOCK算法的变体,因为算法要循环扫描缓冲区像时钟一样转动。所以叫clock算法。

    时钟置换算法是一种性能和开销较均衡的算法,又称CLOCK算法,或最近未用算法(NRU,Not Recently Used)

    简单的CLOCK算法实现方法:为每个页面设置一个访问位,再将内存中的页面都通过链接指针链接成一个循环队列。当某页被访问时,其访问位置为1。当需要淘汰-一个页面时,只需检查页的访问位。如果是0,就选择该页换出;如果是1,则将它置为0,暂不换出,继续检查下一个页面,若第- - ~轮扫描中所有页面都是1,则将这些页面的访问位依次置为0后,再进行第二轮扫描(第二轮扫描中一定会有访问位为0的页面,因此简单的CLOCK算法选择–个淘汰页面最多会经过两轮扫描)
    在这里插入图片描述

    2.3.5 改进型的时钟置换算法

    简单的时钟置换算法仅考虑到一个页面最近是否被访问过。事实上,如果被淘汰的页面没有被修改过,就不需要执行I/O操作写回外存。只有被淘汰的页面被修改过时,才需要写回外存

    因此,除了考虑一个页面最近有没有被访问过之外,操作系统还应考虑页面有没有被修改过。在其他条件都相同时,应优先淘汰没有修改过的页面,避免I/O操作。这就是改进型的时钟置换算法的思想。修改位=0,表示页面没有被修改过;修改位=1,表示页面被修改过。

    为方便讨论,用(访问位,修改位)的形式表示各页面状态。如(1, 1)表示一个页面近期被访问过,且被修改过。

    改进型的Clock算法需要综合考虑某一内存页面的访问位和修改位来判断是否置换该页面。在实际编写算法过程中,同样可以用一个等长的整型数组来标识每个内存块的修改状态。访问位A和修改位M可以组成一下四种类型的页面。

    算法规则:将所有可能被置换的页面排成–个循环队列

    第一轮:从当前位置开始扫描到第一个(A =0, M = 0)的帧用于替换。表示该页面最近既未被访问,又未被修改,是最佳淘汰页
    第二轮:若第一轮扫描失败,则重新扫描,查找第一个(A =0, M = 1)的帧用于替换。本轮将所有扫描过的帧访问位设为0。表示该页面最近未被访问,但已被修改,并不是很好的淘汰页。
    第三轮:若第二轮扫描失败,则重新扫描,查找第一个(A =1, M = 0)的帧用于替换。本轮扫描不修改任何标志位。表示该页面最近已被访问,但未被修改,该页有可能再被访问。
    第四轮:若第三轮扫描失败,则重新扫描,查找第一个A =1, M = 1)的帧用于替换。表示该页最近已被访问且被修改,该页可能再被访问。

    由于第二轮已将所有帧的访问位设为0,因此经过第三轮、第四轮扫描一定会有一个帧被选中,因此改进型CLOCK置换算法选择- -个淘汰页面最多会进行四轮扫描

    在这里插入图片描述
    算法规则:将所有可能被置换的页面排成一个循环队列
    第一轮:从当前位置开始扫描到第-一个(0, 0)的帧用于替换。本轮扫描不修改任何标志位。(第一优先级:最近没访问,且没修改的页面)
    第二轮:若第一轮扫描失败,则重新扫描,查找第一个(0, 1)的帧用于替换。本轮将所有扫描过的帧访问位设为0
    (第二优先级: 最近没访问,但修改过的页面)
    第三轮:若第二轮扫描失败,则重新扫描,查找第一个(0, 0)的帧用于替换。本轮扫描不修改任何标志位(第三优先级:最近访问过,但没修改的页面)
    第四轮:若第三轮扫描失败,则重新扫描,查找第一个(0, 1)的帧用于替换。(第四优先级:最近访问过,且修改过的页面)
    由于第二轮已将所有帧的访问位设为0,因此经过第三轮、第四轮扫描一定会有一个帧被选中,因此改进型CLOCK置换算法选择一个淘汰页面最多会进行四轮扫描

    2.3.6 总结

    算法规则 优缺点
    OPT 优先淘汰最长时间内不会被访问的页面 缺页率最小,性能最好;但无法实现
    FIFO 优先淘汰最先进入内存的页面 实现简单;但性能很差,可能出现Belady异常
    LRU 优先淘汰最近最久没访问的页面 性能很好;但需要硬件支持,算法开销大
    CLOCK (NRU) 循环扫描各页面
    第一轮淘汰访问位=0的,并将扫描过的页面访问位改为1。若第-轮没选中,则进行第二轮扫描。
    实现简单,算法开销小;但未考虑页面是否被修改过。
    改进型CLOCK (改进型NRU) 若用(访问位,修改位)的形式表述,则
    第一轮:淘汰(0,0)
    第二轮:淘汰(O,1),并将扫描过的页面访问位都置为0
    第三轮:淘汰(O, 0)
    第四轮:淘汰(0, 1)
    算法开销较小,性能也不错

    2.4 页面分配

    2.4.1 页面分配、置换策略

    在这里插入图片描述
    在这里插入图片描述

    2.4.2 调入页面的时机

    1. 预调页策略:根据局部性原理(主要指空间局部性,即:如果当前访问了某个内存单元,在之后很有可能会接着访问与其相邻的那些内存单元。),一次调入若干个相邻的页面可能比一次调入一个页面更高效。但如果提前调入的页面中大多数都没被访问过,则又是低效的。因此可以预测不久之后可能访问到的页面,将它们预先调入内存,但目前预测 成功率只有50% 左右。故这种策略主要用于进程的首次调入(运行前调入),由程序员指出应该先调入哪些部分。
    2. 请求调页策略:进程在运行期间发现缺页时才将所缺页面调入内存(运行时调入)。由这种策略调入的页面一-定会被访问到,但由于每次只能调入一页,而每次调页都要磁盘I/0操作,因此I/0开销较 大。

    2.4.3 从何时调入页面

    请求分页系统中外存分为两部分:用于存放文件的文件区和用于存放对换页面的对换区。对换区通常是采用连续分配方式,而文件区采用离散分配方式,故对换区的磁盘I/O速度比文件区的更快。

    1.系统拥有足够的对换区空间:页面的调入、调出都是在内存与对换区之间进行,这样可以保证页面的调入、调出速度很快。在进程运行前,需将进程相关的数据从文件区复制到对换区。

    2.系统缺少足够的对换区空间:凡是不会被修改的数据都直接从文件区调入,由于这些页面不会被修改,因此换出时不必写回磁盘,下次需要时再从文件区调入即可。对于可能被修改的部分,换出时需写回磁盘对换区,下次需要时再从对换区调入。

    3.UNIX方式:运行之前进程有关的数据全部放在文件区,故未使用过的页面,都可从文件区调入。若被使用过的页面需要换出,则写回对换区,下次需要时从对换区调入。

    在这里插入图片描述

    2.4.3 抖动(颠簸)现象

    刚刚换出的页面马上又要换入内存,刚刚换入的页面马上又要换出外存,这种频繁的页面调度行为称为抖动,或颠簸。产生抖动的主要原因是进程频繁访问的页面数目高于可用的物理块数(分配给进程的物理块不够)

    为进程分配的物理块太少,会使进程发生抖动现象。为进程分配的物理块太多,又会降低系统整体的并发度,降低某些资源的利用率
    为了研究为应该为每个进程分配多少个物理块,Denning 提出了进程工作集” 的概念

    2.4.4 工作集

    驻留集:指请求分页存储管理中给进程分配的内存块的集合。
    工作集:指在某段时间间隔里,进程实际访问页面的集合。

    原理:让操作系统跟踪每个进程的工作集,并为进程分配大于其工作集的物理块。如果还有空闲物理块,则可以再调一个进程到内存以增加多道程序数。如果所有工作集之和增加以至于超过了可用物理块的总数,那么操作系统会暂停一个进程,将其页面调出并且将其物理块分配给其他进程,防止出现抖动。
    在这里插入图片描述
    工作集大小可能小于窗口尺寸,实际应用中,操作系统可以统计进程的工作集大小,根据工作集大小给进程分配若干内存块。如:窗口尺寸为5,经过一段时间的监测发现某进程的工作集最大为3,那么说明该进程有很好的局部性,可以给这个进程分配3个以上的内存块即可满足进程的运行需要。
    –般来说,驻留集大小不能小于工作集大小,否则进程运行过程中将频繁缺页
    拓展:基于局部性原理可知,进程在–段时间内访问的页面与不久之后会访问的页面是有相关性的。因此,可以根据进程近期访问的页面集合(工作集)来设计- -种页面置换算法- --选择-一个不在工作集中的页面进行淘汰。

    2.4.5 总结

    在这里插入图片描述

    1. 虚拟存储只能基于非连续分配技术。
    2. 虚拟存储器容量既不受外存容量限制,也不受内存容量限制,而是由CPU的寻址范围决定的。
    3. 在请求分页存储器中,由于页面尺寸增大,存放程序需要的页数就会减少,因此缺页中断的次数也会减少。
    4. 进程在执行中发生了缺页中断,经操作系统处理后,应让其执行被中断的那一条指令,缺页中断是访存指令引起的,说明所要访问的页面不在内存中,在进行缺页中断处理后,调入所要访问的页面后,访存指令显然应该重新执行。
    5. 缺页中断调入新页面,肯定要修改页表项和分配页框,同时内存没有页面,需要从外存读入,会发生磁盘I/O。
    6. 虚拟存储技术是基于程序的局部性原理,局部性越好虚拟存储系统越能更好地发挥其作用。
    7. 无论采用什么算法,每种页面第一次访问时不可能在内存中,必然发生缺页,所以缺页次数大于程序所需要的页数。
    8. LRU算法需要对所有的页最近被访问的时间进行记录,查找时间最久的进行替换,这涉及排序,对置换算法而言,开销太大。
    9. 页表项中合法位信息显示着本页面是否在内存中,也即决定了是否会发生页面故障。
    10. 虚拟存储扩充内存的基本方法是将一些页或段从内存中调入,调出,而调入,调出的基本手段是覆盖与交换。
    11. 请求分页存储管理的主要特点是扩充了内存。
    12. 页式虚拟存储管理的特点是不要求将作业同时全部装入到连续区域,一般只装入10%~30%,不要求将作业装入主存连续区域是所有离散式存储管理(包括页式存储管理)的特点,页式虚拟存储管理需要进行中断处理和页面置换。
    13. 快表在计算机系统中是用于地址变换。
    14. 覆盖技术与虚拟存储技术最本质的不同在于覆盖程序段的最大长度要受内存容量大小的限制,而虚拟存储器中的程序最大长度不受内存容量的限制,只受计算机地址结构的限制。另外,覆盖技术中的覆盖段由程序设计,且要求覆盖段中的各个覆盖具有相对独立性,不存在直接联系或相互交叉访问,而虚拟存储技术对用户的程序段之间没有这种要求。
    15. 交换技术与虚存技术相同点是都要在内存与外存之间交换信息,交换技术与虚存中使用的调入/调出技术主要的区别是:交换技术调入/调出整个进程,因此一个进程大小要受内存容量大小的限制,而虚存中使用的调入/调出技术在内存和外存之间来回传递的是页面或分段,而不是整个进程,从而使得进程的地址映射具有了更大的灵活性,且允许进程的大小比可用的内存空间大。
    16. 已知系统为32位实地址,采用48位虚地址,页面大小为4KB,页表项大小为8B,假设系统使用纯页式存储,则采用(4)级页表,页内偏移地址(12)位。
      页面大小为4KB,故页内偏移为12位,系统采用48位虚拟地址,故虚页号48-12=36位。采用多级页表时,最高级页表不能超出一页大小,每页能容纳页表项数位:4KB/8B=512=2^9,36/9=4,故采用4级页表。

    1.9 分段存储管理方式

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

    段内要求连续,段间不要求连续

    在段式系统中,段号和段内偏移量必须由用户显示提供,在高级程序设计语言中,这个工作由编译程序完成。

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

    地址转换机构
    在这里插入图片描述
    为了查询方便,顶级页表最多只能有1个页面

    段页式存储管理方式:

    页式存储管理能有效地提高内存利用率,而分段存储管理能反映程序的逻辑结构并有利于段的共享。

    作业的地址空间首先被分成若干个逻辑段,每段都有自己的段号,然后再将每一段分成若干个大小固定的页。

    逻辑地址有三部分构成:段号,页号,页内偏移量

    段表表项:段号,页表长度,页表起始地址

    页表表项:页号,块号

    段表寄存器:指出段表起始地址和段表长度

    页表寄存器:判断是否越界
    在这里插入图片描述

    重要概念

    1.虚拟内存的管理需要有相关硬件和软件的支持
    有请求分页页表机制,缺页中断机制,地址变换机构等。

    2.在使用交换技术时,如果一个进程正在进行I/O操作时,则不能交换出主存。否则它的I/O数据区将被新换入的进程占用,导致错误,不过可以在操作系统中开辟I/O缓冲区,将数据从外设或将数据输出到外设的I/O活动在系统缓冲区中进行,这时在系统缓冲区与外设I/O时,进程交换不受限制。

    3.当程序要访问某个内存单元时,由硬件检查是否允许,如果允许则执行,否则产生地址越界中断。

    4.段页式存储管理中,地址映射表是每个进程一张页表,每个段一张段表。

    5.内存保护需要由操作系统和硬件机构合作完成,以保证进程空间不被非法访问,内存保护是内存管理的一部分,是操作系统的任务,但是处于安全性和效率考虑,必须由硬件实现,所以需要操作系统和硬件机构的合作来完成。

    6.覆盖技术是早期在单一连续存储管理中使用的扩大存储容量的一种技术,它同样也可用于固定分区分配的存储管理者中。

    7.分页式存储管理有内部碎片,分段式存储管理有外部碎片,固定分区存储管理方式有内部碎片,分页式存储管理方式有内部碎片。

    8.页式存储管理中,页表的始地址存放在寄存器中(页表基址寄存器(PTBR)),这样才能满足在地址变换时,能够较快地完成逻辑地址和物理地址之间的转换。

    9.对重定位存储管理方式,应在这个系统中设置一个重定位寄存器。因为系统处理器在通一个时刻只能执行一条指令或访问数据,所以为每道程序(数据)设置一个寄存器是没有必要的,而只需要在切换程序执行时重置寄存器内容即可。

    10.采用可重用程序是通过减少对换数的方法来改善系统性能的。可重入程序通过共享来使用同一块存储空间,或者通过动态链接的方式将所需程序映射道相关进程中,其最大的优点是减少了对程序的调入/调出,因此减少了对换数量。

    11.实现分页,分段式存储需要特定的数据结构,例如,页表,段表等,为了提高性能还需要提供快存和地址加法器,代价高。分区存储管理满足多道程序设计的最简单存储管理方案,特别适合嵌入式等微型设别。

    12.引入覆盖和交换技术是时间换空间。

    13.页式存储页面大小是等长的。

    14.引入段式存储管理方式,主要是为了满足用户下列要求:方便编程,分段共享,分段保护,动态链接和动态增长

    15.主存分配以块为单位,访问以字节为单位。

    16.存储管理目的:1方便用户2提高内存利用率

    17.分页是一维,分段是二维,因为分页定长。

    18.在多个进程并发执行时,所有进程的页表大多数驻留在内存中,在系统中设置一个页表寄存器(PTR),在其中存放页表在内存的起始地址和页表长度,平时,进程未执行时,页表的起始地址和页表长度存放在本进程的PCB中,当调度到进程时,才将两个数据转入页表寄存器,每个进程都有一个单独的逻辑地址,有一张属于自己的页表。

    19.段页式存储管理兼有页式管理和段式管理的优点,采用分段方法来分配和管理用户地址空间,用分页方法来管理物理存储空间,但它的开销比分段式和页式管理的开销都要大。

    问答

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

    单道处理机系统阶段,一个系统一个时间只执行一个程序,内存分配简单。多道程序引入后,进程之间除了共享的不仅仅是处理机,还有主存储器。共享内存比较复杂,此时不对内存进行管理,容易导致数据混乱,以至于限制进程并发执行。

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

    首先用总的位数m-页内位数n,得到页号的位数x,然后取log2x,然后对结果向上取正,因为页表项以字节为单位。(也可以选择更大的页表项大小以至于让一个页面能够正好容下整数个页表项以方便存储(例如取成4B,那么一页正好可以装下1K个页表项)或者增加点其他什么信息)

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

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

    题目

    1.(2011考研)在虚拟内存管理中,地址变换机构将逻辑地址变换为物理地址,形成该逻辑地址的阶段是(C)

    A.编辑 B.编译 C.链接 D.装载

    编译后的程序需要经过链接才能装载,而链接后形成的目标程序中的地址是逻辑地址。以,C语言为例:C语言经过预处理(cpp)–>编译(ccl)–>汇编(as)–>链接(ld)产生了可执行文件。其链接的前一步,产生了可重定位的二进制的目标文件。C语言采用源文件独立编译的方法,如程序main.c,file.c,file1.h,file2.h,在链接的前一步生成了main.o,file1.o,file2.o,这些目标模块采用的逻辑地址都从0开始,但只是相对该模块的逻辑地址。但只是相对于该模块的逻辑地址,链接器将这三个文件,libc和其他库文件链接成一个可执行文件,链接阶段主要完成重定位,形成整个程序的完整逻辑地址空间。(完成该变换过程的是装载阶段)

    2.(2010考研)

    某计算机采用二级页表的分页存储管理方式,按字节编址,页大小为2^10字节,页表项大小为2字节,逻辑地址结构为

    页目录号|页号|页内偏移量

    逻辑地址空间大小为2^16,则表示整个逻辑地址空间的目录表中包含表项的个数至少是(128)

    展开全文
  • Android内存管理机制详解

    万次阅读 多人点赞 2012-12-13 10:37:33
    这是Linux内存管理的一个优秀特性,在这方面,区别于 Windows的内存管理。主要特点是,无论物理内存有多大,Linux都将其充份利用,将一些程序调用过的硬盘数据读入内存,利用内存读写的高速特性来提高Linux系统的...

    本文主要介绍Android内存相关,最新已整理的Google官方文档《Android内存管理机制官方详解文档》,请各位参阅。

    与windows内存区别

            在Linux中经常发现空闲内存很少,似乎所有的内存都被系统占用了,表面感觉是内存不够用了,其实不然。这是Linux内存管理的一个优秀特性,在这方面,区别于 Windows的内存管理。主要特点是,无论物理内存有多大,Linux都将其充份利用,将一些程序调用过的硬盘数据读入内存,利用内存读写的高速特性来提高Linux系统的数据访问性能。而Windows是只在需要内存时,才为应用程序分配内存,并不能充分利用大容量的内存空间。换句话说,每增加一些物理内存,Linux都将能充分利用起来,发挥了硬件投资带来的好处,而Windows只将其做为摆设,即使增加8GB甚至更大。

    android内存的意义

            其实我们在用安卓手机的时候不用太在意剩余内存,Android上的应用是java,当然需要虚拟机,而android上的应用是带有独立虚拟机的,也就是每开一个应用就会打开一个独立的虚拟机。其实和java的垃圾回收机制类似,系统有一个规则来回收内存。进行内存调度有个阀值,只有低于这个值系统才会按一个列表来关闭用户不需要的东西。当然这个值默认设置得很小,所以你会看到内存老在很少的数值徘徊。但事实上他并不影响速度。相反加快了下次启动应用的速度。这本来就是 android标榜的优势之一,如果人为去关闭进程,没有太大必要。特别是使用自动关进程的软件。为什么内存少的时候运行大型程序会慢呢,原因是:在内存剩余不多时打开大型程序时会触发系统自身的调进程调度策略,这是十分消耗系统资源的操作,特别是在一个程序频繁向系统申请内存的时候。这种情况下系统并不会关闭所有打开的进程,而是选择性关闭,频繁的调度自然会拖慢系统。

     

    进程管理软件

            进程管理软件有无必要呢?有的。就是在运行大型程序之前,你可以手动关闭一些进程释放内存,可以显著的提高运行速度。但一些小程序完全可交由系统自己管理。那么如果不关程序是不是会更耗电。android的应用在被切换到后台时,它其实已经被暂停了,并不会消耗cpu资源只保留了运行状态。所以为什么有的程序切出去重进会到主界面。但是一个程序如果想要在后台处理些东西,如音乐播放,它就会开启一个服务。服务可在后台持续运行,所以在后台耗电的也只有带服务的应用了。我们可以把带服务的进程用进程管理软件关闭就可以了。没有带服务的应用在后台是完全不耗电的没有必要关闭。这种设计本来就是一个非常好的设计,下次启动程序时会更快,因为不需要读取界面资源,何必要关掉他们抹杀这个android的优点呢。

    Android进程种类

    1.       前台进程(foreground)

            目前正在屏幕上显示的进程和一些系统进程。举例来说,Dialer,Storage,Google Search等系统进程就是前台进程;再举例来说,当你运行一个程序,如浏览器,当浏览器界面在前台显示时,浏览器属于前台进程(foreground),但一旦你按home回到主界面,浏览器就变成了后台程序(background)。我们最不希望终止的进程就是前台进程。

    2.       可见进程(visible)

            可见进程是一些不再前台,但用户依然可见的进程,举个例来说:widget、输入法等,都属于visible。这部分进程虽然不在前台,但与我们的使用也密切相关,我们也不希望它们被终止(你肯定不希望时钟、天气,新闻等widget被终止,那它们将无法同步,你也不希望输入法被终止,否则你每次输入时都需要重新启动输入法)

    3.       桌面进程(home app)

            即launcher,保证在多任务切换之后,可以快速返回到home界面而不需重新加载launcher

    4.       次要服务(secondary server)

            目前正在运行的一些服务(主要服务,如拨号等,是不可能被进程管理终止的,故这里只谈次要服务),举例来说:谷歌企业套件,Gmail内部存储,联系人内部存储等。这部分服务虽然属于次要服务,但很一些系统功能依然息息相关,我们时常需要用到它们,所以也太希望他们被终止

    5.       后台进程(hidden)

            即是后台进程(background),就是我们通常意义上理解的启动后被切换到后台的进程,如浏览器,阅读器等。当程序显示在屏幕上时,他所运行的进程即为前台进程(foreground),一旦我们按home返回主界面(注意是按home,不是按back),程序就驻留在后台,成为后台进程(background)。后台进程的管理策略有多种:有较为积极的方式,一旦程序到达后台立即终止,这种方式会提高程序的运行速度,但无法加速程序的再次启动;也有较消极的方式,尽可能多的保留后台程序,虽然可能会影响到单个程序的运行速度,但在再次启动已启动的程序时,速度会有所提升。这里就需要用户根据自己的使用习惯找到一个平衡点

    6.       内容供应节点(content provider)

            没有程序实体,进提供内容供别的程序去用的,比如日历供应节点,邮件供应节点等。在终止进程时,这类程序应该有较高的优先权

    7.       空进程(empty)

            没有任何东西在内运行的进程,有些程序,比如BTE,在程序退出后,依然会在进程中驻留一个空进程,这个进程里没有任何数据在运行,作用往往是提高该程序下次的启动速度或者记录程序的一些历史信息。这部分进程无疑是应该最先终止的。

    幽灵刽子手LMK (Low Memory Killer)

     

    执行条件

            剩余内存小于应用定义的APP_MEM值,开始查看adj值列表,kill相应程序。

    实现机制

    Low Memory Killer的源代码在kernel/drivers/staging/android/lowmemorykiller.c中

    module_init(lowmem_init);
    
    module_exit(lowmem_exit);

        模块加载和退出的函数,主要的功能就是register_shrinker和unregister_shrinker结构体lowmem_shrinker。主要是将函数lowmem_shrink注册到shrinker链表里,在mm_scan调用。

    下面详细的介绍这个函数:

    for (i = 0; i < array_size; i++) {
            if (other_file < lowmem_minfree[i]) {
                min_adj = lowmem_adj[i];
                break;
            }
        }

    other_file, 系统的空闲内存数,根据上面的逻辑判断出,low memory killer需要对adj高于多少(min_adj)的进程进行分析是否释放。

     if (nr_to_scan <= 0 || min_adj == OOM_ADJUST_MAX + 1) {
            lowmem_print(5, "lowmem_shrink %d, %x, return %d\n",
                     nr_to_scan, gfp_mask, rem);
            return rem;
        }

      判断,系统当前的状态是否需要进行low memory killer。

    for_each_process(p) {
            struct mm_struct *mm;
            struct signal_struct *sig;
            int oom_adj;
            task_lock(p);
            mm = p->mm;
            sig = p->signal;
            if (!mm || !sig) {
                task_unlock(p);
                continue;
            }
            oom_adj = sig->oom_adj;
            if (oom_adj < min_adj) {
                task_unlock(p);
                continue;
            }
            tasksize = get_mm_rss(mm);
            task_unlock(p);
            if (tasksize <= 0)
                continue;
            if (selected) {
                if (oom_adj < selected_oom_adj)
                    continue;
                if (oom_adj == selected_oom_adj &&
                    tasksize <= selected_tasksize)
                    continue;
            }
            selected = p;
            selected_tasksize = tasksize;
            selected_oom_adj = oom_adj;
            lowmem_print(2, "select %d (%s), adj %d, size %d, to kill\n",
                     p->pid, p->comm, oom_adj, tasksize);
        }

    对每个sig->oom_adj大于min_adj的进程,找到占用内存最大的进程存放在selected中。

    if (selected) {
            if (fatal_signal_pending(selected)) {
                pr_warning("process %d is suffering a slow death\n",
                       selected->pid);
                read_unlock(&tasklist_lock);
                return rem;
            }
            lowmem_print(1, "send sigkill to %d (%s), adj %d, size %d\n",
                     selected->pid, selected->comm,
                     selected_oom_adj, selected_tasksize);
            force_sig(SIGKILL, selected);
            rem -= selected_tasksize;
        }

    发送SIGKILL信息,杀掉该进程。

            在了解了其机制和原理之后,我们发现它的实现非常简单,与标准的Linux OOM机制类似,只是实现方式稍有不同。标准Linux的OOM Killer机制在mm/oom_kill.c中实现,且会被__alloc_pages_may_oom调用(在分配内存时,即mm/page_alloc.c中)。oom_kill.c最主要的一个函数是out_of_memory,它选择一个bad进程Kill,Kill的方法同样是通过发送SIGKILL信号。在out_of_memory中通过调用select_bad_process来选择一个进程Kill,选择的依据在badness函数中实现,基于多个标准来给每个进程评分,评分最高的被选中并Kill。一般而言,占用内存越多,oom_adj就越大,也就越有可能被选中。

    资源配置

    阈值表可以通过/sys/module/lowmemorykiller/parameters/adj和/sys/module/lowmemorykiller/parameters/minfree进行配置,例如在init.rc中:

    # Write value must be consistent with the above properties.
    
       write /sys/module/lowmemorykiller/parameters/adj 0,1,2,7,14,15
       write /proc/sys/vm/overcommit_memory 1
       write /sys/module/lowmemorykiller/parameters/minfree 1536,2048,4096,5120,5632,6144
    
       class_start default

    进程oom_adj同样可以进行设置,通过write /proc/<PID>/oom_adj ,在init.rc中,init进程的pid为1,omm_adj被配置为-16,永远不会被杀死。

     # Set init its forked children's oom_adj.
    
       write /proc/1/oom_adj -16

         Low memory killer的基本原理我们应该弄清了,正如我前面所说的,进程omm_adj的大小跟进程的类型以及进程被调度的次序有关。进程的类型,可以在ActivityManagerService中清楚的看到:

        static final int EMPTY_APP_ADJ;
        static final int HIDDEN_APP_MAX_ADJ;
        static final int HIDDEN_APP_MIN_ADJ;
        static final int HOME_APP_ADJ;
        static final int BACKUP_APP_ADJ;
        static final int SECONDARY_SERVER_ADJ;
        static final int HEAVY_WEIGHT_APP_ADJ;
        static final int PERCEPTIBLE_APP_ADJ;
        static final int VISIBLE_APP_ADJ;
        static final int FOREGROUND_APP_ADJ;
        static final int CORE_SERVER_ADJ = -12;
        static final int SYSTEM_ADJ = -16;

      ActivityManagerService定义各种进程的oom_adj,CORE_SERVER_ADJ代表一些核心的服务的omm_adj,数值为-12,由前面的分析可知道,这类进程永远也不会被杀死。

    在init.rc中: 

    # Define the oom_adj values for the classes of processes that can be
    # killed by the kernel.  These are used in ActivityManagerService.
        setprop ro.FOREGROUND_APP_ADJ 0
        setprop ro.VISIBLE_APP_ADJ 1
        setprop ro.HOME_APP_ADJ 1
        setprop ro.PERCEPTIBLE_APP_ADJ 2
        setprop ro.HEAVY_WEIGHT_APP_ADJ 3
        setprop ro.SECONDARY_SERVER_ADJ 4
        setprop ro.BACKUP_APP_ADJ 5
        setprop ro.HIDDEN_APP_MIN_ADJ 7
        setprop ro.EMPTY_APP_ADJ 15
    
    # Define the memory thresholds at which the above process classes will
    # be killed.  These numbers are in pages (4k).
        setprop ro.FOREGROUND_APP_MEM 2048
        setprop ro.VISIBLE_APP_MEM 3072
        setprop ro.HOME_APP_MEM 3072
        setprop ro.PERCEPTIBLE_APP_MEM 4096
        setprop ro.HEAVY_WEIGHT_APP_MEM 4096
        setprop ro.SECONDARY_SERVER_MEM 10240
        setprop ro.BACKUP_APP_MEM 10240
        setprop ro.HIDDEN_APP_MEM 10240
        setprop ro.EMPTY_APP_MEM 14336
    
    # Write value must be consistent with the above properties.
    # Note that the driver only supports 6 slots, so we have combined some of
    # the classes into the same memory level; the associated processes of higher
    # classes will still be killed first.
        write /sys/module/lowmemorykiller/parameters/adj 0,1,2,4,7,15
    
        write /proc/sys/vm/overcommit_memory 1
        write /proc/sys/vm/min_free_order_shift 4
      write /sys/module/lowmemorykiller/parameters/minfree 2048,3072,4096,10240,10240,14336
    
        # Set init its forked children's oom_adj.
        write /proc/1/oom_adj -16

    ActivityManagerService.java

    打开程序或者有程序进入后台时都会执行updateOomAdjLocked()函数:

      

        以上就是android内存管理机制的内容了,在一些设备内存比较低的情况下,我们可以对其内存进行优化,从而让我们的设备运行的更加流畅。

    展开全文
  • 20 张图揭开「内存管理」的迷雾,瞬间豁然开朗

    万次阅读 多人点赞 2020-06-30 14:40:44
    每日英语,每天进步一点点 前言 之前有不少读者跟我反馈,能不能写图解操作系统? 既然那么多读者想看,我最近就在疯狂的复习操作系统的知识。 操作系统确实是比较难啃的一门...本篇跟大家说说内存管理内存管理还是
    每日英语,每天进步一点点


    前言

    之前有不少读者跟我反馈,能不能写图解操作系统?

    既然那么多读者想看,我最近就在疯狂的复习操作系统的知识。

    操作系统确实是比较难啃的一门课,至少我认为比计算机网络难太多了,但它的重要性就不用我多说了。

    学操作系统的时候,主要痛苦的地方,有太多的抽象难以理解的词语或概念,非常容易被劝退。

    即使怀着满腔热血的心情开始学操作系统,不过 3 分钟睡意就突然袭来。。。

    该啃的还是得啃的,该图解的还是得图解的,万众期待的「图解操作系统」的系列来了。

    本篇跟大家说说内存管理,内存管理还是比较重要的一个环节,理解了它,至少对整个操作系统的工作会有一个初步的轮廓,这也难怪面试的时候常问内存管理。

    干就完事,本文的提纲:

    本文提纲


    正文

    虚拟内存

    如果你是电子相关专业的,肯定在大学里捣鼓过单片机。

    单片机是没有操作系统的,所以每次写完代码,都需要借助工具把程序烧录进去,这样程序才能跑起来。

    另外,单片机的 CPU 是直接操作内存的「物理地址」

    在这种情况下,要想在内存中同时运行两个程序是不可能的。如果第一个程序在 2000 的位置写入一个新的值,将会擦掉第二个程序存放在相同位置上的所有内容,所以同时运行两个程序是根本行不通的,这两个程序会立刻崩溃。

    操作系统是如何解决这个问题呢?

    这里关键的问题是这两个程序都引用了绝对物理地址,而这正是我们最需要避免的。

    我们可以把进程所使用的地址「隔离」开来,即让操作系统为每个进程分配独立的一套「虚拟地址」,人人都有,大家自己玩自己的地址就行,互不干涉。但是有个前提每个进程都不能访问物理地址,至于虚拟地址最终怎么落到物理内存里,对进程来说是透明的,操作系统已经把这些都安排的明明白白了。

    进程的中间层

    操作系统会提供一种机制,将不同进程的虚拟地址和不同内存的物理地址映射起来。

    如果程序要访问虚拟地址的时候,由操作系统转换成不同的物理地址,这样不同的进程运行的时候,写入的是不同的物理地址,这样就不会冲突了。

    于是,这里就引出了两种地址的概念:

    • 我们程序所使用的内存地址叫做虚拟内存地址Virtual Memory Address
    • 实际存在硬件里面的空间地址叫物理内存地址Physical Memory Address)。

    操作系统引入了虚拟内存,进程持有的虚拟地址会通过 CPU 芯片中的内存管理单元(MMU)的映射关系,来转换变成物理地址,然后再通过物理地址访问内存,如下图所示:

    虚拟地址寻址

    操作系统是如何管理虚拟地址与物理地址之间的关系?

    主要有两种方式,分别是内存分段和内存分页,分段是比较早提出的,我们先来看看内存分段。


    内存分段

    程序是由若干个逻辑分段组成的,如可由代码分段、数据分段、栈段、堆段组成。不同的段是有不同的属性的,所以就用分段(Segmentation)的形式把这些段分离出来。

    分段机制下,虚拟地址和物理地址是如何映射的?

    分段机制下的虚拟地址由两部分组成,段选择子段内偏移量

    内存分段-寻址的方式

    • 段选择子就保存在段寄存器里面。段选择子里面最重要的是段号,用作段表的索引。段表里面保存的是这个段的基地址、段的界限和特权等级等。

    • 虚拟地址中的段内偏移量应该位于 0 和段界限之间,如果段内偏移量是合法的,就将段基地址加上段内偏移量得到物理内存地址。

    在上面,知道了虚拟地址是通过段表与物理地址进行映射的,分段机制会把程序的虚拟地址分成 4 个段,每个段在段表中有一个项,在这一项找到段的基地址,再加上偏移量,于是就能找到物理内存中的地址,如下图:

    内存分段-虚拟地址与物理地址

    如果要访问段 3 中偏移量 500 的虚拟地址,我们可以计算出物理地址为,段 3 基地址 7000 + 偏移量 500 = 7500。

    分段的办法很好,解决了程序本身不需要关心具体的物理内存地址的问题,但它也有一些不足之处:

    • 第一个就是内存碎片的问题。
    • 第二个就是内存交换的效率低的问题。

    接下来,说说为什么会有这两个问题。

    我们先来看看,分段为什么会产生内存碎片的问题?

    我们来看看这样一个例子。假设有 1G 的物理内存,用户执行了多个程序,其中:

    • 游戏占用了 512MB 内存
    • 浏览器占用了 128MB 内存
    • 音乐占用了 256 MB 内存。

    这个时候,如果我们关闭了浏览器,则空闲内存还有 1024 - 512 - 256 = 256MB。

    如果这个 256MB 不是连续的,被分成了两段 128 MB 内存,这就会导致没有空间再打开一个 200MB 的程序。

    内存碎片的问题

    这里的内存碎片的问题共有两处地方:

    • 外部内存碎片,也就是产生了多个不连续的小物理内存,导致新的程序无法被装载;
    • 内部内存碎片,程序所有的内存都被装载到了物理内存,但是这个程序有部分的内存可能并不是很常使用,这也会导致内存的浪费;

    针对上面两种内存碎片的问题,解决的方式会有所不同。

    解决外部内存碎片的问题就是内存交换

    可以把音乐程序占用的那 256MB 内存写到硬盘上,然后再从硬盘上读回来到内存里。不过再读回的时候,我们不能装载回原来的位置,而是紧紧跟着那已经被占用了的 512MB 内存后面。这样就能空缺出连续的 256MB 空间,于是新的 200MB 程序就可以装载进来。

    这个内存交换空间,在 Linux 系统里,也就是我们常看到的 Swap 空间,这块空间是从硬盘划分出来的,用于内存与硬盘的空间交换。

    再来看看,分段为什么会导致内存交换效率低的问题?

    对于多进程的系统来说,用分段的方式,内存碎片是很容易产生的,产生了内存碎片,那不得不重新 Swap 内存区域,这个过程会产生性能瓶颈。

    因为硬盘的访问速度要比内存慢太多了,每一次内存交换,我们都需要把一大段连续的内存数据写到硬盘上。

    所以,如果内存交换的时候,交换的是一个占内存空间很大的程序,这样整个机器都会显得卡顿。

    为了解决内存分段的内存碎片和内存交换效率低的问题,就出现了内存分页。


    内存分页

    分段的好处就是能产生连续的内存空间,但是会出现内存碎片和内存交换的空间太大的问题。

    要解决这些问题,那么就要想出能少出现一些内存碎片的办法。另外,当需要进行内存交换的时候,让需要交换写入或者从磁盘装载的数据更少一点,这样就可以解决问题了。这个办法,也就是内存分页Paging)。

    分页是把整个虚拟和物理内存空间切成一段段固定尺寸的大小。这样一个连续并且尺寸固定的内存空间,我们叫Page)。在 Linux 下,每一页的大小为 4KB

    虚拟地址与物理地址之间通过页表来映射,如下图:

    内存映射

    页表实际上存储在 CPU 的内存管理单元MMU) 中,于是 CPU 就可以直接通过 MMU,找出要实际要访问的物理内存地址。

    而当进程访问的虚拟地址在页表中查不到时,系统会产生一个缺页异常,进入系统内核空间分配物理内存、更新进程页表,最后再返回用户空间,恢复进程的运行。

    分页是怎么解决分段的内存碎片、内存交换效率低的问题?

    由于内存空间都是预先划分好的,也就不会像分段会产生间隙非常小的内存,这正是分段会产生内存碎片的原因。而采用了分页,那么释放的内存都是以页为单位释放的,也就不会产生无法给进程使用的小内存。

    如果内存空间不够,操作系统会把其他正在运行的进程中的「最近没被使用」的内存页面给释放掉,也就是暂时写在硬盘上,称为换出Swap Out)。一旦需要的时候,再加载进来,称为换入Swap In)。所以,一次性写入磁盘的也只有少数的一个页或者几个页,不会花太多时间,内存交换的效率就相对比较高。

    换入换出

    更进一步地,分页的方式使得我们在加载程序的时候,不再需要一次性都把程序加载到物理内存中。我们完全可以在进行虚拟内存和物理内存的页之间的映射之后,并不真的把页加载到物理内存里,而是只有在程序运行中,需要用到对应虚拟内存页里面的指令和数据时,再加载到物理内存里面去。

    分页机制下,虚拟地址和物理地址是如何映射的?

    在分页机制下,虚拟地址分为两部分,页号页内偏移。页号作为页表的索引,页表包含物理页每页所在物理内存的基地址,这个基地址与页内偏移的组合就形成了物理内存地址,见下图。

    内存分页寻址

    总结一下,对于一个内存地址转换,其实就是这样三个步骤:

    • 把虚拟内存地址,切分成页号和偏移量;
    • 根据页号,从页表里面,查询对应的物理页号;
    • 直接拿物理页号,加上前面的偏移量,就得到了物理内存地址。

    下面举个例子,虚拟内存中的页通过页表映射为了物理内存中的页,如下图:

    虚拟页与物理页的映射

    这看起来似乎没什么毛病,但是放到实际中操作系统,这种简单的分页是肯定是会有问题的。

    简单的分页有什么缺陷吗?

    有空间上的缺陷。

    因为操作系统是可以同时运行非常多的进程的,那这不就意味着页表会非常的庞大。

    在 32 位的环境下,虚拟地址空间共有 4GB,假设一个页的大小是 4KB(2^12),那么就需要大约 100 万 (2^20) 个页,每个「页表项」需要 4 个字节大小来存储,那么整个 4GB 空间的映射就需要有 4MB 的内存来存储页表。

    这 4MB 大小的页表,看起来也不是很大。但是要知道每个进程都是有自己的虚拟地址空间的,也就说都有自己的页表。

    那么,100 个进程的话,就需要 400MB 的内存来存储页表,这是非常大的内存了,更别说 64 位的环境了。

    多级页表

    要解决上面的问题,就需要采用的是一种叫作多级页表Multi-Level Page Table)的解决方案。

    在前面我们知道了,对于单页表的实现方式,在 32 位和页大小 4KB 的环境下,一个进程的页表需要装下 100 多万个「页表项」,并且每个页表项是占用 4 字节大小的,于是相当于每个页表需占用 4MB 大小的空间。

    我们把这个 100 多万个「页表项」的单级页表再分页,将页表(一级页表)分为 1024 个页表(二级页表),每个表(二级页表)中包含 1024 个「页表项」,形成二级分页。如下图所示:

    二级分页

    你可能会问,分了二级表,映射 4GB 地址空间就需要 4KB(一级页表)+ 4MB(二级页表)的内存,这样占用空间不是更大了吗?

    当然如果 4GB 的虚拟地址全部都映射到了物理内存上的话,二级分页占用空间确实是更大了,但是,我们往往不会为一个进程分配那么多内存。

    其实我们应该换个角度来看问题,还记得计算机组成原理里面无处不在的局部性原理么?

    每个进程都有 4GB 的虚拟地址空间,而显然对于大多数程序来说,其使用到的空间远未达到 4GB,因为会存在部分对应的页表项都是空的,根本没有分配,对于已分配的页表项,如果存在最近一定时间未访问的页表,在物理内存紧张的情况下,操作系统会将页面换出到硬盘,也就是说不会占用物理内存。

    如果使用了二级分页,一级页表就可以覆盖整个 4GB 虚拟地址空间,但如果某个一级页表的页表项没有被用到,也就不需要创建这个页表项对应的二级页表了,即可以在需要时才创建二级页表。做个简单的计算,假设只有 20% 的一级页表项被用到了,那么页表占用的内存空间就只有 4KB(一级页表) + 20% * 4MB(二级页表)= 0.804MB,这对比单级页表的 4MB 是不是一个巨大的节约?

    那么为什么不分级的页表就做不到这样节约内存呢?我们从页表的性质来看,保存在内存中的页表承担的职责是将虚拟地址翻译成物理地址。假如虚拟地址在页表中找不到对应的页表项,计算机系统就不能工作了。所以页表一定要覆盖全部虚拟地址空间,不分级的页表就需要有 100 多万个页表项来映射,而二级分页则只需要 1024 个页表项(此时一级页表覆盖到了全部虚拟地址空间,二级页表在需要时创建)。

    我们把二级分页再推广到多级页表,就会发现页表占用的内存空间更少了,这一切都要归功于对局部性原理的充分应用。

    对于 64 位的系统,两级分页肯定不够了,就变成了四级目录,分别是:

    • 全局页目录项 PGD(Page Global Directory);
    • 上层页目录项 PUD(Page Upper Directory);
    • 中间页目录项 PMD(Page Middle Directory);
    • 页表项 PTE(Page Table Entry);

    四级目录

    TLB

    多级页表虽然解决了空间上的问题,但是虚拟地址到物理地址的转换就多了几道转换的工序,这显然就降低了这俩地址转换的速度,也就是带来了时间上的开销。

    程序是有局部性的,即在一段时间内,整个程序的执行仅限于程序中的某一部分。相应地,执行所访问的存储空间也局限于某个内存区域。

    程序的局部性

    我们就可以利用这一特性,把最常访问的几个页表项存储到访问速度更快的硬件,于是计算机科学家们,就在 CPU 芯片中,加入了一个专门存放程序最常访问的页表项的 Cache,这个 Cache 就是 TLB(Translation Lookaside Buffer) ,通常称为页表缓存、转址旁路缓存、快表等。

    地址转换

    在 CPU 芯片里面,封装了内存管理单元(Memory Management Unit)芯片,它用来完成地址转换和 TLB 的访问与交互。

    有了 TLB 后,那么 CPU 在寻址时,会先查 TLB,如果没找到,才会继续查常规的页表。

    TLB 的命中率其实是很高的,因为程序最常访问的页就那么几个。


    段页式内存管理

    内存分段和内存分页并不是对立的,它们是可以组合起来在同一个系统中使用的,那么组合起来后,通常称为段页式内存管理

    段页式地址空间

    段页式内存管理实现的方式:

    • 先将程序划分为多个有逻辑意义的段,也就是前面提到的分段机制;
    • 接着再把每个段划分为多个页,也就是对分段划分出来的连续空间,再划分固定大小的页;

    这样,地址结构就由段号、段内页号和页内位移三部分组成。

    用于段页式地址变换的数据结构是每一个程序一张段表,每个段又建立一张页表,段表中的地址是页表的起始地址,而页表中的地址则为某页的物理页号,如图所示:

    段页式管理中的段表、页表与内存的关系

    段页式地址变换中要得到物理地址须经过三次内存访问:

    • 第一次访问段表,得到页表起始地址;
    • 第二次访问页表,得到物理页号;
    • 第三次将物理页号与页内位移组合,得到物理地址。

    可用软、硬件相结合的方法实现段页式地址变换,这样虽然增加了硬件成本和系统开销,但提高了内存的利用率。


    Linux 内存管理

    那么,Linux 操作系统采用了哪种方式来管理内存呢?

    在回答这个问题前,我们得先看看 Intel 处理器的发展历史。

    早期 Intel 的处理器从 80286 开始使用的是段式内存管理。但是很快发现,光有段式内存管理而没有页式内存管理是不够的,这会使它的 X86 系列会失去市场的竞争力。因此,在不久以后的 80386 中就实现了对页式内存管理。也就是说,80386 除了完成并完善从 80286 开始的段式内存管理的同时还实现了页式内存管理。

    但是这个 80386 的页式内存管理设计时,没有绕开段式内存管理,而是建立在段式内存管理的基础上,这就意味着,页式内存管理的作用是在由段式内存管理所映射而成的地址上再加上一层地址映射。

    由于此时由段式内存管理映射而成的地址不再是“物理地址”了,Intel 就称之为“线性地址”(也称虚拟地址)。于是,段式内存管理先将逻辑地址映射成线性地址,然后再由页式内存管理将线性地址映射成物理地址。

    Intel X86 逻辑地址解析过程

    这里说明下逻辑地址和线性地址:

    • 程序所使用的地址,通常是没被段式内存管理映射的地址,称为逻辑地址;
    • 通过段式内存管理映射的地址,称为线性地址,也叫虚拟地址;

    逻辑地址是「段式内存管理」转换前的地址,线性地址则是「页式内存管理」转换前的地址。

    了解完 Intel 处理器的发展历史后,我们再来说说 Linux 采用了什么方式管理内存?

    Linux 内存主要采用的是页式内存管理,但同时也不可避免地涉及了段机制

    这主要是上面 Intel 处理器发展历史导致的,因为 Intel X86 CPU 一律对程序中使用的地址先进行段式映射,然后才能进行页式映射。既然 CPU 的硬件结构是这样,Linux 内核也只好服从 Intel 的选择。

    但是事实上,Linux 内核所采取的办法是使段式映射的过程实际上不起什么作用。也就是说,“上有政策,下有对策”,若惹不起就躲着走。

    Linux 系统中的每个段都是从 0 地址开始的整个 4GB 虚拟空间(32 位环境下),也就是所有的段的起始地址都是一样的。这意味着,Linux 系统中的代码,包括操作系统本身的代码和应用程序代码,所面对的地址空间都是线性地址空间(虚拟地址),这种做法相当于屏蔽了处理器中的逻辑地址概念,段只被用于访问控制和内存保护。

    我们再来瞧一瞧,Linux 的虚拟地址空间是如何分布的?

    在 Linux 操作系统中,虚拟地址空间的内部又被分为内核空间和用户空间两部分,不同位数的系统,地址空间的范围也不同。比如最常见的 32 位和 64 位系统,如下所示:

    用户空间与内存空间

    通过这里可以看出:

    • 32 位系统的内核空间占用 1G,位于最高处,剩下的 3G 是用户空间;
    • 64 位系统的内核空间和用户空间都是 128T,分别占据整个内存空间的最高和最低处,剩下的中间部分是未定义的。

    再来说说,内核空间与用户空间的区别:

    • 进程在用户态时,只能访问用户空间内存;
    • 只有进入内核态后,才可以访问内核空间的内存;

    虽然每个进程都各自有独立的虚拟内存,但是每个虚拟内存中的内核地址,其实关联的都是相同的物理内存。这样,进程切换到内核态后,就可以很方便地访问内核空间内存。

    每个进程的内核空间都是一致的

    接下来,进一步了解虚拟空间的划分情况,用户空间和内核空间划分的方式是不同的,内核空间的分布情况就不多说了。

    我们看看用户空间分布的情况,以 32 位系统为例,我画了一张图来表示它们的关系:

    虚拟内存空间划分

    通过这张图你可以看到,用户空间内存,从低到高分别是 7 种不同的内存段:

    • 程序文件段,包括二进制可执行代码;
    • 已初始化数据段,包括静态常量;
    • 未初始化数据段,包括未初始化的静态变量;
    • 堆段,包括动态分配的内存,从低地址开始向上增长;
    • 文件映射段,包括动态库、共享内存等,从低地址开始向上增长(跟硬件和内核版本有关);
    • 栈段,包括局部变量和函数调用的上下文等。栈的大小是固定的,一般是 8 MB。当然系统也提供了参数,以便我们自定义大小;

    在这 7 个内存段中,堆和文件映射段的内存是动态分配的。比如说,使用 C 标准库的 malloc() 或者 mmap() ,就可以分别在堆和文件映射段动态分配内存。


    总总结结

    为了在多进程环境下,使得进程之间的内存地址不受影响,相互隔离,于是操作系统就为每个进程独立分配一套虚拟地址空间,每个程序只关心自己的虚拟地址就可以,实际上大家的虚拟地址都是一样的,但分布到物理地址内存是不一样的。作为程序,也不用关心物理地址的事情。

    每个进程都有自己的虚拟空间,而物理内存只有一个,所以当启用了大量的进程,物理内存必然会很紧张,于是操作系统会通过内存交换技术,把不常使用的内存暂时存放到硬盘(换出),在需要的时候再装载回物理内存(换入)。

    那既然有了虚拟地址空间,那必然要把虚拟地址「映射」到物理地址,这个事情通常由操作系统来维护。

    那么对于虚拟地址与物理地址的映射关系,可以有分段分页的方式,同时两者结合都是可以的。

    内存分段是根据程序的逻辑角度,分成了栈段、堆段、数据段、代码段等,这样可以分离出不同属性的段,同时是一块连续的空间。但是每个段的大小都不是统一的,这就会导致内存碎片和内存交换效率低的问题。

    于是,就出现了内存分页,把虚拟空间和物理空间分成大小固定的页,如在 Linux 系统中,每一页的大小为 4KB。由于分了页后,就不会产生细小的内存碎片。同时在内存交换的时候,写入硬盘也就一个页或几个页,这就大大提高了内存交换的效率。

    再来,为了解决简单分页产生的页表过大的问题,就有了多级页表,它解决了空间上的问题,但这就会导致 CPU 在寻址的过程中,需要有很多层表参与,加大了时间上的开销。于是根据程序的局部性原理,在 CPU 芯片中加入了 TLB,负责缓存最近常被访问的页表项,大大提高了地址的转换速度。

    Linux 系统主要采用了分页管理,但是由于 Intel 处理器的发展史,Linux 系统无法避免分段管理。于是 Linux 就把所有段的基地址设为 0,也就意味着所有程序的地址空间都是线性地址空间(虚拟地址),相当于屏蔽了 CPU 逻辑地址的概念,所以段只被用于访问控制和内存保护。

    另外,Linxu 系统中虚拟空间分布可分为用户态内核态两部分,其中用户态的分布:代码段、全局变量、BSS、函数栈、堆内存、映射区。


    唠唠叨叨

    小林是专为大家图解的工具人,Goodbye,我们下次见!

    展开全文
  • 深入浅出内存管理--内存管理概述

    千次阅读 2018-12-12 17:26:07
    内存管理我的理解是分为两个部分,一个是物理内存的管理,另一个部分是物理内存地址到虚拟地址的转换。 物理内存管理 内核中实现了很多机制和算法来进行物理内存的管理,比如大名鼎鼎的伙伴系统,以及slab分配器等等...
  • Java应用程序是运行在JVM上的,得益于JVM的内存管理和垃圾收集机制,开发人员的效率得到了显著提升,也不容易出现内存溢出和泄漏问题。但正是因为开发人员把内存的控制权交给了JVM,一旦出现内存方面的问题,如果不...
  • 操作系统内存管理(思维导图详解)

    万次阅读 多人点赞 2010-07-05 11:26:00
    在介绍内存管理的细节前,先要了解一下分层存储器体系: 大部分的计算机都有一个存储器层次结构,即少量的非常快速、昂贵、易变的高速缓存(cache);若干兆字节的中等速度、中等价格、易变的主存储器(RAM);...
  • 内存寻址 、硬件中的分段与分页 、Linux内存管理 页与内存管理区 、kmalloc()和vmalloc()
  • 【C/C++】内存管理总结

    万次阅读 多人点赞 2019-08-28 15:47:11
    文章目录内存管理基础作用域局部变量静态(static)局部变量全局变量静态(static)全局变量extern全局变量声明全局函数和静态函数小总结内存布局内存分区存储类型总结存储类型总结内存操作函数堆区内存分配和释放内存...
  • 内存与操作系统内存管理

    千次阅读 2021-02-28 15:10:12
    内存与操作系统内存管理 文章目录内存与操作系统内存管理一、内存的基础知识二、内存管理2.1 内存空间扩充2.2 内存空间的分配与回收 一、内存的基础知识   内存是用于存放数据的硬件,程序执行前需要将数据放入...
  • Linux内存管理

    千次阅读 2015-11-08 15:24:57
    Linux内存管理 前言 对于内存部分需要知道: 地址映射内存管理的方式缺页异常 正文 在进程看来,内存分为内核态和用户态两部分,经典比例如下: Linux内存-虚拟地址: 从用户态到内核态...
  • Flink内存管理源码解读之内存管理

    千次阅读 2016-04-06 22:55:54
    回顾上一篇文章我们谈了Flink自主内存管理的一些基础的数据结构。那篇中主要讲了数据结构的定义,这篇我们来看看那些数据结构的使用,以及内存的管理设计。概述这篇文章我们主要探讨Flink的内存管理类MemoryManager...
  • matlab 内存管理 清理内存

    千次阅读 2018-11-14 15:17:00
    三、matlab内存管理 一、利用clear清除内存时,要用pack函数进行内存整理 Matlab在运行大数据时,会出现Out of Memory,在程序中加入clear不需要的变量,能否解决Out of Memory问题。答案是或许可以,或许不可以,...
  • 【redis】redis内存管理、淘汰机制、内存优化

    千次阅读 多人点赞 2020-08-25 12:44:21
    redis内存管理、淘汰机制、内存优化
  • 日期 内核版本 架构 作者 GitHub ... Linux内存管理内存管理的上下文中, 初始化(initialization)可以有多种含义. 在许多CPU上, 必须显式设置适用于Linux内核的内存模型. 例如在x86_32上需要切换到
  • Java内存管理

    万次阅读 多人点赞 2016-03-08 21:36:06
    不过看了一遍《深入Java虚拟机》再来理解Java内存管理会好很多。接下来一起学习下Java内存管理吧。请注意上图的这个:我们再来复习下进程与线程吧:进程是具有一定独立功能的程序关于某个数据集合上的一次运行活动,...
  • spark 内存管理

    千次阅读 2016-05-17 15:16:08
    从Spark 1.6版本开始,Spark采用Unified Memory Management这样一种新的内存管理模型。 Spark中的内存使用分为两部分:执行(execution)与存储(storage)。执行内存主要用于shuffles、joins、sorts和aggregations...
  • Spark内核之内存管理

    千次阅读 2020-09-17 22:36:30
    2.1 早期内存管理(静态内存管理) 2.2 统一内存管理 2.3 同一管理内存的优点 三、存储内存管理 3.1 RDD的持久化机制 3.2RDD的缓存过程 3.3 淘汰与落盘 四、执行内存管理 4.1 Shuffle Write 4.2 Shuffle ...
  • Android 内存管理机制

    千次阅读 2018-09-03 14:41:45
    本文主要包括三大部分内容: 内存管理基础:从整个计算机领域... Android的内存管理相关知识:Android又不同于Linux,它是一个移动操作系统,因此其内存管理上也有自己的特性,这一部分详细讲述Android的内存管理...
  • 所谓的内存管理,就是对这两部分区域进行管理。oracle的内存管理经理了如下发展: oracle 9i PGA自动管理,SGA手动管理 oracle10g PGA自动管理,SGA自动管理 oracle11g PGA,SGA统一自动管理 先
  • Linux内存管理机制

    千次阅读 2017-02-16 04:07:24
    这是Linux内存管理的一个优秀特性,主要特点是,无论物理内存有多大,Linux 都将其充份利用,将一些程序调用过的硬盘数据读入内存(buffer/cache),利用内存读写的高速特性来提高Linux系统的数据访问性能。...
  • 本文转自:Spark 内存管理详解(上)——内存分配 Spark作为一个基于内存的分布式计算引擎,其内存管理模块在整个系统中占据着非常重要的角色。理解Spark内存管理的基本原理,有助于更好地开发Spark应用程序和进行...
  • android内存管理

    千次阅读 2016-11-12 15:01:56
    android内存管理与内存泄漏 应用场景 我们在开发应用程序的时候,了解其内存的运作机制以及如何防止内存泄漏都是非常重要的,内存问题也是android面试中经常问到话题。今天我就讲讲有关android内存方面的知识吧。 ...
  • STM32内存管理

    千次阅读 2019-03-15 15:28:43
    内存管理详解 1、介绍 内存管理,是指软件运行时对计算机内存资源的分配和使用的技术。其最主要的目的是如何高效,快速的分配,并且在适当的时候释放和回收内存资源。 内存管理的实现方法有很多种,他们其实最终都是...
  • LiteOS 内存管理

    千次阅读 2019-06-11 11:06:58
    LiteOS操作系统将内核与内存管理分开实现,操作系统内核仅规定了必要的内存管理函数原型,而不关心这些内存管理函数是如何实现的,所以在 LiteOS 中提供了多种内存分配算法(分配策略),但是上层接口(API)却是...

空空如也

空空如也

1 2 3 4 5 ... 20
收藏数 154,240
精华内容 61,696
关键字:

内存管理