2

C语言学习之动态内存管理

 4 weeks ago
source link: https://www.biaodianfu.com/c-memory-management.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

C 语言的内存管理,分成两部分。一部分是系统管理的,另一部分是用户手动管理的。

  • 系统管理的内存,主要是函数内部的变量(局部变量)。这部分变量在函数运行时进入内存,函数运行结束后自动从内存卸载。这些变量存放的区域称为”栈“(stack),”栈“所在的内存是系统自动管理的。
  • 用户手动管理的内存,主要是程序运行的整个过程中都存在的变量(全局变量),这些变量需要用户手动从内存释放。如果使用后忘记释放,它就一直占用内存,直到程序退出,这种情况称为”内存泄漏“(memory leak)。这些变量所在的内存称为”堆“(heap),”堆“所在的内存是用户手动管理的。

为什么要进行动态内存管理?

动态内存分配在编程中是非常重要的,主要有以下几个原因:

  • 运行时决定内存需求:在许多情况下,你在编写代码时可能不知道需要多少内存。例如,你可能需要存储用户输入的数据,但你无法预先知道用户将输入多少数据。动态内存分配允许你的程序在运行时根据需要分配内存。
  • 节省内存:如果你在编译时分配了大量内存(例如,声明了一个大数组),但实际使用的内存很少,那么就会造成内存浪费。通过动态内存分配,你可以确保只分配实际需要的内存量。
  • 数据结构的灵活性:许多高级的数据结构,如链表、树和图,都依赖于动态内存分配。它们需要在运行时根据数据的添加和删除来创建和销毁节点。
  • 内存的连续使用:动态内存分配可以保证分配的内存区域是连续的。这在处理如图像、视频等需要大块连续内存空间的数据时非常有用。
  • 生命周期的控制:通过动态内存分配,程序员可以控制内存的生命周期,使其超过函数调用的生命周期,直到显式释放。

尽管动态内存分配提供了许多优点,但也需要谨慎处理,如避免内存泄漏、空指针解引用、野指针等问题。

C语言进程映像

在操作系统中,进程是程序运行的一个实例。当程序运行时,它的代码和当前状态被加载到内存中,形成了一个被称为进程映像(Process Image)的结构。

Process-Image.png

C语言进程映像一般包括以下几个部分:

  • 文本段(Text Segment):也被称为代码段,这个区域存储了程序的机器代码(即编译后的二进制代码)。这部分通常是只读的,以防止程序误修改其自身的指令。
  • 数据段(Data Segment):数据段用于存储程序中的全局变量。它通常分为初始化的全局变量和未初始化的全局变量两部分。前者存储了在程序开始执行之前就有初始值的全局变量,后者存储了那些没有初始值或初始值被设为0的全局变量。
  • 堆(Heap):堆是用于动态内存分配的区域,如C语言中的malloc和free操作就是在堆上进行的。堆通常从低地址向高地址扩展。
  • 栈(Stack):栈用于存储局部变量和函数调用的信息。每当一个函数被调用时,一个新的栈帧(Stack Frame)就会被推到栈上。栈帧中包含了函数的局部变量、返回地址以及其他函数调用的信息。当函数返回时,对应的栈帧就会从栈上弹出。栈通常从高地址向低地址扩展。
  • 命令行参数和环境变量:当进程启动时,命令行参数和环境变量也会被加载到内存中。这些信息通常存储在一个特殊的区域,而不是在堆或栈上。

这些区域合起来构成了进程映像,代表了一个运行中的程序在内存中的状态。操作系统通过这个进程映像来管理和调度进程,而进程自身则通过修改这个进程映像来执行其任务。

虚拟地址空间在32位环境下的大小为 4GB,在64位环境下的大小为256TB,对于32位环境,理论上程序可以拥有4GB的虚拟地址空间,我们在C语言中使用到的变量、函数、字符串等都会对应内存中的一块区域。但是,在这 4GB 的地址空间中,要拿出一部分给操作系统内核使用,应用程序无法直接访问这一段内存,这一部分内存地址被称为内核空间(Kernel Space)。Windows 在默认情况下会将高地址的 2GB 空间分配给内核(也可以配置为1GB),而 Linux默认情况下会将高地址的1GB 空间分配给内核。也就是说,应用程序只能使用剩下的 2GB 或 3GB 的地址空间,称为用户空间(User Space)。

