C内存管理

103

我一直听说在C语言中必须非常注意如何管理内存。虽然我还在开始学习C,但到目前为止,我还没有进行过任何与内存管理相关的活动。我一直想象要释放变量并做各种丑陋的事情。但似乎情况并不是这样。

有人能给我展示(附带代码示例),什么情况下需要进行“内存管理”?


好地方学习G4G - EsmaeelE
12个回答

246

变量可以存储在内存的两个位置。当您创建一个变量时,例如:

int  a;
char c;
char d[16];

变量是在“堆栈”中创建的。堆栈变量在超出范围时(也就是代码无法再访问它们时)会自动释放。你可能会听到它们被称为“自动”变量,但这已经过时了。
许多初学者的例子只使用堆栈变量。
堆栈很好,因为它是自动的,但它也有两个缺点:(1)编译器需要预先知道变量的大小,(2)堆栈空间有一定限制。例如:在 Windows 下,Microsoft 链接器的默认设置下,堆栈大小为 1 MB,并且并非全部可用于您的变量。
如果您不知道编译时数组的大小,或者需要一个大数组或结构体,则需要“备选方案”。
备选方案称为“”。您通常可以创建尽操作系统允许的大小的变量,但必须自己完成。以前的帖子展示了一种方法,您可以使用它来完成,虽然还有其他方法:
int size;
// ...
// Set size to some value, based on information available at run-time. Then:
// ...
char *p = (char *)malloc(size);

(请注意,堆中的变量不是直接操作,而是通过指针进行操作)
一旦您创建了一个堆变量,问题在于编译器无法确定何时完成使用,因此失去了自动释放。这就是您所提到的“手动释放”的地方。现在您的代码需要负责决定何时不再需要该变量,以释放内存以供其他用途使用。对于上述情况,如下所示:
free(p);

