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

C 语言动态内存分配实践指南

2023-08-107.2k 阅读

C 语言动态内存分配概述

在 C 语言编程中,内存管理是一项关键任务。静态内存分配在编译时就确定了变量所需的内存大小,虽然简单直接,但缺乏灵活性。而动态内存分配允许程序在运行时根据实际需求分配和释放内存,大大提高了程序的适应性和资源利用率。

为什么需要动态内存分配

  1. 灵活性:例如编写一个处理用户输入数据的程序,在编译时无法预知用户会输入多少数据。若使用静态数组,大小设置过小可能导致数据溢出,设置过大又会浪费内存。动态内存分配可以根据用户实际输入的数据量来分配内存。
  2. 资源管理:对于一些临时使用的数据结构,如链表节点,在使用完后可以释放其所占用的内存,将资源归还给系统,提高内存的利用率。

动态内存分配函数

malloc 函数

malloc(memory allocation)函数是 C 语言中用于动态内存分配的基本函数。它的原型如下:

void *malloc(size_t size);

其中,size 是要分配的内存字节数。malloc 函数在堆内存中分配一块指定大小的连续内存空间,并返回一个指向该内存块起始地址的指针。如果分配失败(例如系统内存不足),则返回 NULL

以下是一个简单的示例,展示如何使用 malloc 分配一个整数数组:

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

int main() {
    int *arr;
    int n = 5;
    arr = (int *)malloc(n * sizeof(int));
    if (arr == NULL) {
        printf("内存分配失败\n");
        return 1;
    }
    for (int i = 0; i < n; i++) {
        arr[i] = i + 1;
    }
    for (int i = 0; i < n; i++) {
        printf("%d ", arr[i]);
    }
    free(arr);
    return 0;
}

在上述代码中,首先通过 malloc 分配了能容纳 n 个整数的内存空间,并将返回的指针强制转换为 int * 类型。然后对数组进行赋值和打印操作,最后使用 free 函数释放分配的内存。

calloc 函数

calloc(contiguous allocation)函数也用于动态内存分配,它与 malloc 的主要区别在于,calloc 会将分配的内存空间初始化为 0。其原型为:

void *calloc(size_t num, size_t size);

num 表示要分配的元素个数,size 表示每个元素的大小(以字节为单位)。calloc 函数会分配 num * size 字节的内存空间,并返回指向该内存块起始地址的指针。若分配失败,同样返回 NULL

下面是使用 calloc 分配一个浮点数数组的示例:

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

int main() {
    float *arr;
    int n = 3;
    arr = (float *)calloc(n, sizeof(float));
    if (arr == NULL) {
        printf("内存分配失败\n");
        return 1;
    }
    for (int i = 0; i < n; i++) {
        printf("%f ", arr[i]);
    }
    free(arr);
    return 0;
}

由于 calloc 初始化了内存,所以数组元素默认都为 0.0,直接打印即可看到初始值。

realloc 函数

realloc(re - allocation)函数用于重新分配已分配内存块的大小。它可以扩大或缩小已分配内存块的尺寸。其原型为:

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

ptr 是指向先前通过 malloccallocrealloc 分配的内存块的指针。size 是新的内存块大小(以字节为单位)。

如果 ptrNULLrealloc 的行为就如同 malloc,分配一块新的内存并返回指针。如果 size 为 0 且 ptr 不为 NULLrealloc 会释放 ptr 指向的内存块,并返回 NULL

以下示例展示了如何使用 realloc 扩大一个整数数组的大小:

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

int main() {
    int *arr;
    int n = 3;
    arr = (int *)malloc(n * sizeof(int));
    if (arr == NULL) {
        printf("内存分配失败\n");
        return 1;
    }
    for (int i = 0; i < n; i++) {
        arr[i] = i + 1;
    }
    int new_n = 5;
    arr = (int *)realloc(arr, new_n * sizeof(int));
    if (arr == NULL) {
        printf("内存重新分配失败\n");
        return 1;
    }
    for (int i = n; i < new_n; i++) {
        arr[i] = i + 1;
    }
    for (int i = 0; i < new_n; i++) {
        printf("%d ", arr[i]);
    }
    free(arr);
    return 0;
}

在这个示例中,首先分配了一个包含 3 个整数的数组,然后使用 realloc 将其扩大为包含 5 个整数的数组,并对新增加的元素进行赋值和打印。

动态内存分配中的常见问题

内存泄漏

内存泄漏是动态内存分配中最常见的问题之一。当程序分配了内存,但在使用完毕后没有释放,这块内存就无法再被其他程序使用,从而造成内存浪费。随着程序的运行,内存泄漏会导致系统可用内存逐渐减少,最终可能使程序崩溃或系统性能严重下降。

