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

阿里云最近在作活动,低至2折,有兴趣能够看看:
https://promotion.aliyun.com/...

为了保证的可读性,本文采用意译而非直译。javascript

本系列的第一篇文章简单介绍了引擎、运行时间和堆栈的调用。第二篇文章研究了谷歌V8 JavaScript引擎的内部机制,并介绍了一些编写JavaScript代码的技巧。html

在这第三篇文章中,咱们将讨论另外一个重要主题——内存管理,这是因为平常使用的编程语言愈来愈成熟和复杂,开发人员容易忽视这一问题。咱们还将提供一些有关如何处理JavaScript中的内存泄漏的技巧,在SessionStack中遵循这些技巧,既能确保SessionStack 不会致使内存泄漏,也不会增长咱们集成的Web应用程序的内存消耗。前端

想阅读更多优质文章请猛戳GitHub博客,一年百来篇优质文章等着你!java

概述

像 C 这样的编程语言,具备低级内存管理原语,如malloc()和free()。开发人员使用这些原语显式地对操做系统的内存进行分配和释放。git

而JavaScript在建立对象(对象、字符串等)时会为它们分配内存,再也不使用对时会“自动”释放内存,这个过程称为垃圾收集。这种看“自动”似释放资源的的特性是形成混乱的根源,由于这给JavaScript(和其余高级语言)开发人员带来一种错觉,觉得他们能够不关心内存管理的错误印象,这是想法一个大错误。github

即便在使用高级语言时,开发人员也应该了解内存管理(或者至少懂得一些基础知识)。有时候,自动内存管理存在一些问题(例如垃圾收集器中的bug或实现限制等),开发人员必须理解这些问题,以即可以正确地处理它们(或者找到一个适当的解决方案,以最小代价来维护代码)。算法

内存的生命周期

不管使用哪一种编程语言,内存的生命周期都是同样的:编程

图片描述

这里简单介绍一下内存生命周期中的每个阶段:segmentfault

  • 分配内存 —  内存是由操做系统分配的,它容许您的程序使用它。在低级语言(例如C语言)中,这是一个开发人员须要本身处理的显式执行的操做。然而,在高级语言中,系统会自动为你分配内在。
  • 使用内存 — 这是程序实际使用以前分配的内存,在代码中使用分配的变量时,就会发生读和写操做。
  • 释放内存 — 释放全部再也不使用的内存,使之成为自由内存,并能够被重利用。与分配内存操做同样,这一操做在低级语言中也是须要显式地执行。

内存是什么?

在介绍JavaScript中的内存以前,咱们将简要讨论内存是什么以及它是如何工做的。数组

硬件层面上,计算机内存由大量的触发器缓存的。每一个触发器包含几个晶体管,可以存储一位,单个触发器均可以经过惟一标识符寻址,所以咱们能够读取和覆盖它们。所以,从概念上讲,能够把的整个计算机内存看做是一个能够读写的巨大数组。

做为人类,咱们并不擅长用比特来思考和计算,因此咱们把它们组织成更大的组,这些组一块儿能够用来表示数字。8位称为1字节。除了字节,还有字(有时是16位,有时是32位)。

不少东西都存储在内存中:

  1. 程序使用的全部变量和其余数据。
  2. 程序的代码,包括操做系统的代码。

编译器和操做系统一块儿为你处理大部份内存管理,可是你仍是须要了解一下底层的状况,对内在管理概念会有更深刻的了解。

在编译代码时,编译器能够检查基本数据类型,并提早计算它们须要多少内存。而后将所需的大小分配给调用堆栈空间中的程序,分配这些变量的空间称为堆栈空间。由于当调用函数时,它们的内存将被添加到现有内存之上,当它们终止时,它们按照后进先出(LIFO)顺序被移除。例如:

图片描述

编译器可以当即知道所需的内存:4 + 4×4 + 8 = 28字节。

这段代码展现了整型和双精度浮点型变量所占内存的大小。可是大约20年前,整型变量一般占2个字节,而双精度浮点型变量占4个字节。你的代码不该该依赖于当前基本数据类型的大小。

编译器将插入与操做系统交互的代码,并申请存储变量所需的堆栈字节数。

