九:动态内存分配——在运行时申请和释放内存

一、回顾与本篇目标

上一篇你学会了结构体——把不同类型的数据打包成一个整体。你还学会了用结构体数组来存储多个学生的信息。

但到目前为止,我们所有的变量和数组都是在编译时就确定大小的。比如 int arr[100],这个 100 是你写代码时就定好的。如果实际只需要存 10 个数据,剩下的 90 个空间就浪费了。如果实际需要存 200 个数据,数组又不够用。

真正灵活的程序需要在运行时根据需要动态地申请和释放内存。比如根据用户输入的学生人数来创建数组,或者在程序运行过程中随时增加数据。这就是动态内存分配要解决的问题。

这一篇是 C 语言中最容易出错的领域,也是区分 C 语言和 JavaScript/Python 最根本的差异之一。在 JS 和 Python 中,你几乎从来不需要关心内存怎么分配、什么时候释放——垃圾回收器帮你搞定一切。在 C 语言中,你申请的内存,你必须自己释放。

本篇的目标:

  1. 理解栈和堆的区别
  2. 学会用 malloc 申请堆内存
  3. 学会用 free 释放内存
  4. 了解 callocrealloc 的用法
  5. 理解内存泄漏和悬空指针的危险
  6. 学会动态创建结构体和结构体数组

二、栈和堆:两种不同的内存区域

在理解动态内存分配之前,必须先理解 C 语言程序使用的两种内存区域:

栈:自动管理的内存

你之前声明的所有局部变量和数组,都是分配在上的:

void some_function() {
    int age = 28;          // 在栈上分配 4 字节
    char name[50];         // 在栈上分配 50 字节
    double scores[100];    // 在栈上分配 800 字节
}
// 函数结束时,这些栈内存自动释放

栈的特点:

  • 自动管理:变量离开作用域(函数结束、代码块结束)时,栈内存自动释放。你不需要做任何事。
  • 速度快:分配和释放只需要移动栈指针,开销极小。
  • 空间有限:栈的大小通常只有几 MB(Windows 默认 1MB,Linux 默认 8MB)。如果你在栈上分配了一个超大的数组(比如 int huge[1000000]),程序会崩溃——这叫栈溢出
  • 大小在编译时确定:数组的大小必须是编译时就能确定的常量。

堆:手动管理的内存

是程序中一块巨大的内存区域,由程序员手动申请、手动释放

// 在堆上申请一块内存
int *p = malloc(100 * sizeof(int));  // 申请 100 个 int 的空间

// ... 使用这块内存 ...

// 用完后必须手动释放
free(p);

堆的特点:

  • 手动管理:你需要显式地申请(malloc)和释放(free)。忘了释放会导致内存泄漏
  • 速度较慢:分配和释放需要操作系统介入,比栈慢。
  • 空间巨大:堆可以使用的内存远大于栈(受限于系统的物理内存和虚拟内存)。
  • 大小在运行时确定:可以根据用户的输入动态决定申请多少内存。
特性
管理方式 自动(离开作用域释放) 手动(malloc/free)
分配速度 极快 较慢
空间大小 有限(几 MB) 巨大(几 GB)
生命周期 离开作用域即结束 由程序员控制
典型用途 局部变量、小数组 大数据、动态大小的数据
对应的 JS/Python 基本类型(栈) 对象/数组(堆,但自动回收)

在 JavaScript 和 Python 中,对象和数组也是分配在堆上的,但垃圾回收器会自动释放不再使用的内存。C 语言把这个责任完全交给了你。

三、malloc:申请内存

malloc 是 C 标准库中最常用的内存分配函数。它的全称是 memory allocation

基本语法

#include <stdlib.h>

void *malloc(size_t size);
  • 参数:要申请的字节数
  • 返回值:一个 void* 类型的指针,指向申请到的内存块的首地址。如果申请失败(比如内存不够了),返回 NULL
  • void* 是“通用指针”类型,可以赋值给任何类型的指针变量。

申请一个整数

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

int main() {
    // 在堆上申请一个 int 的空间
    int *p = malloc(sizeof(int));

    // 检查申请是否成功
    if (p == NULL) {
        printf("内存分配失败!\n");
        return 1;
    }

    // 像使用普通变量一样使用这块内存
    *p = 42;
    printf("*p = %d\n", *p);  // 42

    // 用完后释放
    free(p);

    return 0;
}

