关于翻译:javascript是如何工作的03内存管理和如何处理4种常见的内存泄漏

33次阅读

共计 9314 个字符,预计需要花费 24 分钟才能阅读完成。

概述

像 C 语言这种具备底层内存治理的原始语言,例如 malloc()free()。开发人员应用这些原始语言明确地给操作系统调配和开释内存。

同时,JavaScript 在创立事物(对象,字符串,等等)的时候分配内存,并且在不再应用的时候“主动”开释内存,这是一个 垃圾回收 的过程。开释资源的这种看起来“主动”的个性是凌乱的起源,给 JavaScript(和其余高级语言)开发者一种他们能够不再关怀内存治理的谬误印象。这是一个大谬误

即便在应用高级语言工作的时候,开发者应该了解内存治理(或者起码是基本知识)。有时,主动内存治理存在问题(例如垃圾回收中的 bug 或者实现限度,等),开发人员必须理解这些问题能力正确处理它们(或者找到适当的解决办法,同时尽量减少老本和代码累赘)。

内存生命周期

无论你应用哪种编程语言,内存生命周期简直都是一样的:

以下是对周期中的每一步产生的状况概述:

  • 分配内存 – 内存是由操作系统调配的,操作系统容许你的程序应用它。在底层级别语言(例如 C)中,这是你作为一个开发者应该解决的明确操作。然而,在高级别语言,这是你应该解决的。
  • 应用内存 – 这实际上是程序应用之前调配的内存的时候。操作在代码中分配内存的时候产生。
  • 开释内存 – 当初是时候开释你不再须要的整个内存了,这样它能够从新开释并且可再次应用。与 分配内存 一样,在低级别语言中这是明确的。

要疾速理解调用栈和内存堆的概念,能够浏览这个主题的第一篇文章。

什么是内存?

在间接进入 JavaScript 内存之前,咱们将简略地探讨下什么是个别内存,以及它是如何工作的。

在硬件层面,计算机内存是由大量的触发器组成。
每个触发器蕴含几个晶体管,并且能够存储一个比特。每个触发器通过一个 惟一的标识符 寻址,因而咱们能够读取和复写它们。因而,从概念上讲,咱们能够认为整个计算机的内存是一组比特数组,咱们能够读写他们。

然而作为人类,咱们并不善于应用比特来实现咱们所有的思考和算数,咱们将它们组成更大的组,它们能够一起来示意数字。8 比特被称为 1 字节。除了字节,还有单词(有时是 16,有时是 32 位)。

内存中存储大量的货色:

  1. 所有的变量和所有程序应用的数据。
  2. 编程的代码,包含操作系统的。

编译器和操作系统一起工作,为你解决大量的内存治理,然而咱们倡议你查看一下背地产生的事件。

当你编译你的代码的时候,编译器能够查看原始数据类型,并且提前计算它们须要多少内存。而后在调用 堆栈空间 中将须要的内存调配给程序。调配这些变量的空间叫做堆栈空间,因为在调用函数的时候,他们的内存会增加到现有的内存之上。一旦他们停止,它们将以 LIFO(后入先出)的程序移除。例如,思考上面的申明:

init n; // 4bytes
int x[4]; // array of 4 elements, each 4 bytes
double m; // 8 bytes

编译器能够立马看到这些代码须要多大内存

4 + 4 * 4 + 8 = 28 bytes.

这就是它如何解决以后整型和 double 的大小。大略 20 年前,整型通常是 2bytes,和 4 字节。你的代码不应该依赖以后根本数据类型的大小。

编译器将插入与操作系统交互的代码,它须要申请以后栈上所需存储变量的大小的字节数。

在下面的例子中,编译器确切地晓得每个变量的内存地址。实际上,不论你什么时候书写变量n,这将会在外部被翻译成相似于“内存地址 4127963”。