在上面的例子中,编译器知道每一个变量的确切内存地址。事实上,每当咱们写入变量 n 时,它就会在内部被转换成相似“内存地址4127963”这样的信息。

注意,若是咱们尝试访问 x[4],将访问与m关联的数据。这是由于访问数组中一个不存在的元素(它比数组中最后一个实际分配的元素x[3]多4字节),可能最终读取(或覆盖)一些 m 位。这确定会对程序的其他部分产生不可预知的结果。

图片描述

当函数调用其余函数时,每一个函数在调用堆栈时得到本身的块。它保存全部的局部变量,但也会有一个程序计数器来记住它在执行过程当中的位置。当函数完成时,它的内存块将再次用于其余地方。

动态分配

不幸的是,当编译时不知道一个变量须要多少内存时,事情就有点复杂了。假设咱们想作以下的操做:

图片描述

在编译时,编译器不知道数组须要使用多少内存,由于这是由用户提供的值决定的。

所以,它不能为堆栈上的变量分配空间。相反,咱们的程序须要在运行时显式地向操做系统请求适当的空间,这个内存是从堆空间分配的。静态内存分配和动态内存分配的区别总结以下表所示:

静态内存分配 动态内存分配
大小必须在编译时知道 大小不须要在编译时知道
在编译时执行 在运行时执行
分配给堆栈 分配给堆
FILO (先进后出) 没有特定的分配顺序

要彻底理解动态内存分配是如何工做的,须要在指针上花费更多的时间,这可能与本文的主题有太多的偏离,这里就不太详细介绍指针的相关的知识了。

在JavaScript中分配内存

如今将解释第一步:如何在JavaScript中分配内存。

JavaScript为让开发人员免于手动处理内存分配的责任——JavaScript本身进行内存分配同时声明值。

图片描述

某些函数调用也会致使对象的内存分配:

图片描述

方法能够分配新的值或对象:

图片描述

在JavaScript中使用内存

在JavaScript中使用分配的内存意味着在其中读写,这能够经过读取或写入变量或对象属性的值,或者将参数传递给函数来实现。

当内存再也不须要时进行释放

大多数的内存管理问题都出如今这个阶段

这里最困难的地方是肯定什么时候再也不须要分配的内存,它一般要求开发人员肯定程序中哪些地方再也不须要内存的并释放它。

高级语言嵌入了一种称为垃圾收集器的机制,它的工做是跟踪内存分配和使用,以便发现任什么时候候一块再也不须要已分配的内在。在这种状况下,它将自动释放这块内存。

不幸的是,这个过程只是进行粗略估计,由于很难知道某块内存是否真的须要 (不能经过算法来解决)。

大多数垃圾收集器经过收集再也不被访问的内存来工做,例如,指向它的全部变量都超出了做用域。可是,这是能够收集的内存空间集合的一个不足估计值,由于在内存位置的任何一点上,仍然可能有一个变量在做用域中指向它,可是它将永远不会被再次访问。

垃圾收集

因为没法肯定某些内存是否真的有用,所以,垃圾收集器想了一个办法来解决这个问题。本节将解释理解主要垃圾收集算法及其局限性。

内存引用

垃圾收集算法主要依赖的是引用。

在内存管理上下文中,若是对象具备对另外一个对象的访问权(能够是隐式的,也能够是显式的),则称对象引用另外一个对象。例如,JavaScript对象具备对其原型(隐式引用)和属性值(显式引用)的引用。

在此上下文中,“对象”的概念被扩展到比常规JavaScript对象更普遍的范围,而且还包含函数范围(或全局词法做用域)。

词法做用域定义了如何在嵌套函数中解析变量名:即便父函数已经返回,内部函数也包含父函数的做用

引用计数垃圾收集算法

这是最简单的垃圾收集算法。若是没有指向对象的引用,则认为该对象是“垃圾可回收的”,以下代码:

图片描述

循环会产生问题

当涉及到循环时,会有一个限制。在下面的示例中,建立了两个对象,两个对象互相引用,从而建立了一个循环。在函数调用以后将超出做用域,所以它们其实是无用的,能够被释放。然而,引用计数算法认为,因为每一个对象至少被引用一次,因此它们都不能被垃圾收集。

