引入问题

举一个烂大街的例子:

现在我们要录入一些书籍信息,事先不知道有多少本书,直到程序运行时才知道.

那么我们该如何编写程序呢.

首先,很容易想到,我们需要一个数组来存储若干个学号;接着,为了存储若干个学号,我们可以声明一个固定大小数组来存储.


上面的方法在我们事先(敲代码时)就已经知道要输入的学号数量时十分有效,但是在这个问题中,我们并不知道学号数量,只能在运行时才能获知,此时直接声明一个固定大小的数组就不再是一个好的选择,因为很有可能实际输入的数量超过数组的长度,导致溢出.

溢出是十分严重的问题,会导致程序错误地访问甚至修改不此时应该被访问的地方,轻则导致程序运行错误,重则程序崩溃,甚至破坏重要数据.


我们需要一种方法来让程序实现在运行时动态地分配内存的功能,这里即"动态地分配一个指定大小的数组,这个大小只能在运行时获取".

这就是动态内存分配,事实上,我们在函数内直接定义的数组,它们的存储空间称为栈区,栈的大小(一般)都是固定的,即在函数调用时就已经确定,无法更改,因此大小有限;而使用接下来讲解的动态内存分配来分配的内存,则被存储在称为堆区的部分,这里的数据都是可以随时分配,随时释放的,也就避免了固定大小带来的问题.

另一方面,全局变量不同,它们是定义在全局区的,其大小也是固定的.

栈区,堆区,全局区,代码区是内存的四个分区,有关内存四区的内容,读者可以自行查阅资料,我们这里先不急于展开.

C语言的动态内存管理

所谓的动态内存分配,其实就是分配堆内存.

内存中专门有一个分区,称为堆区,这里的内存空间是允许各个程序在运行时随时进行请求,随时分配给程序的.当然,在程序使用完这块内存(取决于程序的逻辑),需要释放掉,即将这块内存还给堆区,以备下一次或其他程序请求分配.

请求堆内存

C标准库提供了一套函数,即alloc系函数,用于内存管理,他们声明于stdlib.h头文件中,即Standard library,这个头文件包含了C语言最常用的一些系统函数的声明.

分配的内存会返回其首地址,用一个指针进行存储.

malloc函数

最常用的函数是malloc函数,函数名是Memory allocation的缩写,顾名思义,专门用于分配内存.

函数原型如下:

void* malloc( size_t size );

其中:

  • 返回值是一个void*类型的指针,指向成功的分配内存空间的首地址
  • 参数是一个size_t类型的无符号整数,代表请求分配的内存大小,单位为字节.
  • 如果函数调用失败,即内存未能成功分配(例如堆空间不足,但是这种情况实际很少出现),则返回NULL指针.
  • malloc函数返回的内存空间是连续的,完全可以看做一个特定长度的数组来使用.
  • malloc函数并不会对返回的内存进行初始化,这段内存中的任何值均应该视为垃圾值.

注意:

  • 如果size参数为零,则malloc的行为是由实现定义的,例如可以返回一个空指针,或者返回一个非空指针,但不应对该指针解引用!并且应该将其传递给free函数以避免内存泄漏.

例如,我们需要分配一个长为n的int数组,n由用户来输入,并在数组中存储1~n这些数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <stdlib.h> // 需要包含stdlib.h头文件
#include <stdio.h>

int main() {
int n;
// 输入一个n
printf("Please input the length of the array: ");
scanf("%d", &n);
// 分配内存,由于malloc的参数是字节数,我们需要将n乘以sizeof(int)
// 来计算出实际需要的总字节数
int *arr = (int *)malloc(n * sizeof(int));
if(arr == NULL)
return 1; // malloc调用失败,未能成功分配内存.(大部分情况下不会发生)
for (int i = 0; i < n; i++) {
arr[i] = i + 1;
}
// 打印数组
for (int i = 0; i < n; i++) {
printf("%d ", arr[i]);
}
printf("\n");
// 使用free函数释放内存,该函数无需内存的实际长度,因为堆内存的长度由操作系统来管理.
free(arr);
return 0;
}

运行结果如下:

image-20240327125544630

这样便可以在运行时分配不同长度的数组,以提高灵活性和安全性.

