内存分配的历史
编程语言的发展史就像一份记录,记载着编程语言不断走向抽象化和自动化的过程。
静态分配
静态分配是最简单的分配策略。程序中所有的变量名都在编译时绑定在某个存储位置上,这些绑定不会在运行时改变。静态分配有 3 个局限:
- 每个数据结构的大小必须在编译时可知。
- 过程是不能递归的,因为对于过程中的每个活动,局部变量在内存中共享相同的位置。
- 无法动态的创建数据结构。
不过,静态分配的确有 2 个重要的优点:
- 采用这一策略的编程语言的运行效率高,因为不需要在程序执行时创建或销毁栈帧(stack frame)等数据结构。由于编译器知道所有数据的位置,它可以直接访问而不是简介访问存储位置。
- 静态分配还提供了一个安全保证:程序不可能因为耗尽内存而运行失败,因为它的内存需求可以预知。
栈分配
块结构语言通过在栈上分配内存,克服了静态分配的一些限制。每次过程调用时,一个活动记录(activation record)或是帧被压入系统栈,并在返回时弹出。基于栈的分配方式有 5 个特点:
- 同一过程的不同活动中,局部变量不再共享相同的地址。递归调用称为可能,从而大大增强了语言的表达能力。
- 像数组这样的局部数据结构,其大小可能取决于传递过程的参数。
- 栈分配的局部变量的值,无法从一个活动保持到下一个活动。
- 被调用者的活动记录不可能比它的调用者寿命更长。
- 只有大小能在编译时确定的对象,才能作为过程的结果返回。
堆分配
与栈所遵循的后进先出的规律不同,堆中的数据结构能够以任意次序分配与释放。因而活动记录和动态数据结构可能比创建它们的过程更长寿。堆分配有许多优点:
- 设计就是通过创建抽象为客观世界的问题建模,而这些抽象中,许多天生具有层次结构,最常见的例子就是链表和树。堆分配使得这类抽象的具体表示是可以递归的。
- 数据结构的大小不再固定,而是可以动态变化,有可能会发生数组索引越界的问题。
- 动态大小的对象可以作为过程的结果返回。
- 许多现代编程语言允许把一个过程作为另一个过程的结果返回。如果禁止嵌套的过程,栈分配的语言也可以做到这一点:它们使用被返回的过程的静态地址。
状态、存活性和指针可到达性
程序可以直接操作下列 3 种位置中的值:寄存器、程序栈(包括局部变量和临时变量)和全局变量。在这些位置中,有些值保存了指向堆数据的引用,它们构成了根(root)集合。
堆中独立分配的数据,我们可以称之为节点(node)、单元(cell)或对象(object)。从存储机制的角度看,堆中对象构成的图的存活性(liveness)由指针的可到达性(pointer reachability)所定义。堆中对象存活的条件是:某个根保存着它的地址,或是另外一个存活的堆节点保存了指向它的指针。换句话说,堆中存活的节点的集合是根集合在这个关系下传递引用的闭包(transitive referential closure),也就是最小集合 live:
live={N∈Nodes∣(∃r∈Roots.r→N)⋁(∃M∈live.M→N)}" role="presentation" style="position: relative;">live={N∈Nodes∣(?r∈Roots.r→N)?(?M∈live.M→N)}live={N∈Nodes∣(?r∈Roots.r→N)?(?M∈live.M→N)}
我们注意到,对于堆中存活单元的上述观点,只是对程序实际有可能访问到的单元集合的一个保守估计。它可能包括了一些这样的单元:经由对程序正文的分析或是由一个优化的编译器进行的数据流的分析,我们可以确定它们其实已经死亡。典型的例子包括过程中已经使用完毕的局部变量,帧栈中尚未初始化的位置或是遗留在寄存器中的已经被丢弃的指针(为了避免清除它的开销)。
要确定一个节点的存活性,既可以采用直接的方法,也可以采用间接的方法。直接的方法需要为堆中每一个节点关联一份记录,记录其他堆节点或是根节点到该节点的引用。其中最常见的方法是在这个单元内部保存指向这个单元的指针的数量,也就是它的引用计数法值(reference count)。为分布式系统设计的直接算法,可能为每一个对象保存一个包含有指向该对象引用的远端处理机的列表,以取代简单的引用计数。无论哪一种,当用户程序改变堆中图的连通性时,这些记录必须随之更新。
间接的或者说追踪到的收集器的典型做法,则是在每次用户程序请求更多内存失败时,重新生成存活节点的集合。收集器从根出发,沿着指针链,访问所有可到达的节点。这些节点被认为是存活的,而让所有其他节点所占据的内存变得可以回收利用。如果恢复了足够多的内存,用户程序的请求得到满足时,可以重新开始运行。