以下是一个内存泄漏的示例:

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

int main() {
    int *ptr;
    for (int i = 0; i < 10; i++) {
        ptr = (int *)malloc(sizeof(int));
        *ptr = i;
        // 这里没有释放 ptr 指向的内存
    }
    return 0;
}

在上述代码中,每次循环都分配了一块内存,但没有使用 free 函数释放,导致内存泄漏。

悬空指针

悬空指针是指指向已释放内存的指针。当内存块被释放后,指针仍然保存着原来的内存地址,但这块内存可能已经被重新分配给其他用途。如果继续使用悬空指针访问内存,会导致未定义行为,可能使程序崩溃或产生不可预测的结果。

以下是一个悬空指针的示例:

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

int main() {
    int *ptr;
    ptr = (int *)malloc(sizeof(int));
    *ptr = 10;
    free(ptr);
    // 此时 ptr 成为悬空指针
    printf("%d\n", *ptr);
    return 0;
}

free(ptr) 之后,ptr 指向的内存已经被释放,再尝试通过 ptr 访问内存就是未定义行为。为了避免悬空指针问题,在释放内存后,应立即将指针赋值为 NULL,如下所示:

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

int main() {
    int *ptr;
    ptr = (int *)malloc(sizeof(int));
    *ptr = 10;
    free(ptr);
    ptr = NULL;
    // 此时 ptr 不再是悬空指针,访问 ptr 不会导致未定义行为
    return 0;
}

内存越界

内存越界是指访问超出已分配内存块边界的内存位置。这可能会导致覆盖其他变量的数据,引发程序错误或崩溃。在动态内存分配中,特别是在使用数组时,容易发生内存越界问题。

以下是一个内存越界的示例:

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

int main() {
    int *arr;
    int n = 5;
    arr = (int *)malloc(n * sizeof(int));
    if (arr == NULL) {
        printf("内存分配失败\n");
        return 1;
    }
    for (int i = 0; i <= n; i++) {
        // 这里 i 可以取到 n,导致内存越界
        arr[i] = i + 1;
    }
    free(arr);
    return 0;
}

在上述代码中,for 循环的条件 i <= n 使得 arr[n] 被访问,而实际上 arr 只有 n 个元素,有效索引范围是 0n - 1,这就造成了内存越界。

动态内存分配的应用场景

链表

链表是一种常用的数据结构,它通过动态内存分配来实现节点的创建和删除,具有很好的灵活性。每个节点包含数据部分和指向下一个节点的指针。

以下是一个简单的单向链表示例:

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

// 定义链表节点结构
struct Node {
    int data;
    struct Node *next;
};

// 创建新节点
struct Node *createNode(int value) {
    struct Node *newNode = (struct Node *)malloc(sizeof(struct Node));
    newNode->data = value;
    newNode->next = NULL;
    return newNode;
}

// 打印链表
void printList(struct Node *head) {
    struct Node *current = head;
    while (current != NULL) {
        printf("%d -> ", current->data);
        current = current->next;
    }
    printf("NULL\n");
}

// 释放链表内存
void freeList(struct Node *head) {
    struct Node *current = head;
    struct Node *nextNode;
    while (current != NULL) {
        nextNode = current->next;
        free(current);
        current = nextNode;
    }
}

int main() {
    struct Node *head = createNode(1);
    struct Node *node2 = createNode(2);
    struct Node *node3 = createNode(3);
    head->next = node2;
    node2->next = node3;
    printList(head);
    freeList(head);
    return 0;
}

在这个示例中,通过 malloc 动态分配内存创建链表节点,使用完毕后通过 freeList 函数释放链表占用的所有内存,避免内存泄漏。

动态数组

虽然 C 语言本身没有内置的动态数组类型,但可以通过动态内存分配模拟动态数组的行为。可以使用 realloc 函数根据需要调整数组的大小。

以下是一个简单的动态数组示例:

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

int main() {
    int *arr;
    int capacity = 2;
    int size = 0;
    arr = (int *)malloc(capacity * sizeof(int));
    if (arr == NULL) {
        printf("内存分配失败\n");
        return 1;
    }
    // 添加元素
    for (int i = 0; i < 5; i++) {
        if (size == capacity) {
            capacity *= 2;
            arr = (int *)realloc(arr, capacity * sizeof(int));
            if (arr == NULL) {
                printf("内存重新分配失败\n");
                return 1;
            }
        }
        arr[size++] = i + 1;
    }
    // 打印数组
    for (int i = 0; i < size; i++) {
        printf("%d ", arr[i]);
    }
    free(arr);
    return 0;
}