留神到,如果咱们试图在这里拜访 x[4],咱们将须要拜访与之关联的 m。这是因为咱们拜访了数组上不存在的元素 – 它比实际上调配给数组的最初一个元素x[3] 多了 4 字节,并且可能将会最终读取(或者复写)一些 m 的比特。这将对剩下的程序产生十分不冀望的结果。

当一个函数调用另一个函数时,每个函数在调用堆栈时都会失去本人的堆栈块。它将其所有的本地变量都保留在这里,并且有一个程序计数器去记住执行过程中的地位。当这个函数调用实现,它的内存块将会用于其余用处。

动态分配

可怜的是,当咱们不晓得一个变量在编译的时候须要多少内存,事件就变得不简略了,如果咱们要做上面的事件:

int n = readInput(); // reads input from the user
...
// create an array with "n" elements

这里,在编译的时候,编译器不晓得这个数组须要读书内存,因为它是由 user 提供的值决定的。

因而,它不能为堆栈上的变量调配空间。相同,咱们的程序须要在运行的时候向操作系统申请适当的空间。这个内存是从 堆空间 调配的。动态和动静分配内存的不同能够总结为如下的表格:

要齐全了解动态内存调配是如何工作的,咱们须要破费一些工夫在 指针 上,这可能有点偏离了本文的主体。如果你有趣味理解更多,请在评论区让我晓得,咱们能够在当前的文章中再去深刻理解这些细节。

在 JavaScript 中调配

当初咱们将解释第一步在 JavaScript 中内存调配是如何工作的。

JavaScript 加重了开发者内存调配的责任 – JavaScript 除了申明之外,本人做了这部分工作。

var n = 374; // allocates memory for a number
var s = 'sessionstack'; // allocates memory for a string 

var o = {
  a: 1,
  b: null
}; // allocates memory for an object and its contained values

var a = [1, null, 'str'];  // (like object) allocates memory for the  array and its contained values
                           
function f(a) {return a + 3;} // allocates a function (which is a callable object)

// function expressions also allocate an object
someElement.addEventListener('click', function() {someElement.style.backgroundColor = 'blue';}, false);

一些对象里的函数调用也会导致内存调配:

var d = new Date(); // allocates a Date object

var e = document.createElement('div'); // allocates a DOM element

办法能够调配新的值或者对象:

var s1 = 'sessionstack';
var s2 = s1.substr(0, 3); // s2 is a new string
// Since strings are immutable, 
// JavaScript may decide to not allocate memory, 
// but just store the [0, 3] range.

var a1 = ['str1', 'str2'];
var a2 = ['str3', 'str4'];
var a3 = a1.concat(a2); 
// new array with 4 elements being
// the concatenation of a1 and a2 elements

在 JavaScript 中应用内存

个别的,在 JavaScript 中应用分配内存,就是读写。

这能够通过给一个变量或者对象赋值,甚至是给函数传递一个参数来实现读或写。

当内存不再须要的时候开释

大多数的内存治理问题都出在这个阶段。

这里最艰难的工作就是计算出调配的内存不再须要。它通常依赖开发者决定再程序中哪块内存不再须要并且开释它。

高级语言把嵌入一块叫作 垃圾回收 的软件,它的工作就是追踪内存调配,并且应用它找到哪块调配的内存不再被须要,在这种状况下主动开释。

可怜的是,这个过程是不精确的,因为个别的问题是晓得哪块内存被须要是不可判断的(应用算法解决不了)。

大多数垃圾回收工作是通过收集哪块内存是不会再被拜访的,例如:指向它的所有变量都超过了作用域。然而,它能够收集到一些内存空间也是不精确的,因为在作用域中很可能始终有一个变量指向它的内存地址,尽管它从未被再次拜访。

垃圾回收

因为找出“内存不再须要”是不可判断的,垃圾回收实现了对个别问题的解决方案的限度。这个局部将解释垃圾回收的次要算法和他们的限度的一些必要概念。

内存援用

垃圾回收算法次要的概念依赖之一就是 援用