逐行解释:

  • malloc(sizeof(int))sizeof(int) 是 4(在大多数系统上),所以这行代码在堆上申请了 4 字节的空间。
  • int *p = ...malloc 返回的 void* 自动转换成 int*,赋给 p。现在 p 指向堆上的一块 4 字节内存。
  • if (p == NULL)每次调用 malloc 后都必须检查返回值是否为 NULL。 如果系统内存不足,malloc 会返回 NULL。不检查就直接使用会导致程序崩溃。
  • *p = 42:通过解引用操作堆上的内存,和操作普通变量一样。
  • free(p):释放 malloc 申请的内存。

申请一个数组

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

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

    // 在堆上申请 n 个 int 的空间
    int *arr = malloc(n * sizeof(int));

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

    // 像使用普通数组一样使用
    for (int i = 0; i < n; i++) {
        arr[i] = i * 10;  // arr[i] 等价于 *(arr + i)
    }

    printf("数组内容:");
    for (int i = 0; i < n; i++) {
        printf("%d ", arr[i]);
    }
    printf("\n");

    // 释放整个数组
    free(arr);

    return 0;
}

关键点malloc(n * sizeof(int)) 申请了 n × 4 字节的空间,这相当于在堆上创建了一个长度为 n 的 int 数组。数组的大小 n 是运行时由用户输入的,不是编译时写死的常量。 这就是动态内存分配的核心价值。

和普通数组的区别

  • int arr[100]:在上分配,大小必须编译时确定,函数结束自动释放。
  • int *arr = malloc(n * sizeof(int)):在上分配,大小可以运行时确定,需要手动 free(arr)

四、free:释放内存

free 用来释放 malloc(或 callocrealloc)申请的内存。

int *p = malloc(100 * sizeof(int));
// ... 使用 p ...
free(p);    // 释放内存
p = NULL;   // 好习惯:释放后把指针设为 NULL,防止误用

free 的三个重要规则:

  1. 只能释放 malloc/calloc/realloc 返回的指针。不能 free 栈上的变量。
  2. 不能重复释放。同一块内存 free 两次会导致未定义行为(通常程序崩溃)。
  3. 释放后不要再使用。使用已释放的内存是悬空指针问题。

错误的示例

// 错误 1:释放栈上的变量
int x = 10;
// free(&x);  // 错误!x 在栈上,不能 free

// 错误 2:重复释放
int *p = malloc(10);
free(p);
// free(p);    // 错误!p 已经被释放了

// 错误 3:使用已释放的内存(悬空指针)
int *q = malloc(sizeof(int));
*q = 42;
free(q);
// printf("%d\n", *q);  // 未定义行为!q 指向的内存已经被回收

五、内存泄漏

如果使用 malloc 申请了内存,但忘记了调用 free,这块内存就永远无法被回收——这就叫内存泄漏

// 这是一个有内存泄漏的函数
void leaky_function() {
    int *p = malloc(1000 * sizeof(int));  // 申请内存
    // 使用 p ...
    // 忘记 free(p)!
    return;  // 函数结束,p 变量消失,但堆上的 4000 字节没有被释放
}
// 每次调用这个函数,就会泄漏 4000 字节
// 如果这个函数被反复调用,程序占用的内存会越来越多

在 JavaScript 和 Python 中,类似的情况不会造成泄漏——垃圾回收器会检测到 p 指向的内存已经没有变量引用了,自动回收它。在 C 语言中,你忘了 free,内存就真的永远丢了。

内存泄漏的后果:程序运行时间越长,占用的内存越大,最终可能耗尽系统内存,导致系统变慢甚至程序崩溃。

六、calloc 和 realloc

除了 malloc,标准库还提供了两个相关的内存分配函数。

calloc:分配并初始化为零

// calloc(元素个数, 每个元素的大小)
int *arr = calloc(10, sizeof(int));
// 等价于 malloc(10 * sizeof(int)),但所有元素被初始化为 0

callocmalloc 的区别:

  • malloc 只分配内存,不初始化(里面是垃圾值)。
  • calloc 分配内存并把所有字节初始化为 0。代价是稍微慢一点。
  • 当你需要一块全零的内存时,用 calloc 更安全、更方便。

realloc:调整已分配内存的大小

realloc 可以改变之前用 malloccalloc 分配的内存块的大小:

// 先申请 5 个 int
int *arr = malloc(5 * sizeof(int));
for (int i = 0; i < 5; i++) {
    arr[i] = i * 10;
}

// 现在需要扩大到 10 个 int
arr = realloc(arr, 10 * sizeof(int));
// 前 5 个元素的值保持不变,后 5 个元素是未初始化的

// 也可以缩小
arr = realloc(arr, 3 * sizeof(int));
// 只保留前 3 个元素

free(arr);

realloc 的注意事项:

  • 可能移动数据:如果原内存后面没有足够的空间扩展,realloc 会找一块新的更大的内存,把旧数据复制过去,然后释放旧内存。所以 realloc 返回的指针可能和原来的指针不同。
  • 不要用原来的指针realloc 之后,必须使用返回的新指针,原来的指针可能已经失效。
  • realloc(NULL, size) 等价于 malloc(size)
  • realloc(ptr, 0) 等价于 free(ptr),返回 NULL

七、动态分配结构体

结合上一篇学的结构体,可以在堆上动态创建结构体:

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

typedef struct {
    char name[50];
    int age;
    float score;
} Student;

int main() {
    // 在堆上创建一个学生
    Student *s = malloc(sizeof(Student));

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

    // 通过指针设置成员
    strcpy(s->name, "张三");
    s->age = 20;
    s->score = 92.5;

    // 通过指针读取成员
    printf("姓名:%s\n", s->name);
    printf("年龄:%d\n", s->age);
    printf("成绩:%.1f\n", s->score);

    free(s);
    return 0;
}

动态结构体数组

int n;
printf("请输入学生人数:");
scanf("%d", &n);

// 在堆上创建 n 个学生的数组
Student *students = malloc(n * sizeof(Student));

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

// 使用这个动态数组
for (int i = 0; i < n; i++) {
    printf("学生 %d 姓名:", i + 1);
    scanf("%s", students[i].name);  // name 是数组,不需要 &
    printf("年龄:");
    scanf("%d", &students[i].age);
    printf("成绩:");
    scanf("%f", &students[i].score);
}

// 输出所有学生
for (int i = 0; i < n; i++) {
    printf("%s,%d 岁,%.1f 分\n",
           students[i].name, students[i].age, students[i].score);
}

free(students);

八、综合演示:可动态增长的学生列表

下面这个示例展示了动态内存分配的完整用法——一个可以根据用户输入动态添加学生的程序:

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

typedef struct {
    char name[50];
    int age;
    float score;
} Student;

int main() {
    int capacity = 2;         // 初始容量
    int count = 0;            // 当前学生数量
    Student *students = malloc(capacity * sizeof(Student));

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

    printf("===== 学生信息录入 =====\n");
    printf("输入 'quit' 作为姓名来结束录入\n");

    while (1) {
        char input[50];
        printf("\n学生 %d 姓名:", count + 1);
        scanf("%s", input);

        if (strcmp(input, "quit") == 0) {
            break;
        }

        // 如果数组满了,用 realloc 扩容
        if (count >= capacity) {
            capacity *= 2;  // 容量翻倍
            Student *temp = realloc(students, capacity * sizeof(Student));
            if (temp == NULL) {
                printf("内存扩展失败!\n");
                free(students);
                return 1;
            }
            students = temp;
            printf("(数组已扩容至 %d)\n", capacity);
        }

        // 录入学生信息
        strcpy(students[count].name, input);
        printf("年龄:");
        scanf("%d", &students[count].age);
        printf("成绩:");
        scanf("%f", &students[count].score);

        count++;
    }

    // 输出所有学生
    printf("\n===== 学生列表(共 %d 人)=====\n", count);
    float total = 0;
    for (int i = 0; i < count; i++) {
        printf("%d. %s,%d 岁,%.1f 分\n",
               i + 1, students[i].name, students[i].age, students[i].score);
        total += students[i].score;
    }
    printf("平均分:%.1f\n", total / count);

    free(students);
    return 0;
}

代码解析:

  • 初始容量和动态扩容:程序从 capacity = 2 开始,当 count >= capacity 时,用 realloc 把容量翻倍。这模拟了 Python 列表和 JavaScript 数组的底层扩容策略。
  • realloc 的安全用法:先用一个临时变量 temp 接收 realloc 的返回值。如果成功,再赋给 students。如果 realloc 失败,原来的内存块仍然有效,可以安全地 free(students) 退出。如果直接写 students = realloc(...),失败时原指针就丢失了。
  • 用户输入 "quit" 结束录入,程序输出汇总信息后释放内存。

九、动态内存分配的常见错误

错误 示例 后果
忘记 free int *p = malloc(100); return; 内存泄漏
重复 free free(p); free(p); 程序崩溃或未定义行为
使用已释放的内存 free(p); *p = 10; 悬空指针,未定义行为
忘记检查 NULL int *p = malloc(...); *p = 10; 如果 malloc 失败,程序崩溃
越界访问 arr[100] = 10;(只申请了 50 个) 破坏其他内存数据

最佳实践总结:

  1. 每次 malloc 后立即检查返回值是否为 NULL。
  2. 每次 free 后立即把指针设为 NULL。
  3. 谁申请,谁释放。 避免在一个函数中申请、在另一个函数中释放,这样很难追踪。
  4. 用 valgrind 等工具检测内存泄漏。 在 Linux/Mac 上,用 valgrind ./your_program 可以检查是否有内存泄漏。

十、本篇动手练习

练习 1:动态数组操作

新建 practice9-1.c,让用户输入一个整数 n,用 malloc 创建大小为 n 的 int 数组,填充 1 到 n,计算总和和平均值,最后 free

练习 2:字符串动态复制

新建 practice9-2.c,写一个函数 char* my_strdup(const char *src),用 malloc 申请刚好够用的内存,把 src 复制过去,返回新字符串的指针。在 main 中测试并 free

练习 3:动态图书管理

新建 practice9-3.c,用动态内存分配实现图书管理。定义 Book 结构体,用户可以不断添加书籍,数组自动扩容。用户输入 "quit" 时停止录入,输出所有书籍信息后释放内存。

练习 4:检测内存泄漏

新建 practice9-4.c,故意写一个有内存泄漏的程序(申请内存但不释放)。如果你的系统有 valgrind,用 valgrind --leak-check=full ./practice9-4 运行,观察 valgrind 如何报告内存泄漏。

十一、本篇小结

这一篇你学会了 C 语言中最重要的内存管理机制——动态内存分配:

  • 栈和堆的区别:栈自动管理、速度快、空间小、编译时确定大小。堆手动管理、速度较慢、空间大、运行时确定大小。
  • malloc(size):在堆上申请指定字节数的内存,返回 void* 指针。必须检查返回值是否为 NULL
  • free(ptr):释放 malloc/calloc/realloc 申请的内存。释放后把指针设为 NULL 是良好习惯。
  • calloc(n, size):申请 n 个元素的空间,并全部初始化为 0。
  • realloc(ptr, new_size):调整已分配内存块的大小。可能移动数据,必须使用返回的新指针。
  • 内存泄漏:申请了内存但忘了释放。程序运行越久占内存越多,最终耗尽系统资源。
  • 悬空指针:指向已释放内存的指针。使用悬空指针会导致未定义行为。
  • 动态结构体malloc(sizeof(Student)) 在堆上创建结构体,-> 访问成员。

动态内存分配是 C 语言和高级语言(JS/Python)之间最大的差异之一。掌握它之后,你就能写出真正灵活、可应对不确定数据量的程序。下一篇,我们学习 C 语言中另一个重要概念——文件操作,让程序能够读写硬盘上的文件。

下一篇预告

下一篇——《文件操作——让程序读写硬盘上的数据》:用 fopen 打开文件、fprintf/fscanf 读写文件、fgets/fputs 处理文本、二进制文件的读写。有了文件操作,你的程序就能实现数据持久化——把数据存到硬盘上,下次运行时还能读出来。

C/C++ 零基础入门,每周更新。

© 版权声明
THE END
喜欢就支持一下吧
点赞5 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容