我们暂时不关心内核空间的内存分布情况,下图是Linux下32位环境的一种经典内存模型:

memory.jpg

对各个内存分区的说明:

内存分区 说明
程序代码区(code) 存放函数体的二进制代码。一个C语言程序由多个函数构成,C语言程序的执行就是函数之间的相互调用。
常量区(constant) 存放一般的常量、字符串常量等。这块内存只有读取权限,没有写入权限,因此它们的值在程序运行期间不能改变。
全局数据区(global data) 存放全局变量、静态变量等。这块内存有读写权限,因此它们的值在程序运行期间可以任意改变。
堆区(heap) 一般由程序员分配和释放,若程序员不释放,程序运行结束时由操作系统回收。malloc()、calloc()、free() 等函数操作的就是这块内存,这也是本章要讲解的重点。
注意:这里所说的堆区与数据结构中的堆不是一个概念,堆区的分配方式倒是类似于链表。

(这里的堆区上面的未被分配的内存,当我们动态分配内存时,堆区会向上增长,使用它上面的未分配的内存拿来作为堆内存,)

动态链接库 用于在程序运行期间加载和卸载动态链接库。(个人:静态链接库已经在运行之前的链接阶段链接到可执行程序里面了,所以静态链接库里面的函数此时放置在程序代码区)
栈区(stack) 存放函数的参数值、局部变量的值等,其操作方式类似于数据结构中的栈。(这里的栈区向下增长,使用它下面未被分配的内存)

对内存的研究,重点是对数据分区的研究。

程序代码区、常量区、全局数据区在程序加载到内存后就分配好了,并且在程序运行期间一直存在,不能销毁也不能增加(大小已被固定),只能等到程序运行结束后由操作系统收回,所以全局变量、字符串常量等在程序的任何地方都能访问,因为它们的内存一直都在。

  • 常量区和全局数据区有时也被合称为静态数据区,意思是这段内存专门用来保存数据,在程序运行期间一直存在。
  • 函数被调用时,会将参数、局部变量、返回地址等与函数相关的信息压入栈中,函数执行结束后,这些信息都将被销毁。所以局部变量、参数只在当前函数中有效,不能传递到函数外部,因为它们的内存不在了。
  • 常量区、全局数据区、栈上的内存由系统自动分配和释放,不能由程序员控制。
  • 程序员唯一能控制的内存区域就是堆(Heap):它是一块巨大的内存空间,常常占据整个虚拟空间的绝大部分,在这片空间中,程序可以申请一块内存,并自由地使用(放入任何数据)。堆内存在程序主动释放之前会一直存在,不随函数的结束而失效。在函数内部产生的数据只要放到堆中,就可以在函数外部使用。

以下详细分析这几个主要的分区:

静态/全局存储区

在C程序中,静态/全局存储区是内存的一种分配方式,主要用于存储全局变量、静态变量和常量。以下是一些关于静态/全局存储区的详细信息:

全局变量

全局变量在整个程序的生命周期内都是存在的。它们在程序的全局作用域中定义,可以在程序的任何地方被访问和修改。全局变量在程序开始执行时分配内存,在程序结束时释放内存。

