现在的位置: 首页 > 综合 > 正文

内存分配原理

2013年10月02日 ⁄ 综合 ⁄ 共 9343字 ⁄ 字号 评论关闭

原文出处:http://hi.baidu.com/spbeijilang/blog/item/a2c9dc432aabe9169213c637.html

摘要:内存管理向来是C/C++程序设计的一块雷区,大家都不怎么愿意去碰她,但是有时不得不碰它。虽然利用C++中的smart pointer已经可以完全避免使用指针,但是对于对于指针的进一步了解,有助于我们编写出更有效率的代码,也有助于我们读懂以前编写的程序。

    五大内存分区
    在C++中,内存分成5个区,他们分别是堆、栈、自由存储区、全局/静态存储区和常量存储区。
    栈,就是那些由编译器在需要的时候分配,在不需要的时候自动清楚的变量的存储区。里面的变量通常是局部变量、函数参数等。
    堆,就是那些由new分配的内存块,他们的释放编译器不去管,由我们的应用程序去控制,一般一个new就要对应一个delete。如果程序员没有释放掉,那么在程序结束后,操作系统会自动回收。
    自由存储区,就是那些由malloc等分配的内存块,他和堆是十分相似的,不过它是用free来结束自己的生命的。
    全局/静态存储区,全局变量和静态变量被分配到同一块内存中,在以前的C语言中,全局变量又分为初始化的和未初始化的,在C++里面没有这个区分了,他们共同占用同一块内存区。
    常量存储区,这是一块比较特殊的存储区,他们里面存放的是常量,不允许修改(当然,你要通过非正当手段也可以修改,而且方法很多,在《const的思考》一文中,我给出了6种方法)
   
    明确区分堆与栈
    在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、管理方式不同;
    2、空间大小不同;
    3、能否产生碎片不同;
    4、生长方向不同;
    5、分配方式不同;
    6、分配效率不同;
    管理方式:对于栈来讲,是由编译器自动管理,无需我们手工控制;对于堆来说,释放工作由程序员控制,容易产生memory leak。
    空间大小:一般来讲在32位系统下,堆内存可以达到4G的空间,从这个角度来看堆内存几乎是没有什么限制的。但是对于栈来讲,一般都是有一定的空间大小的,例如,在VC6下面,默认的栈空间大小是1M(好像是,记不清楚了)。当然,我们可以修改:   
    打开工程,依次操作菜单如下:Project->Setting->Link,在Category 中选中Output,然后在Reserve中设定堆栈的最大值和commit。
注意:reserve最小值为4Byte;commit是保留在虚拟内存的页文件里面,它设置的较大会使栈开辟较大的值,可能增加内存的开销和启动时间。
    碎片问题:对于堆来讲,频繁的new/delete势必会造成内存空间的不连续,从而造成大量的碎片,使程序效率降低。对于栈来讲,则不会存在这个问题,因为栈是先进后出的队列,他们是如此的一一对应,以至于永远都不可能有一个内存块从栈中间弹出,在他弹出之前,在他上面的后进的栈内容已经被弹出,详细的可以参考数据结构,这里我们就不再一一讨论了。
    生长方向:对于堆来讲,生长方向是向上的,也就是向着内存地址增加的方向;对于栈来讲,它的生长方向是向下的,是向着内存地址减小的方向增长。
    分配方式:堆都是动态分配的,没有静态分配的堆。栈有2种分配方式:静态分配和动态分配。静态分配是编译器完成的,比如局部变量的分配。动态分配由alloca函数进行分配,但是栈的动态分配和堆是不同的,他的动态分配是由编译器进行释放,无需我们手工实现。
    分配效率:栈是机器系统提供的数据结构,计算机会在底层对栈提供支持:分配专门的寄存器存放栈的地址,压栈出栈都有专门的指令执行,这就决定了栈的效率比较高。堆则是C/C++函数库提供的,它的机制是很复杂的,例如为了分配一块内存,库函数会按照一定的算法(具体的算法可以参考数据结构/操作系统)在堆内存中搜索可用的足够大小的空间,如果没有足够大小的空间(可能是由于内存碎片太多),就有可能调用系统功能去增加程序数据段的内存空间,这样就有机会分到足够大小的内存,然后进行返回。显然,堆的效率比栈要低得多。
    从这里我们可以看到,堆和栈相比,由于大量new/delete的使用,容易造成大量的内存碎片;由于没有专门的系统支持,效率很低;由于可能引发用户态和核心态的切换,内存的申请,代价变得更加昂贵。所以栈在程序中是应用最广泛的,就算是函数的调用也利用栈去完成,函数调用过程中的参数,返回地址,EBP和局部变量都采用栈的方式存放。所以,我们推荐大家尽量用栈,而不是用堆。
    虽然栈有如此众多的好处,但是由于和堆相比不是那么灵活,有时候分配大量的内存空间,还是用堆好一些。
    无论是堆还是栈,都要防止越界现象的发生(除非你是故意使其越界),因为越界的结果要么是程序崩溃,要么是摧毁程序的堆、栈结构,产生以想不到的结果,就算是在你的程序运行过程中,没有发生上面的问题,你还是要小心,说不定什么时候就崩掉,那时候debug可是相当困难的:)
    对了,还有一件事,如果有人把堆栈合起来说,那它的意思是栈,可不是堆,呵呵,清楚了?

