MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

C 语言malloc()和free()深入解析

2022-10-187.6k 阅读

C 语言中动态内存分配的基石:malloc() 函数

1. malloc() 函数的基本概念

在 C 语言中,malloc() 函数是用于动态内存分配的核心函数之一。它的全称是 “memory allocation”,即内存分配。malloc() 函数的作用是在堆(heap)内存区域分配指定字节数的连续内存空间,并返回一个指向该内存起始地址的指针。如果分配成功,返回的指针可以用来访问和操作这块新分配的内存;如果分配失败,malloc() 函数将返回 NULL 指针。

malloc() 函数定义在 <stdlib.h> 头文件中,其函数原型如下:

void *malloc(size_t size);

这里,size_t 是一个无符号整数类型,用于表示内存大小(以字节为单位)。malloc() 函数接受一个参数 size,表示需要分配的内存字节数,并返回一个 void * 类型的指针,void * 类型指针是一种通用指针类型,可以指向任何类型的数据。

2. 使用 malloc() 函数进行简单的内存分配

下面通过一个简单的示例代码来演示如何使用 malloc() 函数分配内存:

#include <stdio.h>
#include <stdlib.h>

int main() {
    // 分配 10 个整数大小的内存空间
    int *ptr = (int *)malloc(10 * sizeof(int));

    if (ptr == NULL) {
        printf("内存分配失败\n");
        return 1;
    }

    // 使用分配的内存
    for (int i = 0; i < 10; i++) {
        ptr[i] = i * 2;
    }

    // 输出内存中的数据
    for (int i = 0; i < 10; i++) {
        printf("ptr[%d] = %d\n", i, ptr[i]);
    }

    // 释放分配的内存
    free(ptr);
    ptr = NULL;

    return 0;
}

在上述代码中,首先使用 malloc() 函数分配了足够容纳 10 个 int 类型数据的内存空间,并将返回的指针强制转换为 int * 类型,赋值给 ptr 指针变量。然后检查 ptr 是否为 NULL,以确保内存分配成功。如果分配成功,就对这块内存进行初始化和数据访问操作,最后使用 free() 函数释放已分配的内存,并将 ptr 置为 NULL,防止出现悬空指针(dangling pointer)。

3. malloc() 函数的内存分配机制

malloc() 函数在底层是如何实现内存分配的呢?实际上,malloc() 函数的具体实现依赖于操作系统的内存管理机制。在大多数现代操作系统中,内存管理采用虚拟内存(virtual memory)技术,将进程的地址空间划分为不同的区域,其中堆(heap)区域用于动态内存分配。

当调用 malloc() 函数时,它会与操作系统的内存管理子系统进行交互。操作系统会在堆区域中查找一块大小合适的空闲内存块来满足分配请求。如果找到足够大的空闲内存块,操作系统会将该内存块标记为已使用,并返回指向该内存块起始地址的指针。如果堆中没有足够大的连续空闲内存块,malloc() 函数可能会请求操作系统增加堆的大小(通过系统调用,如 brk()sbrk() 在 Linux 系统中),然后再次尝试分配内存。如果操作系统无法满足增加堆大小的请求,malloc() 函数将返回 NULL

4. 内存对齐与 malloc() 函数

内存对齐(memory alignment)是计算机系统中一个重要的概念,它对 malloc() 函数的内存分配也有影响。不同的计算机体系结构对内存对齐有不同的要求,通常要求数据存储的地址是其数据类型大小的整数倍。例如,在 32 位系统中,int 类型通常占用 4 个字节,那么 int 类型数据的存储地址应该是 4 的倍数。

malloc() 函数分配的内存块会满足系统默认的内存对齐要求。这意味着,即使请求分配的内存大小不是对齐边界的整数倍,malloc() 函数实际分配的内存大小可能会大于请求的大小,以确保内存地址的对齐。例如,在一个要求 8 字节对齐的系统中,如果请求分配 5 个字节的内存,malloc() 函数可能会实际分配 8 个字节的内存,以保证内存地址是 8 的倍数。

