“瑜珈山夜话” ----内存分配(三)

类别:VC语言 点击:0 评论:0 推荐:

    存储器的结构层次
    我想大家都很清楚,在计算机的存储中,有各种各样的存储器,对他们的访问频率和访问方式直接影响到我们的程序效率,一般来说,可以分为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重载,让他做我们喜欢的事情。

    内存分配的开销
    内存的开销主要来自两部分:维护开销、对齐开销。
    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运输量达到最大。

本文地址:http://com.8s8s.com/it/it1623.htm