在内存治理的上下文中,如果一个对象能够拜访前面的对象(隐式或者显式)则能够说是一个对象援用另一个对象。例如,一个 JavaScript 对象对原型(隐式援用)和对它属性值(显式援用)有一个援用。

在这种状况下,“对象”的范畴扩大到比惯例的 JavaScript 对象更宽泛的内容,并且蕴含函数作用域(或者全局 词法作用域)。

词法作用域定义了如何在嵌套函数中解析变量名:外部函数蕴含父函数的作用域,即便父函数曾经返回。

援用 – 计算垃圾回收

这是最简略的垃圾回收算法。一个对象如果没有一个援用指向它,那么认为能够进行“垃圾回收”。

看上面的代码:

var o1 = {
  o2: {x: 1}
};
// 2 objects are created. 
// 'o2' is referenced by 'o1' object as one of its properties.
// None can be garbage-collected

var o3 = o1; // the 'o3' variable is the second thing that 
            // has a reference to the object pointed by 'o1'. 
                                                       
o1 = 1;      // now, the object that was originally in 'o1' has a         
            // single reference, embodied by the 'o3' variable

var o4 = o3.o2; // reference to 'o2' property of the object.
                // This object has now 2 references: one as
                // a property. 
                // The other as the 'o4' variable

o3 = '374'; // The object that was originally in 'o1' has now zero
            // references to it. 
            // It can be garbage-collected.
            // However, what was its 'o2' property is still
            // referenced by the 'o4' variable, so it cannot be
            // freed.

o4 = null; // what was the 'o2' property of the object originally in
           // 'o1' has zero references to it. 
           // It can be garbage collected.

循环正在制作问题

当呈现循环的时候有一个限度。在上面的例子中,创立两个对象并且援用另一个,这样产生了一个循环。函数被调用后,它们将超出作用域,因而他们是无用的,能够被开释的。然而,援用计算算法认为因为两个对象至多被援用一次,因而不能被垃圾回收。

function f() {var o1 = {};
  var o2 = {};
  o1.p = o2; // o1 references o2
  o2.p = o1; // o2 references o1. This creates a cycle.
}

f();

标记扫描算法

为了确定对象是否须要,这个算法决定对象是否可拜访。

标记扫描算法须要通过这 3 步:

  1. Roots:通常,roots 是代码中援用的全局变量。例如在 JavaScript 中,一个能够充当 root 的全局变量是 ”window” 对象。Node.js 中雷同的对象是“global”。垃圾回收生成所有 roots 残缺的列表。
  2. 算法将会查看 roots 的所有变量曾经他们的 children 并且标记为流动(意思是,他们不是垃圾)。root 拜访不到的货色将会被标记为垃圾。
  3. 最初,垃圾回收将会开释没有被标记为流动的内存片段,并且将内存返回给操作系统。

这个算法比之前那个好,因为“一个对象零援用”导致这个对象不可拜访。相同的状况并不像咱们看到的循环援用那样。

截止到 2012 年,所有的古代浏览器都实现了一个标记扫描垃圾回收。过来几年在 JavaScript 垃圾回收畛域所做的所有优化都是对该算法(标记扫描)的改良实现,而不是优化垃圾回收算法自身,也不是一个对象是否可拜访的指标。

在这篇文章, 你能够读到对于追踪垃圾回收十分具体的介绍,并且包含标记扫描的优化。

循环不再是问题

在下面的第一个例子中,当函数调用返回后,两个对象不再被全局对象的任何货色拜访。因而,垃圾回收将会找到无法访问的他们。

即便在两个对象之间有援用,然而从 root 开始再也无法访问。

垃圾回收器的反直觉行为