下面的代码示例展示了内存对齐对 malloc() 函数分配内存大小的影响:

#include <stdio.h>
#include <stdlib.h>

int main() {
    // 分配 5 个字节的内存
    char *ptr = (char *)malloc(5);

    if (ptr == NULL) {
        printf("内存分配失败\n");
        return 1;
    }

    // 获取分配内存块的地址
    unsigned long address = (unsigned long)ptr;

    // 输出分配内存块的地址和大小
    printf("分配的内存地址: %p\n", (void *)address);
    printf("请求的内存大小: 5 字节\n");
    printf("实际分配的内存大小: %zu 字节\n", sizeof(void *) + 5);

    // 释放分配的内存
    free(ptr);
    ptr = NULL;

    return 0;
}

在上述代码中,虽然请求分配 5 个字节的内存,但实际分配的内存大小可能会大于 5 字节,以满足内存对齐的要求。在 64 位系统中,sizeof(void *) 通常为 8 字节,因此实际分配的内存大小可能为 sizeof(void *) + 5 字节(这里只是一种可能的情况,具体的内存对齐和实际分配大小取决于系统实现)。

5. 多次调用 malloc() 函数的情况

在实际编程中,常常需要多次调用 malloc() 函数来分配多个内存块。每次调用 malloc() 函数都会在堆中分配一块新的内存空间,并且返回的指针是独立的,指向不同的内存地址。

下面的代码示例展示了多次调用 malloc() 函数分配多个内存块的情况:

#include <stdio.h>
#include <stdlib.h>

int main() {
    // 第一次分配内存
    int *ptr1 = (int *)malloc(5 * sizeof(int));

    if (ptr1 == NULL) {
        printf("第一次内存分配失败\n");
        return 1;
    }

    // 第二次分配内存
    int *ptr2 = (int *)malloc(3 * sizeof(int));

    if (ptr2 == NULL) {
        printf("第二次内存分配失败\n");
        free(ptr1);
        return 1;
    }

    // 使用分配的内存
    for (int i = 0; i < 5; i++) {
        ptr1[i] = i;
    }

    for (int i = 0; i < 3; i++) {
        ptr2[i] = i * 3;
    }

    // 输出内存中的数据
    printf("ptr1 中的数据:\n");
    for (int i = 0; i < 5; i++) {
        printf("ptr1[%d] = %d\n", i, ptr1[i]);
    }

    printf("ptr2 中的数据:\n");
    for (int i = 0; i < 3; i++) {
        printf("ptr2[%d] = %d\n", i, ptr2[i]);
    }

    // 释放分配的内存
    free(ptr1);
    free(ptr2);
    ptr1 = NULL;
    ptr2 = NULL;

    return 0;
}

在上述代码中,首先调用 malloc() 函数分配了一块可以容纳 5 个 int 类型数据的内存块,并将返回的指针赋值给 ptr1。然后再次调用 malloc() 函数分配了另一块可以容纳 3 个 int 类型数据的内存块,并将返回的指针赋值给 ptr2。分别对这两块内存进行初始化和数据访问操作,最后使用 free() 函数释放这两块内存,并将 ptr1ptr2 置为 NULL

释放动态内存的关键:free() 函数

1. free() 函数的基本概念

free() 函数是与 malloc() 函数配套使用的,用于释放由 malloc()calloc()realloc() 函数分配的动态内存。free() 函数的作用是将已分配的内存归还给堆,使其可以被再次分配使用。如果不及时释放不再使用的动态内存,会导致内存泄漏(memory leak),即程序占用的内存不断增加,最终可能耗尽系统内存资源。

free() 函数同样定义在 <stdlib.h> 头文件中,其函数原型如下:

void free(void *ptr);

