Java 指针引论

Chapter 0 Introduction to Pointers in Java

| 关于   «  4. 指针语法   ::   目录   ::   6. 堆内存  »

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; 这样的语句会使用这些局部存储。 当函数最终退出时,它的局部存储被释放。

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

  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

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

5.1.2. 局部参数

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

5.1.3. 局部量的优点

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

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

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

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

5.1.4. 局部量的缺点

局部量有两个缺点:

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

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

5.1.5. "局部" 的同义词

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

5.1.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 设计者为它提供了直接支持。

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

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

   «  4. 指针语法   ::   目录   ::   6. 堆内存  »

关闭窗口