跳转到内容

编程语言导论/堆

来自维基教科书,开放的书籍,开放的世界

在本节中,我们将描述堆分配,但首先,让我们了解什么是堆。当编译器读取函数的源代码时,它确切地知道该函数将在堆栈中使用多少空间。编译器知道函数有多少个局部变量,其返回值的大小等等。

但是,当谈到在堆上分配的对象时,情况就有点不同了。程序员倾向于使用堆来分配依赖于用户输入的对象,因此它们的大小在编译时是未知的。如果你曾经用 C 编程,每当你使用关键字 `malloc`(或 C++ 中的 `new`)或它的变体时,你都在堆上分配一个对象。

堆分配非常具有挑战性。在接下来的部分中,我们将描述其主要问题。


堆管理问题

[编辑 | 编辑源代码]

堆管理的第一个挑战在于将分配的对象放在哪里。基本上有三个选项:首次适应、最佳适应和最差适应。我们将使用一个类比来描述它们。

假设你周末带着家人去商场。因为停车场不像工作日那样拥挤,所以你有许多选择。如果天气晴朗,你想尽快进入商场和孩子们一起吃冰淇淋,你可能会停在第一个空着的停车位。另一方面,如果你不着急吃冰淇淋,想知道你决定回家时停车场会不会满了,你可能会尝试停在一个对你来说有更多可用空间的停车位。最后,如果你是一名经验丰富的司机并且喜欢挑战,你会寻找最适合你汽车尺寸的停车位。

如果操作系统使用首次适应策略,它将像渴望吃冰淇淋的司机一样,在它找到的第一个可用位置分配对象。另一方面,如果操作系统使用最差适应策略,它将与意识停车场满意的司机几乎一样,在有尽可能多可用空间的位置分配对象。最后,如果操作系统使用最佳适应策略,它将像经验丰富的司机一样,在最适合对象大小的可用位置分配对象。通常,操作系统使用首次适应策略。

内存管理常见错误

[编辑 | 编辑源代码]

现在我们将降低一点难度,暂时放下汽车、停车场等等,专注于 C 程序。


内存泄漏
[编辑 | 编辑源代码]

当程序员想要使用内存时,他/她可以通过几种方式来实现:一种是声明静态或全局变量,在这种情况下,变量将被放在静态存储区;另一种是声明函数内的局部变量,在这种情况下,变量将被放在堆栈中;最后,他/她可以动态地分配变量,在这种情况下,变量将被放在堆中。在这种情况下,操作系统会在正在执行的程序请求变量空间时在堆中分配空间。

每当程序员分配内存但没有释放该内存时,就会发生内存泄漏。我们将在下面展示一个这种内存错误的示例。在这个示例中,我们请求了变量 i 的空间,但我们没有 `free` 它的空间。

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

void problem() {
  int* i = (int*) malloc (sizeof(int));
  *i = 3;
  printf("%d\n", *i);
}

int main() {
  problem();
}

要编译上面的程序,我们可以这样做

   $> gcc -g Leak.c

通过使用 valgrind,我们可以找到错误。有关 valgrind 的更详细教程,请参阅此处。要找到像上面那样简单的错误,你可以只使用

   $> valgrind -v ./a.out


悬挂指针
[编辑 | 编辑源代码]

另一个常见但更隐蔽的内存错误称为 悬挂指针。在计算机编程中,悬挂指针和野指针是指向无效对象的指针,这些对象并非相应的类型。这些是内存安全违反的特殊情况。

当对象被删除或释放时,而指针的值没有改变,因此指针仍然指向已释放内存的内存位置,就会出现悬挂指针。由于系统可能会将以前释放的内存重新分配给另一个进程,因此如果原始程序随后取消引用(现在)悬挂指针,则可能会导致不可预测的行为,因为内存现在可能包含完全不同的数据。

下面的程序包含一个悬挂指针。尝试检查它以找到问题。

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

void dangling() {
  int* i = (int*) malloc (sizeof(int));
  int* j;
  *i = 3;
  free(i);
  j = (int*) malloc (sizeof(int));
  *j = 8;
  printf("%d\n", *i);
} 

int main() {
  dangling();
}


我们将尝试更详细地解释上面的程序中正在发生的事情。下图显示了执行第 2 行时发生了什么。首先,为变量 i 请求内存,并且一个内存块(灰色部分)被使用,因此,除非它被释放,否则操作系统必须在其他块中分配变量。


分配变量 i 的空间。


在第 3 行,我们将一个值存储到 i 使用的内存块中。下图显示了它。

将值存储到 i 指向的内存位置中。

在将值存储到 i 使用的内存块中之后,我们释放它使用的空间。下图显示了这种情况。


释放变量 i 使用的内存。


请注意,i 仍然指向该块,也就是说,i 仍然包含该块的地址。此外,以前存储在其中的值仍然存在。


现在,让我们停下来思考一下第 6 行发生了什么。首先,假设我们使用 首次适应策略在堆中分配变量。从这个假设出发,因为第一个内存块(以前被 i 使用的块)是空闲的,我们可以在它上面分配 j。这正是发生的事情,如下所示。


分配变量 j 的空间。


现在,一个不同的值被存储到 j 指向的内存块中

将值存储到变量 j 中。


最后,在第 8 行,我们打印 i。常识告诉我们应该打印 3 或者可能是另一个值,但现在我们能够解释为什么实际上打印的值是 8。

华夏公益教科书