new/delete究竟做了些什么?
   
在理解这个问题之前,我们先看一下下面的这段程序,有这么一个程序段:
       class A
       {
       public:
           A() {   cout<<"A is here!"<<endl;   }
           ~A(){   cout<<"A is dead!"<<endl;   }
       private:
           int i;
       };
       A* pA=new A;
       delete pA;
    在这个简单的程序段里面,new/delete究竟做了些什么?
    实际上,这段程序里面隐含调用了一些我们没有看到的东西,那就是:
    static void* operator new(size_t sz);
    static void operator delete(void* p);
    值得注意的是,这两个函数都是static的,所以如果我们重载了这2个函数(我们要么不重载,要重载就要2个一起行动),也应该声明为static的,如果我们没有声明,系统也会为我们自动加上。另外,这是两个内存分配原语,要么成功,要么没有分配任何内存。
    size_t是什么东西呢?我在第一次看到这个动动的时候也是十分的困惑,毕竟以前没有见过。size_t在<cstddef>中定义,是一种无符号整数类型(不一定是int),用来保存对象的大小,这一用法是从C语言中借用过来的,现在你应该明白了吧(我学习的时候可是郁闷了好几天,没有人可以问,因为不知道有个csdn:)
    new A;实际上做了2件事:调用opeator new,在自由存储区分配一个sizeof(A)大小的内存空间;然后调用构造函数A(),在这块内存空间上类砖砌瓦,建造起我们的对象。同样对于delete,则做了相反的两件事:调用析构函数~A(),销毁对象,调用operator delete,释放内存。不过需要注意的是,new分配一块内存的时候,并没有对这块内存空间做清零等任何动作,只是拿了过来,这块内存上放的仍然是原来的数据(垃圾数据),delete的时候,也只是释放这块内存,归还给操作系统,上面的数据还在上面,所以delete pA之后,pA的值没变,他指向的那块内存的值也没有变,不过似乎有什么问题,我们看一下下面的这个程序段:
int *p=new int(50000);
cout<<*p<<"   "<<p<<endl;
delete p;
cout<<*p<<"   "<<p<<endl;
    我们可以清楚地看到,指针p存放的数据仍然是原来的地址,但是*p的内容却发生了变化,在我的机器上(win2000, VC6)始终是-572662307,不清楚这是为什么,难道系统做了什么手脚?还望高手指教。  
    在这里我们可以看到,new的工作实际上就是保证相互分离的存储分配和初始化工作能够很好的在一起工作,不过这里可能让初学者迷惑的是,我们定义了一个带有参数的new,但是我们用的时候却没有显式的去调用,而是让系统“神秘”的去提供这个参数。是的,这样做毫无疑问增加了复杂性,但是让基类获取了为一集派生类提供分配和释放服务的能力。  

    new/delete有什么好处和坏处?
    从C程序员转换过来的C++程序员总是有个困惑:new/delete到底究竟和C语言里面的malloc/free比起来有什么优势?或者是一样的?
    其实,就算我不说,你也应该很清楚了,new/delete当然比malloc/free要好,要不然,为什么还引进这个东东呢?其实通过上面的分析,我们看到了new/delete实际上做了很多malloc/free没有做的事情:malloc/free只是对内存进行分配和释放;new/delete还负责完成了创建和销毁对象的任务。
    另外,new的安全性要高一些,因为他返回的就是一个所创建的对象的指针,对于malloc来说返回的则是void*,还要进行强制类型转换,显然这是一个危险的漏洞。
    最后,我们可以对new/delete重载,使内存分配按照我们的意愿进行,这样更具有灵活性,malloc则不行。
    不过,new/delete也并不是十分完美,大概最大的缺点就是效率低(针对的是缺省的分配器),原因不只是因为在自由存储区上分配(和栈上对比),具体的原因目前不是很清楚,不过在MCD上说了2个可能的原因:
    1、new只是对于堆分配器(malloc/realloc/free)的一个浅层包装,没有针对小型的内存分配做优化。
    2、缺省分配器具有通用性,它管理的是一块内存池,这样的管理往往需要消耗一些额外空间。

    各种各样的new
    一般来说,new有很多种形式,不过真的归纳起来,也就是2种:
    1、最常用的形式:
       void *operator new(std::size_t sz)    throw(std::bad_alloc);   (普通的)
       void *operator new[](std::size_t sz) throw(std::bad_alloc);    (数组的)
       void *operator new(std::size_t sz);
       void *operator new[](std::size_t sz)
    这一种大家用得最为频繁,我就不举例子了。
    2、放置new形式:
       void *operator new(std::size_t count, void *ptr)   throw();    (普通的)
       void *operator new[](std::size_t count, void *ptr) throw();     (数组的)
     要使用这种方式,必须包含头文件<new>。这个机制引入的初始目的是为了解决2个相关的问题:
       1、把一个对象放在某个特定位置;
       2、在某个特定分配区里面分配对象;
       但是引入之后,发现这种机制远超出了简单的存储分配机制,我们可以给特定的存储位置关联任意的逻辑性值,这样一来,new就有了一种通用资源管理器的作用。同时第二个参数,也被扩展成了任意的可以识别的类型,并且配备了相应的nothrow版本:
       void* operator new(std::size_t, const std::nothrow_t&)   throw();
       void* operator new[](std::size_t, const std::nothrow_t&) throw();

    new能够返回NULL么?
    我们经常看到有很多初学者喜欢写如下代码:
    A* p=new A();
    if(p==NUL) ....
    写下这段代码的可能是受到了一些书上错误的影响,因为new A()从来就不可能返回NULL,如果在这个过程中用完了内存,那么他就会抛出bad_alloc异常,绝对不会返回NULL,如果你想让他返回null,应该用new(nothrow) A(),而不是new A()。不过从异常的观点来看,这实际上是一种倒退,我们应该尽量回避。