上面的代码需要注意的是:

  • 我们需要的是int数组,而malloc返回的内存是单纯的字节数组,它取决于我们如何使用它,因此,这里我们将返回值强制类型转换为int*,赋值给arr指针.
  • 使用后的内存需要free掉,否则会发生内存泄漏,该问题后面会讲解.

calloc函数

使用malloc函数务必需要注意,它不会对分配的内存进行初始化,我们必须手动为其初始化:

1
2
3
#include <stdlib.h>
char *str = (char *)malloc(sizeof(char)*100);
memset(str,0,sizeof(char)*100);

memset函数用于将第一个参数指向的一段内存的每个字节用第二个参数指定的值进行"填充",填充长度为第三个参数指定的字节数.


calloc函数可以在分配内存的同时进行零初始化,函数原型如下:

1
void* calloc( size_t num, size_t size );

其中:

  • num: 对象数目
  • size: 每个对象的大小

calloc分配一个numsize大小的对象的数组,并且将分配的内存中所有字节初始化为.

注意:

  • size为零,则行为是实现定义的.

将刚才的代码用calloc重写一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <stdlib.h> // 需要包含stdlib.h头文件
#include <stdio.h>

int main() {
int n;
// 输入一个n
printf("Please input the length of the array: ");
scanf("%d", &n);
// 分配内存,由于malloc的参数是字节数,我们需要将n乘以sizeof(int)
// 来计算出实际需要的总字节数
int *arr = (int *)calloc(n, sizeof(int));
if(arr == NULL)
return 1; // malloc调用失败,未能成功分配内存.(大部分情况下不会发生)
// 打印数组
for (int i = 0; i < n; i++)
printf("%d ", arr[i]);
printf("\n");
for (int i = 0; i < n; i++)
arr[i] = i + 1;
// 打印数组
for (int i = 0; i < n; i++)
printf("%d ", arr[i]);
printf("\n");
// 使用free函数释放内存,该函数无需内存的实际长度,因为堆内存的长度由操作系统来管理.
free(arr);
return 0;
}

运行结果如下:

image-20240330105442786

显然可以看到,在这段内存分配后,就已经被初始化(用0填充).

释放堆内存

free函数

想要释放一段内存十分简单,只要使用free函数,并传入其首地址即可.

函数原型:

1
void free( void* ptr );

注意:

  • 如果ptr参数为空指针,则free函数不进行操作.
  • 如果ptr参数的值并非由malloc,calloc,realloc等函数返回,则行为未定义.例如ptr执行某个普通的局部变量,此时ptr指向的是栈内存!
  • 若 ptr 所指代的内存区域已经被解分配,则行为未定义.所谓解分配,即已经对ptr调用了free等函数.(注:根据标准,情况并不止free函数一种,请参阅文档free - cppreference.com)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

例如:

```c
#include <stdlib.h> // 需要包含stdlib.h头文件
#include <stdio.h>

int main() {
int *mem = (int *)malloc(sizeof(int) * 10);
for(int i = 0; i < 10; i++)
mem[i] = i;
for(int i = 0; i < 10; i++)
printf("%d ", mem[i]);
free(mem); // 将mem指向的堆内存释放掉
return 0;
}

内存泄漏问题

我们在使用完一段内存后,一定要对其进行释放,否则这段内存会"丢失",即我们的程序不再使用这段内存,但是操作系统认为程序请求了这段内存,所以这段内存是无法被释放的,也就会被持续占用,这种情况叫做内存泄漏.

当然,如果我们的程序很短,在用完这段内存之后很快就运行结束了,而不主动释放内存,那么在程序结束后,堆内存会自动进行释放,而不是仍然"丢失".但是很多程序往往需要运行很长时间,例如服务器上运行的一些程序,往往数个月都在运行,此时如果发生内存泄漏,带来的问题是非常大的,程序请求的内存会被一直占用,但是程序本身却并不使用它.

更严重的是,由于分配的堆内存需要使用一个指针来记录,如果程序直接将该指针指向其他位置,例如重新分配一段堆内存.那么原来那段内存就是真正的丢失了,因为我们无法找回这段内存的位置,自然也就无法进行释放.唯一的办法就是在程序退出后由操作系统回收.

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <stdlib.h> // 需要包含stdlib.h头文件
#include <stdio.h>

int main() {
// 内存泄漏问题
int *mem = NULL;
int count = 10000;
// 申请了count次内存,但是只使用了最后一次申请的内存
for(int i = 0; i < count; i++)
mem = (int *)malloc(sizeof(int)*10);
for(int i=0;i<10;++i)
mem[i] = i;
for(int i=0;i<10;++i)
printf("%d ", mem[i]);
return 0;
}

这段程序是可以运行的,但是问题显而易见,前9999次分配的内存在运行时全部丢失(如果均分配成功的话),原因在于,程序中访问分配的内存的唯一方法是使用mem指针进行访问,但是程序的循环中,每次分配内存都让mem指向新的堆内存,这就导致我们已经无法访问到原来的内存.

此时即时我们有心想要将之前分配的内存释放掉,也已经无法做到.只要程序不退出,这9999次内存就依然被这个程序"占用",但是实际情况是,连程序本身都无法再访问到它们,更别提释放.

我们只能等待程序运行结束后,让操作系统来进行回收.

扩容堆内存

堆内存的一个特点便是灵活,C语言标准库还提供了realloc函数,以实现堆内存的扩容,当我们已经分配好的一段堆内存不够用时,可以使用realloc函数进行扩容.

realloc函数

realloc函数用于重新分配给定的内存区域,但是需要注意,该函数有许多需要注意的问题!

函数原型:

1
void *reallocvoid *ptr, size_t new_size );

其中:

  • ptr: 指向需要重新分配的内存区域的指针
  • new_size: 数组的新大小(字节数)
  • 返回值: 返回指向新分配内存的指针,原指针ptr失效.

注意:

  • 函数执行成功时,返回指向新分配内存的指针,同样,返回的指针后续依旧需要进行释放. 原指针ptr失效,再次对其进行访问是未定义的,即使重分配在就地.
  • 函数执行失败时,返回空指针,原指针ptr保持有效,后续依旧需要释放.
  • 如果参数ptr为非NULL,则它必须是malloc,callocrealloc函数所分配,并且尚未被freerealloc函数释放. 否则,结果未定义.
  • 若参数ptrNULL,则行为与调用malloc(new_size)相同.否则:
  • new_size为零,则行为由实现定义(可能返回空指针,该情况下可能或可能不释放旧内存,或可能返回某个不能用于访问存储的非空指针), (注意:该用法在C17起被弃用,并且在C23之后变成行为未定义!)

重分配操作有2种执行方法,按其中之一执行:

  1. 可能的话,扩张或收缩ptr所指向的现有内存区域. 新旧大小中的较小者范围内的区域的内容保持不变. 若扩张范围,则数组新增部分的内容是未定义的.
  2. 分配一个大小为new_size节的新内存块,并复制大小等于新旧大小中较小者的内存区域,然后释放旧内存块.

若无足够内存,则不释放旧内存块,返回空指针.

以上就是realloc函数绝大部分需要注意的问题,当然作为初学者,只要正常地使用,一般并无太大问题. 一般情况下都是可以正确执行的,但是决不能完全信任它,因此我们依旧需要进行检查.

下面是一个例子,分配一段堆内存来存储10个整数,后续将其扩容以存储20个整数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
#include <stdio.h>
#include <stdlib.h>

int main() {
// 使用malloc分配内存空间,存储10个整数
int* ptr = (int*)malloc(10 * sizeof(int));

// 检查内存是否成功分配
if (ptr == NULL) {
printf("Memory allocation failed\n");
return 1;
}

for(int i = 0; i < 10; i++) {
ptr[i] = i;
}

// 打印原始数组
printf("Before realloc:\n");
for(int i = 0; i < 10; i++) {
printf("%d ", ptr[i]);
}
printf("\n");

// 使用realloc扩大内存空间,现在可以存储20个整数
ptr = (int*)realloc(ptr, 20 * sizeof(int));

// 检查内存是否成功重新分配
if (ptr == NULL) {
printf("Memory reallocation failed\n");
return 1;
}

// 在新的内存空间添加额外的10个整数
for(int i = 10; i < 20; i++) {
ptr[i] = i;
}

// 打印扩大后的数组
printf("After realloc:\n");
for(int i = 0; i < 20; i++) {
printf("%d ", ptr[i]);
}
printf("\n");

// 释放内存
free(ptr);

return 0;
}

运行结果:
image.png|525
最后,同样需要对其进行释放.

内存泄漏问题

内存泄漏(memory leak)问题并不仅仅是C语言要考虑的问题,而是任何编程语言都需要注意的问题.

所有使用malloc,calloc等函数分配的堆内存,在使用之后必须进行释放,否则程序便会一直占用该内存段.

一个简单的例子

我们考虑一个内存泄漏的简单例子,在这个例子中,我们有一个程序A,存储n个单词(长度不超过100),代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <stdio.h>
#include <stdlib.h>

#define MAX_LEN 100 // 定义字符串的最大长度

int main() {
int n; // 用来存储用户输入的字符串数量
char *p; // 用来指向动态分配的字符串

scanf("%d", &n); // 从用户获取字符串数量

for (int i = 0; i < n; ++i) {
// 为每个字符串动态分配内存,长度为MAX_LEN + 1(包括字符串结束符'\0')
p = (char*)malloc(sizeof(char) * (MAX_LEN + 1));

scanf("%s", p); // 从用户获取字符串

puts(p); // 输出字符串
}

free(p); // 释放最后分配的字符串内存

return 0;
}

运行这个程序,简单地测试一下:
image.png|344
看起来没有问题,然而,这里发生了内存泄漏,我们来跟踪一下:

  1. 首先,我们输入3,则循环执行3次,每次循环都分配一段内存,并接受一个单词,然后输出:
    image.png|475
  2. 第一次循环中,为p分配了第一段内存M1,存储第一个单词"i",并输出.循环体结束后如下所示:
    image.png|475
  3. 第二次循环中,为p分配了第二段内存M2,存储第二个单词"love",并输出.循环体结束后如下所示:
    image.png|475
    然而,必须注意,此时并没有进行free函数的调用,因此M1依旧存在,并且操作系统认为程序A依然持有着该内存!问题是,指针p已经指向了新的M2,M1的指针已经丢失,因此程序A实际上已经无法找回该内存!
  4. 第三次循环中,为p分配了第三段内存M2,存储第三个单词"world",并输出.循环体结束后如下所示:
    image.png|475
    同理,M1M2此时均丢失!现在,该退出循环了.
  5. 执行free函数,释放掉p当前指向的堆内存,结果如下所示:
    image.png|475
  6. 执行return 0;退出程序. 此时由操作系统来负责回收M1M2:
    |475
    到此,内存泄漏才算结束,因为程序(进程)结束后,操作系统会去回收内存.

如上所述,这是一个很简单的例子,在运行过程中发生了内存泄漏,该例子中,M1M2的指针被销毁(覆盖),导致两段内存丢失(从程序A的视角来看),然而,只要程序A仍然在运行,这2段内存一直是被占用的(从操作系统的视角来看).

最后,程序仅仅正常释放了M3,而M1M2是由操作系统回收的. 由于程序太短(这是个简单的例子),可能并无大碍.

但是,如果有类似问题的程序,它是运行在服务器上的一个关键程序,往往需要几天乃至数个月不停止运行,并且每次分配的内存量很大. 那么,即使计算机的内存再大,也会被这程序"败光"很大资源.

换句话说,只要这程序不结束,那么被占用的内存就一直无法被释放,然而这个有问题的程序自己早已不再使用这段内存,甚至已经丢失掉了(正如上面那个例子所示),这样的话,被"泄漏"的内存就会一直空闲,却无法被使用.

因此,内存泄漏是一个非常重要的问题,像C语言这种没有垃圾回收功能的语言,必须认真对待!

注:

  1. 垃圾回收: 自动进行内存管理,将无用的内存释放掉,而无需用户(程序员)手动管理释放.
  2. 并不是一定要丢失指针才会发生内存泄漏,如果某段内存从程序逻辑上不再使用,却并不释放,这也叫内存泄漏.
  3. 内存泄漏也有着许多种类和原因,具体视情况不同,严重程度也不同. 这里不再赘述.

本章讲解了C语言的`动态内存管理`相关的函数,与`内存泄漏`这一问题. 动态内存(堆内存)和栈内存都是非常重要的内存分配方式,各有优缺点,在实际应用时,要视情况不同来进行选择. 这一内容的详细讨论则超出了本教程的讨论范畴.

本教程基于C99进行讲解,关于新标准(例如C11,C17,C23)在动态内存管理方面还有着许多新内容,具体请参阅Cppreference文档.

——WAHAHA 2024.4.6



上一篇:C语言教程-14_5-枚举
下一篇:C语言教程-15_2-存储期,作用域与链接