第二种方式变得“讨厌”的原因是,当你不需要这个变量时,往往很难知道它已经不再需要了。如果在不需要某个变量的情况下忘记释放它,将会导致程序消耗比实际需要更多的内存。这种情况被称为“泄漏”。泄漏的内存在程序结束并且操作系统恢复所有资源之前不能用于任何事情。如果在你真正完成使用堆变量之前错误地释放了它,那么可能会出现更严重的问题。
在C和C++中,像上面展示的一样清理堆变量是你的责任。然而,有些语言和环境(例如Java和.NET语言C#)采用了一种不同的方法,其中堆自动进行清理。这第二种方法被称为“垃圾收集”,对开发人员来说更容易,但会付出额外的开销和性能代价。这是一个权衡考虑的问题。
(我略过了许多细节,以便给出一个更简单但希望更平衡的答案)

3
如果你想把某个东西放入栈中,但不知道它在编译时有多大,alloca()函数可以扩大栈帧以腾出空间。但没有freea()函数,当函数返回时整个栈帧都会弹出。使用alloca()进行大量分配存在许多风险。 - DGentry
1
也许你可以添加一两句关于全局变量内存位置的说明。 - Michael Käfer

23

以下是一个例子。假设您有一个strdup()函数,它可以复制一个字符串:

char *strdup(char *src)
{
    char * dest;
    dest = malloc(strlen(src) + 1);
    if (dest == NULL)
        abort();
    strcpy(dest, src);
    return dest;
}

然后你这样调用它:

main()
{
    char *s;
    s = strdup("hello");
    printf("%s\n", s);
    s = strdup("world");
    printf("%s\n", s);
}

你可以看到程序在运行,但你已经使用malloc分配了内存却没有释放。当你第二次调用strdup时,你失去了对第一个内存块的指针。

对于这么少量的内存来说这不是什么大问题,但考虑以下情况:

for (i = 0; i < 1000000000; ++i)  /* billion times */
    s = strdup("hello world");    /* 11 bytes */

现在您已经使用了11GB的内存(根据您的内存管理器,可能更多),如果您没有崩溃,那么您的进程可能运行得非常缓慢。

要解决这个问题,您需要在使用完malloc()获取的所有内容后调用free():

s = strdup("hello");
free(s);  /* now not leaking memory! */
s = strdup("world");
...
希望这个例子能够帮助您!

我更喜欢这个答案。但我有一个小问题。我期望像这样的问题可以使用库来解决,难道没有一个库可以模仿基本数据类型并为它们添加内存释放功能,以便在变量被使用时也自动释放? - Lorenzo
没有任何标准的一部分。如果你使用C++,你会得到自动内存管理的字符串和容器。 - Mark Harrison
我明白了,所以有一些第三方库?你能告诉我它们的名称吗? - Lorenzo

10

当您需要在堆上使用内存而不是栈上时,必须进行“内存管理”。如果在运行时之前不知道要创建多大的数组,则必须使用堆。例如,您可能想要将某些内容存储在字符串中,但在程序运行之前不知道其内容的大小。在这种情况下,您可以编写如下代码:

 char *string = malloc(stringlength); // stringlength is the number of bytes to allocate

 // Do something with the string...

 free(string); // Free the allocated memory

9
我认为回答这个问题最简洁的方法是考虑指针在C语言中的作用。指针是一种轻量却强大的机制,它给你带来了极大的自由,但也会付出极高的代价——容易犯错。
在C语言中,确保指针指向你拥有的内存是你自己的责任。这需要有组织、有纪律的方法,否则就难以写出有效的C代码,除非你放弃使用指针。
迄今为止发布的答案集中在自动(栈)和堆变量分配上。使用栈分配确实可以使内存自动管理和方便,但在某些情况下(大缓冲区、递归算法)可能会导致可怕的栈溢出问题。准确地知道你可以在栈上分配多少内存非常依赖于系统。在一些嵌入式场景下,几十字节可能是你的限制,在一些桌面场景下,你可以安全地使用数兆字节。
堆分配对语言来说不太固有。它基本上是一组库调用,授予你拥有给定大小的内存块的所有权,直到你准备好返回(“释放”)它。听起来很简单,但与无数程序员的痛苦相关。问题很简单(两次释放相同的内存,或者根本不释放[内存泄漏],没有分配足够的内存[缓冲区溢出]等),但很难避免和调试。高度纪律性的方法在实践中绝对是必须的,但当然语言并没有强制执行。
我想提到另一种被其他帖子忽略的内存分配类型。可以通过在任何函数外部声明变量来静态分配变量。我认为通常情况下这种分配方式因为全局变量而声名狼藉。然而,并没有什么规定说只能将以这种方式分配的内存用作杂乱代码中的不受约束的全局变量。静态分配方法可以简单地用于避免堆和自动分配方法中的一些陷阱。一些C程序员会惊讶地发现,大型和复杂的C嵌入式和游戏程序完全没有使用堆分配。

6
这里有一些关于如何分配和释放内存的好答案。在我看来,使用C语言更具挑战性的方面是确保你所使用的内存都是你已经分配的内存。如果这个过程没有正确完成,你可能会遇到与此网站类似的问题——缓冲区溢出——并且你可能会覆盖其他应用程序正在使用的内存,导致非常不可预测的结果。
例如:
int main() {
    char* myString = (char*)malloc(5*sizeof(char));
    myString = "abcd";
}

在这一点上,你已经为myString分配了5个字节并用"abcd\0"填充它(字符串以空字符 - \0 结尾)。 如果你的字符串分配是

myString = "abcde";

您将在为程序分配的5个字节中指定“abcde”,并将尾随的空字符放置在此末尾-这是未分配给您使用的内存部分,可能是空闲的,但也可能被另一个应用程序使用-这是内存管理的关键部分,错误将导致不可预测(有时不可重复)的后果。

在这里,您分配了5个字节。通过分配指针来释放它。任何尝试释放此指针都会导致未定义的行为。请注意,C-字符串不会重载=运算符,因此没有复制。 - Martin York
虽然这真的取决于你使用的malloc。许多malloc运算符对齐到8字节。因此,如果此malloc使用头/尾系统,则malloc将保留5 + 4 * 2(头和尾各4个字节)。那将是13个字节,malloc只会为对齐给您额外的3个字节。我并不是说使用这个是一个好主意,因为它只适用于其malloc工作方式的系统,但至少知道为什么做错事可能会起作用很重要。 - kodai
洛基:我已经编辑回答,使用strcpy()而不是=;我认为那是克里斯·B-C的意图。 - echristopherson
1
我相信现代平台的硬件内存保护可以防止用户空间进程覆盖其他进程的地址空间;否则你会得到一个分段错误。但这并不是 C 本身的一部分。 - echristopherson

4

一个需要记住的事情是要始终将指针初始化为NULL,因为未初始化的指针可能包含一个伪随机的有效内存地址,这可能会导致指针错误默默地继续执行。通过强制指针使用NULL进行初始化,您可以始终捕获是否在未初始化的情况下使用该指针。原因是操作系统将虚拟地址0x00000000“连接”到一般保护异常以捕获空指针使用。


2
我写信是因为我感觉到迄今为止的答案不太对。
需要提及内存管理的原因是当你有一个需要创建复杂结构的问题/解决方案时。(如果你的程序在一次性分配过多堆栈空间时崩溃,那就是一个 bug。)通常,你需要学习的第一个数据结构是某种list。这是我脑海中的一个单链表示例:
typedef struct listelem { struct listelem *next; void *data;} listelem;

listelem * create(void * data)
{
   listelem *p = calloc(1, sizeof(listelem));
   if(p) p->data = data;
   return p;
}

listelem * delete(listelem * p)
{
   listelem next = p->next;
   free(p);
   return next;
}

void deleteall(listelem * p)
{
  while(p) p = delete(p);
}

void foreach(listelem * p, void (*fun)(void *data) )
{
  for( ; p != NULL; p = p->next) fun(p->data);
}

listelem * merge(listelem *p, listelem *q)
{
  while(p != NULL && p->next != NULL) p = p->next;
  if(p) {
    p->next = q;
    return p;
  } else
    return q;
}

自然地,你可能需要一些其他的功能,但基本上,这就是你需要内存管理的原因。我应该指出,“手动”内存管理有许多技巧,例如:
  • 利用 malloc 保证(由语言标准)返回可被4整除的指针,
  • 为了自己的某种邪恶目的而分配额外的空间,
  • 创建 内存池
获得一个好的调试器……祝你好运!


学习数据结构是理解内存管理的下一个关键步骤。学习适当运行这些结构的算法将向您展示克服这些障碍的适当方法。这就是为什么您会发现数据结构和算法在同一课程中教授的原因。 - aj.toulan

2
此外,当您需要定义一个巨大的数组时,比如int[10000],您可能希望使用动态内存分配。您不能将其放在堆栈中,因为这样会导致堆栈溢出。
另一个很好的例子是数据结构的实现,比如链表或二叉树。我没有示例代码可以粘贴在这里,但您可以轻松地通过搜索引擎找到它们。

0
@Ted Percival
...你不需要强制转换malloc()的返回值。
当然,你是正确的。我相信这一直都是正确的,尽管我没有K&R的副本来检查。
我不喜欢C语言中的很多隐式转换,所以我倾向于使用强制转换来使“魔法”更加可见。有时它有助于可读性,有时不会,有时它会导致编译器捕获到一个静默的错误。尽管如此,我对此并没有强烈的意见。
这尤其可能发生在你的编译器理解C++风格注释的情况下。
是的...你抓住了我。我在C++中花费的时间比在C中多得多。谢谢你注意到了这一点。

@echristopherson,谢谢。你是对的 - 但请注意,这个Q/A是在2008年8月之前发布的,甚至在Stack Overflow还没有公开测试版之前。那时候,我们仍在摸索网站应该如何运作。这个问题/答案的格式不一定被视为使用SO的模型。谢谢! - Euro Micelli
啊,谢谢你指出来——我没意识到网站的那个方面还不太稳定。 - echristopherson

0

@欧罗·米切利

需要补充的一点是,当函数返回时,指向堆栈的指针将不再有效,因此您不能从函数中返回指向堆栈变量的指针。这是一个常见的错误,也是您不能仅使用堆栈变量的主要原因。如果您的函数需要返回指针,则必须使用malloc并处理内存管理。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接