概述
某些语言,比如C有低级的原生内存管理原语,像malloc()
和free()
。开发人员使用这些原语可以显式分配和释放操作系统的内存。
相对地,JavaScript会在创建变量(对象、字符串)时自动分配内存,并在这些变量不被使用时自动释放内存,这个过程被称为垃圾回收。这个“自动”释放资源的特性带来了很多困惑,让JavaScript(和其他高级级语言)开发者误以为可以不关心内存管理。这是一个很大的错误
即使使用高级级语言,开发者也应该对于内存管理有一定的理解(至少有基本的理解)。有时自动内存管理存在一些问题(例如垃圾回收实现可能存在缺陷或者不足),开发者必须弄明白这些问题,以便找一个合适解决方法。
无论你用哪一种编程语言,内存生命周期几乎总是一样的:
Here is an overview of what happens at each step of the cycle: 这是对生命周期中的每一步大概的说明:
分配内存— 内存是被操作系统分配,这允许程序使用它。在低级语言中(例如C),这是一个作为开发者需要处理的显式操作。在高级语言中,然而,这些操作都代替开发者进行了处理。
使用内存。实际使用之前分配的内存,通过在代码操作变量对内在进行读和写。
释放内存 。不用的时候,就可以释放内存,以便重新分配。与分配内存操作一样,释放内存在低级语言中也需要显式操作。
什么是内存
在直接探讨Javascript中的内存之前,我们先简要的讨论一下什么是内存、内存大概是怎么样工作的。
在硬件中,电脑的内存包含了大量的触发电路,每一个触发电路都包含一些能够储存1位数据的晶体管。触发器通过唯一标识符来寻址,从而可以读取和覆盖它们。因此,从概念上来讲,可以认为电脑内存是一个巨大的可读写阵列。
人类不善于把我们所有的思想和算术用位运算来表示,我们把这些小东西组织成一个大家伙,这些大家伙可以用来表现数字:8位是一个字节。字节之上是字(16位、32位)。
许多东西被存储在内存中:
所有的变量和程序中用到的数据;
程序的代码,包括操作系统的代码。
编译器和操作系统共同工作帮助开发者完成大部分的内存管理,但是我们推荐你了解一下底层到底发生了什么。
编译代码的时候,编译器会解析原始数据类型,提前计算出它们需要多大的内存空间。然后将所需的数量分配在栈空间中。之所以称为栈空间,是因在函数被调用的时候,他们的内存被添加在现有内存之上(就是会在栈的最上面添加一个栈帧来指向存储函数内部变量的空间)。终止的时候,以LIFO(后进先出)的顺序移除这些调用。例如:
编译器马上知道需要内存 4 + 4 × 4 + 8 = 28字节。
这是当前整型和双精度的大小。大约20年以前,整型通常只需要2个字节,双精度需要4个字节,你的代码不受基础数据类型大小的限制。
编译器会插入与操作系统交互的代码,来请求栈中必要大小的字节来储存变量。
在上面的例子中,编辑器知道每个变量准确的地址。事实上,无论什么时候我们写变量n
,将会在内部被翻译成类似“memory address 4127963”的语句。
注意,如果我们尝试访问x[4]
的内存(开始声明的x[4]是长度为4的数组,x[4]
表示第五个元素),我们会访问m的数据。那是因为我们正在访问一个数组里不存在的元素,m比数组中实际分配内存的最后一个元素x[3]
要远4个字节,可能最后的结果是读取(或者覆盖)了m
的一些位。这肯定会对其他程序产生不希望产生的结果。 当函数调用其他函数的时候,每一个函数被调用的时候都会获得自己的栈块。在自己的栈块里会保存函数内所有的变量,还有一个程序计数器会记录变量执行时所在的位置。当函数执行完之后,会释放它的内存以作他用。
不幸的是,事情并不是那么简单,因为在编译的时候我们并不知道一个变量将会需要多少内存。假设我们做了下面这样的事:
编译器不知道这个数组需要多少内存,因为数组大小取决于用户提供的值。
因此,此时不能在栈上分配空间。程序必须在运行时向操作系统请求够用的空间。此时内存从堆空间中被分配。静态与动态分配内存之间的不同在下面的表格中被总结出来:
静态分配内存与动态分配内存的区别。
为了完全理解动态内存是如何分配的,我们需要花更多的时间在指针上,这个可能很大程度上偏离了这篇文章的主题。如果你有兴趣学习更多的知识,那就在评论中让我知道,我就可以在之后的文章中写更多关于指针的细节。
现在我们来解释JavaScript中的第一步(分配内存)是如何工作的。
JavaScript在开发者声明值的时候自动分配内存。
在JavaScript中使用被分配的内存,本质上就是对内在的读和写。
比如,读、写变量的值或者对象的属性,抑或向一个函数传递参数。
大部分的内存管理问题都在这个阶段出现。
这里最难的任务是找出这些被分配的内存什么时候不再被需要。这常常要求开发者去决定程序中的一段内存不在被需要而且释放它。
高级语言嵌入了一个叫垃圾回收的软件,它的工作是跟踪内存的分配和使用,以便于发现一些内存在一些情况下不再被需要,它将会自动地释放这些内存。
不幸的是,这个过程是一个近似的过程,因为一般关于知道内存是否是被需要的问题是不可判断的(不能用一个算法解决)。
大部分的垃圾回收器会收集不再被访问的内存,例如指向它的所有变量都在作用域之外。然而,这是一组可以收集的内存空间的近似值。因为在任何时候,一个内存地址可能还有一个在作用域里的变量指向它,但是它将不会被再次访问。
由于找到一些内存是否是“不再被需要的”这个事实是不可判定的,垃圾回收的实现存在局限性。本节解释必要的概念去理解主要的垃圾回收算法和它们的局限性。
垃圾回收算法依赖的主要概念是引用。
在内存管理的语境下,一个对象只要显式或隐式访问另一个对象,就可以说它引用了另一个对象。例如,JavaScript对象引用其Prototype(隐式引用),或者引用prototype对象的属性值(显式引用)。
在这种情况下,“对象”的概念扩展到比普通JavaScript对象更广的范围,并且还包含函数作用域。(或者global词法作用域)
词法作用域定义变量的名字在嵌套的函数中如何被解析:内部的函数包含了父级函数的作用域,即使父级函数已经返回。
这是最简单的垃圾回收算法。 一个对象在没有其他的引用指向它的时候就被认为“可被回收的”。
看一下下面的代码:
在涉及循环引用的时候有一个限制。在下面的例子中,两个对象被创建了,而且相互引用,这样创建了一个循环引用。它们会在函数调用后超出作用域,应该可以释放。然而引用计数算法考虑到2个对象中的每一个至少被引用了一次,因此都不可以被回收。