free() 函数接受一个参数 ptr,该参数必须是之前通过 malloc()calloc()realloc() 函数分配内存时返回的指针。如果传递给 free() 函数的指针不是上述函数返回的指针,或者该指针已经被释放过,那么行为是未定义的,可能导致程序崩溃或出现其他不可预测的错误。

2. 使用 free() 函数释放内存

下面通过一个简单的示例代码来演示如何使用 free() 函数释放内存:

#include <stdio.h>
#include <stdlib.h>

int main() {
    // 分配 10 个字符大小的内存空间
    char *ptr = (char *)malloc(10 * sizeof(char));

    if (ptr == NULL) {
        printf("内存分配失败\n");
        return 1;
    }

    // 使用分配的内存
    for (int i = 0; i < 9; i++) {
        ptr[i] = 'a' + i;
    }
    ptr[9] = '\0';

    // 输出内存中的数据
    printf("分配的字符串: %s\n", ptr);

    // 释放分配的内存
    free(ptr);
    ptr = NULL;

    return 0;
}

在上述代码中,首先使用 malloc() 函数分配了足够容纳 10 个 char 类型数据的内存空间,并将返回的指针强制转换为 char * 类型,赋值给 ptr 指针变量。然后对这块内存进行初始化,形成一个字符串,并输出该字符串。最后使用 free() 函数释放已分配的内存,并将 ptr 置为 NULL,以防止悬空指针的产生。

3. free() 函数的实现原理

free() 函数的实现原理与操作系统的内存管理机制密切相关。当调用 free() 函数并传递一个已分配内存块的指针时,free() 函数会将该内存块标记为空闲状态,使其可以被后续的内存分配函数(如 malloc())再次使用。

在具体实现中,操作系统通常会维护一个空闲内存块链表(free list),用于记录堆中所有空闲的内存块。当 free() 函数被调用时,它会将指定的内存块插入到空闲内存块链表中。如果相邻的内存块也处于空闲状态,操作系统可能会将它们合并成一个更大的空闲内存块,以提高内存的利用率。

例如,假设当前堆中有两个相邻的空闲内存块 AB,以及一个已分配的内存块 C。当 C 被释放时,如果 AB 仍然空闲,操作系统可能会将 ABC 合并成一个更大的空闲内存块,这样在后续的内存分配请求中,就可以更有效地利用这块内存。

4. 释放内存时的注意事项

在使用 free() 函数释放内存时,有几个重要的注意事项需要牢记:

  • 避免重复释放:不要对同一个指针多次调用 free() 函数。一旦内存块被释放,再次调用 free() 函数会导致未定义行为。例如:
#include <stdio.h>
#include <stdlib.h>

int main() {
    int *ptr = (int *)malloc(5 * sizeof(int));

    if (ptr == NULL) {
        printf("内存分配失败\n");
        return 1;
    }

    free(ptr);
    // 错误:重复释放内存
    free(ptr);

    return 0;
}

在上述代码中,对 ptr 进行了两次 free() 操作,这是不允许的,可能会导致程序崩溃或其他不可预测的错误。

  • 确保指针有效:传递给 free() 函数的指针必须是之前通过 malloc()calloc()realloc() 函数分配内存时返回的有效指针。如果传递一个无效指针(如 NULL 指针或未初始化的指针),虽然在某些情况下可能不会导致程序立即崩溃,但行为是未定义的。例如:
#include <stdio.h>
#include <stdlib.h>

int main() {
    int *ptr = NULL;
    // 错误:释放无效指针
    free(ptr);

    return 0;
}

在上述代码中,ptr 是一个 NULL 指针,对其调用 free() 函数是不恰当的,尽管在大多数系统中不会导致程序立即出错,但这是不符合规范的行为。

  • 防止悬空指针:在释放内存后,应该立即将指针设置为 NULL,以防止悬空指针的产生。悬空指针是指指向已释放内存的指针,如果继续使用悬空指针,可能会导致程序访问无效内存,引发段错误(segmentation fault)等问题。例如:
#include <stdio.h>
#include <stdlib.h>

