《深入理解C指针》-第2章 C的动态内存管理-阅读所得

        第2章 C的动态内存管理

 

由于可以先分配内存然后释放,因而应用程序可以更灵活高效地管理内存,无需为适应数据结构可能的最大长度分配内存,只要分配实际需要的内存即可。

 

2.1 动态内存分配

在C中动态分配内存的基本步骤有:
1. 用malloc类的函数分配内存;
2. 用这些内存支持应用程序;
3. 用free函数释放内存。

 

我们用malloc函数为整数分配内存。指针将分配的内存赋值为5,然后内存被free函数释放。

一旦内存被释放,就不应该再访问它了。通常我们不会在释放内存后有意去访问,通常的做法是总是把被释放的指针赋值为NULL。

malloc函数的参数指定要分配的字节数。如果成功,它会返回从堆上分配的内存的指针。如果失败则会返回空指针。

每次调用malloc(或类似函数),程序结束时必须有对应的free函数调用,以防止内存泄漏

使用sizeof操作符求数据类型的大小,作为malloc的入参。

int *pi;
*pi = (int*) malloc(sizeof(int));

//赋值符号的左边,使用了"解引指针",这个做法是错误。这样会把malloc函
数返回的地址赋给pi中存放的地址所在的内存单元。如果这是第一次对指针进行赋值操作,那指针所包含的地址可能无效。

pi = (int*) malloc(sizeof(int)); // 这个做法是正确的

 

分配内存时,堆管理器维护的数据结构中会保存额外的信息。这些信息包括块大小和其他一些东西,通常放在紧挨着分配块的位置。如果应用程序的写入操作超出了这块内存,数据结构可能会被破坏。这可能会造成程序奇怪的行为或者堆损坏。(内存越界访问)

 

内存越界访问例子:

我们为字符串分配内存,让它可以存放最多5个字符外加结尾的NUL字符。for循环在每个位置写入0,但是没有在写入
6字节后停止。for语句的结束条件是写入8字节。写入的0是二进制0而不是ASCII字符0的值。

 

6字节的字符串后面还分配了额外的内存,这是堆管理器用来记录内存分配的。如果我们越过字符串的结尾边界写入,额外
的内存中的数据会损坏。在本例中,额外的内存跟在字符串后面。不过,实际的位置和原始信息取决于编译器。

 

2.1.1内存泄漏

1.丢失内存地址
2.应该调用free函数却没有调用(有时候也称为隐式泄漏)

内存泄漏的一个问题是无法回收内存并重复利用,堆管理器可用的内存将变少。如果内存不断地被分配并丢失,那么当需要更多内存而malloc又不能分配时程序可能会终止,因为它用光了内存。在极端情况下,操作系统可能崩溃。

chunk变量指向堆上的内存。然而,在它指向另一块内存之前没有释放这块内存。最终,程序会用光内存然后非正常终止,即使没有终止,至少内存的利用效率也不高.

 

1.丢失内存地址

例子1:

当pi被赋值为一个新地址时丢失内存地址的例子。当pi又指向第二次分配的内存时,第一次分配的内存的地址就会
丢失。

“前”和“后”分别表示在执行第二次malloc之前和之后的程序状态。由于没有释放地址500处的内存,程序已经没有地方持有这个地址。

 

例子2:

例子是为字符串分配内存,将其初始化,并逐个字符打印字符串:

然而每次迭代name都会增加1,最后name会指向字符串结尾的NUL字符,如下图,分配内存的起始地址丢失了。

 

2. 隐式内存泄漏

如果程序应该释放内存而实际却没有释放,也会发生内存泄漏。如果我们不再需要某个对象但它仍然保存在堆上,就会发生隐式内存泄漏,一般这是程序员忽视所致。这类泄漏的主要问题是对象在使用的内存其实已经不需要了,应该归还给堆。最差的情况是,堆管理器可能无法按需分配内存,导致程序不得不终止。最好的情况是我们持有了不必要的内存。