尽管垃圾回收器十分不便有着本人的一套推导计划。其中之一是非决定的。换句话说,CGs 是不可预测的。你无奈真正判断何时执行回收。这意味着在某些状况下,程序会应用比它理论须要的更多的内存。在其余状况下,在一些十分敏感的应用程序中短暂进展是非常明显的。尽管非确定性意味着不能确定什么时候执行回收,大多数 GC 在分配内存期间实现执行汇合传递的通用模式。如果不执行调配,大多数 GCs 处于闲暇状态。思考以下状况:

  1. 执行一组相当大的调配。
  2. 大多数这些元素(或者所有)都被标记为无法访问(假如咱们将不再须要的援用指向缓存)。
  3. 没有更多的执行调配。

在这种状况下,大多数 GCs 将不会进一步执行回收。换句话说,对于收集器就算有不可拜访的变量援用,收集器也不会清理这些。这些严格意义上来说不是援用,然而会导致比通常更高的内存。

什么是内存透露?

正如内存示意,内存透露是应用程序过来应用,然而将来不再须要的内存片段,并且没有返回给操作系统或者闲暇内存池。

编程语言偏向于不同的内存治理形式。然而,确定一个内存片段应用与否是一个难以确定的问题。换句话说,只有开发者能够分明地确定一个内存片段是否能够返回给操作系统。

某些编程语言提供了帮忙开发者实现这个的性能。其余则冀望开发者分明的晓得一段内存未被应用。维基百科对于内存治理的手动和主动有着十分好的文章。

4 种常见的 JavaScript 内存透露

1: 全局变量

JavaScript 应用一种乏味的形式解决未声明变量:当一个未声明的变量被援用,那么就在 全局 对象中创立一个新变量。在浏览器,全局对象可能就是window,就是这个意思

function foo(arg) {bar = "some text";}

和上面的成果是一样的

function foo(arg) {window.bar = "some text";}

假如 bar 的目标是仅援用在 foo 函数中的变量。然而,如果你不应用 var 来申明它,一个多余的全局变量就被创立。在下面的例子中,这不会造成很大的危害。不过你必定能够想出一个更具破坏力的场景。

你也能够意外地应用 this 创立全局变量:

function foo() {this.var1 = "potential accidental global";}

// Foo called on its own, this points to the global object (window)
// rather than being undefined.
foo();

你可通过在你的 JavaScript 文件开始增加 'use strict'; 去防止这些,它将切换为更加严格的模式去解析 JavaScript, 能够阻止意外的创立全局变量。

然而,全局变量的确是一个问题,你的代码通常可能会呈现显式的全局变量定义,这是垃圾回收无奈收集的。须要特地留神用于零时存储和解决大量信息的全局变量。如果你必须这么应用全局变量来存储数据,当你这么做的时候,必须确保 给它赋值为 null 或者重新分配 当它实现工作的时候。

2: 被忘记的定时器和回调

咱们应用常常在 JavaScript 中应用的 setInterval作为例子。

提供观察器和其余承受回调的工具库通常要确保所有的援用和他们的实例都变成不可拜访。不过,上面的代码并不少见:

var serverData = loadData();
setInterval(function() {var renderer = document.getElementById('renderer');
    if(renderer) {renderer.innerHTML = JSON.stringify(serverData);
    }
}, 5000); //This will be executed every ~5 seconds.

下面的片段展现了应用不再须要的援用节点和数据的后果。

renderer对象能够在某个点被替换或者删除,这使得应用距离快解决变得冗余。如果这种状况产生,不论是处理器,还是依赖都须要被回收,首先须要进行距离(记住,它依然是流动的)。归根结底,serverData必定不会被存储和解决加载数据,也不会被回收。

在应用观察者时,你须要确保应用完他们之后,明确的来删除它们(不论是不再须要观察者,或者对象变成无法访问)。

侥幸的是,大多数的古代浏览器都会为你实现这项工作:即便你遗记删除监听,一旦察看对象编程不可拜访,他们将会主动回收察看处理程序。在过来,一些浏览器不能解决这些状况(很好的旧 IE6)。

尽管如此,一旦对象变的过期了,最佳实际是移除这些观察者。参见上面的例子:

var element = document.getElementById('launch-button');
var counter = 0;

function onClick(event) {
   counter++;
   element.innerHtml = 'text' + counter;
}

