内存分配的发展历史

来源:互联网 发布:linux安装mysql5.7配置 编辑:程序博客网 时间:2024/06/01 07:43

今天看到了一篇很不错的文章,阅读之后对内存分配和垃圾回收有了一个整体性认识,想要记录一下。
有兴趣的同学可以阅读下英文原文:Back to basic: Series on dynamic memory management
我们现在常用的编程语言都有一大堆不同的内存分配方式。我们现在可以选择使用静态内存分配,栈内存分配和堆内存分配。然而过去并不如现在美好,过去的语言仅仅支持静态内存分配,然后慢慢地出现了栈分配,再然后有了堆分配,直到现在很多语言有了自动内存管理功能。
然而这并不能说过去语言的极简的内存管理方式不好,接下来慢慢分析。

静态内存分配(Static allocation)

静态分配方式是所有内存在编译期间被分配(或称为被保留,像局部变量只有在执行时才使用内存,生命期无交叉的局部变量可能会使用相同的内存空间),程序中声明的变量在编译期间就已经被绑定到目标内存。它是最简单的内存分配方式,像50年代后期出现的Fortran支持这种内存分配方式。我个人认为汇编语言也算是这种内存分配方式,因为汇编语言基本在编码期间就能确定内存的分配和占用情况。在这种内存分配方式下,开发者要预先分配他可能会用到的最大内存。
这也意味着在程序运行之前我们的数据结构占用内存的大小都要是已知的,例如,只能使用固定长度的数组,而不能使用动态长度的链表,因为链表若在程序运行时增加节点,需要动态内存分配(像c语言的 malloc())。既然程序声明的变量和内存空间是绑定的,一些我们看来很简单的事却不再能实现,例如递归,当一个方法第二次实例化调用时,其变量指向的内存空间和第一的实例化调用相同,所以方法内的数据被覆盖了,递归调用便得不到我们期望的结果。
尽管静态分配方式对编程有很强的限制,它依然有它的优点。最显而易见的是强鲁棒性和程序行为的高可预知性。既然没有了运行时的内存分配,当然也就没有了内存溢出异常(OutOfMemoryException ),同时也无需运行时的内存管理及堆和栈的拆建工作,因此程序运行速度非常高。编译器甚至编译出直接的内存访问指令,而不需要让程序在运行时经过一些变换来实现间接的内存操作。

栈内存分配(Stack Allocation)

栈分配是将电脑中内存的部分区域划分成不同的内存帧,当一个方法被调用时,便分配一个内存帧并将其压入系统栈供方法使用。基于方法输入参数的不同,我们可以使用不同大小的内存帧,因此方法内的变量具有不同的内存空间(至少局部变量占用不同的内存空间),然而方法的返回值在编译器需要有固定的大小,因为调用者需要知道在栈中为方法预留多少内存以供传递返回值。第一个支持栈分配的语言是50年后后期的Algol,它消除了很多静态分配的限制。
在栈分配的情况下,递归调用成为了可能,因为每一次方法的调用都拥有其独占的内存帧。当方法调用结束返回时,其占用的内存帧将会被释放,同时该方法中在内存帧上分配的内存空间也同样不再可用。
由于栈内存分配的控制流程,程序数据可能会溢出到自己的栈外,从而降低了程序的鲁棒性。并且额外的栈管理开销会降低程序的运行速度(尽管这个影响不大)。
无论如何,栈分配的出现是编程史上一个很大的进步!

堆内存分配(Heap Allocation)

堆分配这个称呼很有趣,但是这是目前内存分配最先进的技术,已经存在了很长时间,不断的进步。在堆分配中,数据可以存放在可变大小的内存块中,用完之后再释放掉,不同数据占用的空间的分配和释放不再需要要求它们有序。而栈分配中,最后申请的内存要最先释放掉,即被调用者的内存要比调用者申请的内存先释放掉,具有栈的后进先出的特性。在堆分配的方式中,我们可以在被调用者申请可变的任意大小的内存块来存储数据,并返回给调用者。我们同时也可以更方便灵活地管理内存。例如,使用可扩展的非连续的链表来存储数据序列,将现实中的数据模型采用更贴近它们的真是形态来存储,像用树存储目录结构,用图存储通信网路节点间的连接。
在堆分配出现之处,编程工作似乎变得容易了许多,而事实上却是艰难了许多。程序的鲁棒性糟糕的一塌糊涂。我们必须小心谨慎的管理动态分配的内存,因为如果内存在使用之后没有得到释放,它将变成垃圾内存,不能再被使用(内存泄漏),最后随着时间的推移内存被逐渐耗尽,程序因再不能申请到内存而停止运行。另外堆内存分配是一个花费很高的操作,严重影响性能(随着硬件性能提升,也逐渐变得可接受)。
值得一说的是,即使有很全的工具支持,内存泄露问题依然很难定位。

垃圾回收(Garbage Collection)

在1959年,LISP语言开始了一项自动内存回收的新技术,这项技术以“垃圾回收(GC)”这个术语被人熟知。这项技术通过使用显式的内存分配(例如,像JAVA使用 new关键字来申请内存,实例化对象,并且在申请内存的时候会在内存管理单元做一些记录)尝试解决内存泄露问题,并且不再需要开发人员手动地释放它们。并且它在语言级上实现了自动追踪不再使用的内存,且不需要用户编码干预而直接释放掉它们。
正如我们所想,GC提升了系统的鲁棒性并且很大程度上提高了编码效率,使开发人员不再耗费经历去规划内存的申请与释放,但是同时也极大的降低了系统运行效率。这也导致很多应用场景中无法使用GC,例如对实时性要求较高的实时系统和系统驱动(可能会有极少数的例外)。而有一些场景就特别适合使用GC,例如需要对大量内存进行频繁申请和释放的Web开发,即使这可能会增加一些系统相应的时延,但GC带来的开发效率的提升显然更重要一些。

总结

大多数现代的编程语言都对这三种内存管理方式提供支持,例如C++,C#
尽管这三种内存管理方式可以同时使用,对它们的选择依然是确保鲁棒性和达到系统性能要求的关键。不幸的是,很多开发者对内存管理方式的选择带有一定的偶然性,最后随着系统的增大,对内存的使用越来越凌乱复杂,从而陷入困境。
用一下伪代码做示例:

void foo() {    MyType m1;         // 方式1 栈分配    MyType* m2 = AllocateOnHeap(sizeof(MyType)); // 方式2 堆分配    ...    if (SomeCond)       foo();     // 递归调用        ...    DeAllocate(m2); // m2内存在堆上申请,需手动释放}

这是的foo()是一个递归调用函数。假设MyType对象需要占用很多内存,当我们使用方式1也就是栈分配的时候,这个函数递归调用的深度将极大的减少,因为每一次的函数调用都需要使用很大的栈空间来存储MyType实例对象。然而当我们使用方式2也就是堆分配时,程序对栈内存的使用就极大地减少,增加了递归调用的深度,但是同时堆分配也降低了系统的运行效率。所以我们应当根据MyType对象的大小,小心谨慎的选择内存分配的方式。