JavaScript 内存管理

生命周期中的每一步大概的说明:

  • 分配内存 — 内存是被操作系统分配,这允许程序使用它。在低级语言中(例如C),这是一个作为开发者需要处理的显式操作。在高级语言中,然而,这些操作都代替开发者进行了处理。
  • 使用内存。 实际使用之前分配的内存,通过在代码操作变量对内在进行读和写。
  • 释放内存 。不用的时候,就可以释放内存,以便重新分配。与分配内存操作一样,释放内存在低级语言中也需要显式操作。

想要快速的了解堆栈和内存的概念,可以阅读本系列第一篇文章。

什么是内存?内存大概是怎么样工作?

在硬件中,电脑的内存包含了大量的触发电路,每一个触发电路都包含一些<span >能够储存1位数据的</span>晶体管。触发器通过 唯一标识符 来寻址,从而可以读取和覆盖它们。因此,从概念上来讲,可以认为电脑内存是一个巨大的可读写阵列。

人类不善于把我们所有的思想和算术用位运算来表示,我们把这些小东西组织成一个大家伙,这些大家伙可以用来表现数字:8位是一个字节。字节之上是字(16位、32位)。

许多东西被存储在内存中:

  1. 所有的变量和程序中用到的数据;
  2. 程序的代码,包括操作系统的代码。

编译器和操作系统共同工作帮助开发者完成大部分的内存管理,但是我们推荐你了解一下底层到底发生了什么。

编译代码的时候,编译器会解析原始数据类型,提前计算出它们需要多大的内存空间。然后将所需的数量分配在 栈空间 中。之所以称为栈空间,是因在函数被调用的时候,他们的内存被添加在现有内存之上(就是会在栈的最上面添加一个栈帧来指向存储函数内部变量的空间)。终止的时候,以LIFO(后进先出)的顺序移除这些调用。

例如:

  1.  
    int n; // 4字节
  2.  
    int x[4]; // 4个元素的数组,每个元素4字节
  3.  
    double m; // 8字节

编译器马上知道需要内存

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 的一些位。这肯定会对其他程序产生不希望产生的结果。

当函数调用其他函数的时候,每一个函数被调用的时候都会获得自己的栈块。在自己的栈块里会保存函数内所有的变量,还有一个程序计数器会记录变量执行时所在的位置。当函数执行完之后,会释放它的内存以作他用。

动态分配

不幸的是,事情并不是那么简单,因为在编译的时候我们并不知道一个变量将会需要多少内存。假设我们做了下面这样的事:

  1.  
    int n = readInput(); //读取用户的输入
  2.   ...
  3. //创建一个有n个元素的数组

编译器不知道这个数组需要多少内存,因为数组大小取决于用户提供的值。

因此,此时不能在栈上分配空间。程序必须在运行时向操作系统请求够用的空间。此时内存从 堆空间 中被分配。静态与动态分配内存之间的不同在下面的表格中被总结出来:

静态分配内存与动态分配内存的区别。

JavaScript中的内存分配

现在我们来解释JavaScript中的第一步( 分配内存 )是如何工作的。

