C语言内存管理详解:系统内存分配、创建与使用不当的解决方案
C语言与C++内存管理超详细分析

2022 年 05 月 06 日 11 时 30 分 01 秒进行了更新,更新者是程序员编程指南。
C 语言的内存管理指的是对系统内存进行分配、创建以及使用等一系列的操作。在内存管理这个范畴里,因为涉及的是操作系统的内存,要是使用不当的话,就会导致比较麻烦的结果。本文将会从系统内存的分配和创建这两个方面入手,并且通过举例的方式来说明内存管理不当可能会出现的情况以及相应的解决办法。
目录
一、内存
在计算机里,每个应用程序的内存是相互独立的。一般情况下,应用程序 A 无法访问应用程序 B。不过,一些特殊技巧可以实现访问,但本文未详细说明。比如,在计算机中,一个视频播放程序和一个浏览器程序,它们的内存不能相互访问,每个程序所拥有的内存是分区进行管理的。
在计算机系统里,运行程序 A 时会在内存中开启一个属于程序 A 的内存区域 1 。运行程序 B 时会在内存中开启一个属于程序 B 的内存区域 2 。并且内存区域 1 和内存区域 2 是相互逻辑分隔的。
1.1 内存四区
超过该大小会造成栈溢出。
如果在程序结束时仍未释放,系统将会自动进行回收。
数据区主要用来存放全局变量、常量和静态变量。数据区可以被划分,分为全局区和静态区。全局变量和静态变量会被存放在这个区域。
代码区比较容易理解。它主要的作用是存储可执行代码。并且该区域具有只读的属性。
1.2 使用代码证实内存四区的底层结构
因为栈区和堆区的底层结构有着比较直观的呈现,所以在这里通过代码来仅仅演示这两个概念。首先去查看代码,以此来观察栈区的内存地址分配状况。
#includeint main() { int a = 0; int b = 0; char c='0'; 这里的具体地址值 1 为变量 a 的地址,即 &a;具体地址值 2 为变量 b 的地址,即 &b;具体地址值 3 为变量 c 的地址,即 &c。 }
运行结果为:
我们能观察到变量 a 的地址是 2293324,同时变量 b 的地址是 2293320。因为 int 的数据大小是 4,所以可以得出两者之间间隔为 4。接着查看变量 c,我们发现变量 c 的地址为 2293319,它与变量 b 的地址 2293324 间隔 1,这是因为 c 的数据类型为 char,其类型大小为 1。我们在此观察到,当我创建变量时,其顺序是从 a 到 b 再到 c。然而,令人疑惑的是,它们之间的地址并非是依次增加的,而是呈现出减少的情况。这是因为栈区存在一种数据存储结构,该结构为先进后出,就如同所展示的图那样。
栈的顶部是地址的“最小”索引,接着往下地址依次增大。因为堆栈的特殊存储结构,若先存储变量 a,它的一个索引地址会是最大的,然后依次减少。第二次存储的值是 b,b 的地址索引比 a 小。由于 int 的数据大小为 4,a 的地址为 2293324 时,往上减少 4 得到 2293320。存储 c 时为 char,大小为 1,其地址为 2293319。因为 a、b、c 这三个变量都在同一个栈内,所以它们地址的索引是连续的。那么如果创建一个静态变量会怎样呢?在前面的内容里说明了静态变量存储在静态区内,现在我们就来验证一下。
#includeint main() { int a = 0; int b = 0; char c='0'; static int d = 0; printf("变量a的地址是:%d\n变量b的地址是:%d\n变量c的地址是:%d\n", &a, &b, &c); printf("静态变量d的地址是:%d\n", &d); }
[id_104732605]
以上代码创建了一个变量 d,此变量 d 是静态变量。运行代码后,从结果来看,静态变量 d 的地址与一般变量 a、b、c 的地址不连续,它们两者的内存地址是分开的。接下来在此创建一个全局变量。由上述内容可知,全局变量与静态变量都应存储在静态区,代码如下:
#includeint e = 0; int main() { int a = 0; int b = 0; char c='0'; static int d = 0; printf("变量a的地址是:%d\n变量b的地址是:%d\n变量c的地址是:%d\n", &a, &b, &c); printf("静态变量d的地址是:%d\n", &d); printf("全局变量e的地址是:%d\n", &e); }
运行结果如下:
从上述运行结果可以证实上述内容是真实的,同时还得到一个知识点,那就是栈区和数据区都采用栈结构来存储数据。
在上述内容里还表明了栈的一个特性,即容量是固定大小的,一旦超过最大容量就会导致栈溢出。请看如下代码:
#includeint main() { char arr_char[1024*1000000]; arr_char[0] = '0'; }
以上代码定义了一个字符数组 arr_char,其大小被设置为 1024*1000000,这样设置是为了便于查看大小。接着在数组的头部进行了赋值。运行结果呈现如下:
程序出现运行错误,原因是导致了栈的溢出。在平常的开发过程里,如果需要大容量的内存,就需要用到堆。
堆没有栈那样的结构,也不具备栈那样的先进后出特性。它需要人们对内存进行人为的分配和使用。代码如下:
#include#include #include int main() { 分配了 1024 乘以 1000000 这么大的内存空间,并将其首地址存储在字符指针 p1 中。 strcpy(p1, "这里是堆区"); printf("%s\n", p1); }
以上代码中,通过 strcpy 函数将数据“这里是堆区”传递到手动开辟的内存空间 p1 中。手动开辟空间使用了 malloc 函数,传入的申请开辟空间大小为 1024 * 1000000。在栈中开辟如此大的空间必然会导致栈溢出,而堆本身具有大容量,不会出现这种情况。随后输出了所开辟内存中的内容,其运行结果如下:
在此要注意p1是表示开辟的内存空间地址。
二、malloc 和 free
在 C 语言里,malloc 和 free 是系统所提供的函数,并且是成对使用的。它们主要用于从堆当中进行内存的分配和释放操作。malloc 的全称是 memory allocation,意思是“动态内存分配”。
2.1 malloc 和 free 的使用
开辟堆空间时我们所使用的函数是 malloc。malloc 在 C 语言里是用来申请内存空间的。malloc 函数的原型为如下形式:
void *malloc(size_t size);
在 malloc 函数里,size 用于表示需要申请的内存空间的大小。若申请成功,就会返回该内存空间的地址;若申请失败,就会返回 NULL。并且,即便申请成功了,也不会自动进行初始化。
细心的同学或许能察觉到,该函数的返回值说明是 void *。在此处,void * 并非指代某一种特定的类型,而是表明该类型处于不确定的状态,通过接收的指针变量来实现类型的转换。在分配内存时需注意,即便程序关闭后系统会自动回收手动申请的内存,然而仍需进行手动释放,以确保内存在不需要时能返回堆空间,让内存能够合理地分配使用。
释放空间使用 free 函数,函数原型如下:
void free(void *ptr);
free 函数的返回值是 void 类型,它没有具体的返回值。该函数接收的参数是通过 malloc 分配的内存空间的指针。以下是一个完整的堆内存申请与释放的例子:
#include#include #include int main() { int n, *p, i; printf("请输入一个任意长度的数字来分配空间:"); scanf("%d", &n); 分配了一个指针 p,该指针指向一块连续的内存空间,其大小为 n 个 int 类型的大小,通过 malloc 函数实现,即 p = (int *)malloc(n * sizeof(int)); if(p==NULL){ printf("申请失败\n"); return 0; }else{ printf("申请成功\n"); } 使用 memset 函数将 p 所指向的内存区域填充为 0,填充的长度为 n 乘以 sizeof(int)。 //查看 for (i = 0; i < n; i++) printf("%d ", p[i]); printf("\n"); free(p); p = NULL; return 0; }
以上代码中,通过 malloc 创建了一个内存,其大小由用户输入来确定。接着判断了内存地址的创建是否成功。并且使用 memset 函数对该内存空间进行了填充值的操作。之后使用 for 循环对其进行了查看。最后使用 free 函数释放了内存,接着把 p 赋值为 NULL。这一点需要特别注意,不能让指针指向未知地址,而应将其置于 NULL 状态。否则,后续的开发者可能会误以为它是一个正常的指针,从而通过该指针去进行一些操作。但此时该指针已经没有用处,它所指向的内存也不知道被如何使用了。如果这时出现意外情况,将会造成无法预估的后果,甚至可能导致系统崩溃。在使用 malloc 时,更需要格外注意这一点。
2.2 内存泄漏与安全使用实例与讲解
内存泄漏指的是在动态分配的内存范围内,存在未释放内存的情况,或者由于某些原因导致内存无法被释放。这种情况如果程度较轻,会造成系统内存资源的浪费;如果程度严重,会致使整个系统崩溃等情况出现。
内存泄漏通常较为隐蔽。少量的内存泄漏发生时,不一定会导致无法承受的后果。然而,由于这种错误的积累,会造成整体系统的性能下降或系统崩溃。在较为大型的系统中,如何有效地防止内存泄漏等问题的出现,变得尤为重要。一些长时间运行的程序,在运行之初可能仅有少量内存泄漏问题,但随着运行时间增长和系统业务处理增加,内存泄漏会累积出现;这极大可能会造成不可预知的后果,像整个系统崩溃,造成的损失难以承受。所以,防止内存泄漏对底层开发人员尤为重要。
C 程序员在开发时,会不可避免地遇到内存操作方面的问题。尤其在频繁申请动态内存的时候,很容易引发内存泄漏事故。申请一块内存空间后,若未初始化就读取其中内容;间接申请动态内存却未进行释放;释放完动态申请的内存后仍继续引用该内存内容。如上述情况,这些都是导致内存泄漏的原因。这些原因往往较为隐蔽,在测试时不一定能完全清楚,会在项目上线后长时间运行时,导致灾难性后果发生。
以下是一个在子函数中进行了内存空间的申请操作,然而却没有对其进行释放:
#include#include #include void m() { char *p1; p1 = malloc(100); printf("开始对内存进行泄漏..."); } int main() { m(); return 0; }
在上述代码里,申请了 100 个单位的内存空间是通过 malloc 来实现的。之后,却没有对其进行释放操作。倘若这个 m 函数在当前的系统中被频繁调用,那么每次使用都会导致 100 个单位的内存空间无法释放。长此以往,就会引发严重的后果。在 p1 使用完毕之后,理应添加 free 来进行释放操作。
free(p1);
以下示范一个读取文件时不规范的操作:
#include#include #include int m(char *filename) { FILE* f; int key; f = fopen(filename, "r"); fscanf(f, "%d", &key); return key; } int main() { m("number.txt"); return 0; }
以上文件在读取时未进行 fclose 操作。这样会产生多余的内存。一次可能影响不大,但多次操作会使内存成倍增加。可以通过循环来进行调用。之后在任务管理器中能够查看该程序运行时所占的内存大小。代码如下:
#include#include #include int m(char *filename) { FILE* f; int key; f = fopen(filename, "r"); fscanf(f, "%d", &key); return key; } int main() { int i; for(i=0;i<500;i++) { m("number.txt"); } return 0; }
查看添加循环后的程序,再查看添加循环前的程序,对比它们的内存占用情况,就能够发现添加了循环的代码会增加占用容量。
未初始化的指针有可能导致内存泄漏。因为指针未初始化时,其指向是不可控的。例如:
int *p; *p = val;
包括错误的释放内存空间:
pp=p; free(p); free(pp);
申请动态内存后,用指针指向该内存,使用完毕后通过 free 函数释放申请的内存,此时该内存可被其他程序申请。但我们使用过的动态内存指针仍指向该地址,若其他程序下一秒申请该区域内存地址并进行操作,就会产生悬空指针。当我继续使用已经被 free 释放掉的指针去进行下一步操作时,或者进行了一次计算,这样就会导致结果截然不同,甚至会出现其他灾难性的后果。因此,对于这些指针在其生存期结束之后,也要将它们设置为 null。来看一个例子,因为在 free 释放之后仍然使用该指针,所以导致计算结果差异巨大:
#include#include #include int m(char *freep) { int val=freep[0]; 输出的内容为:“2 乘以 freep 的结果为:%d”,其中具体的值为 val 乘以 2 的结果,即 val*2 。 free(freep); val=freep[0]; printf("2*freep=:%d\n",val*2); } int main() { 分配一个整型大小的内存空间,并将其地址强制转换为整型指针类型,赋值给 freep 指针。即使用 malloc 函数分配 sizeof(int) 大小的内存,然后将其地址转换为 (int *) 类型并赋值给 freep ,此时 freep 指向分配的整型内存空间。 freep[0]=1; m(freep); return 0; }
以上代码先使用 malloc 申请了一个内存并传值为 1。在函数内,首先用 val 值接收 freep 的值,接着将 val 乘 2,然后释放 free 并重新赋值给 val,最后再次用 val 乘 2。这样的操作导致结果发生了极大改变,最恐怖的是此错误很难被发现,隐蔽性很强,而其造成的后患也难以承受。运行结果如下:
三、new 和 delete
在 C++ 里,通过 new 和 delete 来在堆上进行内存的分配与释放。new 和 delete 属于运算符,而非函数。并且它们需要成对使用,后面会说明为何要成对使用。
new/delete 除了具备分配内存以及释放内存的功能(类似于 malloc/free 的功能)之外,还会做其他更多的事情,所以在 C++中,人们不再使用 malloc/free ,而是使用 new/delete 。
3.1 new 和 delete 使用
new 一般使用格式如下:
C++中的 new 有三种用法,分别是 plain new、nothrow new 和 placement new。
我们最常使用的 new 的方式就是 plain new ,它在 C++ 中的定义如下:
定义了一个名为 operator new 的函数,它接受一个 std::size_t 类型的参数,并抛出 std::bad_alloc 异常,该函数返回一个 void* 类型的指针。 这个函数用于释放通过 `new` 操作符分配的内存空间。它的返回类型为 `void`,不接受任何参数,并且在执行时会抛出异常。当程序不再需要通过 `new` 分配的内存时,可以调用这个函数来释放内存。
在分配失败的情况下,plain new 会抛出异常 std::bad_alloc 而不是返回 NULL 。所以,通过判断返回值是否为 NULL 是没有意义的。
获取指定大小的内存空间,该内存空间由一个字符指针来表示,指定的大小为无符号长整型的 size 。
{
char * p = new char[size];
return p;
}
void main(void)
{
try{
// 这里调用 getMemory 函数获取内存,可能会出现异常情况
// ...
delete [] p;
}
捕获(const std::bad_alloc 类型的异常 ex)
{
cout << ex.what();
}
}
不抛出异常的运算符 new 的形式是 nothrow new。当 nothrow new 失败时,会返回 NULL。其定义如下:
一个 void 类型的指针 operator new 被调用,参数为一个 std::size_t 类型的值以及一个 const std::nothrow_t& 类型的引用,并且会抛出一个空值。 这个运算符用于释放通过 `new` 分配的内存空间。它是一个特殊的函数,名为 `operator delete`,接受一个 `void*` 类型的参数,表示要释放的内存地址。当程序不再需要某个动态分配的对象时,可以调用这个函数来释放与之关联的内存。它还带有一个 `throw()` 声明,表明在释放内存时不会抛出异常。
定义一个名为 func 的函数,该函数接受一个无符号长整型参数 length 。
{
无符号字符指针 p 是通过 new(nothrow) 操作符在堆上分配的无符号字符数组,其大小为 length 。
使用这种 new 时需加上 (nothrow),意味着不进行异常处理。
if (p == NULL) // 不抛异常,一定要检查
cout << "allocte failed !";
// ...
delete [] p;
}
placement new 的意思是“放置”。这种 new 能够在一块已经成功分配的内存上重新构建对象或对象数组。placement new 无需担心内存分配失败的问题,因为它根本不进行内存分配,它所做的唯一一件事就是调用对象的构造函数。其定义如下:
这个运算符 new 有两个参数,一个是 size_t 类型的大小,另一个是 void* 类型的指针;它的返回值是 void* 类型的指针。这个运算符 new 用于在已分配的内存空间上进行对象的创建操作。 定义了一个名为 operator delete 的函数,它接受两个 void* 类型的参数。 这个函数用于释放由指定的指针所指向的内存空间。 其中第一个参数是要释放的内存的地址,第二个参数可能与内存分配相关的一些额外信息。 它的作用是在程序中进行内存的释放操作,以确保不再使用的内存能够被正确回收。
placement new 的主要用途为,反复利用一块较大的动态分配内存,以构建不同类型的对象或者这些对象的数组。对于 placement new 构造出来的对象或其数组,需要显式地调用它们的析构函数进行销毁,绝对不要使用 delete 。
void main()
{
using namespace std;
创建一个不抛出异常的动态字符数组,其大小为 4,并将其地址存储在指针 p 中。
if (p == NULL)
{
cout << "allocte failed" << endl;
exit( -1 );
}
// ...
long * q = new (p) long(1000);
delete []p; // 只释放 p,不要用q释放。
}
p 和 q 只是首址相同,它们所构建的对象类型可以不同。所“放置”的空间要比原空间小,以防出现意外情况。当“放置 new”超出了申请的范围时,在 Debug 版下会崩溃,而在 Release 版下能够运行且不会出现崩溃的情况。
该运算符的作用是:只要第一次分配成功,不再担心分配失败。
void main()
{
using namespace std;
使用 `nothrow` 关键字在堆上分配了一个大小为 100 的字符数组,并将其地址存储在指针 `p` 中。 新分配的内存空间用于存储字符数据。 若分配失败,`p` 将被设置为 `nullptr` ,而不会抛出异常。 这样可以在内存分配失败时进行适当的处理,避免程序崩溃。
if (p == NULL)
{
cout << "allocte failed" << endl;
exit(-1);
}
long * q1 = new (p) long(100);
// 使用q1 ...
int * q2 = new (p) int[100/sizeof(int)];
// 使用q2 ...
ADT * q3 = new (p) ADT[100/sizeof(ADT)];
// 使用q3 然后释放对象 ...
delete [] p; // 只释放空间,不再析构对象。
}
使用该运算符构造的对象或数组时,必须要显式地调用析构函数,不能用 delete 来替代析构函数。因为对于通过 placement new 构造的对象,其大小与原空间不再相同。
void main()
{
using namespace std;
分配一块内存空间,使用 `new(nothrow)` 操作符,分配的内存大小为 `sizeof(ADT)+2`,并将其地址存储在指针 `p` 中,`p` 是一个指向 `char` 类型的指针,即 `char * p = new(nothrow) char [sizeof(ADT)+2];`
if (p == NULL)
{
cout << "allocte failed" << endl;
exit(-1);
}
// ...
ADT * q = new (p) ADT;
// ...
// delete q; // 错误
q->ADT 调用其析构函数;这是显式的调用;目的仅仅是释放该对象;释放对象的操作通过析构函数来完成
首先,删除指针 p 所指向的内存空间;接着,使用原指针来释放内存。
}
placement new 的主要用途在于能够反复利用已申请成功的一块内存空间。它能够避免申请失败所带来的徒劳,同时也能避免使用完后进行释放。
特别要注意的是,绝对不可以调用 placement new 的 delete。因为这个 new 只是在使用别人为它申请的地方。释放内存这件事是 nothrow new 的职责,也就是说,要使用原来的指针来释放内存。不要重复调用 free/delete ,它被系统立即回收后会被再利用。若再次进行 free/delete ,很可能会把不是自己的内存释放掉,从而导致异常甚至崩溃。
上面提到 new 和 delete 比 malloc 和 free 做的事情更多。new 相对于 malloc 会额外进行一些初始化工作,而 delete 相对于 free 则会多做一些清理工作。
class A
public:
A()
cont