在释放用struct关键字创建的结构体时也可能发生内存泄漏。如果结构体包含指向动态分配的内存的指针,那么可能需要在释放结构体之前先释放这些指针。

 


2.2 动态内存分配函数

大部分系统的stdlib.h头文件中都有如下函数:动态内存分配函数

malloc
realloc
calloc
free

动态内存从堆上分配,至于一连串内存分配调用,系统不保证内存的顺序和所分配内存的连续性。不过,分配的内存会根据指针的数据类型对齐,比如说,4字节的整数会分配在能被4整除的地址边界上。堆管理器返回的地址是最低字节的地址。


2.2.1 使用malloc函数

malloc函数从堆上分配一块内存,所分配的字节数由该函数唯一的参数指定,返回值是void指针,如果内存不足,就会返回NULL。此函数不会清空或者修改内存,所以我们认为新分配的内存包含垃圾数据。

这个函数只有一个参数,类型是size_t。传递参数给这个函数时要小心,因为如果参数是负数就会引发问题。在有些系统中,参数是负数会返回NULL。

如果malloc的参数是0,其行为是实现相关的:可能返回NULL指针,也可能返回一个指向分配了0字节区域的指针。如果malloc函数的参数是NULL,那么一般会生成一个警告然后返回0字节。

以下是malloc函数的典型用法:

int *pi = (int*) malloc(sizeof(int));

执行malloc函数时会进行以下操作:
1. 从堆上分配内存;
2. 内存不会被修改或是清空;
3. 返回首字节的地址。

因为当malloc无法分配内存时会返回NULL,在使用它返回的指针之前先检查NULL是不错的做法

1.要不要强制类型转换? 使用malloc需要注意的问题
malloc函数返回void *,可以将void指针赋值给其他任何指针类型,所以就不再需要显式类型转换了。

 

2.分配内存失败?使用malloc需要注意的问题

如果声明了一个指针,但没有在使用之前为它指向的地址分配内存,那么内存通常会包含垃圾,这往往会导致一个无效内存引用的错误。考虑如下代码片段:

下面错误代码:

char *name;
printf("Enter a name: ");
scanf("%s",name); //这里使用的name没有指向实际的内存,并且引用了。

 

3.没有给malloc传递正确的参数?使用malloc需要注意的问题

malloc函数分配的字节数是由它的参数指定的,在用这个函数分配正确的字节数时要小心。比如说要为10个双精度浮点数分配空间,那就需要80字节,通过下面的代码可以做到:

const int NUMBER_OF_DOUBLES = 10;

错误代码:
double *pd = (double*)malloc(NUMBER_OF_DOUBLES);//这段代码实际只分配了10字节。

正确代码:

double *pd = (double*)malloc(NUMBER_OF_DOUBLES * sizeof(double));

为数据类型分配指定字节数时尽量用sizeof操作符。

 

4. 确认所分配的内存数,使用malloc需要注意的问题

malloc可分配的最大内存是跟系统相关的,看起来这个大小由size_t限制。不过这个限制可能受可用的物理内存和操作系统的其
他限制所影响。

如果我们为一个字符串分配64字节,堆管理器会分配额外的内存来管理这个块。所分配内存的总大小,以及堆管理器所用到的
内存数,是两者的和。

 

5.静态、全局指针和malloc,使用malloc需要注意的问题

初始化静态或全局变量时不能调用函数。下面的代码声明一个静态变量,并试图用malloc来初始化:

static int *pi = malloc(sizeof(int)); //这么操作是不正确的。

 

对于静态变量,可以通过在后面用一个单独的语句给变量分配内存来避免这个问题。但是全局变量不能用单独的赋值语句,因为全局变量是在函数和可执行代码外部声明的,赋值语句这类代码必须出现在函数中:

static int *pi;
pi = malloc(sizeof(int));

在编译器看来,作为初始化操作符的=和作为赋值操作符的=不一样。


2.2.2 使用calloc函数

calloc会在分配的同时清空内存。该函数的原型如下:

 