存储器的结构层次
    我想大家都很清楚,在计算机的存储中,有各种各样的存储器,对他们的访问频率和访问方式直接影响到我们的程序效率,一般来说,可以分为5个等级:寄存器、一级缓存、二级缓存、主存、磁盘存储器。下面我们就把他们的特性大体的说一下:
    1、寄存器,是所有存储器里面延迟时间最短、带宽最大、开销最少的,毫无疑问,这是目前速度最快的存储器,但是代价比较昂贵,所以寄存器的个数有限,我们的程序应该尽量充分利用寄存器,C/C++里面不是有一个关键字:register么?对于那些频繁使用,有可能成为性能瓶颈的变量放入寄存器,可能会在效率上有些提高(毕竟程序的瓶颈在一个变量的情况很少出现,一般都在算法上进行改进),但是对于个别的函数,性能可能会有数量级上的提升。不过,和inline一样的是,register也只是一个提示符,提示编译器不需要为变量分配内存,但是决定权在编译器,有的编译器(当然时说那些比较差的了)会完全忽略register指示符。
    2、一级缓存,又称为芯片内缓存,速度也是非常的快,其延迟也与寄存器处在同一个数量级,但是容量也很有限。目前,很多CPU上面都有2级芯片内缓存,我们可以把他们看作是一个芯片内缓存,不影响我们的讨论。
       根据1999年的数据,我们知道寄存器延迟2ns,一级缓存也是2ns,,看起来似乎2者具有相同的性能,实则不然。通常情况下,一个寄存器组可以在一个时钟周期内读2个操作数并且写一个操作数,一共处理3个操作数,对于一级缓存来说2个时钟周期才能够处理一个操作数,相比之下,寄存器组的带宽大约是一级缓存的6倍。当然,对于某些特殊的硬件可能不符合这个规律,但是无论如何,寄存器的带宽都要比一级缓存的大。
    3、二级缓存,又称为芯片外缓存,速度比一级缓存要慢上1-2倍。
    4、主存,也就是我们平常所说的内存,是一种半导体动态随机访问存储器,常见的有DRAM、SDRAM、RAMBUS等,就目前来说,内存的容量已经变得很大了,常见的是256M、512M。
       计算机的主存访问有一个非常有规律的特性,就是人们所说的“6-1-1-1-2-1-1-1特性”,什么意思呢?就是说,最初访问内存需要6个总线周期,随后的3次访问,每次只需一个总线周期,接下来的1次访问需要2个总线周期,随后的3次访问,又是只需要一个总线周期。这就是所谓的“6-1-1-1-2-1-1-1特性”
    5、磁盘存储器,最常用的就是我们所说的硬盘,速度和上面的相比,差了好几个数量级,访问硬盘的动作属于I/O访问,对于性能影响很大,所以尽量避免。当然,有时候非要访问不可,那就要采用一些有效的策略,例如:开个缓冲池。对于大型的数据访问,其性能会和系统的虚拟内存机制紧密联系,也与文件结构紧密相关。

虚拟内存的秘密
    虚拟内存机制是一种很不错的机制,表面上看来,他把有限的内存转化为无限的内存空间,特别是现代的操作系统,往往具有把永久数据映射到系统的虚拟存储来访问这些永久数据的能力,系统的增强,也加剧了我们编写的程序大量依赖于虚拟内存机制。
    虽然,在我们访问硬盘文件时,数据在内存中的驻留有系统控制,系统在硬盘上开辟一段空间作为虚拟内存,用这块虚拟内存来动态的管理运行时的文件。但是,不要忘了,由于硬盘的访问,使它不得不使用内存管理代码,结果他的开销变得和系统的虚拟换页系统一样的昂贵。
    根据硬盘厂商提供的数据,磁盘访问平均需要12--20ms,典型的磁盘访问至少包括2次上下文转换以及低级设备接口的执行,这就需要数千条指令,数百万个时钟周期的延迟。所以如果我们的程序对于性能要求比较高的话,在使用虚拟内存的时候要考虑一下。

    内存分配策略
   
