目录
前言
堆内存的设计是为了满足程序在运行时的动态内存分配需求。在早期的计算机系统中,内存管理通常是静态的,即在编译时确定所有变量的内存分配。这种方式的缺点是缺乏灵活性,因为预先分配的内存可能会被浪费或者不足以满足程序运行时的需求。
为了解决这个问题,程序员开始使用堆内存来动态地分配和释放内存。堆内存的设计使得程序员可以在运行时根据需要创建和释放内存,从而更好地适应程序运行时的需求。这种灵活性使得程序可以更加高效地使用内存资源,并且可以实现更复杂的数据结构和算法。
此外,堆内存的设计也使得程序可以更加模块化。通过将插件或模块加载到堆内存中,可以实现程序的动态扩展和修改。这种设计使得程序可以根据需要进行定制和扩展,从而提高了程序的灵活性和可重用性。
总之,堆内存的设计是为了满足程序在运行时的动态内存分配需求,提高程序的灵活性和效率。它是程序中除了栈和静态存储区之外的第三种内存分区,为程序员提供了更多的内存管理手段。接下来我们先从程序的内存分区讲起。
一、内存分区
C代码经过预处理、编译、汇编、链接4步后生成一个可执行程序。在 Linux 系统环境下,程序是一个普通的可执行文件。如果有一个程序文件名称是test,执行命令 size test,就会得到其文件结构如下:
通过上图可以得知,在没有运行程序前,也就是说程序没有加载到内存前,可执行程序内部已经分好3段信息,分别为代码区(text)、数据区(data)和未初始化数据区(bss)3 个部分(有些人直接把data和bss合起来叫做静态区或全局区)。
1)代码区
存放 CPU 执行的机器指令。通常代码区是可共享的(即另外的执行程序可以调用它),使其可共享的目的是对于频繁被执行的程序,只需要在内存中有一份代码即可。代码区通常是只读的,使其只读的原因是防止程序意外地修改了它的指令。另外,代码区还规划了局部变量的相关信息。
2)全局初始化数据区/静态数据区(data段)
该区包含了在程序中明确被初始化的全局变量、已经初始化的静态变量(包括全局静态变量和局部静态变量)和常量数据(如字符串常量)。
3)未初始化数据区(又叫 bss 区)
存入的是全局未初始化变量和未初始化静态变量。未初始化数据区的数据在程序开始执行之前被内核初始化为 0 或者空(NULL)。
程序在加载到内存前,代码区和全局区(data和bss)的大小就是固定的,程序运行期间不能改变。然后,运行可执行程序,系统把程序加载到内存,除了根据可执行程序的信息分出代码区(text)、数据区(data)和未初始化数据区(bss)之外,还额外增加了栈区、堆区。具体分区如下图所示。
下面再简介一下各内存分区:
1)代码区:这是存储程序代码的区域。它包含了程序的二进制代码,这些代码由CPU直接执行。此区域的大小在程序编译时确定,并且在运行时不能改变。任何试图修改这段内存的代码都会导致程序崩溃。
2)未初始化数据区(BSS):当程序开始运行时,操作系统会将此区域初始化为零或空。它主要用于存储全局变量和静态变量,这些变量在程序开始运行时尚未初始化。
3)全局/静态初始化数据区(数据段):此区域包含初始化的全局变量和静态变量,以及常量字符串。这些数据在程序加载时从磁盘上的可执行文件中读入内存。
4)栈区:每当一个函数被调用时,函数的信息(例如返回地址和参数)会被推送到栈中。同样,局部变量也会存储在栈中。栈是连续的内存块,遵循LIFO原则。
5)堆区:堆是用于动态内存分配的区域。例如,当你使用C语言的malloc函数时,分配的内存就来自于这个区域。程序员需要手动释放这部分内存,否则可能会导致内存泄漏。堆的大小通常远远大于栈,而且没有特定的顺序。
注意:各种标识符修饰的变量函数作用域,生命周期,存储分区位置如下图所示做一下说明。
二、常用的内存操作函数
1)memset函数
#include <string.h>
void *memset(void *s, int c, size_t n);
功能:将s的内存区域的前n个字节以参数c填入
参数:
s:需要操作内存s的首地址
c:填充的字符,c虽然参数为int,但必须是unsigned char , 范围为0~255
n:指定需要设置的大小
返回值:s的首地址
示例代码:
#include <stdio.h>
#include <string.h>
int main() {
char str[20] = "Hello, World!";
printf("Before memset: %s\n", str);
// 使用 memset 将字符串中的所有字符设置为 'A'
memset(str, 'A', 12); // 从索引 0 开始,共12个字符,即 'Hello, World!' 中的字符
printf("After memset: %s\n", str);
return 0;
}
运行结果输出如下:
注意,memset 是按字节进行操作的,所以如果你试图用 memset 来修改字符串或其他类型的对象,长度控制不好,可能会产生不可预知的结果。因为可能实际上修改了字符串中的'\0'字符,导致字符串打印出来比预期的长。因此在使用 memset 时一定要小心。
2)memcpy函数
#include <string.h>
void *memcpy(void *dest, const void *src, size_t n);
功能:拷贝src所指的内存内容的前n个字节到dest所值的内存地址上。
参数:
dest:目的内存首地址
src:源内存首地址,注意:dest和src所指的内存空间不可重叠
n:需要拷贝的字节数
返回值:dest的首地址
代码示例:
#include <stdio.h>
#include <string.h>
int main() {
int a[10] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
int b[10];
memcpy(b, a, sizeof(a));
int i = 0;
for (i = 0; i < 10; i++)
{
printf("%d ", b[i]);
}
printf("\n");
}
3)memmove函数
memmove()功能用法和memcpy()一样,区别在于:dest和src所指的内存空间重叠时,memmove()仍然能处理,不过执行效率比memcpy()低些。
如果我们使用VS的编译器,memcpy与memmove的实现方式其实是一致的,两者等价。
4)memcmp函数
#include <string.h>
int memcmp(const void *s1, const void *s2, size_t n);
功能:比较s1和s2所指向内存区域的前n个字节
参数:
s1:内存首地址1
s2:内存首地址2
n:需比较的前n个字节
返回值:
相等:=0
大于:>0
小于:<0
代码示例:
#include <stdio.h>
#include <string.h>
int main() {
int a[10] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
int b[10] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
int flag = memcmp(a, b, sizeof(a));
printf("flag = %d\n", flag);
return 0;
}
三、堆内存的使用
在程序设计中,对于要处理的批量数据,我们往往是选用数组作为存放这些数据的数据结构,然而,数组有一个明显的缺点,就是在定义数组时,其长度必须是常值,无法根据需要动态地定义。这样,在很多情况下,不是定义的数组长度不够,就是定义太长以至于浪费。采用动态分配可以克服这一缺点,并且可以随时释放。
动态分配内存空间的步骤:
1)定义一个指针变量;
2)申请一片内存空间,并将其首地址赋给指针变量,此时便可通过指针变量访问这片内存;
3)用完后释放这片内存空间。
在这里主要涉及两个函数,即 malloc申请空间,free释放空间。
3.1 malloc函数
#include <stdlib.h>
void *malloc(size_t size);
功能:在内存的动态存储区(堆区)中分配一块长度为size字节的连续区域,用来存放类型说明符指定的类型。分配的内存空间内容不确定,一般使用memset初始化。
参数:
size:需要分配内存大小(单位:字节)
返回值:
成功:分配空间的起始地址
失败:NULL
3.2 free函数
#include <stdlib.h>
void free(void *ptr);
功能:释放ptr所指向的一块内存空间,ptr是一个任意类型的指针变量,指向被释放区域的首地址。对同一内存空间多次释放会出错。
参数:
ptr:需要释放空间的首地址,被释放区应是由malloc函数所分配的区域。
返回值:无
代码示例:
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
int main()
{
int count, * array, n;
printf("请输入要申请数组的个数:\n");
scanf("%d", &n);
array = (int*)malloc(n * sizeof(int));
if (array == NULL)
{
printf("申请空间失败!\n");
return -1;
}
//将申请到空间清0
memset(array, 0, sizeof(int) * n);
for (count = 0; count < n; count++) /*给数组赋值*/
array[count] = count;
for (count = 0; count < n; count++) /*打印数组元素*/
printf("%2d", array[count]);
free(array);
return 0;
}
3.3 calloc函数
calloc是C语言中的一个函数,它用于分配内存。与malloc函数不同,calloc函数会根据指定的数量和大小来分配内存,并初始化所分配的内存为零。因此,calloc函数特别适合为数组或结构体等数据结构分配内存,并确保分配的内存被初始化为零。
下面是calloc函数的原型:
void *calloc(size_t num, size_t size);
其中,num是所需的元素数量,size是每个元素的大小(以字节为单位)。
以下是一个使用calloc函数的示例代码:
#include <stdio.h>
#include <stdlib.h>
int main() {
int* array;
int n = 10;
// 使用calloc分配内存并初始化
array = (int*)calloc(n, sizeof(int));
if (array == NULL) {
printf("内存分配失败\n");
return 1;
}
// 使用分配的内存存储数据
for (int i = 0; i < n; i++) {
array[i] = i + 1;
}
// 打印数组中的数据
for (int i = 0; i < n; i++) {
printf("%d ", array[i]);
}
printf("\n");
// 释放分配的内存
free(array);
return 0;
}
在上面的示例中,我们使用calloc函数分配了一个包含10个整数的数组,并将每个元素初始化为零。然后,我们使用分配的内存存储了一些数据,并打印了数组中的数据。最后,我们使用free函数释放了分配的内存。
四、悬空指针和野指针
在C语言中,悬空指针和野指针都是指那些指向无效内存区域的指针,但是它们产生的原因和解决方法有所不同。
1)悬空指针:
悬空指针是指指向已经被释放的内存区域的指针。这种指针通常是因为程序员在释放内存后没有将指针置为NULL,而继续使用这个指针导致的。
代码示例:
#include <stdio.h>
#include <stdlib.h>
int main() {
int* p = (int*)malloc(sizeof(int)); // 分配内存
if (p == NULL) {
printf("内存分配失败\n");
return -1;
}
*p = 10; // 将分配的内存初始化为10
printf("p指向的值为:%d\n", *p); // 输出10
free(p); // 释放内存
printf("p指向的值为:%d\n", *p); // 输出不确定的值,因为p指向的内存已经被释放了
return 0;
}
在上面的代码中,我们先分配了一块内存,并将指针p指向这块内存。然后输出p指向的值,再释放这块内存,最后再次输出p指向的值。由于在释放内存后没有将p置为NULL,所以最后输出p指向的值时会输出一个不确定的值,因为p指向的内存已经被释放了,成为一个悬空指针。
2)野指针
野指针是指指向未被分配内存区域的指针。这种指针通常是因为程序员错误地将指针指向了一个不应该被分配内存的地址,或者在释放内存后没有将指针置为NULL,而继续使用这个指针导致的。
示例代码:
#include <stdio.h>
int main() {
int* p = NULL; // 定义一个空指针
*p = 10; // 试图给空指针所指向的内存赋值,会导致未定义的行为 运行会访问内存冲突
printf("p指向的值为:%d\n", *p); // 输出不确定的值,因为p指向的内存没有被分配
return 0;
}
在上面的代码中,我们先定义了一个空指针p,然后试图给p所指向的内存赋值,这会导致未定义的行为。接着输出p指向的值,由于p指向的内存没有被分配,所以输出的值是不确定的。这就是一个野指针的示例。
总结:
释放了内存之后,指针,应该被及时的赋值为 NULL。不赋值为NULL 的话,称为悬空指针。
指针变量被定义之后,没有被初始化,这种指针被称为野指针。
指针变量,要么指向有意义的地方 (变量,数组,堆),要么就指向 NULL(0)。
五、堆内存的使用提醒
使用堆,实际上就大量的会使用指针,记住以下原则,会让你受益匪浅:
1)刚刚分配的动态内存的初始值是不确定的。
2)不能对同一指针(地址) 连续两次进行 free 操作。
3)不能对指向静态内存区(全局变量)或栈内存区(局部变量)的指针应用 free(但可以对空指针NULL应用 free),对一个指针应用 free 之后,它的值不会改变,但它指向了一个无效的内存区,这个也就是上面讲的“悬空指针”。
4)如果没有及时释放某块动态内存,并且将指向它的指针指向了别处,就会造成“内存泄漏”,执行 malloc和free 函数有一定的代价,所以对于较小的数据量不应该放在动态内存之中,并且尽量避免频繁地分配和释放动态内存。
在我们进行内存区域的申请时,主要需注意避免发生以下错误:
1)内存分配未成功,却使用了它;
2)内存分配虽然成功,但是尚未初始化就引用它。(误认为初始值为0);
3)内存分配成功并且已经初始化,但操作越过了内存的边界;
4)忘记了释放内存,造成内存泄露;
5)释放了内存却继续使用它。
代码示例如下:
int* p = NULL;
p = (int*)malloc(5 * sizeof(int));
memset( //一般使用这个函数给申请的空间初始化为0
p, //起始地址
0, //设置的值
5 * sizeof(int) //大小
);
int Num = 0;
//一级指针的使用方式和数组类似
for (int i = 0; i < 5; i++)
{
p[i] = i;
}
p = (int*)malloc(10 * sizeof(int));
//....
free(p); //p指针malloc了两次,此时释放的就是后面申请的,前面申请的地址丢失也就无法释放了
//free(p); //错误, 对同一块堆空间不能释放两次。
p[0] = 1; //此时使用了悬空指针。
//p = &Num;
//free(p); //不能释放堆空间之外的内存。
//p = NULL; //及时置为空,配合下面的代码,避免悬空指针
if (p != NULL)
{
//使用指针之前,检测一下是不是空指针是一个好的习惯
}