图片描述

图片描述

标记-清除(Mark-and-sweep)算法

该算法可以判断出某个对象是否能够访问,从而知道该对象是否有用,该算法由如下步骤组成:

  1. 垃圾收集器构建一个“根”列表,用于保存引用的全局变量。在JavaScript中,“window”对象是一个可做为根节点的全局变量。
  2. 而后,算法检查全部根及其子节点,并将它们标记为活动的(这意味着它们不是垃圾)。任何根不能到达的地方都将被标记为垃圾。
  3. 最后,垃圾收集器释放全部未标记为活动的内存块,并将该内存返回给操做系统。

图片描述

这个算法比上一个算法要好,由于“一个对象没有被引用”就意味着这个对象没法访问。

截至2012年,全部现代浏览器都有标记-清除垃圾收集器。过去几年在JavaScript垃圾收集(分代/增量/并发/并行垃圾收集)领域所作的全部改进都是对该算法(标记-清除)的实现改进,而不是对垃圾收集算法自己的改进,也不是它决定对象是否可访问的目标。

在这篇文章中,你能够更详细地阅读到有关跟踪垃圾收集的详细信息,同时还包括了标记-清除算法及其优化。

循环再也不是问题

在上面的第一个例子中,在函数调用返回后,这两个对象再也不被从全局对象中可访问的对象引用。所以,垃圾收集器将发现它们不可访问。

图片描述

尽管对象之间存在引用,但它们对于根节点来讲是不可达的。

垃圾收集器的反直观行为

尽管垃圾收集器很方便,但它们有一套本身的折衷方案,其中之一就是非决定论,换句话说,GC是不可预测的,你没法真正判断什么时候进行垃圾收集。这意味着在某些状况下,程序会使用更多的内存,这其实是必需的。在对速度特别敏感的应用程序中,可能会很明显的感觉到短期的停顿。若是没有分配内存,则大多数GC将处于空闲状态。看看如下场景:

  1. 分配一组至关大的内在。
  2. 这些元素中的大多数(或所有)被标记为不可访问(假设引用指向一个再也不须要的缓存)。
  3. 再也不进一步的分配

在这些场景中,大多数GCs 将再也不继续收集。换句话说,即便有不可访问的引用可供收集,收集器也不会声明这些引用。这些并非严格意义上的泄漏,但仍然会致使比一般更高的内存使用。

内存泄漏是什么?

从本质上说,内存泄漏能够定义为:再也不被应用程序所须要的内存,出于某种缘由,它不会返回到操做系统或空闲内存池中。

图片描述

编程语言支持不一样的内存管理方式。然而,是否使用某一块内存其实是一个没法肯定的问题。换句话说,只有开发人员才能明确一块内存是否能够返回到操做系统。

某些编程语言为开发人员提供了帮助,另外一些则指望开发人员能清楚地了解内存什么时候再也不被使用。维基百科上有一些有关人工自动内存管理的很不错的文章。

四种常见的内存泄漏

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本身调用,它指向全局对象(window),而不是未定义。
foo();
能够在JavaScript文件的开头经过添加“use strict”来避免这一切,它将开启一个更严格的JavaScript解析模式,以防止意外建立全局变量。

尽管咱们讨论的是未知的全局变量,但仍然有不少代码充斥着显式的全局变量。根据定义,这些是不可收集的(除非被指定为空或从新分配)。用于临时存储和处理大量信息的全局变量特别使人担心。若是你必须使用一个全局变量来存储大量数据,那么请确保将其指定为null,或者在完成后将其从新赋值。

2.被遗忘的定时器和回调

setInterval为例,由于它在JavaScript中常用。

var serverData = loadData();
setInterval(function() {
    var renderer = document.getElementById('renderer');
    if(renderer) {
        renderer.innerHTML = JSON.stringify(serverData);
    }
}, 5000); //每五秒会执行一次

上面的代码片断演示了使用定时器时引用再也不须要的节点或数据。

