C语言堆内存堆申请与文件读入的性能分析

前言:linux

    对于C语言的学习者来讲,对于内存的分析与管理是不得不接触到的问题。这篇文章我但愿来讨论下对与C语言对堆内存的使用问题。写这篇博文的缘由是因为最近在学习C的过程当中的一个查字典的小项目实战,项目中提供了一个约22万个词汇以及其解释的词库文件,在字典程序中要首先对字典文件进行读取,解析,设计数据结构,存入内存,并进行排序,并将字典的内存中的二进制形式存入到缓存文件中,在有缓存存的状况下直接载入缓存,跳过初始化过程。centos

字典文件格式以下(#开头一行是单词,Trans:后第二行是解释,多个解释以@分割):数组

#abrasion
Trans:n. 磨去;磨损;磨损处
#abrasive
Trans:n. 研磨剂@a. 研磨的

设计的单词结构体以下:缓存

/* 单词结构体 */
struct WORD{
	char *key;				// 单词字段
	int ntrans;				// 单词解释个数
	char **trans;                           // 单词解释
};
typedef struct WORD word;

    每载入一个单词,为单词malloc一个堆空间,并将首地址存入key指针中,记录下单词有几个解释,存入ntrans中,并开辟一个对应长度的指针数组给trans,并为每一个解释开辟空间,存入指针数组。 由上文叙述,这样的一个字典程序,初始化的过程有诸多方法,可是这里关于效率有许问题我感到困惑,这里接下来讨论下(下面全部代码以CentOS6.5+GCC x64环境下 编译运行作为测试的参考环境,全部代码均亲自编写测试,保证结果然实准确,其余平台下不保证代码正确性和结果的一致,因此来看博客的朋友们,我也是初学者有问题欢迎指正,如结果有所出入的话,欢迎共同探讨,勿喷):数据结构


问题:并发

    1. 读取文件大小有多少字节的方式有两种,第一种是经过读取文件的属性,但这种方式要依赖于操做系统,对于跨平台来讲彷佛有点不是特别可取。另外一种是先将文件指针移动到结尾处,在计算指针的位置,由此思路想到的问题是假设说文件很大,文件指针的移动方式是怎样的,文件指针移动100和移动100000的性能开销是相同的吗?函数

#include <stdio.h>
#include <stdlib.h>
#include <sys/timeb.h>
#define N 50				// 每次申请的长度
#define T 104856	                // 申请空间的次数
long long getTime() {
    struct timeb t;
    ftime(&t);
    return 1000 * t.time + t.millitm;
}
int main(int argc, char **argv){
    long long start,temp;
    int i;
    
    start = getTime();
    for(i=0;i<T;i++);
    temp = getTime()-start;
    
    start = getTime();
    
    for(i=0;i<T;i++)
	malloc(N);
    printf("time is : %d\n",getTime()-start-temp);
}

N=1    T=1024*1024    time is : 2
N=2    T=1024*1024    time is : 2
N=3    T=1024*1024    time is : 4
N=5    T=1024*1024    time is : 4
N=10   T=1024*1024    time is : 4
N=50   T=1024*1024    time is : 6
N=100  T=1024*1024    time is : 7
N=500  T=1024*1024    time is : 23
N=1000 T=1024*1024    time is : 41

     以上数据均是屡次测试取平均值得结果,由上数据不难看出对于较小空间的申请,单次申请操做所消耗的时间几乎没有任何差异,对于一次性申请较大内存空间所须要消耗的时间变化比较明显,但并未看出有数量级上的差距,很明显对于malloc申请内存的时间开销,会应为单次申请的空间长度增长而增长,但增长的时间开销成本并非很大。由此接下来将对每次申请的内存大小作成倍的增加,看看对于申请一个较大的内存空间,采用一次申请和屡次申请的性能开销差异。性能

N=1024*1024*1    T=1    time is : 0
N=1024*1024*10   T=1    time is : 0
N=1024*1024*100  T=1    time is : 0
N=1024*1024*1000 T=1    time is : 0

     由以上数据综合以前的分析,malloc函数进行对内存的申请来讲,单次申请的过程,时间开销随申请的空间大小增长有所增长,但差异却小到能够几乎能够忽略不计,所以能够认为对于一次malloc申请内存空间的时间开销与所申请的内存空间大小无关,所以对于大内存的申请来讲,一次性申请要远比屡次申请的性能高上不少不少。学习


    2. 文件读取函数fread()用于文件的批量读入。一口气将整个文件读入内存和一行一行读入,或者一个字符一个字符的读取,以此读取所有内容的性能开销是否一致。或者说差距是否很大?测试

#include <stdio.h>
#include <stdlib.h>
#include <sys/timeb.h>

long long getTime() {
    struct timeb t;
    ftime(&t);
    return 1000 * t.time + t.millitm;
}
int main(int argc, char **argv){
    long long start;
    char *buffer;
    FILE *dict;				// 词库文件句柄
    unsigned int size;	        	// 文件大小
	
    // 打开目标文件
    dict = fopen("dict", "r");
    if(!dict)
        return 0;
	
    // 读取文件大小,并申请空间
    fseek(dict, 0, SEEK_END);
    size = (unsigned)ftell(dict);
    rewind(dict);
    buffer = (char*)malloc(size);
    
    // 计时开始
    start = getTime();
	
    fread(buffer, 1, size, dict);	// 将文件读入缓冲区
	
    // 计时结束
    printf("time is : %d\n",getTime()-start);
	
    fclose(dict);
}

执行结果:time is : 3


#include <stdio.h>
#include <stdlib.h>
#include <sys/timeb.h>

long long getTime() {
    struct timeb t;
    ftime(&t);
    return 1000 * t.time + t.millitm;
}
int main(int argc, char **argv){
    long long start;
    char *buffer;
    FILE *dict;				// 词库文件句柄
    unsigned int size;	        	// 文件大小
	
    // 打开目标文件
    dict = fopen("dict", "r");          // 这是一个44万行的字典库文件
    if(!dict)
        return 0;
	
    // 读取文件大小,并申请空间
    fseek(dict, 0, SEEK_END);
    size = (unsigned)ftell(dict);
    rewind(dict);
    buffer = (char*)malloc(size);
    
    // 计时开始
    start = getTime();
	
    while(fgets(buffer, size, dict));	// 将文件读入缓冲区
	
    // 计时结束
    printf("time is : %d\n",getTime()-start);
	
    fclose(dict);
}

执行结果:time is : 13


#include <stdio.h>
#include <stdlib.h>
#include <sys/timeb.h>

long long getTime() {
    struct timeb t;
    ftime(&t);
    return 1000 * t.time + t.millitm;
}
int main(int argc, char **argv){
    long long start;
    char *buffer;
    FILE *dict;				// 词库文件句柄
    unsigned int size;	        	// 文件大小
	
    // 打开目标文件
    dict = fopen("dict", "r");
    if(!dict)
        return 0;
	
    // 读取文件大小,并申请空间
    fseek(dict, 0, SEEK_END);
    size = (unsigned)ftell(dict);
    rewind(dict);
    buffer = (char*)malloc(size);
    
    // 计时开始
    start = getTime();
	
    while(fgetc(dict)!=EOF);	// 将文件读入缓冲区
	
    // 计时结束
    printf("time is : %d\n",getTime()-start);
	
    fclose(dict);
}

执行结果:time is : 45


     综上所述,文件读取时,fread()进行批量读取的效率远高于一行一行读取,单个字符循环读取的效率是最低的。做为补充此处对一样的22万行数据读入内存后进行遍历,以便更直观的了解移动文件指针读取内容的性能开销进行对比。

#include <stdio.h>
#include <stdlib.h>
#include <sys/timeb.h>

long long getTime() {
    struct timeb t;
    ftime(&t);
    return 1000 * t.time + t.millitm;
}
int main(int argc, char **argv){
    long long start;
    char *buffer;
    FILE *dict;				// 词库文件句柄
    unsigned int size;	        	// 文件大小
	
    // 打开目标文件
    dict = fopen("dict", "r");
    if(!dict)
	return 0;
	
    // 读取文件大小,并申请空间
    fseek(dict, 0, SEEK_END);
    size = (unsigned)ftell(dict);
    rewind(dict);
    buffer = (char*)malloc(size);
    fread(buffer, 1, size, dict);	// 将文件读入缓冲区
	
    // 计时开始
    start = getTime();
	
    while(*buffer++ != EOF);

    // 计时结束
    printf("time is : %d\n",getTime()-start);
	
    fclose(dict);
}

执行结果:time is : 16

    综上,对于须要将文件内容逐字符获取的状况来讲,先将整个文件fread读入内存,再进行遍历的执行效率要好的多。但细心的读者也应该想到了,对于先将文件内容所有读入内存再进行遍历的方式,前提是要有足够的内存,若是文件很是的大的状况下,对内存的开销,即便是使用完后立刻释放,在那一瞬间也十分巨大。此外并不是任何场景下对文件的内容读取后都有驻留内存的须要,而这种时候逐字符读取的空间消耗是最低的。不过在本文的前提下,明显与载入文件内容的性能要好的多。对于一次性读取一行的方式在此时的性能开销与一口气读入再遍历的开销接近,对于须要逐行读取并遍历的状况彷佛性价比挺高,这里就不具体分析。不过这里有个未能解决的小困惑是,笔者这里的用的是固态硬盘的设备,若这里与磁盘IO速度关系较大的话,在机械硬盘上也许看似并不明显的速度差也许会更明显。可是总之若是在内存充足的状况下,先将文件批量读入的效率必定会高于其余方式,磁盘IO速度越慢恐怕差距会越明显,对于空间换时间的方式,如何选择,就看具体情形吧。


    3. 对于一个打开了的文件,文件指针的移动距离是否与性能开销有关?

#include <stdio.h>
#include <stdlib.h>
#include <sys/timeb.h>

long long getTime() {
    struct timeb t;
    ftime(&t);
    return 1000 * t.time + t.millitm;
}
int main(int argc, char **argv){
    long long start;
    int i;
    FILE *dict;	
	
    // 打开目标文件
    dict = fopen("dict", "r");
    if(!dict)
	return 0;
	
    // 计时开始
    start = getTime();
	
    for(i=0;i<1000000;i++){
	fseek(dict, 0, SEEK_END);
	rewind(dict);
    }

    // 计时结束
    printf("time is : %d\n",getTime()-start);
	
	fclose(dict);
}

执行结果: time is : 700


#include <stdio.h>
#include <stdlib.h>
#include <sys/timeb.h>

long long getTime() {
    struct timeb t;
    ftime(&t);
    return 1000 * t.time + t.millitm;
}
int main(int argc, char **argv){
    long long start;
    int i;
    FILE *dict;	
	
    // 打开目标文件
    dict = fopen("dict", "r");
    if(!dict)
	return 0;
	
    // 计时开始
    start = getTime();
	
    for(i=0;i<1000000;i++){
	fseek(dict, 1, SEEK_SET);
	rewind(dict);
    }

    // 计时结束
    printf("time is : %d\n",getTime()-start);
	
	fclose(dict);
}

执行结果: time is : 220

    这里说明下上面两段代码的执行结果其实并不是一个确切的指,因为计算机在执行的时候有不少因素,在屡次执行的结果是不同的,以上给出的220和700是我根据屡次执行的结果求平均给出的大概值,220指的是每次的执行结果大部分都落在220这个数字附近,执行时间为220这个数量级,这里使用的文件一样为那个22万行左右的字典文件。由上面两段测试代码能够看出来,第一段代码是将文件指针移动到文件尾部再移动回来,重复1000000次,第二段代码是然文件指针移从开头向后移动一个位置再移动回来,一样重复1000000次,这两种作法产生了大约三倍的时间差,所以若是咱们猜想说文件指针的移动距离同移动效率无关显然是不正确的,由此,进一步进行下列测试:

#include <stdio.h>
#include <stdlib.h>
#include <sys/timeb.h>
#define N 10000
long long getTime() {
    struct timeb t;
    ftime(&t);
    return 1000 * t.time + t.millitm;
}
int main(int argc, char **argv){
    long long start;
    int i;
    FILE *dict;	
	
    // 打开目标文件
    dict = fopen("dict", "r");
    if(!dict)
	return 0;
	
    // 计时开始
    start = getTime();
	
    for(i=0;i<1000000;i++){
	fseek(dict, N, SEEK_SET);
	rewind(dict);
    }

    // 计时结束
    printf("time is : %d\n",getTime()-start);
	
    fclose(dict);
}

N=1     time is : 220
N=10    time is : 220
N=100   time is : 220
N=1000  time is : 220
N=10000 time is : 480

     由上面的这组数据发现一个很奇怪的现象,当N在1-1000的时候,测试结果几乎都落在220左右这个数量级上。与以前得出的结论,移动距离与效率有影响发生了矛盾,而到10000时,时间开销忽然变化到480这个数量级附近,产生了两倍多的时间差。因而这是后产生了一个猜测,文件指针的移动是否会和分页机制有关,在同页面内移动文件指针的开销是相同的,而每次跨页的过程则要产生额外的性能开销,为此执行下面两组用例再进行一次测试。

N=4096  time is : 220
N=4097  time is : 420

    很明显,因为一个分页通常默认是4K,4096未发生跨页,时间开销与以前同样,都在220这个数量级内。而4097落到了第二页,性能开销明显马上落到了另外一个数量级。由此能够发现文件指针的移动是基于分页机制进行的。


    4. ftell的效率是否和文件指针的位置有关?

#include <stdio.h>
#include <stdlib.h>
#include <sys/timeb.h>
#define N 1
long long getTime() {
    struct timeb t;
    ftime(&t);
    return 1000 * t.time + t.millitm;
}
int main(int argc, char **argv){
    long long start;
    int i;
    FILE *dict;	
	
    // 打开目标文件
    dict = fopen("dict", "r");
    if(!dict)
	return 0;
    fseek(dict, N, SEEK_SET);
	
    // 计时开始
    start = getTime();
	
    for(i=0;i<1000000;i++)
	ftell(dict);

    // 计时结束
    printf("time is : %d\n",getTime()-start);
	
    fclose(dict);
}

N=1     time is : 16
N=10    time is : 16
N=100   time is : 16
N=1000  time is : 16
N=10000 time is : 16

    由此能够看出查看指针为位置的时间开销与指针当前位置无关,且开销极低,与文件大小无关。

 

   4. 关于realloc函数,据了解,用法是若是当前空间后还有空余的空间,则直接继续分配空间给他,若不存在空余空间,则从新malloc新的空间,并将原来的内容拷贝进去。因而可知如果后者状况下realloc的性能开销成本显然很大。那么realloc在什么状况下分配的内容后面会有东西,何时是空余空间?在单线程的状况下,最新malloc的地址,是否只要没有再malloc过新的内容,后面的内存空间就必定为闲置内存?

#include <stdio.h>
#include <stdlib.h>
#include <sys/timeb.h>
long long getTime() {
    struct timeb t;
    ftime(&t);
    return 1000 * t.time + t.millitm;
}
int main(int argc, char **argv){
    long long start;
    int i;
    char *suffer;	
    suffer = malloc(1);
	
    // 计时开始
    start = getTime();
	
    for(i=2;i<1000002;i++)
	suffer = malloc(1);
	
    // 计时结束
    printf("time is : %d\n",getTime()-start);
}

time is : 36


#include <stdio.h>
#include <stdlib.h>
#include <sys/timeb.h>
long long getTime() {
    struct timeb t;
    ftime(&t);
    return 1000 * t.time + t.millitm;
}
int main(int argc, char **argv){
    long long start;
    int i;
    char *suffer;	
    suffer = malloc(1);
	
    // 计时开始
    start = getTime();
	
    for(i=2;i<1000002;i++)
	suffer = realloc(suffer,i);
	
    // 计时结束
    printf("time is : %d\n",getTime()-start);
}

time is : 10

     由上面看出,经过realloc申请到1000001字节内存地址的时间开销相比于malloc执行1000000次,明显要快了很是多,所以对于不定长空间的动态申请来讲,经过realloc来进行要快不少。可是不难发现,在上面的代码范例中,因为以前了解到relloc存在两种状况,第一种是当前地址空间以后还有足够的空余空间,由此只要对已申请到的空间进行扩展就好了,不然须要新申请并拷贝。这里显然是前者,若是是后者的状况,realloc的效率应该很慢猜对。由此能够猜想,malloc的申请规则,是连续分配的,每申请一个空间,就接着以前申请空间进行申请,所以对于上面代码中,未在suffer以后申请新的空间,所以suffer的后面始终都是空余空间,由此为了验证上述猜测,设计了下面这段代码进行验证分析:

#include <stdio.h>
#include <stdlib.h>
int main(int argc, char **argv){
    char *buffer_0,*buffer_1;	
    buffer_0 = malloc(1);
    buffer_1 = malloc(1);
    printf("buffer_0 = %p\n",buffer_0);
    printf("buffer_1 = %p\n",buffer_1);
    buffer_0 = realloc(buffer_0,2);
    printf("buffer_0 = %p\n",buffer_0);
}

buffer_0 = 0x22f1010
buffer_1 = 0x22f1030
buffer_0 = 0x22f1010

    从打印出来的地址能够看出,先为buffer_0分配1字节到地址0x22f01010,再为buffer_1分配的时候,buffer_1的地址确实是在buffer_0以后,却不是0x22f01011,而是间隔了32个字节。在为buffer_0进行realloc的时候,因为刚刚观察过内存地址的结构,在buffer_0以后并非牢牢跟着buffer_1的,而是间隔了32字节,有31字节的空余,因此天然的会想到以前使用过的规则,在地址后有足够的空闲空间,因而直接对地址进行扩充便可。

    由上述现象进行分析,有了解过结构体的内存存储结构的朋友,我想此时应该和我同样都会想到了C语言在处理结构体内存中存储方式时,因为读取效率的考虑所引入的字节对齐机制。由此猜测,C语言,或者说是操做系统在处理内存申请的时候为了提升性能应该也采用了字节对齐的机制,而这里的测试来看应该是32字节对齐,对申请不满32字节的申请对齐到32字节的位置进行内存分配,若是此时进行了realloc,realloc的长度小于32,也就是以后必定会有足够的空闲空间,或者当前地址对应的空间申请到的是最末端的地址空间。以及同时还有个疑问,假设对realloc的长度超过32,且非末端地址,也就是须要在未使用的地址中从新开辟一个大空间,那么原来该地址的空间应该空余了出来,此时再进行malloc新变量是继续向后存放,仍是直接对闲置出的内存碎片进行利用?为了验证上述猜测,继续测试下面这段代码:

#include <stdio.h>
#include <stdlib.h>
int main(int argc, char **argv){
    char *buffer_0,*buffer_1,*buffer_2;	
	buffer_0 = malloc(1);
	buffer_1 = malloc(1);
	printf("buffer_0 = %p\n",buffer_0);
	printf("buffer_1 = %p\n",buffer_1);
	buffer_0 = realloc(buffer_0,33);
	printf("buffer_0 = %p\n",buffer_0);
	buffer_2 = malloc(1);
	printf("buffer_2 = %p\n",buffer_2);
}

buffer_0 = 0x2569010
buffer_1 = 0x2569030
buffer_0 = 0x2569050
buffer_2 = 0x2569010

    分析上面代码和执行结果,能够发现,以前的猜测显然是正确的,buffer_0一开始获得的地址是0x2569010,buffer_1获得的地址是0x2569030,按照32字节对齐的规律分配。接着对buffer_0进行realloc了33字节,在原地址扩充显然不够,因而从新到下一个32字节对齐位置,也就是0x2569050进行存放,此时为buffer_2进行分配,buffer_2获得的地址和buffer_0一开始的地址相同。为此,显然在realloc的时候buffer_0的地址进行了更改,同时释放了原来占有的空间,且这出于低地址被释放的内存空间在新变量申请的时候会被优先分配。不过顺带提下,在写这篇博文的同时,笔者的一个朋友顺手测了下win32平台下的状况彷佛有很多区别,这里使用的centos6.5+gcc环境下,不论测试多少次都严格遵循32字节对齐的结论,而win32下彷佛也存在对齐现象,两次内存申请的首地址以前存在空闲空间,但这个空间长度并不是32,也不是某个确切数值,乍看之下并未看出很明显的规律性。对于细心的读者应该也看出对于上面代码的执行结果,每次执行的时候,即使都是第一次对堆内存进行申请,可是申请到的内存地址只能说大体都是落在0x2000000这个数量级上,并不能很明确说有某一个确切的起始点,而win32下每次都是一个确切起始值。对此这里不对win32进行详细分析,只是说明下现象。感谢这位朋友提供的测试结果。


    5. 由上一个问题的分析,对malloc和realloc在分配内存的问题基本上有了比较清楚的问题。此时又回到了效率的问题上,以前的测试过的realloc与malloc的效率对比,若是考虑了字节对齐这个概念,那么得出的,只要不从新分配拷贝的前提下,从新分配realloc这个结论是否会受字节对齐而改变。而对于发生地址变换拷贝内容的realloc效率低于直接malloc新地址是毋庸置疑的事实,可是对于此时的效率会差距达到怎样的数量级?

#include <stdio.h>
#include <stdlib.h>
#include <sys/timeb.h>
long long getTime() {
    struct timeb t;
    ftime(&t);
    return 1000 * t.time + t.millitm;
}
int main(int argc, char **argv){
    long long start;
    int i;
    char *suffer;	
    suffer = malloc(33);
	
    // 计时开始
    start = getTime();
	
    for(i=66;i<33000000;i+=33)
	suffer = malloc(33);
	
    // 计时结束
    printf("time is : %d\n",getTime()-start);
}

time is : 41


#include <stdio.h>
#include <stdlib.h>
#include <sys/timeb.h>
long long getTime() {
    struct timeb t;
    ftime(&t);
    return 1000 * t.time + t.millitm;
}
int main(int argc, char **argv){
    long long start;
    int i;
    char *suffer;	
    suffer = malloc(33);
	
    // 计时开始
    start = getTime();
	
    for(i=66;i<33000000;i+=33)
	suffer = realloc(suffer,i);
	
    // 计时结束
    printf("time is : %d\n",getTime()-start);
}

time is : 12

    这里简化掉其余更多组相同方式的测试数据和结果,只给出一组较具备表明性的代码。对于彷佛已经可以很好的说明,以前猜测的对齐机制并不会对malloc和realloc的效率构成影响。无论每次请求的空间大小是大于对齐的基数(这里指linux下)仍是小于,时间成本开销都是同样的。接着下面的测试代码对进行了地址迁移的realloc和普通的malloc的性能开销作一个对比分析:

#include <stdio.h>
#include <stdlib.h>
#include <sys/timeb.h>
long long getTime() {
    struct timeb t;
    ftime(&t);
    return 1000 * t.time + t.millitm;
}
int main(int argc, char **argv){
    long long start;
    int i;
    char *buffer[1000000];	
	
    // 计时开始
    start = getTime();
    for(i=0;i<1000000;i++)
	buffer[i] = malloc(33);
	
    // 计时结束
    printf("time is : %d\n",getTime()-start);
}
time is : 44

#include <stdio.h>
#include <stdlib.h>
#include <sys/timeb.h>
#define N 1
long long getTime() {
    struct timeb t;
    ftime(&t);
    return 1000 * t.time + t.millitm;
}
int main(int argc, char **argv){
    long long start;
    int i;
    char *buffer[1000000];	
	
    for(i=0;i<1000000;i++)
	buffer[i] = malloc(1);
	
    // 计时开始
    start = getTime();
    for(i=0;i<1000000;i++)
	buffer[i] = realloc(buffer[i],N);
	
    // 计时结束
    printf("time is : %d\n",getTime()-start);
}
N=1      time is : 16
N=33     time is : 47
N=50     time is : 53
N=64     time is : 58
N=65     time is : 60
N=100    time is : 70
N=300    time is : 137
N=400    time is : 250
N=420    time is : 400
N=450    time is : 1000
N=500    time is : 1187
N=1000   time is : 12411

    由上述测试结果能够看出,对于发生后续空余空间不足的realloc行为的性能开销果真如预期的同样,对于起始地址较小,而再分配的地址也较小的状况下,从新分配和拷贝的性能成本都较低,和直接malloc的成本开销起始差异不大,后者说仅仅只是稍大一些,几乎能够忽略不计,可是随着reallloc空间的增长,时间开销在起初变化较小,但逐渐的成指数增加,所以能够得出结论,若发生重分配的realloc在重分配大地址时的性能开销是巨大的。并且这里其实地址都只是一个字符,内容拷贝的成本应当至关低,若起始内容较大,拷贝成本必然也会显著增长,所以不难发现对于大内容的realloc,若是没法确保realloc的地址出于最末位地址,应当尽可能避免该方式进行内存重申请的使用,一旦遇到大并发的应用场景,或者大量调用的时候,性能损耗将会十分明显。

相关文章
相关标签/搜索