JavaScript如何工作:内存管理+如何处理4个常见内存泄漏

2017-10-19 09:22


该系列的第一篇文章重点介绍了引擎,运行时和调用堆栈的概述Thе 第二后仔细研究谷歌的V8 JavaScript引擎的内部零件,也提供了有关如何写出更好的JavaScript代码的一些提示。

在第三篇文章中,我们将讨论开发人员越来越忽视的另一个关键主题,因为日常使用的编程语言的日益成熟和复杂性 - 内存管理。我们还将提供一些有关如何处理SessionStack中JavaScript内存泄漏的提示,因为我们需要确保SessionStack不会导致内存泄漏,也不会增加我们集成的Web应用程序的内存消耗。

概观

语言(如C)具有低级内存管理原语,例如malloc()free()这些基元由开发人员用来明确地分配和释放内存到操作系统。

同时,JavaScript在创建事物(对象,字符串等)时分配内存,并且“自动”在不再使用时释放它们,这个进程称为垃圾收集释放资源的这种看似“自动”的性质是一个混乱的根源,给了JavaScript(和其他高级语言)开发人员错误的印象,他们可以选择不关心内存管理。这是一个大错误。

即使使用高级语言,开发人员应该对内存管理(或至少基础知识)有所了解。有时,开发人员必须理解的自动内存管理(例如垃圾收集器中的错误或实现限制等)会遇到问题,以便正确处理它们(或找到适当的解决方法,最小折扣和代码债务)。

记忆生命周期

无论使用什么编程语言,内存生命周期几乎总是相同的:

 

以下是对周期每个步骤发生的情况的概述:

  • 分配内存  - 内存由操作系统分配,允许程序使用它。在低级语言(例如C)中,这是您作为开发人员处理的显式操作。然而,在高级语言中,这是为您照顾的。
  • 使用内存 - 这是您的程序实际使用以前分配的内存的时间。读取写入操作都为你使用在代码中分配的变量发生。
  • 释放内存  - 现在是释放您不需要的整个内存的时间,以便它可以再次可用并可用。分配内存操作一样,这种操作在低级语言中是明确的。

要快速了解调用堆栈和内存堆的概念,您可以阅读我们关于该主题的第一篇文章

什么是记忆?

在JavaScript中直接跳转到内存之前,我们将简要讨论一般内存以及它的工作原理。

在硬件层面上,计算机存储器由大量
触发器组成每个触发器包含几个晶体管,并且能够存储一个位。单个触发器可通过唯一的标识符寻址,因此我们可以读取并覆盖它们。因此,在概念上,我们可以将整个计算机内存看作是我们可以阅读和写入的一大堆数组。

既然作为人类,我们不是很善于把我们所有的思考和算术都放在一起,我们把它们组织成更大的组,它们可以一起用来表示数字。8位称为1字节。除了字节之外,还有字(有时是16位,有时是32位)。

很多东西都存储在这个内存中:

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

编译器和操作系统共同合作,为大多数内存管理提供帮助,但我们建议您查看引擎盖下的内容。

编译代码时,编译器可以检查原始数据类型,并提前计算出需要多少内存。然后将所需的数量分配给调用堆栈空间中的程序这些变量分配的空间称为堆栈空间,因为随着函数被调用,它们的内存被添加到现有存储器的顶部。当它们终止时,它们以LIFO(先入先出)顺序被移除。例如,考虑以下声明:

int n; // 4 bytes 
int x [4]; //数组4个元素,每4个字节
双m; // 8个字节

编译器可以立即看到代码需要  
4 + 4×4 + 8 = 28字节。

这是它与当前大小的整数和双精度的工作原理。大约20年前,整数通常为2字节,双字节为4字节。您的代码不应该依赖于此时基本数据类型的大小。

编译器将插入与操作系统交互的代码,以便在堆栈中请求要存储的变量所需的字节数。

在上面的示例中,编译器知道每个变量的精确内存地址。事实上,每当我们写入变量时n,这个内部变换成“内存地址4127963”。

请注意,如果我们尝试访问x[4]这里,我们将访问与m相关联的数据。这是因为我们正在访问数组中不存在的元素 - 比数组中最后一个实际分配的元素还要4字节x[3],并且可能会读取(或重写)某些m位。这几乎肯定会对其他计划产生非常不良的后果。

 