int global_var; // 全局变量
int main() {
global_var = 5; // 在函数中访问全局变量
return 0;
int global_var;  // 全局变量

int main() {
    global_var = 5;  // 在函数中访问全局变量
    return 0;
}

静态变量

静态变量分为两种:一种是在函数内部定义的,另一种是在函数外部定义的。

在函数内部定义的静态变量,其生命周期在整个程序执行过程中,但是其作用域仅限于该函数内部,其值在函数调用之间保持不变。

在函数外部定义的静态变量,其作用域仅限于定义它的文件内,而不是整个程序。

静态变量在程序开始执行时分配内存,在程序结束时释放内存。

static int static_var; // 静态变量
void func() {
static int static_in_func = 0; // 函数内部的静态变量
static_in_func++;
int main() {
func();
func();
return 0;
static int static_var;  // 静态变量

void func() {
    static int static_in_func = 0;  // 函数内部的静态变量
    static_in_func++;
}

int main() {
    func();
    func();
    return 0;
}

在上述代码中,即使func被调用两次,static_in_func也只分配一次内存,并且它的值在两次调用之间保持不变。

常量

常量也存储在静态/全局存储区中。字符串常量和const修饰的变量都存储在这里。这部分内存在程序开始执行时分配,在程序结束时释放。

const int const_var = 5; // 常量
int main() {
printf("%s", "Hello, world!"); // 字符串常量
return 0;
const int const_var = 5;  // 常量

int main() {
    printf("%s", "Hello, world!");  // 字符串常量
    return 0;
}

注意:在静态/全局存储区中,未初始化的静态变量、全局变量和常量默认值为0,已初始化的变量和常量则存储其初始化的值。

在C语言中,栈(Stack)是一个特殊的区域,用于存储函数调用的参数和局部变量。以下是一些关于栈的详细信息:

栈的特性

  • 栈是一种“后进先出”(LIFO)的数据结构,这意味着最后被放入栈的项总是最先被取出。这对于函数调用很重要,因为新的函数调用(和它们的局部变量)总是被放在栈的顶部,而返回则是相反的顺序。
  • 栈是由编译器自动分配和释放的,无需程序员手动操作。
  • 栈中的变量在其所在函数结束时自动被销毁。
  • 栈的大小在程序启动时由操作系统设定,并且在运行时不能改变。

局部变量

函数内部定义的变量被称为局部变量。这些变量在函数被调用时分配在栈上,并在函数返回时被销毁。

void func() {
int local_var = 5; // 局部变量
void func() {
    int local_var = 5;  // 局部变量
}

在上述代码中,local_var是一个局部变量,它在func()被调用时创建,并在func()返回时被销毁。

函数参数

函数参数也存储在栈上。当函数被调用时,实参值被复制并传递给函数,这些复制的值被存储在栈上。

void func(int param) { // 函数参数
// ...
void main() {
func(5);
void func(int param) {  // 函数参数
    // ...
}

void main() {
    func(5);
}

在上述代码中,当func()被调用时,实参5被复制并存储在栈上,param即是这个复制值的别名。

注意:尽管栈对于存储局部变量和函数参数非常方便,但也需要注意其限制。栈的大小是有限的,如果函数调用深度太大(递归过深)或局部变量太多,可能会导致栈溢出,这是一种严重的运行时错误。

在C语言中,堆(Heap)是一种用于存储动态分配内存的区域。以下是一些关于堆的详细信息:

堆的特性

  • 堆是程序中一块非结构化的内存区域,可以用来动态地分配和释放内存。
  • 堆内存的分配和释放由程序员控制,不会自动进行。在C中,可以使用malloc(), calloc(), realloc() 和 free()等函数进行操作。
  • 堆的大小在运行时可以改变,并且理论上只受到可用系统内存的限制。

动态内存分配

在堆上分配内存是动态的,这意味着你可以在运行时决定分配多少内存。这对于处理可变大小的数据结构(如链表、树、图和哈希表)非常有用。

int* arr = (int*) malloc(10 * sizeof(int)); // 在堆上分配足够的内存来保存10个整数
if (arr == NULL) {
// 处理内存分配失败
// 使用数组...
free(arr); // 释放内存
int* arr = (int*) malloc(10 * sizeof(int));  // 在堆上分配足够的内存来保存10个整数
if (arr == NULL) {
    // 处理内存分配失败
}

// 使用数组...

free(arr);  // 释放内存

在上述代码中,malloc()函数被用来在堆上分配内存,free()函数被用来释放这块内存。

  • 必须记住释放所有在堆上分配的内存。如果你忘记这样做,可能会导致内存泄漏,这是一种严重的问题,可能会消耗大量的内存,降低程序的性能,甚至使程序崩溃。
  • 尽管堆提供了很大的灵活性,但是比栈要慢,因为需要查找并连接合适的内存块。此外,频繁的内存分配和释放可能会导致内存碎片,这也会影响性能。
  • 在尝试访问已经被释放的内存,或者超出分配的内存范围读写都会导致未定义行为,可能会引发错误或程序崩溃。

在C程序中,代码区(又称为文本区),是内存的一种分配方式,主要用于存储程序的二进制代码。以下是一些关于代码区的详细信息:

代码区的特性:

  • 代码区是只读的,以防止程序在运行时修改其自身的指令。
  • 代码区的内容在程序的整个生命周期内都是存在的。这部分内存在程序开始执行时分配,在程序结束时释放。
  • 代码区通常位于固定的内存位置,因为它的大小在程序运行之前就已经确定。
  • 代码区是共享的,这意味着执行相同程序的所有实例都可以使用同一份二进制代码。这是操作系统为了节省内存而采取的优化措施。

函数体

C程序中的函数体存储在代码区。例如,下面的main()函数的二进制代码就会存储在代码区:

int main() {
printf("Hello, world!");
return 0;
int main() {
    printf("Hello, world!");
    return 0;
}

由于代码区是只读的,因此尝试修改存储在代码区的数据将导致运行时错误。例如,下面的代码会导致错误,因为它尝试修改一个字符串常量,而字符串常量是存储在代码区的:

char* str = "Hello, world!";
str[0] = 'h'; // 运行时错误
char* str = "Hello, world!";
str[0] = 'h';  // 运行时错误

在这个例子中,”Hello, world!”是一个字符串常量,它存储在代码区。尽管str是一个指向char的指针,可以修改通过它指向的值,但是因为它实际上指向代码区,所以尝试修改这个值会导致错误。

动态内存分配函数

C语言提供了四个动态内存分配函数,malloc()、calloc()、realloc()和free()。

function.png
  • malloc(size_t size):它在内存的动态存储区中分配一块长度为size字节的连续区域。
  • calloc(size_t nmemb, size_t size):它在内存中动态地分配nmemb个长度为size的连续空间,并且每一个字节的值都初始化为0。
  • realloc(void* ptr, size_t size):改变之前调用 malloc() 或 calloc() 所分配的 ptr 所指向的内存区域的大小。
  • free(void* ptr):释放之前调用 malloc(), calloc(), 或 realloc() 所分配的内存区域。

malloc()

malloc() 函数是C语言中用来动态分配内存的函数。它的原型是 void* malloc(size_t size),该函数会在堆上动态地分配一块大小为 size 字节的内存区域。

下面是使用 malloc() 的一些步骤:

分配内存

你可以调用 malloc() 来请求分配某个大小的内存。例如,如果你想分配一个整数的内存,你可以这样做:

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

在这里,sizeof(int) 返回一个整数的大小(在字节中),malloc 分配这么多的内存,然后返回一个指向这块内存的指针。malloc返回的是 void* 类型的指针,所以我们需要将其强制转型为 int* 类型。

检查分配是否成功

malloc() 在无法分配内存时会返回 NULL,所以在使用分配的内存之前,你应该检查是否分配成功:

int *p = (int*)malloc(sizeof(int));
if (p == NULL) {
printf("Failed to allocate memory.\n");
// Handle error
int *p = (int*)malloc(sizeof(int));
if (p == NULL) {
    printf("Failed to allocate memory.\n");
    // Handle error
}

使用分配的内存

一旦你检查过返回的指针不是 NULL,你就可以像使用普通指针那样使用它:

*p = 5; // Assigns 5 to the allocated memory
*p = 5;  // Assigns 5 to the allocated memory

释放内存

当你分配了一块内存并且完成了对它的使用,你应该调用 free() 来释放这块内存,这可以防止内存泄漏:

free(p);
free(p);

请注意,一旦你释放了一块内存,你就不应再使用指向它的指针,除非你再次给它分配了新的内存。使用已经释放的内存通常会导致程序崩溃或其他未定义的行为。

calloc()

calloc()函数在C语言中用于动态内存分配,其功能和malloc()类似,但带有两个重要不同之处:首先,calloc()除了分配内存外,还会将内存初始化为零;其次,calloc()接收两个参数,一个表示元素的数量,另一个表示单个元素的大小。

calloc()的函数原型是 void* calloc(size_t num, size_t size),它会分配num * size字节的内存,并返回指向新分配内存的指针。如果分配失败,它将返回 NULL。

以下是calloc()的使用示例:

// 分配足够的内存来保存10个整数,并将内存初始化为0
int *arr = (int*)calloc(10, sizeof(int));
// 检查内存分配是否成功
if (arr == NULL) {
printf("Failed to allocate memory.\n");
//处理错误
// 分配足够的内存来保存10个整数,并将内存初始化为0
int *arr = (int*)calloc(10, sizeof(int));

// 检查内存分配是否成功
if (arr == NULL) {
    printf("Failed to allocate memory.\n");
    //处理错误
}

和malloc()一样,你需要检查calloc()是否成功分配了内存,即检查返回的指针是否为 NULL。

你可以像使用普通数组一样使用动态分配的内存:

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

和malloc()一样,当你完成对动态分配内存的使用后,必须使用free()函数来释放内存,以防止内存泄漏:

free(arr);
free(arr);

请注意,一旦内存被释放,你不应再使用指向该内存的指针,除非你再次给它分配了新的内存。使用已经释放的内存可能会导致程序崩溃或其他未定义的行为。

realloc()

realloc()函数在C语言中用于重新分配之前已分配的内存区域。当你需要改变已分配的内存大小时,可以使用realloc()函数。

realloc()函数的原型为 void* realloc(void* ptr, size_t size),其中ptr是指向之前已分配的内存的指针,size是新的内存大小。realloc()会尝试在原地调整内存大小,如果无法做到,它将分配一块新的内存,将旧数据复制过去,然后释放旧内存。

以下是realloc()的使用示例:

// 开始时分配足够的内存来保存10个整数
int *arr = (int*)malloc(10 * sizeof(int));
// ……在这里使用内存……
// 现在我们需要更多的空间,所以重新分配内存以保存20个整数
arr = (int*)realloc(arr, 20 * sizeof(int));
// 检查内存重新分配是否成功
if (arr == NULL) {
printf("Failed to allocate memory.\n");
//处理错误
// 开始时分配足够的内存来保存10个整数
int *arr = (int*)malloc(10 * sizeof(int));

// ……在这里使用内存……

// 现在我们需要更多的空间,所以重新分配内存以保存20个整数
arr = (int*)realloc(arr, 20 * sizeof(int));

// 检查内存重新分配是否成功
if (arr == NULL) {
    printf("Failed to allocate memory.\n");
    //处理错误
}

和malloc()、calloc()一样,你需要检查realloc()是否成功分配了内存,即检查返回的指针是否为 NULL。

当你完成对动态分配内存的使用后,必须使用free()函数来释放内存,以防止内存泄漏:

free(arr);
free(arr);

请注意,一旦内存被释放,你不应再使用指向该内存的指针,除非你再次给它分配了新的内存。使用已经释放的内存可能会导致程序崩溃或其他未定义的行为。

free()

free()函数在C语言中用于释放之前通过malloc(), calloc(), 或 realloc()函数分配的内存。这是必要的,因为动态分配的内存不会像自动变量那样在生命周期结束时自动释放。如果你不使用free(),那么在程序运行时分配的内存将会一直存在,可能导致内存泄漏问题。

free()函数的原型是 void free(void* ptr),其中ptr是指向要释放的内存的指针。一旦内存被free()释放,该内存区域就可以被操作系统重新分配使用了。

以下是free()的使用示例:

// 分配内存
int *p = (int*)malloc(sizeof(int));
*p = 5;
// 使用完内存后,释放它
free(p);
// 分配内存
int *p = (int*)malloc(sizeof(int));
*p = 5;

// 使用完内存后,释放它
free(p);

以下是一些使用free()时需要注意的地方:

双重释放:如果你尝试释放同一块内存两次,会导致未定义行为,通常会导致程序崩溃。例如,下面的代码就是错误的:

int *p = (int*)malloc(sizeof(int));
free(p);
free(p); // 错误:p已被释放
int *p = (int*)malloc(sizeof(int));
free(p);
free(p);  // 错误:p已被释放

释放未分配的内存:只有通过malloc(), calloc(), realloc()函数分配的内存才能被free()释放。你不能释放栈变量或全局变量的内存。

使用已经释放的内存:一旦你释放了一块内存,就不能再使用它,除非你再次给它分配了新的内存。例如,下面的代码就是错误的:

int *p = (int*)malloc(sizeof(int));
free(p);
*p = 5; // 错误:p已被释放
int *p = (int*)malloc(sizeof(int));
free(p);
*p = 5;  // 错误:p已被释放

空指针:对NULL指针调用free()是安全的,不会有任何效果。这是一种用来处理可能未分配内存的指针的好方法。

int *p = NULL;
free(p); // 安全:没有任何效果
int *p = NULL;
free(p);  // 安全:没有任何效果

内存操作函数

在C语言中,提供了一些函数,可以直接操作内存,复制内存,移动内存等。以下是一些常用的内存操作函数:

  • memcpy(void* dest, const void* src, size_t n): 该函数从src指向的位置开始复制n个字节的数据到dest指向的位置。
  • memmove(void* dest, const void* src, size_t n): 该函数与memcpy()类似,也是复制n个字节的数据。不同的是,memmove()考虑了源内存和目标内存区域重叠的情况。
  • memcmp(const void* ptr1, const void* ptr2, size_t n): 该函数比较内存区域ptr1和ptr2的前n个字节。当ptr1 < ptr2时,返回值<0,当ptr1 = ptr2时,返回值=0,当ptr1 > ptr2时,返回值>0。
  • memset(void* ptr, int value, size_t n): 该函数将ptr所指向的内存区域的前n个字节设置成value指定的值。这常用于将内存初始化为特定的值。

这四个函数都是C语言中用于内存操作的常用函数,功能如下:

memcpy(void* dest, const void* src, size_t n): 这是一种内存复制函数,它从源地址(src)开始,复制n个字节到目标地址(dest)。需要注意的是,memcpy()并未考虑内存重叠的情况,所以当源地址和目标地址有重叠时,可能会出现意想不到的结果。

char src[50] = "http://example.com";
char dest[50];
memcpy(dest, src, strlen(src)+1);
printf("Copied string: %s\n", dest);
char src[50] = "http://example.com";
char dest[50];

memcpy(dest, src, strlen(src)+1);
printf("Copied string: %s\n", dest);

memmove(void* dest, const void* src, size_t n): 这也是一种内存复制函数,和memcpy()类似,但它处理源地址和目标地址重叠的情况。当内存区域有重叠时,memmove()仍然可以正确地复制内存。

char str[] = "memmove can handle overlap.";
memmove(str+20, str+15, 11);
printf("%s\n", str);
char str[] = "memmove can handle overlap.";
memmove(str+20, str+15, 11);
printf("%s\n", str);

memcmp(const void* ptr1, const void* ptr2, size_t n): 这是一个内存比较函数,它比较ptr1和ptr2指向的内存区域的前n个字节。函数返回值基于比较结果:如果ptr1 < ptr2,返回值 < 0;如果ptr1 = ptr2,返回值 = 0;如果ptr1 > ptr2,返回值 > 0。

char buffer1[15] = "DWgaOtP12df0";
char buffer2[15] = "DWGAOTP12DF0";
int n = memcmp(buffer1, buffer2, sizeof(buffer1));
if(n > 0) {
printf("'%s' is greater than '%s'.\n", buffer1, buffer2);
} else if(n < 0) {
printf("'%s' is less than '%s'.\n", buffer1, buffer2);
} else {
printf("'%s' is the same as '%s'.\n", buffer1, buffer2);
char buffer1[15] = "DWgaOtP12df0";
char buffer2[15] = "DWGAOTP12DF0";

int n = memcmp(buffer1, buffer2, sizeof(buffer1));

if(n > 0) {
  printf("'%s' is greater than '%s'.\n", buffer1, buffer2);
} else if(n < 0) {
  printf("'%s' is less than '%s'.\n", buffer1, buffer2);
} else {
  printf("'%s' is the same as '%s'.\n", buffer1, buffer2);
}

memset(void* ptr, int value, size_t n): 这是一种内存设置函数,它将ptr指向的内存区域的前n个字节设置为指定的值(value)。这常用于初始化内存。

char str[50] = "This is string.h library function";
memset(str, '$', 7);
printf("After memset(): %s\n", str);
char str[50] = "This is string.h library function";
memset(str, '$', 7);
printf("After memset():  %s\n", str);

以上这些函数都在<string.h>库中,使用之前需要包含这个头文件。

常见的动态内存错误

动态内存操作是编程中非常重要的部分,但也容易产生错误。以下是一些常见的动态内存错误:

  • 内存泄漏:当你使用malloc(), calloc()或realloc()分配内存后,必须在不再需要该内存时使用free()释放它。如果你忘记这样做,就会发生内存泄漏,这意味着不能被程序再次使用的内存仍然被占用。如果这种情况反复发生,可能会耗尽可用内存,导致程序或系统性能下降甚至崩溃。
  • 双重释放:如果你尝试释放同一块内存两次,这将导致未定义的行为,通常会导致程序崩溃。
  • 悬挂指针:当你释放了一块内存后,所有指向它的指针都会变成悬挂指针。如果你试图解引用一个悬挂指针,这将导致未定义的行为。
  • 无效的内存访问:这包括访问已释放的内存、越过分配的内存边界进行读/写等行为。这些行为都会导致未定义的结果,可能造成程序崩溃或数据损坏。
  • 未初始化的内存读取:如果你尝试读取你分配但未初始化的内存,会得到未定义的值。
  • 分配失败未处理:当malloc(), calloc()或realloc()无法分配请求的内存时,都会返回NULL。如果你没有检查这一点并试图使用返回的结果,那么你的程序可能会崩溃或表现出其他未定义的行为。

为了避免这些错误,你需要谨慎地管理动态内存:始终在分配内存后检查是否成功,只释放你已分配的内存,并在释放内存后确保不再使用它。使用一些现代工具,如静态和动态分析工具,也可以帮助你检测这些错误。

什么是内存泄漏?

内存泄漏是指在程序运行过程中,动态分配的内存没有被正确地释放,导致系统内存资源被逐渐耗尽的现象。

在C语言中,当你使用malloc、calloc或realloc等函数动态分配内存时,这些内存区域会保持分配状态,直到你明确调用free函数将其释放。如果你分配了内存但忘记释放,或者由于程序逻辑错误导致无法释放,那么这部分内存将无法被再次使用,这就是内存泄漏。

内存泄漏可能造成以下问题:

  • 性能下降:随着可用内存减少,系统可能需要将更多的数据和代码页交换到磁盘,导致效率降低。
  • 资源浪费:泄漏的内存无法被其他程序或系统使用,造成资源浪费。
  • 程序崩溃:在严重的内存泄漏情况下,系统可用内存可能被完全耗尽,导致程序或系统崩溃。

因此,正确管理内存,避免内存泄漏,是编程中的一项重要任务。

如何避免内存泄漏

在C语言编程中,内存管理是开发者的责任,需要注意避免出现内存泄漏。以下是一些避免内存泄漏的通用策略:

  • 配对使用 malloc 和 free:每次使用 malloc, calloc 或 realloc 分配的内存,都必须使用 free 来释放。确保每个 malloc 都有一个匹配的 free。
  • 使用智能指针或内存管理库:虽然C语言本身没有提供,但你可以使用第三方库,如 Boehm-Demers-Weiser garbage collector,或者在C++中使用智能指针。
  • 函数返回前释放内存:确保在函数返回之前释放所有该函数分配的内存,除非这些内存需要在函数外部继续使用。
  • 避免在出错时忘记释放内存:如果你的函数在出错时返回,那么你需要在返回之前清理所有已分配的内存。
  • 小心处理复杂的数据结构:链表、树和图等复杂数据结构需要特别小心,确保在删除元素、修改结构时正确地释放内存。
  • 使用内存分析工具:例如Valgrind,它可以帮助你检测程序中的内存泄漏。
  • 避免内存碎片化:频繁的分配和释放小块内存可能会导致内存碎片化,这会降低内存利用率,甚至导致可用内存耗尽。可以通过合理安排内存分配策略,或者使用专门的内存分配器,如jemalloc来避免内存碎片化。

记住,良好的编程习惯是避免内存泄漏的关键。

如何定位内存泄漏

在C语言中,定位内存泄漏可能需要一些特殊工具和技巧。以下是一些常用的方法:

  • 使用内存分析工具:有许多专门的工具可以帮助你检测内存泄漏。例如,Valgrind 是一款在Linux和MacOS上广泛使用的工具,它可以检测出你的程序中的内存泄漏,并告诉你哪些函数分配的内存没有被释放。在Windows平台上也有类似的工具,如 Memory。
  • 使用静态代码分析工具:这些工具可以在不运行程序的情况下分析你的代码,并挑出可能出现内存泄漏的地方。这类工具包括Clang Static Analyzer、Coverity等。
  • 手动跟踪内存分配:你可以在你的代码中添加一些额外的日志信息,来记录哪些内存被分配出去,但是没有被归还。这需要一些额外的工作,但是对于一些复杂的问题来说,可能是必要的。
  • 使用调试器:如果你知道在哪里可能有内存泄漏,你可以使用调试器(如GDB)来跟踪内存的分配和释放。
  • 代码审查:定期对代码进行审查,特别是那些涉及到内存管理的部分,可以帮助发现可能的内存泄漏。
  • 学习和理解C语言的内存管理规则:理解C语言的内存管理规则是防止内存泄漏的关键。例如,知道在何时何地使用 malloc 和 free,如何处理函数返回值时的内存管理,以及如何处理错误处理中的内存释放等。

以上这些方法可以帮助你定位和修复程序中的内存泄漏。需要注意的是,防止内存泄漏需要你始终保持警惕,并养成良好的编程习惯。

什么是内存碎片?

内存碎片是指因为反复分配和释放内存导致可用内存分散,而不是一个连续的大块内存,这种现象被称为内存碎片。内存碎片主要分为两种类型:外部碎片和内部碎片。

  • 外部碎片:外部碎片是指内存中有许多小的、分散的、未被使用的块,这些块太小,无法满足新的内存分配请求。即使这些小块加起来的总内存可能足够,但由于它们不连续,无法被直接使用。
  • 内部碎片:内部碎片是指已经被分配出去(例如,通过 malloc)但尚未被使用(例如,分配了100字节但只使用了50字节)的内存。这部分内存浪费了,因为它既无法用于程序,也无法重新分配给其他请求。

内存碎片化会降低内存利用率,严重时甚至可能导致虽然还有空闲内存,但无法满足内存分配的请求,进而导致程序运行失败。因此,减少内存碎片化是内存管理的重要任务之一。

如何减少内存碎片?

  • 合理选择内存分配策略:例如最佳适应算法(Best Fit)、最坏适应算法(Worst Fit)或首次适应算法(First Fit)等,可以根据实际情况选择。
  • 避免频繁分配和释放小块内存:频繁分配和释放不同大小的小块内存是产生内存碎片的主要原因。可以通过池化技术,预先分配一大块内存,然后按需划分,可以显著减少内存碎片。
  • 使用专门的内存分配器:有些内存分配器被设计为可以减少碎片化,如 jemalloc,tcmalloc。
  • 定期进行内存整理:某些系统和语言提供了内存压缩或垃圾收集机制,可以在运行时动态地整理内存,合并空闲的内存块,减少内存碎片。

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK