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

C语言malloc函数动态分配内存

2022-11-202.7k 阅读

C 语言内存管理基础

在深入探讨 malloc 函数之前,我们先来了解一下 C 语言内存管理的基础知识。C 语言中,程序所使用的内存可以大致分为几个不同的区域,每个区域都有其特定的用途和生命周期。

栈区(Stack)

栈区主要用于存储局部变量、函数参数以及函数调用的上下文信息。当一个函数被调用时,它的局部变量和参数会被分配在栈上。栈是一种后进先出(LIFO, Last In First Out)的数据结构,随着函数的调用和返回,栈上的数据会相应地增加和减少。例如:

#include <stdio.h>

void func() {
    int num = 10; // num 存储在栈区
    printf("num in func: %d\n", num);
}

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

在这个例子中,func 函数中的 num 变量是局部变量,它被分配在栈区。当 func 函数结束时,num 所占用的栈空间会被自动释放。

堆区(Heap)

堆区是一块供程序动态分配内存的区域。与栈区不同,堆区的内存分配和释放由程序员手动控制。这意味着我们可以在程序运行时根据需要在堆区申请和释放内存。堆区的内存管理更加灵活,但也更容易出错,因为如果程序员忘记释放不再使用的堆内存,就会导致内存泄漏。

全局区(静态区,Global/Static)

全局区用于存储全局变量和静态变量。全局变量在程序的整个生命周期内都存在,其作用域是整个程序。静态变量可以分为静态全局变量和静态局部变量,静态全局变量的作用域是当前文件,静态局部变量在函数调用结束后仍然存在,但其作用域仍然局限于函数内部。例如:

#include <stdio.h>

int globalVar; // 全局变量,存储在全局区
static int staticGlobalVar; // 静态全局变量,存储在全局区

void func() {
    static int staticLocalVar; // 静态局部变量,存储在全局区
    staticLocalVar++;
    printf("staticLocalVar: %d\n", staticLocalVar);
}

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

在这个例子中,globalVarstaticGlobalVarstaticLocalVar 都存储在全局区。staticLocalVar 在每次 func 函数调用时都会保留其值,因为它的生命周期贯穿整个程序运行期间。

文字常量区

文字常量区用于存储常量字符串。例如:

#include <stdio.h>

int main() {
    char *str = "Hello, World!"; // "Hello, World!" 存储在文字常量区
    printf("%s\n", str);
    return 0;
}

这里的 "Hello, World!" 是一个常量字符串,存储在文字常量区。str 是一个指针,指向这个常量字符串的首地址。

程序代码区

程序代码区存储程序的二进制代码,也就是 CPU 执行的指令。这部分内存是只读的,以防止程序在运行过程中意外修改自身的代码。

了解了这些内存区域的基本概念后,我们就可以更好地理解 malloc 函数在堆区动态分配内存的机制。

malloc 函数概述

malloc 函数是 C 语言标准库中用于动态内存分配的函数,它的原型定义在 <stdlib.h> 头文件中:

void *malloc(size_t size);

malloc 函数的作用是在堆区分配一块指定大小的内存空间,并返回一个指向该内存块起始地址的指针。如果内存分配成功,返回的指针指向新分配的内存块;如果由于内存不足等原因导致分配失败,malloc 函数将返回 NULL

malloc 函数的参数

malloc 函数只有一个参数 size,它的类型是 size_tsize_t 是一个无符号整数类型,用于表示内存块的大小,单位是字节。例如,如果我们想分配一个能存储 10 个 int 类型数据的内存块,因为 int 类型通常占用 4 个字节(在 32 位系统中),所以我们可以这样调用 malloc 函数:

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

int main() {
    int *arr = (int *)malloc(10 * sizeof(int));
    if (arr == NULL) {
        printf("Memory allocation failed\n");
        return 1;
    }
    // 使用 arr 数组
    for (int i = 0; i < 10; i++) {
        arr[i] = i;
    }
    for (int i = 0; i < 10; i++) {
        printf("%d ", arr[i]);
    }
    free(arr); // 释放内存
    return 0;
}

在这个例子中,malloc(10 * sizeof(int)) 表示分配一个大小为 10 * 4 = 40 字节的内存块,足够存储 10 个 int 类型的数据。然后我们将返回的指针强制转换为 int * 类型,以便通过数组下标方式访问这块内存。

malloc 函数返回值

malloc 函数返回一个 void * 类型的指针。void * 是一种通用指针类型,可以指向任何类型的数据。在实际使用中,我们通常需要将返回的 void * 指针强制转换为我们需要的具体数据类型指针。例如,在上面的例子中,我们将 malloc 返回的指针强制转换为 int * 类型,因为我们要把这块内存当作 int 数组来使用。

需要注意的是,在使用 malloc 分配内存后,一定要检查返回值是否为 NULL。如果返回 NULL,说明内存分配失败,程序应该采取适当的措施,比如输出错误信息并终止程序,以避免后续使用空指针导致程序崩溃。

malloc 函数的工作原理

要深入理解 malloc 函数的工作原理,我们需要了解一些操作系统和内存管理的基础知识。现代操作系统通常采用虚拟内存管理机制,每个进程都有自己独立的虚拟地址空间。虚拟地址空间被划分为多个页(Page),每个页的大小通常是固定的,例如 4KB。

堆内存的组织方式

在 C 语言程序中,堆区位于进程虚拟地址空间的某个区域。堆内存通常是通过链表的方式进行组织的。操作系统维护着一个空闲内存块链表,当程序调用 malloc 函数时,malloc 函数会在空闲链表中查找一个大小合适的空闲内存块。如果找到合适的块,就将其从空闲链表中移除,并返回该块的起始地址。如果没有找到足够大的空闲块,malloc 函数可能会尝试向操作系统请求更多的内存(通过系统调用,如 brkmmap,这取决于操作系统的实现),然后将新获得的内存添加到空闲链表中,并再次查找合适的块进行分配。

内存对齐

在分配内存时,malloc 函数还需要考虑内存对齐的问题。不同的硬件平台对数据的存储地址有不同的对齐要求。例如,某些处理器要求 int 类型数据的存储地址必须是 4 的倍数,double 类型数据的存储地址必须是 8 的倍数。为了满足这些对齐要求,malloc 函数在分配内存时可能会多分配一些字节,使得返回的内存地址满足对齐条件。

例如,假设我们要分配一个 struct 结构体类型的内存:

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

struct MyStruct {
    char c; // 1 字节
    int i;  // 4 字节
};

int main() {
    struct MyStruct *ptr = (struct MyStruct *)malloc(sizeof(struct MyStruct));
    if (ptr == NULL) {
        printf("Memory allocation failed\n");
        return 1;
    }
    // 使用 ptr
    ptr->c = 'a';
    ptr->i = 10;
    printf("c: %c, i: %d\n", ptr->c, ptr->i);
    free(ptr);
    return 0;
}

在这个 struct MyStruct 结构体中,char 类型的 c 成员占用 1 字节,int 类型的 i 成员占用 4 字节。由于 int 类型需要 4 字节对齐,所以整个结构体实际占用的内存大小可能不是简单的 1 + 4 = 5 字节,而是 8 字节(编译器会在 c 成员后面填充 3 个字节以满足 i 的 4 字节对齐要求)。malloc 函数在分配内存时会考虑这种对齐情况,确保返回的内存块能够正确存储 struct MyStruct 结构体。

内存碎片

随着程序不断地调用 mallocfree 函数,堆内存中可能会出现内存碎片的问题。当程序频繁地分配和释放不同大小的内存块时,堆内存中会逐渐形成一些小块的空闲内存,这些小块内存由于太小而无法满足后续较大的内存分配请求,从而造成内存浪费。例如:

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

int main() {
    char *smallBlock1 = (char *)malloc(10);
    char *smallBlock2 = (char *)malloc(10);
    free(smallBlock1);
    char *largeBlock = (char *)malloc(20); // 此时可能无法分配成功,尽管有两个 10 字节的空闲块
    if (largeBlock == NULL) {
        printf("Memory allocation failed\n");
    }
    free(smallBlock2);
    free(largeBlock);
    return 0;
}

在这个例子中,先分配了两个 10 字节的小块内存,然后释放了其中一个。当尝试分配一个 20 字节的大块内存时,尽管总的空闲内存大小足够,但由于两个 10 字节的空闲块不连续,可能导致分配失败,这就是内存碎片的问题。

malloc 函数使用注意事项

在使用 malloc 函数时,有一些重要的注意事项需要牢记,以避免出现内存相关的错误。

检查返回值

如前文所述,每次调用 malloc 函数后,必须检查其返回值是否为 NULL。如果不检查返回值,当内存分配失败时,程序会继续使用空指针,这将导致段错误(Segmentation Fault)等严重问题。例如:

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

int main() {
    int *arr = (int *)malloc(10000000000 * sizeof(int)); // 可能分配失败
    // 没有检查返回值
    for (int i = 0; i < 10000000000; i++) {
        arr[i] = i;
    }
    // 程序可能在此处崩溃
    free(arr);
    return 0;
}

在这个例子中,如果系统内存不足,malloc 函数会返回 NULL,但程序没有检查返回值,继续使用 arr 指针,这将导致未定义行为,很可能使程序崩溃。

释放内存

动态分配的内存使用完毕后,必须调用 free 函数进行释放,以避免内存泄漏。free 函数的原型也在 <stdlib.h> 头文件中:

void free(void *ptr);

free 函数的参数是指向要释放的内存块的指针,该指针必须是先前通过 malloccallocrealloc 函数分配得到的。例如:

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

int main() {
    int *arr = (int *)malloc(10 * sizeof(int));
    if (arr == NULL) {
        printf("Memory allocation failed\n");
        return 1;
    }
    // 使用 arr
    for (int i = 0; i < 10; i++) {
        arr[i] = i;
    }
    free(arr); // 释放内存
    // 不能再次使用 arr,因为它已经被释放
    return 0;
}

在这个例子中,使用完 arr 数组后,我们调用 free(arr) 释放了分配的内存。注意,释放内存后,arr 指针仍然存在,但它指向的内存已经无效,不能再继续使用 arr 指针访问内存,否则会导致未定义行为。

避免重复释放

重复释放内存是一个常见的错误,这会导致程序出现难以调试的问题。例如:

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

int main() {
    int *arr = (int *)malloc(10 * sizeof(int));
    if (arr == NULL) {
        printf("Memory allocation failed\n");
        return 1;
    }
    free(arr);
    free(arr); // 重复释放
    return 0;
}

在这个例子中,第二次调用 free(arr) 时,arr 所指向的内存已经被释放,再次释放会导致未定义行为,可能会引起程序崩溃或其他奇怪的错误。

释放内存后重置指针

为了避免悬空指针(Dangling Pointer)的问题,在释放内存后,最好将指针设置为 NULL。悬空指针是指指向已释放内存的指针。例如:

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

int main() {
    int *arr = (int *)malloc(10 * sizeof(int));
    if (arr == NULL) {
        printf("Memory allocation failed\n");
        return 1;
    }
    free(arr);
    arr = NULL; // 将指针设置为 NULL
    // 如果不设置为 NULL,后续误使用 arr 会导致未定义行为
    return 0;
}

这样,当我们不小心再次使用 arr 指针时,由于它已经是 NULL,程序会在访问 NULL 指针时崩溃,从而更容易发现错误,而不是访问已释放的无效内存导致难以调试的问题。

malloc 相关的其他函数

除了 malloc 函数外,C 语言标准库还提供了一些与动态内存分配相关的其他函数,这些函数在不同的场景下非常有用。

calloc 函数

calloc 函数用于分配并初始化一块内存。它的原型定义在 <stdlib.h> 头文件中:

void *calloc(size_t nmemb, size_t size);

calloc 函数的第一个参数 nmemb 表示要分配的元素个数,第二个参数 size 表示每个元素的大小(单位是字节)。calloc 函数会分配 nmemb * size 字节的内存,并将这块内存初始化为 0。例如:

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

int main() {
    int *arr = (int *)calloc(10, sizeof(int));
    if (arr == NULL) {
        printf("Memory allocation failed\n");
        return 1;
    }
    // arr 中的元素已经被初始化为 0
    for (int i = 0; i < 10; i++) {
        printf("%d ", arr[i]);
    }
    free(arr);
    return 0;
}

在这个例子中,calloc(10, sizeof(int)) 分配了一个能存储 10 个 int 类型数据的内存块,并将每个元素初始化为 0。与 malloc 函数相比,calloc 函数适用于需要初始化内存的场景,而 malloc 分配的内存内容是未定义的。

realloc 函数

realloc 函数用于重新分配已经分配的内存块的大小。它的原型定义在 <stdlib.h> 头文件中:

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

realloc 函数的第一个参数 ptr 是指向先前通过 malloccallocrealloc 函数分配的内存块的指针,第二个参数 size 是新的内存块大小(单位是字节)。realloc 函数会尝试调整 ptr 所指向的内存块的大小为 size

如果新的大小小于原来的大小,realloc 函数可能会直接截断内存块,并返回原指针。如果新的大小大于原来的大小,realloc 函数可能会在原内存块的基础上扩展,如果原内存块后面有足够的空闲空间;否则,realloc 函数会分配一块新的内存块,将原内存块的内容复制到新的内存块中,然后释放原内存块,并返回新内存块的指针。例如:

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