关于内存分配,通常有2种比较常用的分配策略:可变大小分配策略、固定尺寸分配策略。

    可变大小分配策略,关键就在用他们的通用性上,通过他们,用户可以向系统申请任意大小的内存空间,显然,这样的分配方式很灵活,应用也很广泛,但是他们也有自己致命的缺陷,不过,对于我们来说,影响最大的大约在2个方面:
    1、为了满足用户要求和系统的要求,不得不做一些额外的工作,效率自然就会有所下降;
    2、在程序运行期间,可能会有频繁的内存分配和释放动作,利用我们已有的数据结构和操作系统的知识,这样就会在内存中形成大量的、不连续的、不能够直接使用的内存碎片,在很多情况下,这对于我们的程序都是致命的。如果我们能够每隔一段时间就重新启动系统自然就没有问题,但是有的程序不能够中断,就算是能够中断,让用户每隔一段时间去重起系统也是不现实的(谁还敢用你做的东东?)

    固定尺寸分配策略,这个策略的关键就在于固定,也就是说,当我们申请内存时,系统总是为我们返回一个固定大小(通常是2的指数倍)的内存空间,而不管我们实际需要内存的大小。和上面我们所说的通用分配策略相比,显得比较呆板,但是速度更快,不会产生太多的细小碎片。

    一般情况下,可变大小分配策略和固定尺寸分配策略经常共同合作,例如,分配器会有一个分界线M,当申请的内存大小小于M的时候,就采用固定尺寸分配,当申请的大小大于M的时候就采用可变大小的分配。其实,在SGI STL里面就是采用的这种混合策略,它采用的分界线是128B,如果申请的内存大小超过了128B,就移交第一级配置器处理,如果小于128B,则采用内存池策略,维护一个16个free-lists的小额区块。

    内存服务层次
    内存分配有很多种策略,那么我们怎么知道是谁负责内存分配的呢?
    内存的分配服务和存储结构一样,也是分层次的:
    第一层,操作系统内核提供最基本服务,这是内存分配策略的基础,也是和硬件系统联系最紧密的,所以说不同的平台这些服务的特点也是不一样的。
    第二层,编译器的缺省运行库提供自己的分配服务,new/malloc提供的就是基于本地的分配服务,具体的服务方式要依赖于不同的编译器,编译器的服务可以只对本地分配服务做一层简单的包装,没有考虑任何效率上的强化,例如,new就是对malloc的一层浅包装。编译器的服务也可以对本地服务进行重载,使用去合理的方式去分配内存。
    第三层,标准容器提供的内存分配服务,和缺省的运行库提供的服务一样,他也可以简单的利用编译器的服务,例如,SGI中的标准配置器allocator,虽然为了符合STL标准,SGI定义了这个配置器,但是在实际应用中,SGI却把它抛弃了。也可以对器进行重载,实现自己的策略和优化措施。,例如,SGI中使用的具有此配置能力的SGI空剑配置器。
    最外面的一层,用户自定义的容器和分配器提供的服务,我们可以对容器的分配器实施自己喜欢的方案,也可以对new/delete重载,让他做我们喜欢的事情。

    内存分配的开销
   &nbs

p; 内存的开销主要来自两部分:维护开销、对齐开销。
    1、维护开销
       在可变大小的分配策略下,在分配的时候,会采用一定的策略去维护分配和释放内存空间的大小,例如,在VC6下面,就会在分配的内存块其实位置放一个Cookie,,当进行delete的时候,指针前移4个字节,读出内存大小size,然后释放size+4的空间,我们可以用下面的小程序进行简单的测试:
    #include <iostream>
    using namespace std;
    class A
    {
    public:
       A() {cout<<"A"<<endl;}
       int i;
        ~A() {cout<<"~A"<<endl;}
    };
    int main()
    {
     A* pA=new A[5];
     int* p=(int*)pA;
     *(p-1)=1;
     delete []pA;
     return 0;
    }
    对于固定大小分配策略,因为已经知道内存块的确定大小,自然就不需要这方面的开销。
  2、对齐开销
    很多的平台都要求数据的对齐,在数据的间隙或尾端进行填充,我们可以利用sizeof进行测试:
     struct A
        {
           char c1;
           int i;
           char c2;
        };
        在我们进行如下运算的时候,我们可能会发现sizeof(A)=12,但是char只是占用1个字节,int占用4个字节,加起来也不过6个字节,怎么会多了一倍呢?
    这就是对齐现象在起作用,实际占用的空间是这3个变量都占用4个字节,在每一个char型的末尾都会填充3个字节的0。那你把char c2和 int i交换位置,看看结果是多少?怎么解释呢?
    初看起来,只是一种浪费,为什么会有这个特点呢?目的很简单,就是要使bus运输量达到最大。

抱歉!评论已关闭.