5. 局部内存¶
5.1. 局部内存¶
5.1.1. 内存的分配与释放¶
局部变量 是人人都使用却没有人去思考的程序结构。 在初次掌握语法时,你还会稍微想想它们。 但几周之后,变量用起来是如此自动化,你很快就忘了去思考它们是如何工作的。 这种情况要归功于现代编程语言—大多数时候,变量在你需要它们时自动出现,在你用完时自动消失。 对于基础编程来说,这是很好的情形。 然而,对于高级编程来说,了解变量如何工作将会很有用。
变量代表计算机内存中的存储空间。
每个变量在源代码中都呈现为一个方便的名字,比如 length 或 sum 。
在运行时的幕后,每个变量使用计算机内存中的一块区域来存储它的值。
程序中的每个变量并非都拥有一块永久分配的内存。
相反,现代语言很聪明,只在必要时才把内存交给变量。
术语上,当一个变量被给予一块内存来存储其值时,就称这个变量被 分配 了。
在变量处于已分配状态期间,它可以按通常的方式作为变量运作并保存值。
当系统从变量那里收回内存、使它不再拥有存储值的区域时,就称这个变量被 释放 了。
对一个变量而言,从分配到释放之间的这段时间称为它的 生存期 。
在一个程序内,程序中能够看到并访问该变量的那些部分就是它的 作用域 。
你最常使用的变量是函数内的 局部变量 ,比如下面这个函数中的变量 num 和 result 。
所有局部变量和参数合在一起,称为该函数的 局部存储 ,或简称其 "局部量"(locals)。
// Local storage example
int Square(int num) {
int result;
result = num * num;
return result;
}
这些变量被称为 "局部的",是为了表达这样一个概念:它们的生存期与声明它们的函数绑定在一起。
函数每次运行时,它的局部变量都会被分配。
函数退出时,它的局部量就被释放。
对于上面的例子,这意味着当调用 Square() 函数时,会为 num 和 result 分配局部存储。
函数中像 result = num * num; 这样的语句会使用这些局部存储。
当函数最终退出时,它的局部存储被释放。
下面是局部存储规则的更详细版本:
函数被调用时,会为它的所有局部量分配内存。 换句话说,当控制流到达函数的起始符号
{时,它的所有局部量都被分配了内存。 上例中像 num 这样的参数和像 result 这样的局部变量都算作局部量。 参数与局部变量唯一的区别是:参数以从调用方复制来的值开始,而局部变量以初始值开始。 我们的例子大多使用简单的int变量,但局部分配适用于任何类型,包括数组和引用变量。 这些类型同样可以在局部进行分配。只要控制线程仍位于所属函数之内,局部量的内存就会持续处于已分配状态。 即使函数通过调用另一个函数而暂时让出控制线程,局部量也会继续存在。 在整个过程中,局部量不受干扰地存在着。
最后,当函数结束并退出时,它的局部量被释放。 这合理吗? 假设局部量以某种方式继续存在—代码又怎么引用它们呢? 像
num和result这样的名字本来就只在Square()的函数体内才有意义。 一旦控制流离开该函数体,即使局部量仍处于已分配状态,也没有办法再引用它们了。 局部量只在所属函数之内可用("scoped",有作用域)这一性质,称为 词法作用域 。 现在几乎所有的语言都是这样做的。
下面是局部存储生存期的一个简单例子。
下面是一个更大的例子,它展示了 "局部量在所属函数开始运行时被分配、在其退出时被释放" 这条简单规则如何构建出更复杂的行为。 你需要牢固掌握局部分配的工作方式,才能理解后续模块中的内容。 下面的幻灯片展示了当函数 X() 两次调用函数 Y() 时所产生的分配与释放序列。
幻灯片展示了局部量被分配和释放的先后顺序。 注意在幻灯片中,我们是怎样把函数被调用时创建的局部变量 "堆叠" 起来的。 这实际上就是任何编程语言的运行时环境实现局部变量的典型方式。 局部变量被包含在一个称为 运行时栈 的结构中。 实际上,幻灯片展示的正是执行这个例子的过程中,运行时栈随时间的运作情况。
5.1.2. 局部参数¶
局部变量与它们的函数紧密关联—它们只在那里使用,在其他任何地方都不使用。
只有 X() 的代码可以引用它的 a 和 b 。
只有 Y() 的代码可以引用它的 p 和 q 。
局部存储的这种独立性,既是其优点的根源,也是其缺点的根源。
5.1.3. 局部量的优点¶
局部量能够很好地满足程序 90% 的内存需求:
方便。 局部量满足一种方便的需要—函数常常需要一些只在函数计算期间存在的临时内存。 局部变量恰好提供了这种临时、独立的内存。
高效。 与其他内存使用技术相比,局部量是高效的。 分配和释放它们在时间上是高效的(快速),而且它们使用与回收内存的方式在空间上也是高效的。
局部副本。 局部参数基本上是来自调用方信息的局部副本。 这也称为 按值传递 。 参数是局部变量,通过来自调用方的赋值操作
=来初始化。 在指针的意义上,调用方并没有与被调用方 "共享" 参数值—被调用方得到的是它自己的副本。 这样做的好处是,被调用方可以修改自己的局部副本而不影响调用方。 (比如上例中的p参数。) 这种独立性是件好事,它使调用方函数和被调用方函数的运作相互分离,符合良好软件工程的规则—让独立的组件尽可能互不依赖。
5.1.4. 局部量的缺点¶
局部量有两个缺点:
生存期短。 它们的分配与释放时机(即 "生存期")非常严格。 有时程序需要一块内存,即使最初分配它的函数已经退出,这块内存仍要继续保持已分配状态。 局部变量无法做到这一点,因为它们会在所属函数退出时被自动释放。 这个问题将在后面的一节中用 堆 内存来解决。
通信受限。 由于局部量是调用方参数的副本,它们不提供从被调用方回传给调用方的通信手段。 这是 "独立性" 这一优点的另一面—它并不总是优点。 此外,有时出于其他原因,复制值也并不可取。 我们将在下一个模块中看到这个问题的解决方案。
5.1.5. "局部" 的同义词¶
局部变量也被称为 自动变量 ,因为它们的分配与释放作为函数调用机制的一部分是自动完成的。 局部变量有时也被称为 栈变量 ,因为在低层次上,语言几乎总是用内存中的栈结构来实现局部变量。
5.1.6. 局部内存小结¶
局部量对它们所承担的工作来说非常方便—为函数提供方便而高效的内存,且只在函数执行期间存在。 局部量有两个不足,我们将在后面的小节中解决—函数如何向调用方回传信息,以及函数如何分配一块生存期约束更少的独立内存。
5.1.7. 函数调用栈是如何工作的?¶
要正确使用局部变量,你并不需要知道它们在函数调用期间是如何实现的;但如果你好奇,这里给出大致的步骤。 实现的确切细节因语言和编译器而异。 不过,下面描述的基本结构接近于许多不同系统和语言所采用的方法。
要调用像 foo(6, x+1) 这样的函数:
在调用方的上下文中求实际参数表达式的值,比如
x+1。通过把一个大小合适的 "局部块" 内存压入专用于此目的的运行时 调用栈 ,为
foo()的局部量分配内存。 对于参数(而非局部变量),把第 (1) 步得到的值存入foo()局部块中相应的槽位。保存调用方当前的执行地址(其 "返回地址"),并把执行切换到
foo()。foo()开始执行,其局部块就方便地位于调用栈的末端。foo()完成后,通过把它的局部量弹出栈而退出,并使用先前保存的返回地址 "返回" 调用方。 此时调用方的局部量又位于栈的末端,它可以继续执行。
对于特别好奇的读者,这里还有一些关于函数调用过程的杂项说明:
这就是无限递归会导致 "Stack Overflow Error"(栈溢出错误)的原因—代码不断地调用再调用,反复经历步骤 (1) (2) (3)、(1) (2) (3),却永远到不了步骤 (4) 。 最终,调用栈的内存真的会被耗尽。
这就是局部变量具有基于其类型的特定初始值的原因。 第 (2) 步只是用一个操作把整个局部块压入。 每个局部量都有自己的内存区域,但内存中存放的是上一个 "住户" 留下的任何东西。 这些值会被清除,并为所有局部量赋一个默认初始值。
"局部块" 也称为函数的 活动记录 或 栈帧 。 整个块可以在一次 CPU 操作中压入栈(第 2 步)|---| 这是一个非常快的操作。 为什么可以用一次 CPU 操作完成? 因为压入活动记录对任何编程语言来说都是如此基础的操作,以至于 CPU 设计者为它提供了直接支持。
在多线程环境中,每个线程都有自己的调用栈,而不是只有一个全局调用栈。
出于性能原因,一些语言让部分参数通过寄存器传递、其余通过栈传递,因此整体过程比较复杂。 不过,变量的表面生存期始终遵循这里介绍的 "栈" 模型。