int main() {
    int *ptr = (int *)malloc(5 * sizeof(int));

    if (ptr == NULL) {
        printf("内存分配失败\n");
        return 1;
    }

    free(ptr);
    // 错误:未将指针置为 NULL,ptr 成为悬空指针
    // 如果后续不小心使用了 ptr,可能会导致错误
    // 正确做法:
    ptr = NULL;

    return 0;
}

在上述代码中,释放内存后将 ptr 置为 NULL,可以有效地避免悬空指针带来的问题。

malloc() 和 free() 的高级应用与常见问题

1. 动态内存分配与数组

在 C 语言中,动态内存分配常常用于创建动态数组。通过 malloc() 函数分配连续的内存空间,可以模拟数组的行为,并且可以在运行时根据需要调整数组的大小。

下面的代码示例展示了如何使用 malloc() 函数创建一个动态数组,并对其进行操作:

#include <stdio.h>
#include <stdlib.h>

int main() {
    int n;
    printf("请输入数组的大小: ");
    scanf("%d", &n);

    // 分配动态数组的内存
    int *arr = (int *)malloc(n * sizeof(int));

    if (arr == NULL) {
        printf("内存分配失败\n");
        return 1;
    }

    // 初始化动态数组
    for (int i = 0; i < n; i++) {
        arr[i] = i * i;
    }

    // 输出动态数组中的数据
    printf("动态数组中的数据: ");
    for (int i = 0; i < n; i++) {
        printf("%d ", arr[i]);
    }
    printf("\n");

    // 释放动态数组的内存
    free(arr);
    arr = NULL;

    return 0;
}

在上述代码中,首先通过用户输入获取数组的大小 n,然后使用 malloc() 函数分配可以容纳 nint 类型数据的内存空间,将其视为一个动态数组。接着对动态数组进行初始化和数据输出操作,最后使用 free() 函数释放内存,并将指针置为 NULL

2. realloc() 函数与动态内存调整

realloc() 函数是 C 语言中用于调整已分配动态内存大小的函数。它可以在不丢失原有数据的情况下,增大或缩小已分配的内存块。realloc() 函数定义在 <stdlib.h> 头文件中,其函数原型如下:

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

realloc() 函数接受两个参数,ptr 是指向已分配内存块的指针,size 是新的内存大小(以字节为单位)。如果 ptrNULLrealloc() 函数的行为与 malloc() 函数相同,即分配一块新的内存块。如果 size 为 0 且 ptr 不为 NULLrealloc() 函数的行为与 free() 函数相同,即释放内存块。

下面的代码示例展示了如何使用 realloc() 函数调整动态数组的大小:

#include <stdio.h>
#include <stdlib.h>

int main() {
    int n1 = 5;
    // 分配初始动态数组的内存
    int *arr = (int *)malloc(n1 * sizeof(int));

    if (arr == NULL) {
        printf("内存分配失败\n");
        return 1;
    }

    // 初始化初始动态数组
    for (int i = 0; i < n1; i++) {
        arr[i] = i * 2;
    }

    // 输出初始动态数组中的数据
    printf("初始动态数组中的数据: ");
    for (int i = 0; i < n1; i++) {
        printf("%d ", arr[i]);
    }
    printf("\n");

    int n2 = 8;
    // 调整动态数组的大小
    int *newArr = (int *)realloc(arr, n2 * sizeof(int));

    if (newArr == NULL) {
        printf("内存调整失败\n");
        free(arr);
        return 1;
    }

    arr = newArr;
    // 初始化新增加的元素
    for (int i = n1; i < n2; i++) {
        arr[i] = i * 3;
    }

    // 输出调整后动态数组中的数据
    printf("调整后动态数组中的数据: ");
    for (int i = 0; i < n2; i++) {
        printf("%d ", arr[i]);
    }
    printf("\n");

    // 释放动态数组的内存
    free(arr);
    arr = NULL;

    return 0;
}