element.addEventListener('click', onClick);

// Do stuff

element.removeEventListener('click', onClick);
element.parentNode.removeChild(element);

// Now when element goes out of scope,
// both element and onClick will be collected even in old browsers 
// that don't handle cycles well.

你不再须要在使得节点不可拜访之前调用removeEventListener,因为古代浏览器的垃圾回收能够检测这些循环并且适当的解决他们。

如果你应用 jQuery 的 APIs(其余类库和框架也反对),你能够在节点过期之前移除掉这些监听。类库须要确保应用程序即便在低版本的浏览器下运行也不会呈现内存透露。

3:闭包

JavaScript 开发的一个要害方面是闭包:一个能够拜访内部(突围)函数变量的外部函数。因为 JavaScript 运行的实现细节,以下形式很可能会导致内存透露:


var theThing = null;

var replaceThing = function () {

  var originalThing = theThing;
  var unused = function () {if (originalThing) // a reference to 'originalThing'
      console.log("hi");
  };
  
  theThing = {longStr: new Array(1000000).join('*'),
    someMethod: function () {console.log("message");
    }
  };
};

setInterval(replaceThing, 1000);

一旦 replaceThing 被调用,theThing失去一个由很大数组和新闭包(someMethod)组成的新对象。然而,originalThing是由 unused 变量持有的闭包援用的(它是从前一次调用 replaceThing 替换的 theThing 变量)。须要记住的就是一旦为同一父作用域的闭包创立了作用域,那么这个作用域是共享的。

在这种状况下,为闭包 someMethod 创立的作用域与 unused 是共享的。即便 unused 从未被应用,someMethod能够通过 replaceThing 内部的作用域 theThing 来应用。并且,因为 someMethodunused共享闭包作用域,unused援用必须强制 originalThing 放弃流动(两个闭包之间共享整个作用域)。这就阻止了回收。

在下面的例子中,为闭包 someMethod 创立的作用域与 unused 是共享的,同时 unused 援用 originalThingsomeMethod 能够通过 replaceThing 内部的作用域 theThing 来应用,只管实际上 unused 从未被应用。实际上未应用的援用 originalThing 须要放弃 active 状态,因为 someMethodunused共享作用域。

所有的这些会导致相当大的内存透露。当下面的代码一遍又一遍的运行时,你可能会看到内存使用量暴增。当垃圾回收运行的时候它的大小不会放大。一个有序的闭包列表被创立(在本例中 root 是 theThing 变量),并且每个闭包作用域都窜第了一个队大数组的间接援用。

这个问题是 Meteor 团队发现的,他们有一个很好的文章具体的形容了这个问题。

4:脱离 DOM 援用

有些状况下,开发者在数据结构中存储 DOM 节点。假如你心愿疾速更新表格中的几行内容。如果你在一个字典或者数组中存储了每个 DOM 的援用,那么将有两个援用指向同一个 DOM 元素:一个在 DOM 树中,另一个在字典中。如果你决定删除这些行,你须要记住让这两个援用都不可拜访。


var elements = {button: document.getElementById('button'),
    image: document.getElementById('image')
};

function doStuff() {elements.image.src = 'http://example.com/image_name.png';}

function removeImage() {
    // The image is a direct child of the body element.
    document.body.removeChild(document.getElementById('image'));
    // At this point, we still have a reference to #button in the
    //global elements object. In other words, the button element is
    //still in memory and cannot be collected by the GC.
}

此外须要思考 DOM 树中的外部或者子节点的援用问题。如果在代码中保留着对单元格(td 标签)的援用,并且决定从 DOM 中删除这个表格,但须要保留对特定单元格的援用,则可能会导致大的内存透露。你可能会认为垃圾回收会开释除了那个单元格以外的任何货色。然而实际上并非如此。因为单元格是表格的一个子节点,并且子节点保留对父节点的援用,这个对表格单元格的单个援用将整个表格放弃在内存中

正文完
 0