CSci 2101 数据结构

第 6 章 Java 指针引论

| 关于   英文版   «  6.4. 指针语法   ::   目录   ::   6.6. 堆内存  »

6.5. 局部内存

6.5.1. 局部内存

6.5.1.1. 内存的分配与释放

局部变量 是人人都使用却没有人去思考的程序结构。在初次掌握语法时,你还会稍微想想它们。但几周之后,变量用起来是如此自动化,你很快就忘了去思考它们是如何工作的。这种情况要归功于现代编程语言—大多数时候,变量在你需要它们时自动出现,在你用完时自动消失。对于基础编程来说,这是很好的情形。然而,对于高级编程来说,了解变量如何工作将会很有用。

变量代表计算机内存中的存储空间。每个变量在源代码中都呈现为一个方便的名字,比如 length 或 sum 。在运行时的幕后,每个变量使用计算机内存中的一块区域来存储它的值。程序中的每个变量并非都拥有一块永久分配的内存。相反,现代语言很聪明,只在必要时才把内存交给变量。术语上,当一个变量被给予一块内存来存储其值时,就称这个变量被 分配 了。在变量处于已分配状态期间,它可以按通常的方式作为变量运作并保存值。当系统从变量那里收回内存、使它不再拥有存储值的区域时,就称这个变量被 释放 了。对一个变量而言,从分配到释放之间的这段时间称为它的 生存期 。在一个程序内,程序中能够看到并访问该变量的那些部分就是它的 作用域 。

你最常使用的变量是函数内的 局部变量 ,比如下面这个函数中的变量 num 和 result 。所有局部变量和参数合在一起,称为该函数的 局部存储 ,或简称其 "局部量"(locals)。

// Local storage example
int Square(int num) {
  int result;
  result = num * num;
  return result;
}
// Local storage example
int Square(int num) {
  int result;
  result = num * num;
  return result;
}

这些变量被称为 "局部的",是为了表达这样一个概念:它们的生存期与声明它们的函数绑定在一起。函数每次运行时,它的局部变量都会被分配。函数退出时,它的局部量就被释放。对于上面的例子,这意味着当调用 Square() 函数时,会为 num 和 result 分配局部存储。函数中像 result = num * num; 这样的语句会使用这些局部存储。当函数最终退出时,它的局部存储被释放。