int main() {
    int *arr = (int *)malloc(5 * sizeof(int));
    if (arr == NULL) {
        printf("Memory allocation failed\n");
        return 1;
    }
    // 使用 arr
    for (int i = 0; i < 5; i++) {
        arr[i] = i;
    }
    int *newArr = (int *)realloc(arr, 10 * sizeof(int));
    if (newArr == NULL) {
        printf("Memory reallocation failed\n");
        return 1;
    }
    arr = newArr; // 更新指针
    // 继续使用 arr,新增加的元素内容未定义
    for (int i = 5; i < 10; i++) {
        arr[i] = i;
    }
    for (int i = 0; i < 10; i++) {
        printf("%d ", arr[i]);
    }
    free(arr);
    return 0;
}

在这个例子中,我们先使用 malloc 分配了一个能存储 5 个 int 类型数据的内存块,然后使用 realloc 函数将其大小扩展为能存储 10 个 int 类型数据的内存块。如果 realloc 成功,我们更新 arr 指针指向新的内存块,并继续使用它。

需要注意的是,在使用 realloc 函数时,如果 realloc 失败(返回 NULL),原内存块不会被释放,仍然可以继续使用原指针。

示例应用场景

动态数组

动态数组是 malloc 函数的一个常见应用场景。在许多情况下,我们在编写程序时可能无法提前确定数组的大小,这时就可以使用 malloc 动态分配数组内存。例如,我们要编写一个程序,根据用户输入的数量来存储学生的成绩:

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

int main() {
    int numStudents;
    printf("Enter the number of students: ");
    scanf("%d", &numStudents);
    float *scores = (float *)malloc(numStudents * sizeof(float));
    if (scores == NULL) {
        printf("Memory allocation failed\n");
        return 1;
    }
    for (int i = 0; i < numStudents; i++) {
        printf("Enter score for student %d: ", i + 1);
        scanf("%f", &scores[i]);
    }
    for (int i = 0; i < numStudents; i++) {
        printf("Student %d score: %.2f\n", i + 1, scores[i]);
    }
    free(scores);
    return 0;
}

在这个例子中,根据用户输入的学生数量 numStudents,使用 malloc 动态分配了一个能存储相应数量 float 类型成绩的数组。程序结束前,记得释放分配的内存。

链表节点动态分配

链表是一种常用的数据结构,链表的节点通常需要动态分配内存。例如,我们实现一个简单的单向链表:

#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));
    if (newNode == NULL) {
        printf("Memory allocation failed\n");
        return NULL;
    }
    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(10);
    struct Node *node2 = createNode(20);
    struct Node *node3 = createNode(30);
    head->next = node2;
    node2->next = node3;
    printList(head);
    freeList(head);
    return 0;
}

在这个例子中,createNode 函数使用 malloc 为每个链表节点分配内存。链表使用完毕后,通过 freeList 函数释放所有节点的内存,以避免内存泄漏。

二维数组动态分配

有时候我们需要动态分配二维数组的内存。例如,我们要编写一个程序,根据用户输入的行数和列数创建一个二维数组,并对其进行初始化:

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

int main() {
    int rows, cols;
    printf("Enter the number of rows: ");
    scanf("%d", &rows);
    printf("Enter the number of cols: ");
    scanf("%d", &cols);
    int **matrix = (int **)malloc(rows * sizeof(int *));
    if (matrix == NULL) {
        printf("Memory allocation failed\n");
        return 1;
    }
    for (int i = 0; i < rows; i++) {
        matrix[i] = (int *)malloc(cols * sizeof(int));
        if (matrix[i] == NULL) {
            printf("Memory allocation failed\n");
            for (int j = 0; j < i; j++) {
                free(matrix[j]);
            }
            free(matrix);
            return 1;
        }
    }
    for (int i = 0; i < rows; i++) {
        for (int j = 0; j < cols; j++) {
            matrix[i][j] = i * cols + j;
        }
    }
    for (int i = 0; i < rows; i++) {
        for (int j = 0; j < cols; j++) {
            printf("%d ", matrix[i][j]);
        }
        printf("\n");
    }
    for (int i = 0; i < rows; i++) {
        free(matrix[i]);
    }
    free(matrix);
    return 0;
}

在这个例子中,首先使用 malloc 分配了一个指针数组 matrix,每个指针指向一个 int 类型的数组。然后为每个 int 类型的数组分配内存,从而实现了二维数组的动态分配。使用完毕后,需要先释放每个 int 类型数组的内存,再释放指针数组的内存。

通过这些示例,我们可以看到 malloc 函数在实际编程中的广泛应用,它为 C 语言程序员提供了强大的动态内存管理能力,但同时也要求程序员小心使用,以避免内存相关的错误。在编写使用动态内存分配的程序时,遵循良好的编程习惯,如检查返回值、正确释放内存等,是确保程序稳定性和可靠性的关键。