25、动态数组:realloc的使用陷阱与内存碎片

动态数组,说白了就是运行时能自动扩容的数组。C语言里实现它,全靠realloc这个函数。嗯,这函数看着简单,用起来却坑不少。我见过太多人在它上面栽跟头了。

realloc的基本用法

先看个标准用法:

#include <stdlib.h>

int *arr = (int*)malloc(10 * sizeof(int));
if (!arr) exit(1);

// 扩容到20个元素
int *tmp = (int*)realloc(arr, 20 * sizeof(int));
if (!tmp) {
    free(arr);  // 注意:原内存还在!
    exit(1);
}
arr = tmp;

这里有个关键点:永远不要直接用arr = realloc(arr, new_size)。为什么?因为realloc失败时会返回NULL,但原内存块不会被释放。如果你直接覆盖了arr,原指针就丢了,内存泄漏就这么来的。

警告:realloc失败时返回NULL,原内存不变。直接赋值会导致指针丢失。

realloc的三种行为

我个人习惯把realloc的行为分成三种情况:

情况 行为 性能影响
原地扩容 当前内存块后面有足够空间,直接扩展 极快,无拷贝
移动扩容 后面空间不够,重新分配新块,拷贝旧数据 O(n)拷贝,较慢
缩小 缩小内存块,通常原地操作 快,但可能产生碎片

你想想看,如果你频繁扩容,每次都要拷贝大量数据,性能能好吗?我在项目中遇到过一个大数组,每次只加一个元素就用realloc,结果程序跑得比蜗牛还慢。

内存碎片:看不见的杀手

内存碎片是什么?说白了就是内存被切得七零八落,虽然总量够,但找不到一块连续的大空间。

看个典型场景:

// 频繁分配和释放不同大小的块
for (int i = 0; i < 1000; i++) {
    char *a = malloc(128);
    char *b = malloc(256);
    free(a);
    char *c = malloc(192);  // 可能无法复用a的位置
    free(b);
    free(c);
}

为什么会这样?因为每次分配的大小不一样,释放后留下的空洞大小不匹配。久而久之,内存就像瑞士奶酪一样,全是小洞。

核心问题:realloc频繁移动数据 + 大小不一的分配释放 = 严重的内存碎片。

避坑指南:我踩过的那些坑

坑1:realloc缩小内存后,指针还能用吗?

能。缩小操作通常不会移动数据,只是把多余的空间还给系统。但要注意,缩小的那部分数据就没了。

int *arr = malloc(100 * sizeof(int));
// 填充数据...
arr = realloc(arr, 50 * sizeof(int));  // 后50个元素不可访问
// arr[50] 是未定义行为!

坑2:realloc的第一个参数可以是NULL吗?

可以。当第一个参数是NULL时,realloc的行为等价于malloc。这个特性有时候能简化代码:

int *arr = NULL;
// 第一次分配和后续扩容可以用同一套逻辑
int *tmp = realloc(arr, 100 * sizeof(int));
if (tmp) arr = tmp;

坑3:realloc能代替free吗?

可以。当第二个参数是0时,realloc等价于free。但我不建议这么用,可读性太差了。

我的建议:realloc(ptr, 0) 虽然合法,但不同平台行为有差异。老老实实用free吧。

如何减少内存碎片?

我曾经在一个嵌入式项目里被内存碎片折磨得够呛。后来总结了几条经验:

  1. 预分配策略:预估最大容量,一次性分配够。比如动态数组,初始就分配128个元素,不够时翻倍扩容。
  2. 固定大小分配:尽量用固定大小的内存池,避免大小不一。
  3. 批量扩容:不要一次只加一个元素,每次扩容翻倍或加固定大小(如64个元素)。
  4. 及时释放:不再使用的内存尽快还给系统,但注意不要频繁分配释放。

看个翻倍扩容的例子:

typedef struct {
    int *data;
    size_t size;
    size_t capacity;
} DynArray;

bool dynarray_push(DynArray *da, int value) {
    if (da->size >= da->capacity) {
        size_t new_cap = da->capacity ? da->capacity * 2 : 16;
        int *tmp = realloc(da->data, new_cap * sizeof(int));
        if (!tmp) return false;
        da->data = tmp;
        da->capacity = new_cap;
    }
    da->data[da->size++] = value;
    return true;
}

这个模式的好处是:扩容次数少,每次拷贝的数据量可控。平均下来,每个元素的拷贝次数接近常数。

realloc与多线程

嗯,这里要注意。realloc不是线程安全的。多线程环境下,如果你在多个线程里同时操作同一个动态数组,必须加锁。否则,一个线程在realloc移动数据时,另一个线程正在访问旧地址——崩溃就在一瞬间。

警告:多线程中共享动态数组,必须用互斥锁保护。realloc不是原子操作。

总结一下

realloc是个好工具,但用不好就是定时炸弹。记住三点:

  • 永远用临时变量接收返回值,检查NULL
  • 避免频繁小幅度扩容
  • 多线程环境下加锁保护

内存碎片这个问题,说白了就是「分配策略」的问题。你设计得好,碎片就少;设计得随意,系统迟早会还你颜色。我在一个长期运行的服务里见过,因为碎片太多,连续分配4KB都失败,但总内存还剩80%。那排查过程,真是让人头大。

realloc使用陷阱与内存碎片 realloc三种行为 内存碎片产生原因 避坑指南 原地扩容 移动扩容 缩小操作 频繁分配释放不同大小 NULL检查 临时变量 翻倍扩容策略 多线程加锁 解决方案:预分配 + 翻倍扩容 + 内存池 核心原则:用临时变量接收返回值,避免频繁小幅度扩容

好了,关于realloc和内存碎片,就聊这么多。记住,动态内存管理是门手艺活,多实践、多总结,自然就能避开这些坑。

专注资料整理