在上述代码中,首先分配了一个大小为 5 的动态数组,并进行初始化和数据输出。然后使用 realloc() 函数将数组大小调整为 8,检查 realloc() 函数的返回值以确保内存调整成功。如果成功,将新的指针赋值给 arr,并对新增加的元素进行初始化,最后输出调整后动态数组中的数据,并释放内存。

3. 内存泄漏问题及检测

内存泄漏是使用动态内存分配时常见的问题之一。当程序分配了动态内存,但在不再使用时没有释放,就会导致内存泄漏。随着程序的运行,内存泄漏会逐渐消耗系统内存资源,最终可能导致系统性能下降甚至程序崩溃。

下面是一个简单的内存泄漏示例代码:

#include <stdio.h>
#include <stdlib.h>

void memoryLeakFunction() {
    int *ptr = (int *)malloc(10 * sizeof(int));
    // 没有释放内存
}

int main() {
    for (int i = 0; i < 1000; i++) {
        memoryLeakFunction();
    }

    return 0;
}

在上述代码中,memoryLeakFunction() 函数分配了内存,但没有调用 free() 函数释放内存。在 main() 函数中多次调用 memoryLeakFunction() 函数,就会导致大量的内存泄漏。

为了检测内存泄漏,可以使用一些工具,如 Valgrind(在 Linux 系统中)。Valgrind 是一个用于内存调试、内存泄漏检测和性能分析的工具。使用 Valgrind 检测上述代码中的内存泄漏非常简单,只需在命令行中运行 valgrind --leak-check=full./your_program,Valgrind 会详细报告内存泄漏的位置和大小等信息。

4. 堆碎片问题

堆碎片(heap fragmentation)是另一个与动态内存分配相关的问题。当频繁地分配和释放动态内存时,堆中可能会出现许多小块的空闲内存,这些小块内存由于不连续,无法满足较大的内存分配请求,从而降低了内存的利用率。

例如,假设堆的初始状态是一块连续的大空闲内存块。经过一系列的分配和释放操作后,堆中可能会形成一些小块的空闲内存块,分布在已分配内存块之间。当需要分配一个较大的内存块时,虽然堆中总的空闲内存大小足够,但由于这些空闲内存块不连续,无法满足分配请求,导致 malloc() 函数返回 NULL

为了减少堆碎片问题,可以采用一些内存分配策略,如伙伴系统(buddy system)或 slab 分配器(slab allocator)。这些策略通过更合理地管理空闲内存块,尽量减少碎片的产生,提高内存的利用率。在实际编程中,也应该尽量避免频繁地分配和释放小内存块,以降低堆碎片的风险。

5. 内存分配与性能优化

动态内存分配和释放操作通常比栈内存分配和释放要慢,因为它们涉及到与操作系统内存管理子系统的交互。因此,在编写高性能程序时,需要注意合理使用动态内存分配。

  • 减少不必要的动态分配:尽量在程序初始化阶段一次性分配所需的内存,而不是在程序运行过程中频繁地分配和释放内存。例如,如果知道程序需要一个固定大小的数组,并且在程序运行过程中大小不会改变,那么可以在栈上分配该数组,而不是使用动态内存分配。
  • 优化内存分配大小:在分配内存时,尽量准确地估计所需的内存大小,避免分配过多或过少的内存。分配过多的内存会浪费空间,分配过少的内存可能导致需要频繁地调整内存大小,增加性能开销。
  • 缓存重用:对于一些频繁使用的小内存块,可以考虑使用缓存机制,将已释放的小内存块缓存起来,下次需要分配小内存块时,优先从缓存中获取,而不是直接调用 malloc() 函数。这样可以减少系统调用的次数,提高性能。

通过合理地使用动态内存分配和释放函数,以及注意上述性能优化要点,可以编写高效、稳定的 C 语言程序。在实际开发中,需要根据具体的应用场景和需求,综合考虑各种因素,以达到最佳的性能和内存利用率。