下面是局部存储规则的更详细版本:

  1. 函数被调用时,会为它的所有局部量分配内存。 换句话说,当控制流到达函数的起始符号 { 时,它的所有局部量都被分配了内存。上例中像 num 这样的参数和像 result 这样的局部变量都算作局部量。参数与局部变量唯一的区别是:参数以从调用方复制来的值开始,而局部变量以初始值开始。我们的例子大多使用简单的 int 变量,但局部分配适用于任何类型,包括数组和引用变量。这些类型同样可以在局部进行分配。

  2. 只要控制线程仍位于所属函数之内,局部量的内存就会持续处于已分配状态。 即使函数通过调用另一个函数而暂时让出控制线程,局部量也会继续存在。在整个过程中,局部量不受干扰地存在着。

  3. 最后,当函数结束并退出时,它的局部量被释放。 这合理吗?假设局部量以某种方式继续存在—代码又怎么引用它们呢?像 num 和 result 这样的名字本来就只在 Square() 的函数体内才有意义。一旦控制流离开该函数体,即使局部量仍处于已分配状态,也没有办法再引用它们了。局部量只在所属函数之内可用("scoped",有作用域)这一性质,称为 词法作用域 。现在几乎所有的语言都是这样做的。

下面是局部存储生存期的一个简单例子。

Settings

Proficient Saving... Error Saving
Server Error
Resubmit

下面是一个更大的例子,它展示了 "局部量在所属函数开始运行时被分配、在其退出时被释放" 这条简单规则如何构建出更复杂的行为。你需要牢固掌握局部分配的工作方式,才能理解后续模块中的内容。下面的幻灯片展示了当函数 X() 两次调用函数 Y() 时所产生的分配与释放序列。

Settings

Proficient Saving... Error Saving
Server Error
Resubmit

幻灯片展示了局部量被分配和释放的先后顺序。注意在幻灯片中,我们是怎样把函数被调用时创建的局部变量 "堆叠" 起来的。这实际上就是任何编程语言的运行时环境实现局部变量的典型方式。局部变量被包含在一个称为 运行时栈 的结构中。实际上,幻灯片展示的正是执行这个例子的过程中,运行时栈随时间的运作情况。

6.5.1.2. 局部参数

局部变量与它们的函数紧密关联—它们只在那里使用,在其他任何地方都不使用。只有 X() 的代码可以引用它的 a 和 b 。只有 Y() 的代码可以引用它的 p 和 q 。局部存储的这种独立性,既是其优点的根源,也是其缺点的根源。

6.5.1.3. 局部量的优点

局部量能够很好地满足程序 90% 的内存需求:

  • 方便。 局部量满足一种方便的需要—函数常常需要一些只在函数计算期间存在的临时内存。局部变量恰好提供了这种临时、独立的内存。

  • 高效。 与其他内存使用技术相比,局部量是高效的。分配和释放它们在时间上是高效的(快速),而且它们使用与回收内存的方式在空间上也是高效的。

  • 局部副本。 局部参数基本上是来自调用方信息的局部副本。这也称为 按值传递 。参数是局部变量,通过来自调用方的赋值操作 = 来初始化。在指针的意义上,调用方并没有与被调用方 "共享" 参数值—被调用方得到的是它自己的副本。这样做的好处是,被调用方可以修改自己的局部副本而不影响调用方。(比如上例中的 p 参数。)这种独立性是件好事,它使调用方函数和被调用方函数的运作相互分离,符合良好软件工程的规则—让独立的组件尽可能互不依赖。

6.5.1.4. 局部量的缺点

局部量有两个缺点:

  1. 生存期短。 它们的分配与释放时机(即 "生存期")非常严格。有时程序需要一块内存,即使最初分配它的函数已经退出,这块内存仍要继续保持已分配状态。局部变量无法做到这一点,因为它们会在所属函数退出时被自动释放。这个问题将在后面的一节中用 堆 内存来解决。

  2. 通信受限。 由于局部量是调用方参数的副本,它们不提供从被调用方回传给调用方的通信手段。这是 "独立性" 这一优点的另一面—它并不总是优点。此外,有时出于其他原因,复制值也并不可取。我们将在下一个模块中看到这个问题的解决方案。

6.5.1.5. "局部" 的同义词

局部变量也被称为 自动变量 ,因为它们的分配与释放作为函数调用机制的一部分是自动完成的。局部变量有时也被称为 栈变量 ,因为在低层次上,语言几乎总是用内存中的栈结构来实现局部变量。

6.5.1.6. 局部内存小结

局部量对它们所承担的工作来说非常方便—为函数提供方便而高效的内存,且只在函数执行期间存在。局部量有两个不足,我们将在后面的小节中解决—函数如何向调用方回传信息,以及函数如何分配一块生存期约束更少的独立内存。

6.5.1.7. 函数调用栈是如何工作的?

要正确使用局部变量,你并不需要知道它们在函数调用期间是如何实现的;但如果你好奇,这里给出大致的步骤。实现的确切细节因语言和编译器而异。不过,下面描述的基本结构接近于许多不同系统和语言所采用的方法。

要调用像 foo(6, x+1) 这样的函数:

  1. 在调用方的上下文中求实际参数表达式的值,比如 x+1 。

  2. 通过把一个大小合适的 "局部块" 内存压入专用于此目的的运行时 调用栈 ,为 foo() 的局部量分配内存。 对于参数(而非局部变量),把第 (1) 步得到的值存入 foo() 局部块中相应的槽位。

  3. 保存调用方当前的执行地址(其 "返回地址"),并把执行切换到 foo() 。

  4. foo() 开始执行,其局部块就方便地位于调用栈的末端。

  5. foo() 完成后,通过把它的局部量弹出栈而退出,并使用先前保存的返回地址 "返回" 调用方。 此时调用方的局部量又位于栈的末端,它可以继续执行。

对于特别好奇的读者,这里还有一些关于函数调用过程的杂项说明:

  • 这就是无限递归会导致 "Stack Overflow Error"(栈溢出错误)的原因—代码不断地调用再调用,反复经历步骤 (1) (2) (3)、(1) (2) (3),却永远到不了步骤 (4) 。 最终,调用栈的内存真的会被耗尽。

  • 这就是局部变量具有基于其类型的特定初始值的原因。 第 (2) 步只是用一个操作把整个局部块压入。每个局部量都有自己的内存区域,但内存中存放的是上一个 "住户" 留下的任何东西。这些值会被清除,并为所有局部量赋一个默认初始值。

  • "局部块" 也称为函数的 活动记录 或 栈帧 。 整个块可以在一次 CPU 操作中压入栈(第 2 步)|---| 这是一个非常快的操作。为什么可以用一次 CPU 操作完成?因为压入活动记录对任何编程语言来说都是如此基础的操作,以至于 CPU 设计者为它提供了直接支持。

  • 在多线程环境中,每个线程都有自己的调用栈,而不是只有一个全局调用栈。

  • 出于性能原因,一些语言让部分参数通过寄存器传递、其余通过栈传递,因此整体过程比较复杂。 不过,变量的表面生存期始终遵循这里介绍的 "栈" 模型。

   «  6.4. 指针语法   ::   目录   ::   6.6. 堆内存  »

关闭窗口