在这个示例中,初始分配了一个大小为 2 的数组,当数组满时,使用 realloc 将其大小翻倍,从而实现动态数组的功能。

字符串处理

在处理字符串时,动态内存分配也非常有用。例如,当读取用户输入的字符串时,由于不知道用户输入的长度,使用动态内存分配可以根据实际输入长度分配足够的内存。

以下是一个读取用户输入字符串并打印的示例:

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

int main() {
    char *str;
    int size = 10;
    str = (char *)malloc(size * sizeof(char));
    if (str == NULL) {
        printf("内存分配失败\n");
        return 1;
    }
    printf("请输入一个字符串:");
    fgets(str, size, stdin);
    // 检查输入是否超过分配的大小
    if (strchr(str, '\n') == NULL) {
        char c;
        while ((c = getchar()) != '\n' && c != EOF);
        size *= 2;
        str = (char *)realloc(str, size * sizeof(char));
        if (str == NULL) {
            printf("内存重新分配失败\n");
            return 1;
        }
        printf("请重新输入一个字符串:");
        fgets(str, size, stdin);
    }
    // 移除 fgets 读取的换行符
    str[strcspn(str, "\n")] = '\0';
    printf("你输入的字符串是:%s\n", str);
    free(str);
    return 0;
}

在这个示例中,首先分配了大小为 10 的内存用于存储字符串。如果用户输入的字符串长度超过 9(因为 fgets 会读取换行符),则重新分配内存并让用户重新输入,最后打印处理后的字符串并释放内存。

动态内存分配的优化

减少内存碎片

内存碎片是指在堆内存中存在大量不连续的小块空闲内存,导致无法分配较大的连续内存块。频繁地分配和释放不同大小的内存块容易产生内存碎片。

为了减少内存碎片,可以采用以下方法:

  1. 对象池:预先分配一组相同大小的对象,当需要时从对象池中获取,使用完毕后归还到对象池,而不是频繁地调用 mallocfree
  2. 按照大小顺序分配:尽量按照内存块大小的顺序进行分配和释放,这样可以减少内存碎片的产生。例如,先分配小的内存块,再分配大的内存块,释放时也按照类似顺序。

提高分配效率

  1. 缓存常用内存块大小:对于程序中经常使用的特定大小的内存块,可以缓存已分配的内存块,下次需要时直接使用,避免重复调用 malloc
  2. 使用内存分配器优化:一些高级的内存分配器,如 tcmalloc(Thread - Caching Malloc)、jemalloc 等,针对多线程环境和高并发场景进行了优化,可以提高内存分配的效率。可以在程序中使用这些第三方内存分配器来替代系统默认的 malloc 等函数。

内存对齐

内存对齐是指内存地址按照特定的边界进行对齐,通常是为了提高内存访问效率。不同的硬件平台对内存对齐有不同的要求。在 C 语言中,编译器会自动进行一定程度的内存对齐,但在动态内存分配时,也需要注意确保分配的内存块满足对齐要求。

例如,在一些平台上,double 类型的数据需要 8 字节对齐。如果分配的内存块没有正确对齐,可能会导致性能下降甚至程序错误。可以使用 aligned_alloc 函数(C11 标准引入)来分配对齐的内存。其原型为:

void *aligned_alloc(size_t alignment, size_t size);

alignment 是对齐值,必须是 2 的幂次方,且至少为 sizeof(void *)size 是要分配的内存字节数。

以下是一个使用 aligned_alloc 分配对齐内存的示例:

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

int main() {
    double *ptr;
    size_t alignment = 8;
    size_t size = sizeof(double);
    ptr = (double *)aligned_alloc(alignment, size);
    if (ptr == NULL) {
        printf("内存分配失败\n");
        return 1;
    }
    *ptr = 3.14;
    printf("%f\n", *ptr);
    free(ptr);
    return 0;
}

在这个示例中,使用 aligned_alloc 分配了 8 字节对齐的内存用于存储 double 类型的数据。

动态内存分配与多线程

在多线程程序中,动态内存分配需要特别注意线程安全问题。由于多个线程可能同时调用动态内存分配函数,可能会导致数据竞争和未定义行为。

线程安全的内存分配

  1. 使用锁:可以通过互斥锁(pthread_mutex_t)来保护对动态内存分配函数的调用。在调用 malloccallocreallocfree 之前,先获取锁,操作完成后释放锁。 以下是一个使用互斥锁保护动态内存分配的示例:
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>

pthread_mutex_t mutex;