calloc函数会根据numElements和elementSize两个参数的乘积来分配内存,并返回一个指向内存的第一个字节的指针。如果不能分配内存,则会返回NULL。此函数最初用来辅助分配数组内存。如果numElements或elementSize为0,那么calloc可能返回空指
针。如果calloc无法分配内存就会返回空指针,而且全局变量errno会设置为ENOMEM(内存不足),这是POSIX错误码,有的系统上可能没有。申请的内存也是要用free函数进行释放。
下例为pi分配了20字节,全部包含0:

int *pi = calloc(5,sizeof(int));

等价于

int *pi = malloc(5 * sizeof(int));
memset(pi, 0, 5* sizeof(int));

注意 memset函数会用某个值填充内存块。第一个参数是指向要填充的缓冲区的指针,第二个参数是填充缓冲区的值,最后一
个参数是要填充的字节数。

 

代码中黄金思维圈:有这个函数为什么不经常使用,而大部分代码都是用的是malloc?

如果内存需要清零可以使用calloc,不过执行calloc可能比执行malloc慢。

 

2.2.3 使用realloc函数

我们可能需要时不时地增加或减少为指针分配的内存,如果需要一个变长数组这种做法尤其有用。realloc函数会重新分配内存,下面是它的原型:

void *realloc(void *ptr, size_t size);

realloc函数返回指向内存块的指针。该函数接受两个参数,第一个参数是指向原内存块的指针,第二个是请求的大小。重新分配的块大小和第一个参数引用的块大小不同。返回值是指向重新分配的内存的指针。

请求的大小可以比当前分配的字节数小或者大。

如果比当前分配的小,那么多余的内存会还给堆,不能保证多余的内存会被清空。

如果比当前分配的大,那么可能的话,就在紧挨着当前分配内存的区域分配新的内存,否则就会在堆的其他区域分配并把旧的内存复制到新区域。

如果大小是0而指针非空,那么就释放内存。如果无法分配空间,那么原来的内存块就保持不变,不过返回的指针是空指针,且errno会设置为ENOMEM。

 

例子1:

在下例中,我们使用两个变量为字符串分配内存。一开始分配16字节,但只用到了前面的13字节(12个十六进制数字外加null结束字符(0))

char *string1;
char *string2;
string1 = (char*) malloc(16);
strcpy(string1, "0123456789AB");

string2 = realloc(string1, 8); //用realloc函数指定一个范围更小的内存区域
printf("string1 Value: %p [%s]\n", string1, string1);
printf("string2 Value: %p [%s]\n", string2, string2);

输出如下:
string1 Value: 0x500 [0123456789AB]
string2 Value: 0x500 [0123456789AB]

 

堆管理器可以重用原始的内存块,且不会修改其内容。不过程序继续使用的内存超过了所请求的8字节。也就是说,我们没有修改字符串以便它能装进8字节的内存块中。在本例中,我们本应该调整字符串的长度以使它能装进重新分配的8字节。实现这一点最简单的办法是将NUL赋给地址507。实际使用的内存超出分配的内存不是个好做法,应该避免。

 

例子2:

string1 = (char*) malloc(16);
strcpy(string1, "0123456789AB");
string2 = realloc(string1, 64); //堆管理器可以重用原始的内存块,且不会修改其内容。
printf("string1 Value: %p [%s]\n", string1, string1);
printf("string2 Value: %p [%s]\n", string2, string2);

执行以上代码得到类似下面的结果:
string1 Value: 0x500 [0123456789AB]
string2 Value: 0x600 [0123456789AB]

2.2.4 alloca函数和变长数组

alloca函数(微软为malloca)在函数的栈帧上分配内存。函数返回后会自动释放内存。若底层的运行时系统不基于栈,alloca函数会很难实现,所以这个函数是不标准的,如果应用程序需要可移植就尽量避免使用它。

 


2.3 用free函数释放内存

有了动态内存分配,程序员可以将不再使用的内存返还给系统,这样可以释放内存留作他用。通常用free函数实现这一点,该函数的原型如下:

void free(void *ptr);

指针参数应该指向由malloc类函数分配的内存的地址,这块内存会被返还给堆。尽管指针仍然指向这块区域,但是我们应该将它看成指向垃圾数据。稍后可能重新分配这块区域,并将其装进不同的数据。

pi指向分配的内存,这块内存最终会被释放:

int *pi = (int*) malloc(sizeof(int));
free(pi);

pi = NULL;//内存释放完要置成NULL.

 

下图是free函数执行前后瞬间内存的分配情况。地址500处的虚线框表示内存已经释放,但仍然有可能包含原值,pi变量仍然指向
地址500。这种情况称为迷途指针

如果传递给free函数的参数是空指针,通常它什么都不做。如果传入的指针所指向的内存不是由malloc类的函数分配,那么该函数的行为将是未定义的。在下例中,分配给pi的是num的地址,不过这不是一个合法的堆地址:

int num;
int *pi = #
free(pi); // 未定义行为  free释放的是堆的内存

应该在同一层管理内存的分配和释放。比如说,如果是在函数内分配内存,那么就应该在同一个函数内释放它。

 

2.3.1 将已释放的指针赋值为NULL

已释放的指针仍然可能造成问题。如果我们试图解引一个已释放的指针,其行为将是未定义的。所以有些程序员会显式地给指针

赋NULL来表示该指针无效,后续再使用这种指针会造成运行时异常。

这种技术的目的是解决迷途指针类问题。不过,花时间处理造成这类问题的条件要比粗暴地用空指针一刀切好,更何况除了初始化的情况,都不能将NULL赋给指针。

 

 

2.3.2 重复释放

重复释放是指两次释放同一块内存。

int *pi = (int*) malloc(sizeof(int));
*pi = 5;
free(pi);
...
free(pi);

 

调用第二个free函数会导致运行时异常。另一个例子不那么明显,涉及指向同一块内存的两个指针。如下所示,如果我们试图第二次释放同一块内存会发生同样的运行时异常。

p1 = (int*) malloc(sizeof(int));
int *p2 = p1;
free(p1);
...
free(p2);

 

两个指针引用同一个地址称为别名。

不幸的是,堆管理器很难判断一个块是否已经被释放,因此它们不会试图去检测是否两次释放了同一块内存。这通常会导致堆损坏和程序终止,即使程序没有终止,它意味着程序逻辑可能存在问题,同一块内存没有理由释放两次。

有人建议free函数应该在返回时将NULL或其他某个特殊值赋给自身的参数。但指针是传值的,因此free函数无法显式地给它赋
值NULL

 

 

2.3.3 堆和系统内存

堆一般利用操作系统的功能来管理内存。堆的大小可能在程序创建后就固定不变了,也可能可以增长。不过堆管理器不一定会在调用free函数时将内存返还给操作系统。释放的内存只是可供应用程序后续使用。所以,如果程序先分配内存然后释放,从操作系统的角度看,释放的内存通常不会反映在应用程序的内存使用上。

 

2.3.4 程序结束前释放内存

 要不要在程序结束前释放内存?

操作系统负责维护应用程序的资源,包括内存。当应用程序终止时,操作系统要负责重新分配这块内存以便别的应用程序使用。已终止的应用程序的内存状态不管是否损坏都无关紧要,事实上,内存损坏可能正是应用程序终止的原因。异常终止的程序可能无法做清理工作,因此没有理由在程序终止之前释放分配的内存。

操作系统负责回收应用程序终止后的资源,比如内存资源。

如果让程序终止前释放所有内存:

可能得不偿失;
可能很耗时,释放复杂结构也比较麻烦;
可能增加应用程序大小;
导致更长的运行时间;
增加引入更多编程错误的概率。

是否要在程序终止前释放内存取决于具体的应用程序。


2.4 迷途指针

如果内存已经释放,而指针还在引用原始内存,这样的指针就称为迷途指针。迷途指针没有指向有效对象,有时候也称为过早释放。