当函数调用其他函数时,每个函数在调用时都会获得自己的堆栈块。它保存所有的局部变量,还有一个程序计数器,可以记住它在执行中的位置。当功能完成时,其内存块再次可用于其他目的。

动态分配

不幸的是,当我们在编译时不知道一个变量需要多少内存时,事情并不那么容易。假设我们要做如下的事情:

int n = readInput(); //读取用户的输入
...
//创建一个包含“n”个元素的数组

在编译时,编译器不知道数组需要多少内存,因为它由用户提供的值决定。

因此,它不能为堆栈上的变量分配空间。相反,我们的程序需要在运行时明确地要求操作系统获得适当的空间量。这个内存是从堆空间分配的静态和动态内存分配的区别如下表所示:

 
静态和动态分配的内存之间的差异

为了充分了解动态内存分配的工作原理,我们需要花更多的时间在指针上,这可能与这篇文章的主题有太多的偏离。如果您有兴趣了解更多信息,请在评论中通知我们,我们可以在未来的文章中详细介绍指针。

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();
 

标记和扫描算法

为了确定是否需要对象,该算法确定对象是否可访问。

该算法由以下步骤组成:

  1. 垃圾回收器构建“根”列表。根通常是代码中保留引用的全局变量。在JavaScript中,“窗口”对象是可以作为根的全局变量的示例。
  2. 所有根被检查并标记为活动(即不是垃圾)。所有的孩子也被递归检查。从根部到达的一切都不被认为是垃圾。
  3. 所有未被标记为活动的内存现在可以被认为是垃圾。收集器现在可以释放该内存并将其返回到操作系统。
 
标记和扫描算法的可视化在行动

这个算法比前一个更好,因为“一个对象有零引用”导致这个对象是不可达的。相反,我们已经看到了周期。

截至2012年,所有现代浏览器都装载了一个标记和扫描垃圾回收器。过去几年,JavaScript垃圾收集(代数/增量/并行/并行垃圾收集)领域的所有改进都是对该算法(标记和扫描)的实现进行了改进,但并没有对垃圾收集算法本身的改进,其目标是确定一个对象是否可达。

周期不再是问题了

在上面的第一个例子中,在函数调用返回之后,两个对象不再被全局对象可访问的东西引用。因此,垃圾收集器将无法找到它们。

 

即使对象之间有引用,它们也不可从根目录访问。

反垃圾收集器的直观行为

虽然垃圾收集者很方便,但他们自己也有自己的权衡。其中一个是非确定论换句话说,GC是不可预测的。你不能真正地告诉你什么时候会收集。这意味着在某些情况下,程序会使用实际需要的更多内存。在其他情况下,特别敏感的应用程序可能会引起短暂暂停。虽然非确定性意味着在执行集合时无法确定,但大多数GC实现共享在分配期间执行收集遍历的常见模式。如果没有执行分配,大多数GC保持空闲状态。考虑以下情况:

  1. 执行相当大的一组分配。
  2. 这些元素中的大多数(或全部)被标记为不可达(假设我们将指向我们不再需要的缓存的引用置空)。
  3. 不执行进一步的分配。

在这种情况下,大多数GC将不会再运行任何进一步的收集通行证。换句话说,即使有不可达到的参考可供收集,这些都不是由收集器声明。这些不是严格的泄漏,但仍然导致高于通常的内存使用。

什么是内存泄漏?

实质上,内存泄漏可以被定义为应用程序不再需要的内存,但由于某种原因,内存泄漏不会返回到操作系统或可用内存池。

 

编程语言有利于管理内存的不同方法。然而,是否使用某种内存实际上是一个不可判定的问题换句话说,只有开发人员可以清楚一个内存是否可以返回到操作系统。

四种常见的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();
为了防止发生这些错误,请'use strict';在JavaScript文件的开头添加这使得更严格的解析JavaScript模式能够防止意外的全局变量。详细了解这种JavaScript执行模式。

即使我们讨论了未预期的全局变量,但仍然有很多代码用显式的全局变量填充。这些定义是不可收集的(除非分配为null或重新分配)。特别是,用于临时存储和处理大量信息的全局变量值得关注。如果您必须使用全局变量来存储大量数据,请确保将其分配为空值,或者在完成之后将其重新分配