JavaScript在开发者声明值的时候自动分配内存。

  1.  
    var n = 374; // 为数值分配内存
  2.  
    var s = 'sessionstack'; //为字符串分配内存
  3.  
     
  4.  
    var o = {
  5.  
    a: 1,
  6.  
    b: null
  7.  
    }; //为对象和它包含的值分配内存
  8.  
     
  9.  
    var a = [1, null, 'str']; //为数组和它包含的值分配内存
  10.  
     
  11.  
    function f(a) {
  12.  
    return a + 3;
  13.  
    } //为函数(可调用的对象)分配内存
  14.  
     
  15.  
    //函数表达式也会分配一个对象
  16.  
    someElement.addEventListener('click', function() {
  17.  
    someElement.style.backgroundColor = 'blue';
  18.  
    }, false);
  19.  
     
  20.  
    //一些函数调用也会导致对象分配
  21.  
    `var d = new Date(); // allocates a Date object` //分配一个Date对象的内存
  22.  
     
  23.  
    `var e = document.createElement('div'); //分配一个DOM元素的内存
  24.  
     
  25.  
    //方法可以分配新的值或者对象
  26.  
     
  27.  
    var s1 = 'sessionstack';
  28.  
    var s2 = s1.substr(0, 3); //s2是一个新的字符串
  29.  
    // 因为字符串是不可变的
  30.  
    // JavaScript可能决定不分配内存
  31.  
    // 而仅仅存储 0-3的范围
  32.  
     
  33.  
    var a1 = ['str1', 'str2'];
  34.  
    var a2 = ['str3', 'str4'];
  35.  
    var a3 = a1.concat(a2);
  36.  
    //新的数组有4个元素是a1和a2连接起来的。

在JavaScript中使用内存

在JavaScript中使用被分配的内存,本质上就是对内在的读和写。

比如,读、写变量的值或者对象的属性,抑或向一个函数传递参数。

内存不在被需要时释放内存

大部分的内存管理问题都在这个阶段出现。

这里最难的任务是找出这些被分配的内存什么时候不再被需要。这常常要求开发者去决定程序中的一段内存不在被需要而且释放它。

高级语言嵌入了一个叫 垃圾回收 的软件,它的工作是跟踪内存的分配和使用,以便于发现一些内存在一些情况下不再被需要,它将会自动地释放这些内存。

不幸的是,这个过程是一个近似的过程,因为一般关于知道内存是否是被需要的问题是不可判断的(不能用一个算法解决)。

大部分的垃圾回收器会收集不再被访问的内存,例如指向它的所有变量都在作用域之外。然而,这是一组可以收集的内存空间的近似值。因为在任何时候,一个内存地址可能还有一个在作用域里的变量指向它,但是它将不会被再次访问。

垃圾收集

由于找到一些内存是否是“不再被需要的”这个事实是不可判定的,垃圾回收的实现存在局限性。本节解释必要的概念去理解主要的垃圾回收算法和它们的局限性。

内存引用

垃圾回收算法依赖的主要概念是 引用。

在内存管理的语境下,一个对象只要显式或隐式访问另一个对象,就可以说它引用了另一个对象。例如,JavaScript对象引用其Prototype( 隐式引用 ),或者引用prototype对象的属性值( 显式引用 )。

在这种情况下,“对象”的概念扩展到比普通JavaScript对象更广的范围,并且还包含函数作用域。(或者global 词法作用域 )

词法作用域定义变量的名字在嵌套的函数中如何被解析:内部的函数包含了父级函数的作用域,即使父级函数已经返回。

引用计数垃圾回收

这是最简单的垃圾回收算法。 一个对象在没有其他的引用指向它的时候就被认为“可被回收的”。

看一下下面的代码:

  1.  
    var o1 = {
  2.  
    o2: {
  3.  
    x: 1
  4.  
    }
  5.  
    };
  6.  
     
  7.  
    //2个对象被创建
  8.  
    /'o2'被'o1'作为属性引用
  9.  
    //谁也不能被回收
  10.  
     
  11.  
    var o3 = o1; //'o3'是第二个引用'o1'指向对象的变量
  12.  
     
  13.  
    o1 = 1; //现在,'o1'只有一个引用了,就是'o3'
  14.  
    var o4 = o3.o2; // 引用'o3'对象的'o2'属性
  15.  
    //'o2'对象这时有2个引用: 一个是作为对象的属性
  16.  
    //另一个是'o4'
  17.  
     
  18.  
    o3 = '374'; //'o1'原来的对象现在有0个对它的引用
  19.  
    //'o1'可以被垃圾回收了。
  20.  
    //然而它的'o2'属性依然被'o4'变量引用,所以'o2'不能被释放。
  21.  
     
  22.  
    o4 = null; //最初'o1'中的'o2'属性没有被其他的引用了
  23.  
    //'o2'可以被垃圾回收了

循环引用创造麻烦

在涉及循环引用的时候有一个限制。在下面的例子中,两个对象被创建了,而且相互引用,这样创建了一个循环引用。它们会在函数调用后超出作用域,应该可以释放。然而引用计数算法考虑到2个对象中的每一个至少被引用了一次,因此都不可以被回收。

  1.  
    function f() {
  2.  
    var o1 = {};
  3.  
    var o2 = {};
  4.  
    o1.p = o2; // o1 引用 o2
  5.  
    o2.p = o1; // o2 引用 o1. 形成循环引用
  6.  
    }
  7.  
     
  8.  
    f();

标记清除算法

为了决定一个对象是否被需要,这个算法用于确定是否可以找到某个对象。

这个算法包含以下步骤。

  1. 垃圾回收器生成一个根列表。根通常是将引用保存在代码中的全局变量。在JavaScript中,window对象是一个可以作为根的全局变量。
  2. 所有的根都被检查和标记成活跃的(不是垃圾),所有的子变量也被递归检查。所有可能从根元素到达的都不被认为是垃圾。
  3. 所有没有被标记成活跃的内存都被认为是垃圾。垃圾回收器就可以释放内存并且把内存还给操作系统。

上图就是标记清除示意。

这个算法就比之前的(引用计算)要好些,因为“一个对象没有被引用”导致这个对象不能被访问。相反,正如我们在循环引用的示例中看到的,对象不能被访问到,不一定不存在引用。

2012年起,所有浏览器都内置了标记清除垃圾回收器。在过去几年中,JavaScript垃圾回收领域中的所有改进(代/增量/并行/并行垃圾收集)都是由这个算法(标记清除法)改进实现的,但并不是对垃圾收集算法本身的改进,也没有改变它确定对象是否可达这个目标。

循环引用不再是问题

在上面的例子中(循环引用的那个),在函数执行完之后,这个2个对象没有被任何可以到达的全局对象所引用。因此,他们将会被垃圾回收器发现为不可到达的。

尽管在这两个对象之间有相互引用,但是他们不能从全局对象上到达。

垃圾回收器的反常行为

尽管垃圾回收器很方便,但是他们有一套自己的方案。其中之一就是不确定性。换句话说,GC是不可预测的。你不可能知道一个回收器什么时候会被执行。这意味着程序在某些情况下会使用比实际需求还要多的内存。在其他情况下,在特别敏感的应用程序中,可能会出现短停顿。尽管不确定意味着不能确定回收工作何时执行,但大多数GC实现都会在分配内存的期间启动收集例程。如果没有内存分配,大部分垃圾回收就保持空闲。参考下面的情况。

  1. 执行相当大的一组分配。
  2. 这些元素中的大部分(或者所有的)都被标记为不可到达的(假设我们清空了一个指向我们不再需要的缓存的引用。)
  3. 没有更多的分配被执行。

在这种情况下,大多数垃圾回收实现都不会做进一步的回收。换句话说,尽管这里有不可达的引用变量可供回收,回收器也不会管。结果会占用比通常情况下更多的内存。

原文地址:https://www.cnblogs.com/FACESCORE/p/9418165.html