使用迷途指针会造成一系列问题,包括:

如果访问内存,则行为不可预期;
如果内存不可访问,则是段错误;
潜在的安全隐患。

导致这几类问题的情况可能如下:
访问已释放的内存;
返回的指针指向的是上次函数调用中的自动变量。

 

2.4.1 迷途指针示例

用malloc函数为一个整数分配内存,接下来,用free函数释放内存:

int *pi = (int*) malloc(sizeof(int));
*pi = 5;
printf("*pi: %d\n", *pi);
free(pi);

pi变量持有整数的地址,但堆管理器可以重复利用这块内存,且其中存放的可能是非整数数据。下图说明了free函数执行前后的程序状态。假设pi变量属于main函数,位于地址100,malloc函数分配的内存位于地址500。

执行free函数将释放地址500处的内存,此后就不应该再使用这块内存了。但大部分运行时系统不会阻止后续的访问或修改。我们还是可以向这个位置写入数据,如下所示。这么做的结果是不可预期的。

free(pi);
*pi = 10;

 

还有一种迷途指针的情况更难觉察:一个以上的指针引用同一内存区域而其中一个指针被释放。如下所示,p1和p2都引用同一块内存区域(称为指针别名),不过p1被释放了:(源被指向的内存被释放了)

 

使用块语句时也可能出现一些小问题,如下所示。这里pi被赋值为tmp的地址,变量pi可能是全局变量,也可能是局部变量。不过当包含tmp的块出栈之后,地址就不再有效:

大部分编译器都把块语句当做一个栈帧。tmp变量分配在栈帧上,之后在块语句退出时会出栈。pi指针现在指向一块最终可能被其他活跃记录(比如foo函数)覆盖的内存区域。

 

 

2.4.2 处理迷途指针

有时候调试指针诱发的问题会很难解决,以下方法可用来对付迷途指针

1.1  释放指针后置为NULL,后续使用这个指针会终止应用程序。不过,如果存在多个指针的话还是会有问题。因为赋值只会影
响一个指针。
1.2 写一个特殊的函数代替free函数。有些系统(运行时或调试系统)会在释放后覆写数据(比如0xDEADBEEF,取决于被释放的对象,Visual Studio会用0xCC、0xCD或者0xDD)。在不抛出异常的情况下,如果程序员在预期之外的地方看到这些值,可以认为程序可能在访问已释放的内存。
1.3 用第三方工具检测迷途指针和其他问题。

 

在调试迷途指针时打印指针的值可能会有所帮助,但需要注意打印的方式。确保用一致的方式打印,从而避免比较指针的值时产生歧义。assert宏有用。

 

 


2.4.3 调试器对检测内存泄漏的支持

 

 

 


2.5 动态内存分配技术

大部分堆管理器把堆或数据段作为内存资源。这种方法的缺点是会造成碎片,而且可能和程序栈碰撞。尽管如此,它还是实现堆最常用的方法。

堆管理器需要处理很多问题,比如堆是否基于进程和(或)线程分配,如何保护堆不受安全攻击。

 


2.5.1 C的垃圾回收

malloc和free函数提供了手动分配和释放内存的方法。不过对于很多问题,需要考虑使用C的手动内存管理,比如性能、达到好的引用局部性、线程问题,以及优雅地清理内存。


有些非标准的技术可以用来解决部分问题,本节将探讨其中一部分技术。这些技术的关键特性在于自动释放内存。内存不再使用之后会被收集起来以备后续使用,释放的内存称为垃圾,因此,垃圾回收就是指这个过程。

鉴于以下原因,垃圾回收是有价值的:不需要程序员费尽心思决定何时释放内存;让程序员专注应用程序本身的问题

 


2.5.2 资源获取即初始化

 

 


2.5.3 使用异常处理函数

这里的try块包含任何可能在运行时抛出异常的语句。不管有没有异常抛出,都会执行finally块,因此也一定会执行free函数。

 

 

 

 

 

 

 

 

 

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Linux技术芯

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值