2:被遗忘的计时器或回调

setInterval在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,大概存储大量的数据,也不能被收集。

在观察者的情况下,重要的是进行显式调用,以便在不再需要时删除它们(或者相关对象即将无法访问)。

过去,以前特别重要的是某些浏览器(好的旧IE 6)无法管理好循环引用(有关更多信息,请参见下文)。如今,大多数浏览器一旦观察到的对象变得无法访问,就能收集观察者处理程序,即使侦听器没有被明确删除。但是,在处理对象之前,明确删除这些观察者仍然是一个很好的做法。例如:

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.

如今,现代浏览器(包括Internet Explorer和Microsoft Edge)使用现代垃圾收集算法,可以检测这些周期并正确处理它们。换句话说,removeEventListener在使节点无法访问之前不必要进行调用

框架和库(如jQuery)在处理节点之前(在为其使用特定的API时)会删除侦听器。这是由库内部处理的,这也确保没有泄漏,即使在有问题的浏览器下运行,如...是的,IE 6。

3:关闭

JavaScript开发的一个关键方面是闭包:一个可以访问外部(封闭)函数变量的内部函数。由于JavaScript运行时的实现细节,可以通过以下方式泄漏内存:

var theThing = null;
var replaceThing = function(){
  var originalThing = theThing; 
  var unused = function(){ 
    if(originalThing)//对'originalThing'的引用
      console.log(“hi”); 
  };
  theThing = { 
    longStr:new Array(1000000).join('*'),
    someMethod:function(){ 
      console.log(“message”); 
    } 
  }; 
};
setInterval(replaceThing,1000);

这个代码段做了一件事:每次replaceThing调用时,theThing都会获得一个包含一个大数组和一个新的闭包(someMethod的新对象同时,变量unused保存一个引用originalThingtheThing从前一次调用replaceThing的闭包已经有点混乱了吗?重要的是,一旦为同一个父范围内的闭包创建了一个范围,该范围将被共享

在这种情况下,为闭包创建的范围someMethod是共享的unusedunused有参考originalThing即使unused从未使用过,someMethod 可以在(例如全球某地)theThing的范围之外使用replaceThing作为someMethod股份的封闭范围unused,引用unused必须originalThing强制它保持活跃(两个闭包之间的整个共享范围)。这样可以防止其收集。

当这个代码段重复运行时,可以观察到内存使用量的稳定增长。当GC运行时,这不会变小。实质上,创建了一个关闭的链接列表(其根源以theThing变量的形式),并且这些闭包的范围中的每一个都对大阵列进行间接引用,导致相当大的泄漏。

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>在JavaScript代码中保留对表格特定单元格的引用(标记)。有一天,您决定从DOM中删除该表,但保留对该单元格的引用。直观地,可以假设GC将收集除了该单元格之外的所有内容。实际上,这不会发生:该单元格是该表的子节点,并且孩子们保持对父母的引用。也就是说,从JavaScript代码引用表格单元会导致整个表保留在内存中保持对DOM元素的引用时仔细考虑。

我们在SessionStack尝试遵循这些最佳做法来编写正确处理内存分配的代码,这就是为什么:

一旦将SessionStack集成到生产Web应用程序中,它将开始记录所有内容:所有DOM更改,用户交互,JavaScript异常,堆栈跟踪,网络请求失败,调试消息等。  
通过SessionStack,您可以将Web应用中的问题重播为视频和看到你的用户发生的一切。所有这一切都将发生,对您的网络应用程序没有性能影响。
由于用户可以重新加载页面或导航您的应用程序,因此所有观察者,拦截器,可变分配等都必须正确处理,因此不会导致任何内存泄漏或不增加网络应用程序的内存消耗我们整合了。

服务支持

我们珍惜您每一次在线询盘,有问必答,用专业的态度,贴心的服务。

让您真正感受到我们的与众不同!

合作流程
合作流程

重庆网站建设流程从提出需求到网站建设报价,再到网站建设,每一步都是规范和专业的。

常见问题
常见问题

什么是网站定制?网站报价如何?网站常见问题。

常见问题
售后保障

网站建设不难,难的是一如既往的热情服务及技术支持。我们知道:做网站就是做服务,就是做售后。