void *threadFunction(void *arg) {
    int *ptr;
    pthread_mutex_lock(&mutex);
    ptr = (int *)malloc(sizeof(int));
    if (ptr != NULL) {
        *ptr = *((int *)arg);
        printf("线程 %ld 分配内存并赋值: %d\n", pthread_self(), *ptr);
        free(ptr);
    }
    pthread_mutex_unlock(&mutex);
    return NULL;
}

int main() {
    pthread_t threads[2];
    int values[2] = {10, 20};
    pthread_mutex_init(&mutex, NULL);
    for (int i = 0; i < 2; i++) {
        pthread_create(&threads[i], NULL, threadFunction, &values[i]);
    }
    for (int i = 0; i < 2; i++) {
        pthread_join(threads[i], NULL);
    }
    pthread_mutex_destroy(&mutex);
    return 0;
}

在这个示例中,通过 pthread_mutex_lockpthread_mutex_unlock 保护了 mallocfree 的调用,确保在多线程环境下的线程安全。

  1. 使用线程本地存储:线程本地存储(TLS,Thread - Local Storage)可以为每个线程提供独立的内存空间,避免多线程竞争。在 C 语言中,可以使用 __thread 关键字(GCC 扩展)或 pthread_key_create 等函数来实现线程本地存储。

内存泄漏与多线程

在多线程程序中,内存泄漏问题可能更加复杂。由于线程的并发执行,可能会出现一个线程分配了内存,但在其他线程释放该内存之前,分配内存的线程已经结束,导致内存泄漏。

为了避免多线程环境下的内存泄漏,除了确保每个线程正确释放自己分配的内存外,还可以使用引用计数等技术。引用计数是指为每个动态分配的内存块维护一个引用计数,每当有一个线程使用该内存块时,引用计数加 1,使用完毕后减 1,当引用计数为 0 时,释放该内存块。

动态内存分配的调试

使用 valgrind 工具

valgrind 是一款功能强大的内存调试工具,可用于检测内存泄漏、悬空指针、内存越界等问题。在 Linux 系统上,可以通过包管理器安装 valgrind

以下是使用 valgrind 检测前面内存泄漏示例的方法:

  1. 编写好内存泄漏的 C 程序,例如 leak.c
  2. 编译程序:gcc -g leak.c -o leak-g 选项用于生成调试信息,便于 valgrind 分析。
  3. 使用 valgrind 运行程序:valgrind --leak - check = yes./leak

valgrind 会输出详细的内存泄漏信息,包括泄漏的内存块大小、分配的位置等,帮助开发者定位和修复问题。

自定义调试函数

在一些情况下,可能无法使用 valgrind,或者希望在程序中实时监测内存使用情况。可以通过自定义调试函数来实现。

例如,编写自定义的 mallocfree 函数,在这些函数中记录内存分配和释放的信息,如分配的大小、调用的位置等。

以下是一个简单的自定义调试函数示例:

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

#define DEBUG 1

void *myMalloc(size_t size, const char *file, int line) {
    void *ptr = malloc(size);
    if (DEBUG) {
        printf("在 %s:%d 分配了 %zu 字节内存,地址为 %p\n", file, line, size, ptr);
    }
    return ptr;
}

void myFree(void *ptr, const char *file, int line) {
    if (ptr!= NULL) {
        if (DEBUG) {
            printf("在 %s:%d 释放了内存,地址为 %p\n", file, line, ptr);
        }
        free(ptr);
    }
}

#define malloc(size) myMalloc(size, __FILE__, __LINE__)
#define free(ptr) myFree(ptr, __FILE__, __LINE__)

int main() {
    int *arr;
    arr = (int *)malloc(5 * sizeof(int));
    free(arr);
    return 0;
}

在这个示例中,通过宏定义重新定义了 mallocfree 函数,使其在分配和释放内存时打印调试信息,便于定位内存问题。

总结动态内存分配要点

动态内存分配是 C 语言编程中强大而灵活的特性,但也伴随着诸多风险,如内存泄漏、悬空指针和内存越界等问题。通过合理使用 malloccallocrealloc 等函数,并遵循良好的编程习惯,如及时释放内存、避免悬空指针、注意内存对齐等,可以有效地利用动态内存分配,编写出高效、稳定的程序。在多线程环境中,要特别注意线程安全问题,使用合适的同步机制来保护内存分配和释放操作。同时,借助 valgrind 等调试工具和自定义调试函数,可以快速定位和解决动态内存分配中出现的问题。希望通过本指南,读者能对 C 语言动态内存分配有更深入的理解和掌握,在实际编程中能够灵活运用并避免常见错误。