renderer表示的对象可能会在将来的某个时间点被删除,从而致使内部处理程序中的一整块代码都变得再也不须要。可是,因为定时器仍然是活动的,因此,处理程序不能被收集,而且其依赖项也没法被收集。这意味着,存储着大量数据的serverData也不能被收集。

在使用观察者时,您须要确保在使用完它们以后进行显式调用来删除它们(要么再也不须要观察者,要么对象将变得不可访问)。

做为开发者时,须要确保在完成它们以后进行显式删除它们(或者对象将没法访问)。

在过去,一些浏览器没法处理这些状况(很好的IE6)。幸运的是,如今大多数现代浏览器会为帮你完成这项工做:一旦观察到的对象变得不可访问,即便忘记删除侦听器,它们也会自动收集观察者处理程序。然而,咱们仍是应该在对象被处理以前显式地删除这些观察者。例如:

图片描述

现在,如今的浏览器(包括IE和Edge)使用现代的垃圾回收算法,能够当即发现并处理这些循环引用。换句话说,在一个节点删除以前也不是必需要调用removeEventListener。

一些框架或库,好比JQuery,会在处置节点以前自动删除监听器(在使用它们特定的API的时候)。这是由库内部的机制实现的,可以确保不发生内存泄漏,即便在有问题的浏览器下运行也能这样,好比……IE 6。

3.闭包

闭包是javascript开发的一个关键方面,一个内部函数使用了外部(封闭)函数的变量。因为JavaScript运行的细节,它可能如下面的方式形成内存泄漏:

图片描述

这段代码作了一件事:每次调用replaceThing的时候,theThing都会获得一个包含一个大数组和一个新闭包(someMethod)的新对象。同时,变量unused指向一个引用了`originalThing的闭包。

是否是有点困惑了? 重要的是,一旦具备相同父做用域的多个闭包的做用域被建立,则这个做用域就能够被共享。

在这种状况下,为闭包someMethod而建立的做用域能够被unused共享的。unused内部存在一个对originalThing的引用。即便unused从未使用过,someMethod也能够在replaceThing的做用域以外(例如在全局范围内)经过theThing来被调用。

因为someMethod共享了unused闭包的做用域,那么unused引用包含的originalThing会迫使它保持活动状态(两个闭包之间的整个共享做用域)。这阻止了它被收集。

当这段代码重复运行时,能够观察到内存使用在稳定增加,当GC运行后,内存使用也不会变小。从本质上说,在运行过程当中建立了一个闭包链表(它的根是以变量theThing的形式存在),而且每一个闭包的做用域都间接引用了了一个大数组,这形成了至关大的内存泄漏。

4.脱离DOM的引用

有时,将DOM节点存储在数据结构中可能会颇有用。假设你但愿快速地更新表中的几行内容,那么你能够在一个字典或数组中保存每一个DOM行的引用。这样,同一个DOM元素就存在两个引用:一个在DOM树中,另外一个则在字典中。若是在未来的某个时候你决定删除这些行,那么你须要将这两个引用都设置为不可访问。

图片描述

在引用 DOM 树中的内部节点或叶节点时,还须要考虑另一个问题。若是在代码中保留对表单元格的引用(<td>标记),并决定从 DOM 中删除表,同时保留对该特定单元格的引用,那么可能会出现内存泄漏。

你可能认为垃圾收集器将释放除该单元格以外的全部内容。然而,事实并不是如此,因为单元格是表的一个子节点,而子节点保存对父节点的引用,因此对表单元格的这个引用将使整个表保持在内存中,因此在移除有被引用的节点时候要移除其子节点。

编辑中可能存在的bug无法实时知道,过后为了解决这些bug,花了大量的时间进行log 调试,这边顺便给你们推荐一个好用的BUG监控工具Fundebug

原文:https://blog.sessionstack.com...

你的点赞是我持续分享好东西的动力,欢迎点赞!

交流

干货系列文章汇总以下,以为不错点个Star,欢迎 加群 互相学习。

https://github.com/qq44924588...

我是小智,公众号「大迁世界」做者,对前端技术保持学习爱好者。我会常常分享本身所学所看的干货,在进阶的路上,共勉!

关注公众号,后台回复福利,便可看到福利,你懂的。

clipboard.png

相关文章
相关标签/搜索